19 Commits

Author SHA1 Message Date
taekyoungc
215d87f991 feat: 워크스페이스 탭 자동복원 + 주문 입금확인 토글 + 메뉴 개명(전화접수→주문접수)
종량제3 세션 작업.
- 워크스페이스 탭 계정별 저장·자동복원(member_workspace_tabs 자동생성, pref/workspace-tabs)
- 주문접수 관리 입금확인(so_paid) 수동 토글(phoneOrderMarkPaid, order/phone/manage/paid)
- 메뉴 '전화 접수 관리' → '주문접수관리' 개명(피드백 #35) + 시드/개명 SQL

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 08:04:45 +09:00
taekyoungc
dc5c38d242 style: 브랜드 로고 아이콘 SVG 교체 (로그인·헤더·포털·환영 화면)
종량제3 세션 작업. GBLS 브랜드 로고를 새 아이콘 형태로 통일.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:59:06 +09:00
taekyoungc
6c421153e4 feat: 전화접수 관리 — 스캔 현황·포장량·포장 명세 보기 UI
전화접수 관리 화면에 스캔 현황(포장량 합계, 최근 스캔), 포장 명세 보기 모달 추가.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 15:45:28 +09:00
taekyoungc
5e8e81f404 feat: 지정판매소 개인정보(PII) 보호 — 마스킹·암호화·열람 게이트 (기본 OFF)
종량제3 세션 작업. 마스터 플래그(Config\Pii.designatedShopProtection)가 OFF면
현재 동작(원문 그대로) 유지, ON일 때만 마스킹·게이트 적용.

- 표시 계층 단일 지점 마스킹: 대표자명/전화/이메일/계좌/가상계좌 (목록·상세·엑셀·판매대장·전화주문)
- 원문 열람 게이트: 지자체 허용 IP 자동열람 or 2단계 인증(TOTP) step-up + 감사기록(revealPii)
- 정확일치 검색: 블라인드 인덱스(HMAC) 컬럼 있으면 사용, 없으면 평문 폴백
- 저장계층 암호화(encryptAtRest, 기본 OFF): 모델 콜백 + spark 명령 pii:protect-designated-shops
- SQL(추가·멱등): designated_shop_add_pii_bidx / local_government_add_allow_ips
- Auth: TOTP 실제 통과 시에만 세션 auth_2fa_verified=true

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 15:45:28 +09:00
taekyoungc
c5df784f91 feat: 실사 재고 바코드 리스트 레이지 로딩 — 스크롤 시 추가 로딩
- 상위 2,000건 상한 제거 → 초기 200건 렌더 후 스크롤 시 청크(200건) 추가 로딩
- 서버: ajax=1&offset&limit 로 청크 JSON 반환, 합계는 SQL 집계(전체 행 미적재)
- 클라: IntersectionObserver 자동 로딩 + '더 보기' 폴백 버튼
- 인쇄 시 전체 먼저 로드 후 출력(부분 인쇄 방지)
- fetch는 현재 origin 기준 상대경로로 호출(baseURL/포트 불일치에도 동작)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 16:29:50 +09:00
taekyoungc
a3f3e9cd64 feat: 실사 재고 바코드 리스트 — 창고에 있어야 하는 번호 조회/엑셀/인쇄
문의 #34: 실사 시 현재 창고에 남아있어야 하는(재고 상태) 봉투 번호(바코드)
목록을 뽑아 실물과 대조할 수 있게 신규 화면 추가.

- bag/inventory/stock-barcode-list 라우트 + Bag::stockBarcodeList()
- bag_receiving_pack_code 중 brpc_state='in_stock' 기준(기존 실사 스냅샷과 동일)
- 품목/번호(박스·팩·시트·LOT) 필터, 인쇄, 엑셀저장(전체)
- 화면 표시는 상위 2,000건 상한 + 안내 배너(합계·엑셀은 전체 기준)
- 재고 관리 메뉴에 전 지자체 등록

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 14:59:13 +09:00
taekyoungc
f624c13b5b feat: 모든 상위메뉴 소메뉴에 아이콘 표시(기본정보관리 방식 확대)
기본정보관리에만 적용되던 사이드바 소메뉴 아이콘을 전체 상위메뉴로 확대.
전화접수·발주·입고·재고·판매·수불·분석·반품/취소·바코드 등 실제 메뉴명에
맞는 Font Awesome 아이콘 키워드 매핑 확장(서버 렌더 + JS 재구성 동일 유지).
2026-07-14 12:09:31 +09:00
taekyoungc
b814892352 feat: 맞춤 집계표 엑셀 다운로드 추가
- 조회 폼에 '엑셀저장' 버튼(현재 컬럼·필터·제목·집계방식 그대로 반영)
- Excel 2003 XML로 제목·기간·품목구분 머리행 + 선택 컬럼 + 합계행 출력

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 11:35:13 +09:00
taekyoungc
794d34635b feat: 기본정보관리 사이드바 소메뉴에 아이콘 추가
- 이름 키워드 기반 Font Awesome 아이콘 매핑(서버 렌더 + JS 재구성 양쪽 반영)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 12:27:13 +09:00
taekyoungc
e9f1cf2b49 feat: 브랜드 파비콘(SVG) 추가 — 네이비 배경·쓰레기통·에코 잎
- public/favicon.svg 신규, 관리자/인증/포털 head에 링크(기존 .ico 폴백 유지)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 12:27:13 +09:00
taekyoungc
3a611972c1 feat: 메뉴 등록/수정 시 상위·하위 위치 선택 + 지자체 동기화 성능 개선
- 등록/수정 폼에 상위/하위 선택(상위 메뉴 지정), 기존 메뉴도 상위↔하위 이동 가능
- 하위 보유 메뉴의 하위 변경 차단 등 검증
- syncTypeToAllLgs를 dep별 batch insert로 재작성 → 메뉴 저장 시 원격 DB 왕복 급감(30초 타임아웃 해소)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 12:26:58 +09:00
taekyoungc
9365848f9d feat: 문의사항 상세에서 글(본문) 수정 기능
- 의견 내용 패널에 '수정' 토글 → 본문 편집·저장(계정 스코프·2000자 제한)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 12:26:58 +09:00
taekyoungc
65582c3439 feat: 맞춤 집계표 — 기간·품목구분·컬럼 선택 + 제목·계정별 프리셋 저장/인쇄
- 한 화면에서 기간·품목구분(봉투/음식물/폐기물)·집계방식·표시컬럼 선택, 제목 지정 후 조회/인쇄
- 컬럼 13종 선택, 상세/품목별 집계 지원, 실판매(bag_sale) 기준
- 계정별(mb_idx) 프리셋 저장/불러오기/삭제 (report_preset 테이블)
- report_preset 테이블 없으면 프리셋만 비활성, 리포트는 정상 동작

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 12:26:44 +09:00
taekyoungc
03c2bbc05c feat: 글자크기를 계정별·메뉴별로 저장(재로그인 유지)
기존 전역 localStorage 방식(모든 메뉴 일괄 적용)을 화면 경로(메뉴키) 기준
계정별 DB 저장으로 변경. A−/A+ 조정 시 해당 화면만 조정·저장되고, 다음
방문·재로그인 시 각 화면의 저장값이 서버 주입으로 복원된다.

- member_menu_font_scale 테이블 + 저장 엔드포인트(bag/pref/font-scale)
- member_font_scale_for() 헬퍼로 렌더 시 현재 화면 저장값 주입
- admin/portal/embed 레이아웃 + 워크스페이스(탭 iframe)에 적용
  · 워크스페이스는 포커스된 탭(메뉴)만 조정, 디바운스 저장으로 CSRF 경합 방지

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 11:12:43 +09:00
taekyoungc
62f7bda290 feat: 발주입고(스캐너)·일괄입고 좌우 워크스페이스 재설계 + 실시간 스캔 입고
- 발주입고/일괄입고를 공용 워크스페이스로 통합: 좌측 발주현황(탭: 제작업체·발주기간 /
  인수자·인계자, 정렬) · 우측 선택발주 상세 + 스캔/수량 입고 · 하단 입고 세부내역(박스코드)
- 실시간 스캔 입고 엔드포인트 receivingScanBox 추가(스캔 1회=1박스, 수량 직접입력 병행)
- buildReceivingCandidateRows에 카테고리(봉투/음식물·폐기물)·발주기간·대행소 필터 추가
- 일괄입고를 '일괄입고(음식물,폐기물)'로 개편, 음식물·폐기물만 표시(메뉴명 변경 SQL 포함)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 16:20:14 +09:00
taekyoungc
7e958a7ac9 feat: 리스트 페이징을 lazy loading(무한 스크롤)으로 표준화
공용 CI4 pager 템플릿(components/pager.php)을 페이지 번호 대신
무한 스크롤 방식으로 교체. 화면 하단 도달 시(IntersectionObserver) 다음
페이지 HTML을 받아 리스트 표에 이어붙이고, '더보기' 버튼을 폴백으로 둔다.
Config\Pager의 default 템플릿이라 전체 리스트 화면에 일괄 적용된다.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 16:02:20 +09:00
taekyoungc
bfcdd055b2 style: 문의사항 '답변 완료' 배지 색상 회색으로 통일
목록형(index)의 '답변 완료' 배지를 teal → gray로 변경해 전체보기(all)와 통일.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 19:44:19 +09:00
taekyoungc
8d06c8eab4 fix: 사용자 의견 목록/전체보기에서 텍스트 드래그·선택 가능하도록 수정
관리자 레이아웃 body의 select-none 때문에 의견 본문을 선택·복사할 수 없던
문제를 opt-in 클래스(.fb-selectable)로 해제. 목록형·전체보기 화면에 적용하고,
목록형은 텍스트 선택 중 행 클릭 시 상세로 이동하지 않도록 가드 추가.
(의견 상세 화면은 이미 동일 방식으로 적용됨)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 19:32:57 +09:00
taekyoungc
8339f69303 feat: 의견 상세/전체보기의 화면 URL을 클릭 가능한 링크로
fb_page_url을 embed=1 파라미터 제거 후 전체 절대 URL로 변환해
새 탭(target=_blank)으로 바로 열리게 한다. 나머지 쿼리는 보존.
2026-07-06 19:29:27 +09:00
47 changed files with 3335 additions and 416 deletions

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use Config\Database;
/**
* 지정판매소 PII 일괄 암호화 + 블라인드 인덱스 백필 (설계안 P3·P4).
*
* ⚠️ 운영 데이터를 변경하는 비가역적 작업. 반드시 DB 백업 후 실행.
* php spark pii:protect-designated-shops --dry (미리보기: 변경 안 함)
* php spark pii:protect-designated-shops --force (실제 실행)
*
* - 멱등: 이미 ENC: 인 필드는 건너뜀. 두 번 돌려도 안전.
* - 앱 encrypter(.env encryption.key) 사용 → 반드시 키가 설정돼 있어야 함.
*/
class ProtectDesignatedShops extends BaseCommand
{
protected $group = 'PII';
protected $name = 'pii:protect-designated-shops';
protected $description = '지정판매소 PII를 암호화하고 블라인드 인덱스를 백필합니다(비가역적, 백업 필수).';
protected $usage = 'pii:protect-designated-shops [--dry|--force]';
public function run(array $params)
{
helper('pii_encryption');
$dry = array_key_exists('dry', $params) || in_array('--dry', $_SERVER['argv'] ?? [], true);
$force = array_key_exists('force', $params) || in_array('--force', $_SERVER['argv'] ?? [], true);
if (! $dry && ! $force) {
CLI::error('안전을 위해 --dry(미리보기) 또는 --force(실행) 중 하나가 필요합니다.');
return;
}
$cfg = config('Encryption');
if ((string) $cfg->key === '') {
CLI::error('encryption.key 가 비어 있습니다. .env 설정 후 실행하세요. (키 없이 암호화 불가)');
return;
}
$db = Database::connect();
$piiFields = ['ds_rep_name', 'ds_tel', 'ds_rep_phone', 'ds_email', 'ds_va_account'];
$hasTelBidx = $db->fieldExists('ds_tel_bidx', 'designated_shop');
$hasNameBidx = $db->fieldExists('ds_rep_name_bidx', 'designated_shop');
$rows = $db->table('designated_shop')->select('ds_idx, ' . implode(', ', $piiFields))->get()->getResultArray();
CLI::write('대상 행: ' . count($rows) . ($dry ? ' [DRY-RUN: 변경 없음]' : ' [실제 실행]'), $dry ? 'yellow' : 'green');
$encCount = 0; $rowCount = 0;
foreach ($rows as $r) {
$update = [];
foreach ($piiFields as $f) {
$v = (string) ($r[$f] ?? '');
if ($v !== '' && strpos($v, 'ENC:') !== 0) {
$update[$f] = pii_encrypt($v);
$encCount++;
}
}
// 블라인드 인덱스(원본값 기준)
if ($hasTelBidx) {
$update['ds_tel_bidx'] = pii_blind_index((string) ($r['ds_tel'] ?? ''));
}
if ($hasNameBidx) {
$update['ds_rep_name_bidx'] = pii_blind_index((string) ($r['ds_rep_name'] ?? ''));
}
if ($update === []) {
continue;
}
$rowCount++;
if (! $dry) {
$db->table('designated_shop')->where('ds_idx', (int) $r['ds_idx'])->update($update);
}
}
CLI::write('변경 대상 행: ' . $rowCount . ' / 암호화 필드: ' . $encCount, 'green');
if ($dry) {
CLI::write('DRY-RUN 이었습니다. 실제 반영하려면 --force 로 다시 실행하세요.', 'yellow');
} else {
CLI::write('완료. (멱등: 재실행해도 이미 암호화된 값은 건너뜁니다)', 'green');
}
}
}

32
app/Config/Pii.php Normal file
View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Config;
use CodeIgniter\Config\BaseConfig;
/**
* 개인정보 비식별화·암호화 설정.
* 마스터 플래그가 OFF이면 현재 동작(원문 그대로) 유지 — 준비 완료 후 .env에서 켠다.
* .env: pii.designatedShopProtection = true
*/
class Pii extends BaseConfig
{
/** 지정판매소 PII 보호(마스킹·암호화·게이트) 마스터 스위치. 기본 OFF(안전). */
public bool $designatedShopProtection = false;
/** 지정판매소 PII 필드 (암호화 저장 + 마스킹 대상) */
public array $designatedShopPiiFields = [
'ds_rep_name', 'ds_tel', 'ds_rep_phone', 'ds_email', 'ds_va_account',
];
/** 저장 계층 암호화 사용 여부 (P3). 마이그레이션·백업 후 .env에서 켠다. */
public bool $encryptAtRest = false;
/**
* 블라인드 인덱스 HMAC 키 (.env: pii.blindIndexKey).
* encryption.key 와 별도 키 권장. 비어 있으면 검색 인덱스 생성/사용 안 함.
*/
public string $blindIndexKey = '';
}

View File

@@ -38,6 +38,7 @@ $routes->get('bag/issue', 'Bag::issueLegacy');
$routes->get('bag/issue/cancel', 'Bag::issue'); $routes->get('bag/issue/cancel', 'Bag::issue');
$routes->get('bag/inventory', 'Bag::inventory'); $routes->get('bag/inventory', 'Bag::inventory');
$routes->get('bag/inventory/export', 'Bag::inventoryExport'); $routes->get('bag/inventory/export', 'Bag::inventoryExport');
$routes->get('bag/inventory/stock-barcode-list', 'Bag::stockBarcodeList'); // 실사 재고 바코드 리스트(창고에 있어야 하는 번호)
$routes->get('bag/inventory/inspection-select', 'Bag::inspectionSelect'); $routes->get('bag/inventory/inspection-select', 'Bag::inspectionSelect');
$routes->get('bag/inventory/inspection-work', 'Bag::inspectionWork'); $routes->get('bag/inventory/inspection-work', 'Bag::inspectionWork');
$routes->post('bag/inventory/inspection-run', 'Bag::inspectionRun'); $routes->post('bag/inventory/inspection-run', 'Bag::inspectionRun');
@@ -63,6 +64,8 @@ $routes->group('bag', ['filter' => 'loginAuth'], static function ($routes): void
$routes->get('manual/search', 'Bag::manualSearch'); // (:segment) 보다 먼저 $routes->get('manual/search', 'Bag::manualSearch'); // (:segment) 보다 먼저
$routes->get('manual/(:segment)', 'Bag::manualPage/$1'); $routes->get('manual/(:segment)', 'Bag::manualPage/$1');
$routes->post('activity/print-log', 'Bag::printLog'); // 인쇄 활동 기록(beacon) $routes->post('activity/print-log', 'Bag::printLog'); // 인쇄 활동 기록(beacon)
$routes->post('pref/font-scale', 'Bag::saveFontScale'); // 계정별·메뉴별 글자크기 저장
$routes->post('pref/workspace-tabs', 'Bag::saveWorkspaceTabs'); // 계정별 워크스페이스 탭 상태 저장(자동 복원)
}); });
$routes->get('bag/number-lookup', 'Bag::numberLookup'); $routes->get('bag/number-lookup', 'Bag::numberLookup');
@@ -79,6 +82,7 @@ $routes->get('bag/order/phone', 'Bag::phoneOrderCreate');
$routes->get('bag/order/phone/manage', 'Bag::phoneOrderManage'); $routes->get('bag/order/phone/manage', 'Bag::phoneOrderManage');
$routes->post('bag/order/phone/manage/update', 'Bag::phoneOrderUpdate'); $routes->post('bag/order/phone/manage/update', 'Bag::phoneOrderUpdate');
$routes->post('bag/order/phone/manage/scan', 'Bag::phoneOrderManageScan'); $routes->post('bag/order/phone/manage/scan', 'Bag::phoneOrderManageScan');
$routes->post('bag/order/phone/manage/paid', 'Bag::phoneOrderMarkPaid');
$routes->post('bag/order/phone/manage/cancel/(:num)', 'Bag::phoneOrderCancel/$1'); $routes->post('bag/order/phone/manage/cancel/(:num)', 'Bag::phoneOrderCancel/$1');
$routes->get('bag/order/phone/receipt/(:num)', 'Bag::phoneOrderReceipt/$1'); $routes->get('bag/order/phone/receipt/(:num)', 'Bag::phoneOrderReceipt/$1');
$routes->get('bag/order/change', 'Bag::orderChange'); $routes->get('bag/order/change', 'Bag::orderChange');
@@ -94,6 +98,7 @@ $routes->get('bag/receiving/create', 'Bag::receivingCreate');
$routes->post('bag/receiving/store', 'Bag::receivingStore'); $routes->post('bag/receiving/store', 'Bag::receivingStore');
$routes->get('bag/receiving/scanner', 'Bag::receivingScanner'); $routes->get('bag/receiving/scanner', 'Bag::receivingScanner');
$routes->post('bag/receiving/scanner/store', 'Bag::receivingScannerStore'); $routes->post('bag/receiving/scanner/store', 'Bag::receivingScannerStore');
$routes->post('bag/receiving/scan-box', 'Bag::receivingScanBox');
$routes->get('bag/receiving/batch', 'Bag::receivingBatch'); $routes->get('bag/receiving/batch', 'Bag::receivingBatch');
$routes->post('bag/receiving/batch/store', 'Bag::receivingBatchStore'); $routes->post('bag/receiving/batch/store', 'Bag::receivingBatchStore');
$routes->get('bag/receiving/status', 'Bag::receivingStatus'); $routes->get('bag/receiving/status', 'Bag::receivingStatus');
@@ -157,6 +162,7 @@ $routes->group('bag', ['filter' => 'adminAuth'], static function ($routes): void
$routes->get('designated-shops', 'Admin\DesignatedShop::index'); $routes->get('designated-shops', 'Admin\DesignatedShop::index');
$routes->get('designated-shops/create', 'Admin\DesignatedShop::create'); $routes->get('designated-shops/create', 'Admin\DesignatedShop::create');
$routes->post('designated-shops/resolve-address-codes', 'Admin\DesignatedShop::resolveAddressCodes'); $routes->post('designated-shops/resolve-address-codes', 'Admin\DesignatedShop::resolveAddressCodes');
$routes->post('designated-shops/reveal-pii', 'Admin\DesignatedShop::revealPii');
$routes->post('designated-shops/store', 'Admin\DesignatedShop::store'); $routes->post('designated-shops/store', 'Admin\DesignatedShop::store');
$routes->get('designated-shops/edit/(:num)', 'Admin\DesignatedShop::edit/$1'); $routes->get('designated-shops/edit/(:num)', 'Admin\DesignatedShop::edit/$1');
$routes->post('designated-shops/update/(:num)', 'Admin\DesignatedShop::update/$1'); $routes->post('designated-shops/update/(:num)', 'Admin\DesignatedShop::update/$1');
@@ -209,6 +215,9 @@ $routes->group('bag', ['filter' => 'adminAuth'], static function ($routes): void
$routes->post('packaging-units/manage/delete/(:num)', 'Admin\PackagingUnit::delete/$1'); $routes->post('packaging-units/manage/delete/(:num)', 'Admin\PackagingUnit::delete/$1');
$routes->get('packaging-units/manage/history/(:num)', 'Admin\PackagingUnit::history/$1'); $routes->get('packaging-units/manage/history/(:num)', 'Admin\PackagingUnit::history/$1');
$routes->get('reports/custom', 'Admin\SalesReport::customReport');
$routes->post('reports/custom/preset/save', 'Admin\SalesReport::customReportPresetSave');
$routes->post('reports/custom/preset/delete', 'Admin\SalesReport::customReportPresetDelete');
$routes->get('reports/sales-ledger', 'Admin\SalesReport::salesLedger'); $routes->get('reports/sales-ledger', 'Admin\SalesReport::salesLedger');
$routes->get('reports/daily-summary', 'Admin\SalesReport::dailySummary'); $routes->get('reports/daily-summary', 'Admin\SalesReport::dailySummary');
$routes->get('reports/period-sales', 'Admin\SalesReport::periodSales'); $routes->get('reports/period-sales', 'Admin\SalesReport::periodSales');
@@ -253,6 +262,7 @@ $routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): vo
$routes->get('feedback/file/(:num)', 'Admin\Feedback::file/$1'); $routes->get('feedback/file/(:num)', 'Admin\Feedback::file/$1');
$routes->get('feedback/(:num)', 'Admin\Feedback::show/$1'); $routes->get('feedback/(:num)', 'Admin\Feedback::show/$1');
$routes->post('feedback/(:num)/status', 'Admin\Feedback::updateStatus/$1'); $routes->post('feedback/(:num)/status', 'Admin\Feedback::updateStatus/$1');
$routes->post('feedback/(:num)/content', 'Admin\Feedback::updateContent/$1');
$routes->get('/', 'Admin\Dashboard::index'); $routes->get('/', 'Admin\Dashboard::index');
$routes->get('users', 'Admin\User::index'); $routes->get('users', 'Admin\User::index');
$routes->get('users/create', 'Admin\User::create'); $routes->get('users/create', 'Admin\User::create');

View File

@@ -174,7 +174,7 @@ class DesignatedShop extends BaseController
/** /**
* 목록 검색과 동일한 조건을 모델 쿼리에 적용한다. * 목록 검색과 동일한 조건을 모델 쿼리에 적용한다.
*/ */
private function applyDesignatedShopListFilters(DesignatedShopModel $model, int $lgIdx, ?string $dsName, ?string $dsGugunCode, ?string $dsState): void private function applyDesignatedShopListFilters(DesignatedShopModel $model, int $lgIdx, ?string $dsName, ?string $dsGugunCode, ?string $dsState, ?string $dsRep = null, ?string $dsTel = null): void
{ {
$model->where('ds_lg_idx', $lgIdx); $model->where('ds_lg_idx', $lgIdx);
if ($dsName !== null && $dsName !== '') { if ($dsName !== null && $dsName !== '') {
@@ -186,12 +186,47 @@ class DesignatedShop extends BaseController
if ($dsState !== null && $dsState !== '') { if ($dsState !== null && $dsState !== '') {
$model->where('ds_state', (int) $dsState); $model->where('ds_state', (int) $dsState);
} }
$this->applyPiiSearchFilter($model, $dsRep, $dsTel);
}
/**
* 대표자명·전화 검색(정확일치). 블라인드 인덱스 컬럼+키가 있으면 HMAC 정확일치,
* 없으면(플래그 OFF/미마이그레이션) 평문 정확일치로 폴백.
*
* @param DesignatedShopModel|\CodeIgniter\Database\BaseBuilder $q
*/
private function applyPiiSearchFilter($q, ?string $dsRep, ?string $dsTel): void
{
$dsRep = $dsRep !== null ? trim($dsRep) : '';
$dsTel = $dsTel !== null ? trim($dsTel) : '';
if ($dsRep === '' && $dsTel === '') {
return;
}
helper('pii_encryption');
$db = \Config\Database::connect();
if ($dsRep !== '') {
$bidx = pii_blind_index($dsRep);
if ($bidx !== '' && $db->fieldExists('ds_rep_name_bidx', 'designated_shop')) {
$q->where('ds_rep_name_bidx', $bidx);
} else {
$q->where('ds_rep_name', $dsRep); // 평문 폴백(정확일치)
}
}
if ($dsTel !== '') {
$bidx = pii_blind_index($dsTel);
if ($bidx !== '' && $db->fieldExists('ds_tel_bidx', 'designated_shop')) {
$q->where('ds_tel_bidx', $bidx);
} else {
$q->where('ds_tel', $dsTel);
}
}
} }
/** /**
* @return array{1: int, 2: int, 3: int, total: int} * @return array{1: int, 2: int, 3: int, total: int}
*/ */
private function countDesignatedShopsByState(int $lgIdx, ?string $dsName, ?string $dsGugunCode, ?string $dsState): array private function countDesignatedShopsByState(int $lgIdx, ?string $dsName, ?string $dsGugunCode, ?string $dsState, ?string $dsRep = null, ?string $dsTel = null): array
{ {
$db = \Config\Database::connect(); $db = \Config\Database::connect();
$builder = $db->table('designated_shop'); $builder = $db->table('designated_shop');
@@ -205,6 +240,7 @@ class DesignatedShop extends BaseController
if ($dsState !== null && $dsState !== '') { if ($dsState !== null && $dsState !== '') {
$builder->where('ds_state', (int) $dsState); $builder->where('ds_state', (int) $dsState);
} }
$this->applyPiiSearchFilter($builder, $dsRep, $dsTel);
$rows = $builder->select('ds_state, COUNT(*) AS cnt', false) $rows = $builder->select('ds_state, COUNT(*) AS cnt', false)
->groupBy('ds_state') ->groupBy('ds_state')
->get() ->get()
@@ -229,7 +265,7 @@ class DesignatedShop extends BaseController
*/ */
private function buildDesignatedShopDetailPayload(array $list, array $lgMap, array $dongMap = []): array private function buildDesignatedShopDetailPayload(array $list, array $lgMap, array $dongMap = []): array
{ {
helper('admin'); helper(['admin', 'pii_encryption']);
$lgIdx = admin_effective_lg_idx() ?? 0; $lgIdx = admin_effective_lg_idx() ?? 0;
$gugunMap = $lgIdx > 0 ? $this->gugunCodeNameMap($lgIdx) : []; $gugunMap = $lgIdx > 0 ? $this->gugunCodeNameMap($lgIdx) : [];
$payload = []; $payload = [];
@@ -258,7 +294,7 @@ class DesignatedShop extends BaseController
$dongDisplay = $gugunName; $dongDisplay = $gugunName;
} }
$payload[] = [ $p = [
'ds_idx' => (int) $row->ds_idx, 'ds_idx' => (int) $row->ds_idx,
'ds_shop_no' => $sn, 'ds_shop_no' => $sn,
'shop_no_display' => $shortNo, 'shop_no_display' => $shortNo,
@@ -291,7 +327,18 @@ class DesignatedShop extends BaseController
'ds_change_reason' => $this->designatedShopScalar($row, 'ds_change_reason'), 'ds_change_reason' => $this->designatedShopScalar($row, 'ds_change_reason'),
'ds_regdate' => (string) ($row->ds_regdate ?? ''), 'ds_regdate' => (string) ($row->ds_regdate ?? ''),
'lg_name' => $lgMap[(int) ($row->ds_lg_idx ?? 0)] ?? '', 'lg_name' => $lgMap[(int) ($row->ds_lg_idx ?? 0)] ?? '',
'pii_masked' => false,
]; ];
// 개인정보 마스킹(표시 계층 단일 지점) — 열람 권한 없으면 PII 필드 마스킹
if (! can_view_shop_pii((int) ($row->ds_lg_idx ?? 0))) {
foreach (['ds_rep_name', 'ds_tel', 'ds_rep_phone', 'ds_email', 'ds_va_account', 'ds_va_number'] as $f) {
$p[$f] = mask_shop_field($f, (string) $p[$f]);
}
$p['pii_masked'] = true;
}
$payload[] = $p;
} }
return $payload; return $payload;
@@ -311,11 +358,13 @@ class DesignatedShop extends BaseController
return null; return null;
} }
// 다조건 검색 (P2-15) // 다조건 검색 (P2-15) + 대표자·전화 정확일치(블라인드 인덱스)
$dsName = $this->request->getGet('ds_name'); $dsName = $this->request->getGet('ds_name');
$dsGugunCode = $this->request->getGet('ds_gugun_code'); $dsGugunCode = $this->request->getGet('ds_gugun_code');
$dsState = $this->request->getGet('ds_state'); $dsState = $this->request->getGet('ds_state');
$this->applyDesignatedShopListFilters($this->shopModel, $lgIdx, $dsName, $dsGugunCode, $dsState); $dsRep = $this->request->getGet('ds_rep');
$dsTel = $this->request->getGet('ds_tel');
$this->applyDesignatedShopListFilters($this->shopModel, $lgIdx, $dsName, $dsGugunCode, $dsState, $dsRep, $dsTel);
// 페이징 대신 전체 목록을 한 번에 로드 — 리스트 패널 내부 스크롤로 표시 // 페이징 대신 전체 목록을 한 번에 로드 — 리스트 패널 내부 스크롤로 표시
$list = $this->shopModel->orderBy('ds_idx', 'DESC')->findAll(); $list = $this->shopModel->orderBy('ds_idx', 'DESC')->findAll();
@@ -327,7 +376,7 @@ class DesignatedShop extends BaseController
$lgMap[$lg->lg_idx] = $lg->lg_name; $lgMap[$lg->lg_idx] = $lg->lg_name;
} }
$stateCounts = $this->countDesignatedShopsByState($lgIdx, $dsName, $dsGugunCode, $dsState); $stateCounts = $this->countDesignatedShopsByState($lgIdx, $dsName, $dsGugunCode, $dsState, $dsRep, $dsTel);
$gugunNameMap = $this->gugunCodeNameMap($lgIdx); $gugunNameMap = $this->gugunCodeNameMap($lgIdx);
// 각 판매소의 동코드를 주소로 산출 → (컬럼이 있으면) 저장 → 상세 표시에 사용 // 각 판매소의 동코드를 주소로 산출 → (컬럼이 있으면) 저장 → 상세 표시에 사용
@@ -356,6 +405,8 @@ class DesignatedShop extends BaseController
'dsName' => $dsName ?? '', 'dsName' => $dsName ?? '',
'dsGugunCode' => $dsGugunCode ?? '', 'dsGugunCode' => $dsGugunCode ?? '',
'dsState' => $dsState ?? '', 'dsState' => $dsState ?? '',
'dsRep' => $dsRep ?? '',
'dsTel' => $dsTel ?? '',
'gugunCodes' => $gugunCodes, 'gugunCodes' => $gugunCodes,
'stateCounts' => $stateCounts, 'stateCounts' => $stateCounts,
'gugunNameMap' => $gugunNameMap, 'gugunNameMap' => $gugunNameMap,
@@ -508,6 +559,7 @@ class DesignatedShop extends BaseController
return redirect()->to(mgmt_url('designated-shops'))->with('error', '지자체를 선택해 주세요.'); return redirect()->to(mgmt_url('designated-shops'))->with('error', '지자체를 선택해 주세요.');
} }
helper('pii_encryption');
$list = $this->shopModel->where('ds_lg_idx', $lgIdx)->orderBy('ds_idx', 'DESC')->findAll(); $list = $this->shopModel->where('ds_lg_idx', $lgIdx)->orderBy('ds_idx', 'DESC')->findAll();
// 동코드(구·군 동코드) 산출 // 동코드(구·군 동코드) 산출
@@ -522,19 +574,30 @@ class DesignatedShop extends BaseController
$dongDisp = $dCode !== '' ? ($dCode . ($dName !== '' ? ' (' . $dName . ')' : '')) : ''; $dongDisp = $dCode !== '' ? ($dCode . ($dName !== '' ? ' (' . $dName . ')' : '')) : '';
$da = $row->ds_designated_at ?? null; $da = $row->ds_designated_at ?? null;
$daDisp = ($da !== null && $da !== '' && (string) $da !== '0000-00-00') ? substr((string) $da, 0, 10) : ''; $daDisp = ($da !== null && $da !== '' && (string) $da !== '0000-00-00') ? substr((string) $da, 0, 10) : '';
// 엑셀에도 마스킹 적용(열람 권한 없으면)
$canView = can_view_shop_pii((int) ($row->ds_lg_idx ?? 0));
$repName = (string) ($row->ds_rep_name ?? '');
$telVal = (string) ($row->ds_tel ?? '');
$vaAccount = $this->designatedShopScalar($row, 'ds_va_account') !== '' ? $this->designatedShopScalar($row, 'ds_va_account') : (string) ($row->ds_va_number ?? '');
if (! $canView) {
$repName = mask_shop_field('ds_rep_name', $repName);
$telVal = mask_shop_field('ds_tel', $telVal);
$vaAccount = mask_shop_field('ds_va_account', $vaAccount);
}
$rows[] = [ $rows[] = [
$row->ds_idx, $row->ds_idx,
$row->ds_shop_no, $row->ds_shop_no,
$row->ds_name, $row->ds_name,
$row->ds_rep_name, $repName,
$dongDisp, $dongDisp,
$daDisp, $daDisp,
$row->ds_biz_no, $row->ds_biz_no,
$this->designatedShopScalar($row, 'ds_biz_type'), $this->designatedShopScalar($row, 'ds_biz_type'),
$this->designatedShopScalar($row, 'ds_biz_kind'), $this->designatedShopScalar($row, 'ds_biz_kind'),
$this->designatedShopScalar($row, 'ds_va_bank'), $this->designatedShopScalar($row, 'ds_va_bank'),
$this->designatedShopScalar($row, 'ds_va_account') !== '' ? $this->designatedShopScalar($row, 'ds_va_account') : ($row->ds_va_number ?? ''), $vaAccount,
$row->ds_tel ?? '', $telVal,
$row->ds_addr ?? '', $row->ds_addr ?? '',
$this->designatedShopScalar($row, 'ds_zone_code'), $this->designatedShopScalar($row, 'ds_zone_code'),
$this->designatedShopScalar($row, 'ds_branch_no'), $this->designatedShopScalar($row, 'ds_branch_no'),
@@ -1675,6 +1738,51 @@ class DesignatedShop extends BaseController
]); ]);
} }
/**
* [AJAX] 개인정보 원문 보기 — step-up 재인증(TOTP) + 열람사유 → 세션 열람 승인 + 감사기록.
* 조건 미충족(IP밖·Super Admin 등) 사용자가 마스킹된 PII를 열람할 때 사용.
*/
public function revealPii()
{
helper(['admin', 'pii_encryption']);
$lgIdx = admin_effective_lg_idx();
if (! $lgIdx) {
return $this->response->setJSON(['ok' => false, 'error' => '지자체가 선택되지 않았습니다.']);
}
$mbIdx = (int) session()->get('mb_idx');
$code = trim((string) $this->request->getPost('totp_code'));
$reason = trim((string) $this->request->getPost('reason'));
if ($code === '' || ! preg_match('/^\d{6}$/', $code)) {
return $this->response->setJSON(['ok' => false, 'error' => '6자리 인증코드를 입력해 주세요.']);
}
if ($reason === '') {
return $this->response->setJSON(['ok' => false, 'error' => '열람 사유를 입력해 주세요.']);
}
$member = model(\App\Models\MemberModel::class)->find($mbIdx);
$secret = $member ? pii_decrypt((string) ($member->mb_totp_secret ?? '')) : '';
if ($secret === '' || (int) ($member->mb_totp_enabled ?? 0) !== 1) {
return $this->response->setJSON(['ok' => false, 'error' => '2단계 인증(TOTP)이 등록돼 있지 않아 원문 열람을 승인할 수 없습니다.']);
}
$totp = new \App\Libraries\TotpService();
if (! $totp->verify($secret, $code)) {
return $this->response->setJSON(['ok' => false, 'error' => '인증코드가 올바르지 않습니다.']);
}
// 이 세션에서 해당 지자체 PII 원문 열람 승인(10분)
session()->set('pii_stepup_grant', ['lg_idx' => (int) $lgIdx, 'until' => time() + 600]);
// 감사기록 — 누가/언제/사유
helper('audit');
audit_log('pii_view', 'designated_shop', 0, null, [
'lg_idx' => (int) $lgIdx,
'reason' => mb_substr($reason, 0, 200),
'ip' => (string) $this->request->getIPAddress(),
]);
return $this->response->setJSON(['ok' => true]);
}
/** /**
* designated_shop 테이블에 ds_dong_code 컬럼이 존재하는지(1회 조회 후 캐시). * designated_shop 테이블에 ds_dong_code 컬럼이 존재하는지(1회 조회 후 캐시).
*/ */

View File

@@ -142,6 +142,30 @@ class Feedback extends BaseController
return redirect()->to(site_url('admin/feedback/' . $id))->with('success', '처리 상태를 저장했습니다.'); return redirect()->to(site_url('admin/feedback/' . $id))->with('success', '처리 상태를 저장했습니다.');
} }
/** 문의 글(본문) 수정 */
public function updateContent(int $id): ResponseInterface
{
$model = model(FeedbackModel::class);
$row = $model->find($id);
$scope = $this->scopeLgIdx();
if ($row === null || ($scope !== null && (int) $row->fb_lg_idx !== $scope)) {
return redirect()->to(site_url('admin/feedback'))->with('error', '의견을 찾을 수 없습니다.');
}
$content = trim((string) $this->request->getPost('fb_content'));
if ($content === '') {
return redirect()->to(site_url('admin/feedback/' . $id))->with('error', '내용을 입력해 주세요.');
}
$model->update($id, ['fb_content' => mb_substr($content, 0, 2000)]);
$returnTo = (string) $this->request->getPost('return_to');
if ($returnTo === 'all') {
return redirect()->to(site_url('admin/feedback/all') . '#fb-' . $id)->with('success', '문의 내용을 수정했습니다.');
}
return redirect()->to(site_url('admin/feedback/' . $id))->with('success', '문의 내용을 수정했습니다.');
}
/** 스크린샷 이미지 서빙 (관리자만, writable 보호 유지) */ /** 스크린샷 이미지 서빙 (관리자만, writable 보호 유지) */
public function image(int $id): ResponseInterface public function image(int $id): ResponseInterface
{ {

View File

@@ -210,7 +210,41 @@ class Menu extends BaseController
'mm_level' => $this->normalizeMmLevel((int) $row->mt_idx), 'mm_level' => $this->normalizeMmLevel((int) $row->mt_idx),
'mm_is_view' => $this->request->getPost('mm_is_view') ? 'Y' : 'N', 'mm_is_view' => $this->request->getPost('mm_is_view') ? 'Y' : 'N',
]; ];
// 위치(상위↔하위) 변경 지원: mm_pidx 가 바뀌면 상위/하위로 이동
$oldPidx = (int) $row->mm_pidx;
$newPidx = (int) $this->request->getPost('mm_pidx');
$newDep = $newPidx > 0 ? 1 : 0;
$positionChanged = $newPidx !== $oldPidx;
if ($positionChanged) {
if ($newPidx === $id) {
return $this->menusRedirect((int) $row->mt_idx)->with('error', '자기 자신을 상위 메뉴로 지정할 수 없습니다.');
}
// 하위 메뉴를 보유한 상위 메뉴는 하위로 변경 불가(2단 구조 유지)
$childCnt = $this->menuModel->where('mt_idx', (int) $row->mt_idx)->where('lg_idx', $lgIdx)->where('mm_pidx', $id)->countAllResults();
if ($newPidx > 0 && $childCnt > 0) {
return $this->menusRedirect((int) $row->mt_idx)->with('error', '하위 메뉴가 있는 상위 메뉴는 하위로 변경할 수 없습니다. 먼저 하위 메뉴를 옮겨 주세요.');
}
if ($newPidx > 0) {
$parent = $this->menuModel->find($newPidx);
if (! $parent || (int) $parent->lg_idx !== $lgIdx || (int) $parent->mt_idx !== (int) $row->mt_idx || (int) $parent->mm_dep !== 0) {
return $this->menusRedirect((int) $row->mt_idx)->with('error', '올바른 상위 메뉴를 선택해 주세요.');
}
}
$data['mm_pidx'] = $newPidx;
$data['mm_dep'] = $newDep;
$data['mm_num'] = $this->menuModel->getNextNum((int) $row->mt_idx, $lgIdx, $newPidx, $newDep);
}
$this->menuModel->update($id, $data); $this->menuModel->update($id, $data);
if ($positionChanged) {
if ($oldPidx > 0) {
$this->menuModel->updateCnode($oldPidx, -1);
}
if ($newPidx > 0) {
$this->menuModel->updateCnode($newPidx, 1);
}
}
$this->menuModel->pruneInventoryManagementMenus((int) $row->mt_idx, $lgIdx); $this->menuModel->pruneInventoryManagementMenus((int) $row->mt_idx, $lgIdx);
$this->menuModel->syncTypeToAllLgs((int) $row->mt_idx, $lgIdx); $this->menuModel->syncTypeToAllLgs((int) $row->mt_idx, $lgIdx);
return $this->menusRedirect((int) $row->mt_idx)->with('success', '메뉴가 수정되었습니다.'); return $this->menusRedirect((int) $row->mt_idx)->with('success', '메뉴가 수정되었습니다.');

View File

@@ -12,9 +12,57 @@ use App\Models\PackagingUnitModel;
use App\Models\DesignatedShopModel; use App\Models\DesignatedShopModel;
use App\Models\LocalGovernmentModel; use App\Models\LocalGovernmentModel;
use App\Models\SalesAgencyModel; use App\Models\SalesAgencyModel;
use App\Models\ReportPresetModel;
class SalesReport extends BaseController class SalesReport extends BaseController
{ {
/** 맞춤 집계표에서 선택 가능한 컬럼 카탈로그 (표시 순서 = 정의 순서) */
private const CUSTOM_REPORT_COLUMNS = [
'date' => ['label' => '일자', 'type' => 'str'],
'shop_no' => ['label' => '지정번호', 'type' => 'str'],
'shop_name' => ['label' => '판매소명', 'type' => 'str'],
'rep_name' => ['label' => '대표자', 'type' => 'str'],
'address' => ['label' => '소재지', 'type' => 'str'],
'zone' => ['label' => '구역', 'type' => 'str'],
'category' => ['label' => '품목구분', 'type' => 'str'],
'product' => ['label' => '품목', 'type' => 'str'],
'size' => ['label' => '용량', 'type' => 'str'],
'qty' => ['label' => '판매량', 'type' => 'num'],
'amount' => ['label' => '판매금액', 'type' => 'num'],
'fee' => ['label' => '수수료', 'type' => 'num'],
'total' => ['label' => '총액', 'type' => 'num'],
];
private const CUSTOM_REPORT_DEFAULT_COLUMNS = ['date', 'shop_no', 'shop_name', 'product', 'qty', 'amount'];
/** 품목명 → 3분류(봉투/음식물/폐기물) */
private function customReportCategory3(string $name): string
{
if (mb_strpos($name, '음식물') !== false) {
return 'food';
}
if (mb_strpos($name, '폐기물') !== false || mb_strpos($name, '대형') !== false) {
return 'waste';
}
return 'envelope';
}
private function customReportCategoryLabel(string $cat): string
{
return ['all' => '전체', 'envelope' => '봉투', 'food' => '음식물', 'waste' => '폐기물'][$cat] ?? '전체';
}
/** 품목명에서 용량 토큰 추출(예: 10L / 1000원) */
private function customReportSize(string $name): string
{
if (preg_match('/(\d+)\s*[lL]/u', $name, $m) === 1) {
return $m[1] . 'L';
}
if (preg_match('/([\d,]+)\s*원/u', $name, $m) === 1) {
return str_replace(',', '', $m[1]) . '원';
}
return '';
}
/** /**
* P5-01 / PWB-090101-001: 지정 판매소 판매 대장 (일자별·기간별, 엑셀·인쇄) * P5-01 / PWB-090101-001: 지정 판매소 판매 대장 (일자별·기간별, 엑셀·인쇄)
*/ */
@@ -70,6 +118,16 @@ class SalesReport extends BaseController
$hasBsFee $hasBsFee
); );
// 대표자명 PII: 암호화 대비 복호화 + 권한 없으면 마스킹 (표시 계층)
helper('pii_encryption');
$canViewPii = can_view_shop_pii((int) $lgIdx);
foreach ($detailRows as $row) {
if (isset($row->ds_rep_name) && $row->ds_rep_name !== '') {
$rep = pii_decrypt((string) $row->ds_rep_name);
$row->ds_rep_name = $canViewPii ? $rep : mask_shop_field('ds_rep_name', $rep);
}
}
$filtered = []; $filtered = [];
foreach ($detailRows as $row) { foreach ($detailRows as $row) {
if ($this->ledgerRowMatchesCategories($row, $cats)) { if ($this->ledgerRowMatchesCategories($row, $cats)) {
@@ -3378,4 +3436,256 @@ class SalesReport extends BaseController
'salesLabel' => $scopeLabel($salesScope), 'salesLabel' => $scopeLabel($salesScope),
]); ]);
} }
/**
* 맞춤 집계표 — 기간·품목구분·컬럼을 골라 제목을 붙여 조회/인쇄, 계정별 프리셋 저장.
*/
public function customReport()
{
helper(['admin', 'export']);
$lgIdx = admin_effective_lg_idx();
if (! $lgIdx) {
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
}
$mbIdx = (int) session()->get('mb_idx');
// report_preset 테이블이 있을 때만 프리셋 기능 활성(없으면 조회·인쇄는 그대로 동작)
$presetsEnabled = \Config\Database::connect()->tableExists('report_preset');
$presetModel = model(ReportPresetModel::class);
$presets = [];
if ($presetsEnabled) {
$presets = $presetModel->where('rp_lg_idx', $lgIdx)->where('rp_mb_idx', $mbIdx)
->orderBy('rp_name', 'ASC')->findAll();
}
// 활성 설정 결정: 폼 적용(applied) > 프리셋 로드(preset) > 기본값
$presetIdx = (int) ($this->request->getGet('preset') ?? 0);
$loaded = null;
if ($presetsEnabled && $presetIdx > 0) {
$loaded = $presetModel->find($presetIdx);
if ($loaded === null || (int) $loaded->rp_lg_idx !== (int) $lgIdx || (int) $loaded->rp_mb_idx !== $mbIdx) {
$loaded = null;
}
}
$catalogKeys = array_keys(self::CUSTOM_REPORT_COLUMNS);
if ((string) ($this->request->getGet('applied') ?? '') === '1') {
$cols = (array) ($this->request->getGet('cols') ?? []);
$title = (string) ($this->request->getGet('title') ?? '');
$category = (string) ($this->request->getGet('category') ?? 'all');
$mode = (string) ($this->request->getGet('mode') ?? 'detail');
} elseif ($loaded !== null) {
$cols = json_decode((string) ($loaded->rp_columns ?? '[]'), true) ?: [];
$title = (string) $loaded->rp_title;
$category = (string) $loaded->rp_category;
$mode = (string) $loaded->rp_mode;
} else {
$cols = self::CUSTOM_REPORT_DEFAULT_COLUMNS;
$title = '';
$category = 'all';
$mode = 'detail';
}
// 정규화
$selected = array_values(array_filter($catalogKeys, static fn ($k) => in_array($k, $cols, true)));
if ($selected === []) {
$selected = self::CUSTOM_REPORT_DEFAULT_COLUMNS;
}
if (! in_array($category, ['all', 'envelope', 'food', 'waste'], true)) {
$category = 'all';
}
$mode = $mode === 'summary' ? 'summary' : 'detail';
$isYmd = static fn (string $d): bool => preg_match('/^\d{4}-\d{2}-\d{2}$/', $d) === 1;
$startDate = (string) ($this->request->getGet('start_date') ?? date('Y-m-01'));
$endDate = (string) ($this->request->getGet('end_date') ?? date('Y-m-d'));
if (! $isYmd($startDate)) {
$startDate = date('Y-m-01');
}
if (! $isYmd($endDate)) {
$endDate = date('Y-m-d');
}
if ($startDate > $endDate) {
[$startDate, $endDate] = [$endDate, $startDate];
}
$db = \Config\Database::connect();
$hasBsFee = $db->fieldExists('bs_fee', 'bag_sale');
$detail = $this->fetchSalesLedgerDetailRows($db, (int) $lgIdx, $startDate, $endDate, 0, 0, $hasBsFee);
// 카테고리 필터 + 행 매핑
$rows = [];
foreach ($detail as $r) {
$name = (string) ($r->bs_bag_name ?? '');
$cat3 = $this->customReportCategory3($name);
if ($category !== 'all' && $cat3 !== $category) {
continue;
}
$qty = (int) ($r->line_qty ?? 0);
$amount = (float) ($r->line_amount ?? 0);
$fee = (float) ($r->line_fee ?? 0);
$rows[] = [
'date' => substr((string) ($r->bs_sale_date ?? ''), 0, 10),
'shop_no' => (string) ($r->ds_shop_no ?? ''),
'shop_name' => (string) ($r->ds_name ?? ''),
'rep_name' => (string) ($r->ds_rep_name ?? ''),
'address' => (string) ($r->ds_addr ?? ''),
'zone' => (string) ($r->ds_zone_code ?? ''),
'category' => $this->customReportCategoryLabel($cat3),
'product' => $name,
'size' => $this->customReportSize($name),
'qty' => $qty,
'amount' => (int) round($amount),
'fee' => (int) round($fee),
'total' => (int) round($amount + $fee),
];
}
// 집계 모드: 품목(구분+품명+용량) 기준 합산
if ($mode === 'summary') {
$agg = [];
foreach ($rows as $row) {
$key = $row['category'] . '|' . $row['product'] . '|' . $row['size'];
if (! isset($agg[$key])) {
$agg[$key] = ['category' => $row['category'], 'product' => $row['product'], 'size' => $row['size'],
'date' => '', 'shop_no' => '', 'shop_name' => '', 'rep_name' => '', 'address' => '', 'zone' => '',
'qty' => 0, 'amount' => 0, 'fee' => 0, 'total' => 0];
}
$agg[$key]['qty'] += $row['qty'];
$agg[$key]['amount'] += $row['amount'];
$agg[$key]['fee'] += $row['fee'];
$agg[$key]['total'] += $row['total'];
}
$rows = array_values($agg);
}
// 숫자 컬럼 합계
$totals = [];
foreach ($selected as $key) {
if ((self::CUSTOM_REPORT_COLUMNS[$key]['type'] ?? '') === 'num') {
$totals[$key] = array_sum(array_map(static fn ($r) => (int) $r[$key], $rows));
}
}
// 엑셀 다운로드 — 현재 컬럼·필터·제목 그대로
if ((string) ($this->request->getGet('export') ?? '') === '1') {
$headers = array_map(static fn ($k) => self::CUSTOM_REPORT_COLUMNS[$k]['label'], $selected);
$exportRows = [];
foreach ($rows as $row) {
$line = [];
foreach ($selected as $key) {
$isNum = (self::CUSTOM_REPORT_COLUMNS[$key]['type'] ?? '') === 'num';
$line[] = $isNum ? number_format((int) ($row[$key] ?? 0)) : (string) ($row[$key] ?? '');
}
$exportRows[] = $line;
}
// 합계 행
$totalLine = [];
foreach ($selected as $i => $key) {
$isNum = (self::CUSTOM_REPORT_COLUMNS[$key]['type'] ?? '') === 'num';
if ($i === 0) {
$totalLine[] = '합계';
} else {
$totalLine[] = $isNum ? number_format((int) ($totals[$key] ?? 0)) : '';
}
}
$exportRows[] = $totalLine;
$fileTitle = $title !== '' ? $title : '맞춤집계표';
$topRows = [
[$fileTitle],
['조회기간: ' . $startDate . ' ~ ' . $endDate . ' / 품목구분: ' . $this->customReportCategoryLabel($category)],
];
export_excel_2003_xml($fileTitle . '_' . $startDate . '_' . $endDate, mb_substr($fileTitle, 0, 31), $headers, $exportRows, $topRows);
}
return $this->renderWorkPage('맞춤 집계표', 'admin/sales_report/custom_report', [
'columnCatalog' => self::CUSTOM_REPORT_COLUMNS,
'selected' => $selected,
'title' => $title,
'category' => $category,
'mode' => $mode,
'startDate' => $startDate,
'endDate' => $endDate,
'rows' => $rows,
'totals' => $totals,
'presets' => $presets,
'activePresetIdx' => $loaded !== null ? (int) $loaded->rp_idx : 0,
'presetsEnabled' => $presetsEnabled,
]);
}
/** 맞춤 집계표 프리셋 저장(신규/수정) — 계정 소유 */
public function customReportPresetSave()
{
helper('admin');
$lgIdx = admin_effective_lg_idx();
if (! $lgIdx) {
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
}
$mbIdx = (int) session()->get('mb_idx');
if (! \Config\Database::connect()->tableExists('report_preset')) {
return redirect()->to(site_url('bag/reports/custom'))
->with('error', '프리셋 저장 테이블이 아직 생성되지 않았습니다. (report_preset_table.sql 실행 필요)');
}
$name = trim((string) $this->request->getPost('rp_name'));
if ($name === '') {
return redirect()->to(site_url('bag/reports/custom'))->with('error', '프리셋 이름을 입력해 주세요.');
}
$cols = (array) ($this->request->getPost('cols') ?? []);
$catalog = array_keys(self::CUSTOM_REPORT_COLUMNS);
$selected = array_values(array_filter($catalog, static fn ($k) => in_array($k, $cols, true)));
if ($selected === []) {
$selected = self::CUSTOM_REPORT_DEFAULT_COLUMNS;
}
$category = (string) $this->request->getPost('rp_category');
if (! in_array($category, ['all', 'envelope', 'food', 'waste'], true)) {
$category = 'all';
}
$mode = (string) $this->request->getPost('rp_mode') === 'summary' ? 'summary' : 'detail';
$data = [
'rp_lg_idx' => $lgIdx,
'rp_mb_idx' => $mbIdx,
'rp_name' => mb_substr($name, 0, 100),
'rp_title' => mb_substr((string) $this->request->getPost('rp_title'), 0, 200),
'rp_category' => $category,
'rp_mode' => $mode,
'rp_columns' => json_encode($selected, JSON_UNESCAPED_UNICODE),
];
$presetModel = model(ReportPresetModel::class);
$rpIdx = (int) ($this->request->getPost('rp_idx') ?? 0);
if ($rpIdx > 0) {
$existing = $presetModel->find($rpIdx);
if ($existing !== null && (int) $existing->rp_lg_idx === (int) $lgIdx && (int) $existing->rp_mb_idx === $mbIdx) {
$presetModel->update($rpIdx, $data);
}
} else {
$presetModel->insert($data);
$rpIdx = (int) $presetModel->getInsertID();
}
$q = http_build_query(['preset' => $rpIdx, 'start_date' => $this->request->getPost('start_date'), 'end_date' => $this->request->getPost('end_date')]);
return redirect()->to(site_url('bag/reports/custom') . '?' . $q)->with('success', '프리셋을 저장했습니다.');
}
/** 맞춤 집계표 프리셋 삭제 — 계정 소유 */
public function customReportPresetDelete()
{
helper('admin');
$lgIdx = admin_effective_lg_idx();
$mbIdx = (int) session()->get('mb_idx');
$rpIdx = (int) ($this->request->getPost('rp_idx') ?? 0);
$presetModel = model(ReportPresetModel::class);
$existing = $rpIdx > 0 ? $presetModel->find($rpIdx) : null;
if ($existing !== null && (int) $existing->rp_lg_idx === (int) $lgIdx && (int) $existing->rp_mb_idx === $mbIdx) {
$presetModel->delete($rpIdx);
return redirect()->to(site_url('bag/reports/custom'))->with('success', '프리셋을 삭제했습니다.');
}
return redirect()->to(site_url('bag/reports/custom'))->with('error', '삭제할 프리셋을 찾을 수 없습니다.');
}
} }

View File

@@ -233,7 +233,7 @@ class Auth extends BaseController
return $this->handleTotpFailure($member, $this->buildLogData($member->mb_id, (int) $member->mb_idx)); return $this->handleTotpFailure($member, $this->buildLogData($member->mb_id, (int) $member->mb_idx));
} }
return $this->completeLogin($member, $this->buildLogData($member->mb_id, (int) $member->mb_idx)); return $this->completeLogin($member, $this->buildLogData($member->mb_id, (int) $member->mb_idx), true);
} }
public function showTotpSetup() public function showTotpSetup()
@@ -329,7 +329,7 @@ class Auth extends BaseController
return redirect()->to(site_url('login'))->with('error', '회원 정보를 다시 확인할 수 없습니다.'); return redirect()->to(site_url('login'))->with('error', '회원 정보를 다시 확인할 수 없습니다.');
} }
return $this->completeLogin($fresh, $this->buildLogData($fresh->mb_id, (int) $fresh->mb_idx)); return $this->completeLogin($fresh, $this->buildLogData($fresh->mb_id, (int) $fresh->mb_idx), true);
} }
public function logout() public function logout()
@@ -573,7 +573,7 @@ class Auth extends BaseController
/** /**
* @param array<string, mixed> $logData * @param array<string, mixed> $logData
*/ */
private function completeLogin(object $member, array $logData): RedirectResponse private function completeLogin(object $member, array $logData, bool $twofaVerified = false): RedirectResponse
{ {
$this->clearPending2faSession(); $this->clearPending2faSession();
// 중복 로그인 방지(나중 로그인 우선): 새 세션 토큰 발급 → 기존 다른 세션은 다음 요청 때 무효화됨 // 중복 로그인 방지(나중 로그인 우선): 새 세션 토큰 발급 → 기존 다른 세션은 다음 요청 때 무효화됨
@@ -586,6 +586,8 @@ class Auth extends BaseController
'mb_lg_idx' => $member->mb_lg_idx ?? null, 'mb_lg_idx' => $member->mb_lg_idx ?? null,
'logged_in' => true, 'logged_in' => true,
'session_token' => $sessionToken, 'session_token' => $sessionToken,
// 지정판매소 PII 자동 원문 열람 조건에 사용(TOTP 실제 통과 시에만 true)
'auth_2fa_verified' => $twofaVerified,
]; ];
session()->set($sessionData); session()->set($sessionData);

View File

@@ -224,6 +224,120 @@ class Bag extends BaseController
]); ]);
} }
/**
* 계정별·메뉴별 글자크기(zoom %) 저장. AJAX 전용.
* menu_key(화면 경로) + scale(70~150)을 로그인 계정 기준으로 upsert.
*/
public function saveFontScale(): ResponseInterface
{
$mbIdx = (int) (session()->get('mb_idx') ?? 0);
if ($mbIdx <= 0) {
return $this->response->setJSON(['ok' => false, 'csrf' => csrf_hash()]);
}
$menuKey = trim((string) ($this->request->getPost('menu_key') ?? ''));
$scale = (int) ($this->request->getPost('scale') ?? 0);
if ($menuKey === '' || $scale < 70 || $scale > 150) {
return $this->response->setJSON(['ok' => false, 'csrf' => csrf_hash()]);
}
$menuKey = mb_substr($menuKey, 0, 191);
$db = \Config\Database::connect();
if (! $db->tableExists('member_menu_font_scale')) {
$db->query(<<<'SQL'
CREATE TABLE IF NOT EXISTS `member_menu_font_scale` (
`mmf_idx` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`mmf_mb_idx` INT UNSIGNED NOT NULL,
`mmf_menu_key` VARCHAR(191) NOT NULL,
`mmf_scale` SMALLINT UNSIGNED NOT NULL DEFAULT 100,
`mmf_updated` DATETIME NOT NULL,
PRIMARY KEY (`mmf_idx`),
UNIQUE KEY `uk_mb_menu` (`mmf_mb_idx`,`mmf_menu_key`),
KEY `idx_mb` (`mmf_mb_idx`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL);
}
$now = date('Y-m-d H:i:s');
$exists = $db->table('member_menu_font_scale')
->where('mmf_mb_idx', $mbIdx)->where('mmf_menu_key', $menuKey)->countAllResults();
if ($exists > 0) {
$db->table('member_menu_font_scale')
->where('mmf_mb_idx', $mbIdx)->where('mmf_menu_key', $menuKey)
->update(['mmf_scale' => $scale, 'mmf_updated' => $now]);
} else {
$db->table('member_menu_font_scale')->insert([
'mmf_mb_idx' => $mbIdx, 'mmf_menu_key' => $menuKey,
'mmf_scale' => $scale, 'mmf_updated' => $now,
]);
}
return $this->response->setJSON(['ok' => true, 'csrf' => csrf_hash()]);
}
/** member_workspace_tabs 테이블 보장(없으면 생성) */
private function ensureWorkspaceTabsTable(\CodeIgniter\Database\BaseConnection $db): void
{
if ($db->tableExists('member_workspace_tabs')) {
return;
}
$db->query(<<<'SQL'
CREATE TABLE IF NOT EXISTS `member_workspace_tabs` (
`mwt_mb_idx` INT UNSIGNED NOT NULL,
`mwt_state` MEDIUMTEXT NULL,
`mwt_updated` DATETIME NOT NULL,
PRIMARY KEY (`mwt_mb_idx`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL);
}
/** 계정별 워크스페이스 탭 상태 조회(자동 복원용). 없으면 'null'. */
public function loadWorkspaceTabsState(int $mbIdx): string
{
if ($mbIdx <= 0) {
return 'null';
}
$db = \Config\Database::connect();
if (! $db->tableExists('member_workspace_tabs')) {
return 'null';
}
$row = $db->table('member_workspace_tabs')->select('mwt_state')->where('mwt_mb_idx', $mbIdx)->get()->getRowArray();
$s = (string) ($row['mwt_state'] ?? '');
return $s !== '' ? $s : 'null';
}
/** 계정별 워크스페이스 탭 상태 저장 (자동 복원) */
public function saveWorkspaceTabs(): ResponseInterface
{
$mbIdx = (int) (session()->get('mb_idx') ?? 0);
if ($mbIdx <= 0) {
return $this->response->setJSON(['ok' => false, 'csrf' => csrf_hash()]);
}
$state = (string) ($this->request->getPost('state') ?? '');
// 크기 제한 + JSON 유효성
if (strlen($state) > 30000) {
return $this->response->setJSON(['ok' => false, 'csrf' => csrf_hash()]);
}
if ($state !== '') {
json_decode($state);
if (json_last_error() !== JSON_ERROR_NONE) {
return $this->response->setJSON(['ok' => false, 'csrf' => csrf_hash()]);
}
}
$db = \Config\Database::connect();
$this->ensureWorkspaceTabsTable($db);
$now = date('Y-m-d H:i:s');
$exists = $db->table('member_workspace_tabs')->where('mwt_mb_idx', $mbIdx)->countAllResults();
if ($exists > 0) {
$db->table('member_workspace_tabs')->where('mwt_mb_idx', $mbIdx)
->update(['mwt_state' => $state, 'mwt_updated' => $now]);
} else {
$db->table('member_workspace_tabs')->insert(['mwt_mb_idx' => $mbIdx, 'mwt_state' => $state, 'mwt_updated' => $now]);
}
return $this->response->setJSON(['ok' => true, 'csrf' => csrf_hash()]);
}
// ────────────────────────────────────────────── // ──────────────────────────────────────────────
// 기본정보관리 (단가·포장 단위 진입 허브) // 기본정보관리 (단가·포장 단위 진입 허브)
// ────────────────────────────────────────────── // ──────────────────────────────────────────────
@@ -1262,6 +1376,142 @@ class Bag extends BaseController
); );
} }
/**
* 실사 재고 바코드 리스트 — 현재 창고에 있어야 하는(재고로 남아있는) 봉투 번호(바코드) 목록.
* bag_receiving_pack_code 중 brpc_state='in_stock' 인 팩/박스 코드를 품목·박스·팩·시트번호(시작~끝)·매수로 뽑아
* 실사 전 대조용으로 조회/엑셀/인쇄한다. (기존 실사 스냅샷과 동일한 in_stock 기준)
*/
public function stockBarcodeList(): string|ResponseInterface|RedirectResponse
{
$lgIdx = $this->lgIdx();
if (! $lgIdx) {
return redirect()->to(site_url('bag/inventory'))->with('error', '지자체를 선택해 주세요.');
}
$db = \Config\Database::connect();
$bagCode = trim((string) ($this->request->getGet('bag_code') ?? ''));
$keyword = trim((string) ($this->request->getGet('keyword') ?? ''));
$pageSize = 200; // 레이지 로딩 청크 크기
// 공통 필터(재고 상태 + 품목/키워드)를 빌더에 적용
$applyFilter = static function ($builder) use ($lgIdx, $bagCode, $keyword) {
$builder->where('brpc_lg_idx', $lgIdx)->where('brpc_state', 'in_stock');
if ($bagCode !== '') {
$builder->where('brpc_bag_code', $bagCode);
}
if ($keyword !== '') {
$builder->groupStart()
->like('brpc_box_code', $keyword)
->orLike('brpc_pack_code', $keyword)
->orLike('brpc_sheet_start_code', $keyword)
->orLike('brpc_sheet_end_code', $keyword)
->orLike('brpc_lot_no', $keyword)
->groupEnd();
}
return $builder;
};
// 필터 적용된 행을 offset/limit(0=전체)로 조회
$fetchRows = function (int $offset, int $limit) use ($db, $applyFilter): array {
$builder = $applyFilter($db->table('bag_receiving_pack_code'))
->select('brpc_bag_code, brpc_bag_name, brpc_lot_no, brpc_box_code, brpc_pack_code, brpc_sheet_start_code, brpc_sheet_end_code, brpc_sheet_qty')
->orderBy('brpc_bag_code', 'ASC')
->orderBy('brpc_box_code', 'ASC')
->orderBy('brpc_pack_code', 'ASC');
if ($limit > 0) {
$builder->limit($limit, max(0, $offset));
}
return $builder->get()->getResultArray();
};
// 합계 — 전체 기준 SQL 집계(전체 행을 메모리에 올리지 않음)
$agg = $applyFilter($db->table('bag_receiving_pack_code'))
->select('COUNT(*) AS total_packs, COALESCE(SUM(brpc_sheet_qty),0) AS total_sheets, COUNT(DISTINCT brpc_box_code) AS total_boxes', false)
->get()
->getRowArray();
$totalPacks = (int) ($agg['total_packs'] ?? 0);
$totalSheets = (int) ($agg['total_sheets'] ?? 0);
$totalBoxes = (int) ($agg['total_boxes'] ?? 0);
// AJAX 레이지 로딩 — 다음 청크만 JSON 으로 반환
if ((string) ($this->request->getGet('ajax') ?? '') === '1') {
$offset = max(0, (int) ($this->request->getGet('offset') ?? 0));
$limit = (int) ($this->request->getGet('limit') ?? $pageSize);
$limit = $limit <= 0 ? $pageSize : min($limit, 5000);
$chunk = $fetchRows($offset, $limit);
$out = [];
foreach ($chunk as $r) {
$out[] = [
'name' => ($r['brpc_bag_name'] ?? '') !== '' ? (string) $r['brpc_bag_name'] : (string) ($r['brpc_bag_code'] ?? ''),
'lot' => (string) ($r['brpc_lot_no'] ?? ''),
'box' => (string) ($r['brpc_box_code'] ?? ''),
'pack' => (string) ($r['brpc_pack_code'] ?? ''),
'start' => (string) ($r['brpc_sheet_start_code'] ?? ''),
'end' => (string) ($r['brpc_sheet_end_code'] ?? ''),
'qty' => (int) ($r['brpc_sheet_qty'] ?? 0),
];
}
$next = $offset + count($chunk);
return $this->response->setJSON([
'rows' => $out,
'offset' => $next,
'hasMore' => $next < $totalPacks,
'total' => $totalPacks,
]);
}
// 엑셀 다운로드 — 현재 필터 전체
if ((string) ($this->request->getGet('export') ?? '') === '1') {
helper('export');
$exportRows = [];
foreach ($fetchRows(0, 0) as $r) {
$exportRows[] = [
($r['brpc_bag_name'] ?? '') !== '' ? (string) $r['brpc_bag_name'] : (string) ($r['brpc_bag_code'] ?? ''),
(string) ($r['brpc_lot_no'] ?? ''),
(string) ($r['brpc_box_code'] ?? ''),
(string) ($r['brpc_pack_code'] ?? ''),
(string) ($r['brpc_sheet_start_code'] ?? ''),
(string) ($r['brpc_sheet_end_code'] ?? ''),
number_format((int) ($r['brpc_sheet_qty'] ?? 0)),
];
}
$exportRows[] = ['합계', '', '박스 ' . number_format($totalBoxes), '팩 ' . number_format($totalPacks), '', '', number_format($totalSheets)];
export_xlsx(
'실사재고바코드리스트_' . date('Ymd') . '.xlsx',
'창고재고바코드',
['품목', 'LOT', '박스코드', '팩코드', '시작번호', '끝번호', '매수'],
$exportRows
);
}
// 품목 선택 옵션 — 현재 재고로 남아있는 품목만
$itemOptions = $db->table('bag_receiving_pack_code')
->select('brpc_bag_code, MAX(brpc_bag_name) AS bag_name, COUNT(*) AS pack_cnt, SUM(brpc_sheet_qty) AS sheet_qty', false)
->where('brpc_lg_idx', $lgIdx)
->where('brpc_state', 'in_stock')
->where('brpc_bag_code !=', '')
->groupBy('brpc_bag_code')
->orderBy('brpc_bag_code', 'ASC')
->get()
->getResultArray();
// 초기 1페이지만 렌더 — 나머지는 스크롤 시 레이지 로딩
$rows = $fetchRows(0, $pageSize);
return $this->render('실사 재고 바코드 리스트', 'bag/inventory_stock_barcode_list', [
'bagCode' => $bagCode,
'keyword' => $keyword,
'itemOptions' => $itemOptions,
'rows' => $rows,
'totalBoxes' => $totalBoxes,
'totalPacks' => $totalPacks,
'totalSheets' => $totalSheets,
'pageSize' => $pageSize,
'loadedCount' => count($rows),
]);
}
/** /**
* @return array{ * @return array{
* rows: list<array{group:string,name:string,total_qty:int,gugun_qty:int,agency_qty:int}>, * rows: list<array{group:string,name:string,total_qty:int,gugun_qty:int,agency_qty:int}>,
@@ -4704,12 +4954,20 @@ SQL);
} }
/** /**
* 발주 입고(스캐너 대체 수동입력) * 발주 입고(스캐너) — 좌우 분할 워크스페이스로 렌더.
* - 미입고가 남은 발주의 LOT·봉투(이름)로 조회 범위를 좁힌 뒤 입고 처리
* - 인수자: 대행소(agency) 담당자, 기본값 동명이면 로그인 사용자명과 일치하는 담당자
* - 인계자: 제작업체(company) 담당자
*/ */
public function receivingScanner(): string|RedirectResponse public function receivingScanner(): string|RedirectResponse
{
return $this->renderReceivingWorkspace('scanner');
}
/**
* 발주입고(스캐너) / 일괄입고 공용 워크스페이스.
* 좌측: 발주 현황(탭: 제작업체·발주기간 / 인수자·인계자, 정렬) · 우측: 선택 발주 상세 + 스캔/수량 입고 + 입고 세부내역.
*
* @param string $mode 'scanner'(종량제 봉투) | 'batch'(음식물·폐기물)
*/
private function renderReceivingWorkspace(string $mode): string|RedirectResponse
{ {
helper('admin'); helper('admin');
$lgIdx = $this->lgIdx(); $lgIdx = $this->lgIdx();
@@ -4717,75 +4975,206 @@ SQL);
return redirect()->to(site_url('bag/purchase-inbound'))->with('error', '지자체를 선택해 주세요.'); return redirect()->to(site_url('bag/purchase-inbound'))->with('error', '지자체를 선택해 주세요.');
} }
$companyIdx = (int) old('company_idx', (int) ($this->request->getGet('company_idx') ?? 0)); $isBatch = ($mode === 'batch');
$lotNo = ''; $category = $isBatch ? 'bulk' : 'bag';
$bagCode = ''; $title = $isBatch ? '일괄입고(음식물,폐기물)' : '발주 입고(스캐너)';
$tab = (string) ($this->request->getGet('tab') ?? 'company');
if (! in_array($tab, ['company', 'agency'], true)) {
$tab = 'company';
}
$companyIdx = (int) ($this->request->getGet('company_idx') ?? 0);
$agencyIdx = (int) ($this->request->getGet('agency_idx') ?? 0);
$startMonth = (string) ($this->request->getGet('start_month') ?? '');
$endMonth = (string) ($this->request->getGet('end_month') ?? '');
$startDate = preg_match('/^\d{4}-\d{2}$/', $startMonth) ? $startMonth . '-01' : '';
$endDate = preg_match('/^\d{4}-\d{2}$/', $endMonth) ? date('Y-m-t', strtotime($endMonth . '-01 00:00:00')) : '';
$companies = model(CompanyModel::class) $companies = model(CompanyModel::class)
->where('cp_lg_idx', $lgIdx) ->where('cp_lg_idx', $lgIdx)->where('cp_type', '제작업체')->where('cp_state', 1)
->where('cp_type', '제작업체') ->orderBy('cp_name', 'ASC')->findAll();
->where('cp_state', 1) $agencies = model(SalesAgencyModel::class)
->orderBy('cp_name', 'ASC') ->where('sa_lg_idx', $lgIdx)->orderForDisplay()->findAll();
->findAll();
$defaultCompanyIdx = ! empty($companies) // 조회 기준: company 탭 = 제작업체+발주기간 / agency 탭 = 인수자(대행소)+인계자(제작업체)
? (int) ($companies[0]->cp_idx ?? 0) $rows = $this->buildReceivingCandidateRows(
: 0; $lgIdx,
$companyIdx,
if ($companyIdx > 0) { '',
$validCompany = false; true,
foreach ($companies as $company) { '',
if ((int) ($company->cp_idx ?? 0) === $companyIdx) { $tab === 'agency' ? $agencyIdx : 0,
$validCompany = true; $category,
break; $tab === 'company' ? $startDate : '',
} $tab === 'company' ? $endDate : ''
} );
if (! $validCompany) {
$companyIdx = $defaultCompanyIdx;
}
} elseif ($defaultCompanyIdx > 0) {
// 초기 진입 시 드롭다운 최상단 제작업체를 기본 선택한다.
$companyIdx = $defaultCompanyIdx;
}
$lotChoices = [];
$bagFilterOptions = $this->receivingBagFilterOptions($lgIdx, $companyIdx, '');
$pick = $this->receivingManagerPickers($lgIdx);
$recvSel = $this->receivingReceiverSelect($lgIdx);
$receiverRef = (string) old('br_receiver_ref', $recvSel['defaultReceiverRef']);
$receiverRef = $this->sanitizeReceiverRef($recvSel['receiverOptions'], $receiverRef);
if ($receiverRef === '') {
$receiverRef = $recvSel['defaultReceiverRef'];
}
$senderIdx = (int) old('br_sender_idx', $pick['defaultSenderIdx']);
$rows = $companyIdx > 0
? $this->buildReceivingCandidateRows($lgIdx, $companyIdx, '', true, '')
: [];
$rowsByKey = []; $rowsByKey = [];
foreach ($rows as $row) { foreach ($rows as $row) {
$rowsByKey[(string) $row['row_key']] = $row; $rowsByKey[(string) $row['row_key']] = $row;
} }
return $this->render( $pick = $this->receivingManagerPickers($lgIdx);
'발주 입고(스캐너)', $recvSel = $this->receivingReceiverSelect($lgIdx);
'bag/receiving_scanner',
[ // 발주기간 드롭다운 옵션(최근 5년치 월)
'companyIdx' => $companyIdx, $monthOptions = [];
$baseYear = (int) date('Y');
for ($y = $baseYear; $y >= $baseYear - 4; $y--) {
for ($m = 12; $m >= 1; $m--) {
$v = sprintf('%04d-%02d', $y, $m);
$monthOptions[] = ['value' => $v, 'label' => $y . '년 ' . $m . '월'];
}
}
return $this->render($title, 'bag/receiving_scanner', [
'mode' => $mode,
'isBatch' => $isBatch,
'workspaceTitle' => $title,
'tab' => $tab,
'companies' => $companies, 'companies' => $companies,
'lotNo' => '', 'agencies' => $agencies,
'bagCode' => '', 'companyIdx' => $companyIdx,
'bagFilterOptions' => $bagFilterOptions, 'agencyIdx' => $agencyIdx,
'lotChoices' => $lotChoices, 'startMonth' => $startMonth,
'endMonth' => $endMonth,
'monthOptions' => $monthOptions,
'receiverOptions' => $recvSel['receiverOptions'], 'receiverOptions' => $recvSel['receiverOptions'],
'receiverRef' => $receiverRef, 'receiverRef' => $recvSel['defaultReceiverRef'],
'senders' => $pick['senders'], 'senders' => $pick['senders'],
'senderIdx' => $senderIdx, 'senderIdx' => $pick['defaultSenderIdx'],
'rows' => $rows, 'rows' => $rows,
'rowsByKey' => $rowsByKey, 'rowsByKey' => $rowsByKey,
] ]);
}
/**
* 실시간 입고 처리(스캔 1회 = 1박스, 또는 수량 직접입력). AJAX 전용.
* 선택 발주행에 대해 입고 레코드 생성 → 재고 반영 → 박스코드(brpc) 생성 후 반환.
*/
public function receivingScanBox(): ResponseInterface
{
helper('admin');
$lgIdx = $this->lgIdx();
if (! $lgIdx) {
return $this->response->setJSON(['ok' => false, 'message' => '지자체를 선택해 주세요.']);
}
$rowKey = (string) ($this->request->getPost('row_key') ?? '');
$receiverRef = (string) ($this->request->getPost('br_receiver_ref') ?? '');
$senderIdx = (int) ($this->request->getPost('br_sender_idx') ?? 0);
$receiveDate = (string) ($this->request->getPost('br_receive_date') ?? date('Y-m-d'));
$barcode = trim((string) ($this->request->getPost('barcode') ?? ''));
$mode = (string) ($this->request->getPost('mode') ?? 'scanner');
$qtySheetIn = (int) ($this->request->getPost('qty_sheet') ?? 0);
$category = ($mode === 'batch') ? 'bulk' : 'bag';
$refresh = ['csrf' => csrf_hash()];
if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $receiveDate)) {
return $this->response->setJSON(array_merge($refresh, ['ok' => false, 'message' => '입고일 형식을 확인해 주세요.']));
}
$recvSel = $this->receivingReceiverSelect($lgIdx);
$receiverRef = $this->sanitizeReceiverRef($recvSel['receiverOptions'], $receiverRef);
if ($receiverRef === '') {
$receiverRef = $recvSel['defaultReceiverRef'];
}
$receiverIdx = $this->parseReceiverRefToStoredIdx($lgIdx, $receiverRef);
if ($receiverIdx <= 0) {
return $this->response->setJSON(array_merge($refresh, ['ok' => false, 'message' => '인수자를 선택해 주세요.']));
}
$rows = $this->buildReceivingCandidateRows($lgIdx, 0, '', true, '', 0, $category, '', '');
$map = [];
foreach ($rows as $r) {
$map[(string) $r['row_key']] = $r;
}
if (! isset($map[$rowKey])) {
return $this->response->setJSON(array_merge($refresh, ['ok' => false, 'message' => '선택한 발주를 찾을 수 없거나 이미 전량 입고되었습니다.']));
}
$base = $map[$rowKey];
$pending = (int) $base['pending_qty_sheet'];
if ($pending <= 0) {
return $this->response->setJSON(array_merge($refresh, ['ok' => false, 'message' => '미입고 잔량이 없습니다.']));
}
$totalPerBox = max(1, (int) $base['total_per_box']);
$qty = $qtySheetIn > 0 ? $qtySheetIn : $totalPerBox; // 스캔 1회 = 1박스
if ($qty > $pending) {
$qty = $pending;
}
$qtyBox = intdiv($qty, $totalPerBox);
$senderName = $this->resolveCompanySenderName($lgIdx, $senderIdx);
$sender = $senderName !== '' ? $senderName : (string) ($base['company_rep_name'] ?? '');
$recvModel = model(BagReceivingModel::class);
$invModel = model(BagInventoryModel::class);
$db = \Config\Database::connect();
$db->transStart();
$recvModel->insert([
'br_bo_idx' => (int) $base['bo_idx'],
'br_lg_idx' => $lgIdx,
'br_bag_code' => (string) $base['bag_code'],
'br_bag_name' => (string) $base['bag_name'],
'br_qty_box' => $qtyBox,
'br_qty_sheet' => $qty,
'br_receive_date' => $receiveDate,
'br_receiver_idx' => $receiverIdx,
'br_sender_name' => $sender,
'br_type' => 'scanner',
'br_regdate' => date('Y-m-d H:i:s'),
]);
$brIdx = (int) $recvModel->getInsertID();
$invModel->adjustQty($lgIdx, (string) $base['bag_code'], (string) $base['bag_name'], $qty);
$this->createReceivingPackCodes(
$lgIdx,
$brIdx,
(int) $base['bo_idx'],
(string) $base['bag_code'],
(string) $base['bag_name'],
$qty,
max(1, (int) $base['pack_per_sheet']),
$totalPerBox,
(string) ($base['lot_no'] ?? '')
); );
$db->transComplete();
if (! $db->transStatus()) {
return $this->response->setJSON(array_merge($refresh, ['ok' => false, 'message' => '입고 처리 중 오류가 발생했습니다.']));
}
$boxRows = $db->table('bag_receiving_pack_code')
->select('brpc_box_code, COUNT(*) AS pack_cnt, SUM(brpc_sheet_qty) AS sheet_sum')
->where('brpc_br_idx', $brIdx)
->groupBy('brpc_box_code')
->orderBy('brpc_box_code', 'ASC')
->get()->getResultArray();
$boxes = array_map(static fn ($b): array => [
'box_code' => (string) ($b['brpc_box_code'] ?? ''),
'packs' => (int) ($b['pack_cnt'] ?? 0),
'sheet' => (int) ($b['sheet_sum'] ?? 0),
], $boxRows);
// 갱신된 미입고/입고량 재계산
$after = $this->buildReceivingCandidateRows($lgIdx, 0, '', false, '', 0, $category, '', '');
$updated = null;
foreach ($after as $r) {
if ((string) $r['row_key'] === $rowKey) {
$updated = $r;
break;
}
}
return $this->response->setJSON(array_merge($refresh, [
'ok' => true,
'row_key' => $rowKey,
'bag_name' => (string) $base['bag_name'],
'qty_sheet' => $qty,
'qty_box' => $qtyBox,
'received_qty_sheet' => (int) ($updated['received_qty_sheet'] ?? ((int) $base['received_qty_sheet'] + $qty)),
'pending_qty_sheet' => (int) ($updated['pending_qty_sheet'] ?? max(0, $pending - $qty)),
'boxes' => $boxes,
'scanned' => $barcode,
]));
} }
public function receivingScannerStore(): RedirectResponse public function receivingScannerStore(): RedirectResponse
@@ -4906,51 +5295,12 @@ SQL);
/** /**
* 일괄 입고: LOT-봉투 행 기준 미입고량 전체 입고. * 일괄 입고: LOT-봉투 행 기준 미입고량 전체 입고.
*/ */
/**
* 일괄입고(음식물,폐기물) — 발주입고(스캐너)와 동일 워크스페이스, 음식물·폐기물만 표시.
*/
public function receivingBatch(): string|RedirectResponse public function receivingBatch(): string|RedirectResponse
{ {
helper('admin'); return $this->renderReceivingWorkspace('batch');
$lgIdx = $this->lgIdx();
if (! $lgIdx) {
return redirect()->to(site_url('bag/purchase-inbound'))->with('error', '지자체를 선택해 주세요.');
}
$companyIdx = (int) ($this->request->getGet('company_idx') ?? 0);
$bagCode = trim((string) ($this->request->getGet('bag_code') ?? ''));
$companies = model(CompanyModel::class)
->where('cp_lg_idx', $lgIdx)
->where('cp_type', '제작업체')
->where('cp_state', 1)
->orderBy('cp_name', 'ASC')
->findAll();
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$bagCodeOptions = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
$pick = $this->receivingManagerPickers($lgIdx);
$recvSel = $this->receivingReceiverSelect($lgIdx);
$receiverRef = (string) old('br_receiver_ref', $recvSel['defaultReceiverRef']);
$receiverRef = $this->sanitizeReceiverRef($recvSel['receiverOptions'], $receiverRef);
if ($receiverRef === '') {
$receiverRef = $recvSel['defaultReceiverRef'];
}
// 조회 화면에서는 입고완료 행도 함께 보여 미입고량 0을 확인할 수 있게 한다.
$rows = $this->buildReceivingCandidateRows($lgIdx, $companyIdx, $bagCode, false, '');
return $this->render(
'일괄 입고',
'bag/receiving_batch',
[
'companyIdx' => $companyIdx,
'bagCode' => $bagCode,
'companies' => $companies,
'bagCodeOptions' => $bagCodeOptions,
'receiverOptions' => $recvSel['receiverOptions'],
'receiverRef' => $receiverRef,
'senders' => $pick['senders'],
'senderIdx' => (int) old('br_sender_idx', $pick['defaultSenderIdx']),
'rows' => $rows,
]
);
} }
public function receivingBatchStore(): RedirectResponse public function receivingBatchStore(): RedirectResponse
@@ -5266,7 +5616,28 @@ SQL);
* *
* @param string $lotNo 빈 문자열이면 LOT 제한 없음. 지정 시 해당 LOT(최신 헤드) 발주만. * @param string $lotNo 빈 문자열이면 LOT 제한 없음. 지정 시 해당 LOT(최신 헤드) 발주만.
*/ */
private function buildReceivingCandidateRows(int $lgIdx, int $companyIdx = 0, string $bagCode = '', bool $onlyPending = true, string $lotNo = ''): array /**
* 봉투 이름 기준 품목 분류 (판매대장 통계와 동일 규칙).
* 반환: '음식물' | '폐기물' | '봉투'
*/
private function receivingBagCategory(string $bagName): string
{
if (mb_strpos($bagName, '음식물') !== false) {
return '음식물';
}
if (mb_strpos($bagName, '폐기물') !== false || mb_strpos($bagName, '대형') !== false) {
return '폐기물';
}
return '봉투';
}
/**
* @param string $category '' = 전체, 'bulk' = 음식물+폐기물(일괄입고용), 'bag' = 봉투(종량제)만
* @param string $startDate 'YYYY-MM-DD' (발주일 하한, '' = 무시)
* @param string $endDate 'YYYY-MM-DD' (발주일 상한, '' = 무시)
*/
private function buildReceivingCandidateRows(int $lgIdx, int $companyIdx = 0, string $bagCode = '', bool $onlyPending = true, string $lotNo = '', int $agencyIdx = 0, string $category = '', string $startDate = '', string $endDate = ''): array
{ {
$orderBuilder = model(BagOrderModel::class) $orderBuilder = model(BagOrderModel::class)
->where('bo_lg_idx', $lgIdx) ->where('bo_lg_idx', $lgIdx)
@@ -5280,6 +5651,15 @@ SQL);
if ($companyIdx > 0) { if ($companyIdx > 0) {
$orderBuilder->where('bo_company_idx', $companyIdx); $orderBuilder->where('bo_company_idx', $companyIdx);
} }
if ($agencyIdx > 0) {
$orderBuilder->where('bo_agency_idx', $agencyIdx);
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate)) {
$orderBuilder->where('bo_order_date >=', $startDate);
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate)) {
$orderBuilder->where('bo_order_date <=', $endDate);
}
$orders = $orderBuilder->findAll(); $orders = $orderBuilder->findAll();
if (empty($orders)) { if (empty($orders)) {
return []; return [];
@@ -5348,6 +5728,16 @@ SQL);
$unit = $unitMap[$itemBagCode] ?? ['pack_per_sheet' => 1, 'total_per_box' => 1]; $unit = $unitMap[$itemBagCode] ?? ['pack_per_sheet' => 1, 'total_per_box' => 1];
$companyInfo = $companyMap[(int) ($order->bo_company_idx ?? 0)] ?? ['name' => '', 'rep' => '']; $companyInfo = $companyMap[(int) ($order->bo_company_idx ?? 0)] ?? ['name' => '', 'rep' => ''];
$bagName = (string) ($item->boi_bag_name ?? '');
$bagCategory = $this->receivingBagCategory($bagName);
// 카테고리 필터 (일괄입고: 음식물+폐기물만 / bag: 봉투만)
if ($category === 'bulk' && $bagCategory === '봉투') {
continue;
}
if ($category === 'bag' && $bagCategory !== '봉투') {
continue;
}
$rows[] = [ $rows[] = [
'row_key' => $boIdx . '|' . $itemBagCode, 'row_key' => $boIdx . '|' . $itemBagCode,
@@ -5359,7 +5749,8 @@ SQL);
'company_rep_name' => (string) ($companyInfo['rep'] ?? ''), 'company_rep_name' => (string) ($companyInfo['rep'] ?? ''),
'agency_name' => (string) ($agencyMap[(int) ($order->bo_agency_idx ?? 0)] ?? ''), 'agency_name' => (string) ($agencyMap[(int) ($order->bo_agency_idx ?? 0)] ?? ''),
'bag_code' => $itemBagCode, 'bag_code' => $itemBagCode,
'bag_name' => (string) ($item->boi_bag_name ?? ''), 'bag_name' => $bagName,
'bag_category' => $bagCategory,
'order_qty_sheet' => $orderQtySheet, 'order_qty_sheet' => $orderQtySheet,
'received_qty_sheet' => $receivedQtySheet, 'received_qty_sheet' => $receivedQtySheet,
'pending_qty_sheet' => $pendingQtySheet, 'pending_qty_sheet' => $pendingQtySheet,
@@ -7289,16 +7680,25 @@ SQL;
$shopMap = []; $shopMap = [];
$dsIdxs = array_values(array_unique(array_filter(array_map(static fn ($o): int => (int) ($o->so_ds_idx ?? 0), $orders), static fn ($v): bool => $v > 0))); $dsIdxs = array_values(array_unique(array_filter(array_map(static fn ($o): int => (int) ($o->so_ds_idx ?? 0), $orders), static fn ($v): bool => $v > 0)));
if ($dsIdxs !== [] && $db->tableExists('designated_shop')) { if ($dsIdxs !== [] && $db->tableExists('designated_shop')) {
helper('pii_encryption');
$canViewPii = can_view_shop_pii((int) $lgIdx); // 이 화면은 현재 지자체 판매소만
$shopRows = $db->table('designated_shop') $shopRows = $db->table('designated_shop')
->select('ds_idx, ds_shop_no, ds_name, ds_rep_name, ds_tel, ds_addr, ds_addr_detail') ->select('ds_idx, ds_shop_no, ds_name, ds_rep_name, ds_tel, ds_addr, ds_addr_detail')
->whereIn('ds_idx', $dsIdxs) ->whereIn('ds_idx', $dsIdxs)
->get()->getResultArray(); ->get()->getResultArray();
foreach ($shopRows as $s) { foreach ($shopRows as $s) {
// 암호화 저장 대비 복호화 후, 권한 없으면 마스킹
$rep = pii_decrypt((string) ($s['ds_rep_name'] ?? ''));
$tel = pii_decrypt((string) ($s['ds_tel'] ?? ''));
if (! $canViewPii) {
$rep = mask_shop_field('ds_rep_name', $rep);
$tel = mask_shop_field('ds_tel', $tel);
}
$shopMap[(int) $s['ds_idx']] = [ $shopMap[(int) $s['ds_idx']] = [
'shop_no' => (string) ($s['ds_shop_no'] ?? ''), 'shop_no' => (string) ($s['ds_shop_no'] ?? ''),
'name' => (string) ($s['ds_name'] ?? ''), 'name' => (string) ($s['ds_name'] ?? ''),
'rep_name' => (string) ($s['ds_rep_name'] ?? ''), 'rep_name' => $rep,
'tel' => (string) ($s['ds_tel'] ?? ''), 'tel' => $tel,
'addr' => trim((string) ($s['ds_addr'] ?? '') . ' ' . (string) ($s['ds_addr_detail'] ?? '')), 'addr' => trim((string) ($s['ds_addr'] ?? '') . ' ' . (string) ($s['ds_addr_detail'] ?? '')),
]; ];
} }
@@ -7560,6 +7960,37 @@ SQL;
return redirect()->to(site_url('bag/order/phone/manage'))->with('success', '주문 수정 저장이 완료되었습니다.'); return redirect()->to(site_url('bag/order/phone/manage'))->with('success', '주문 수정 저장이 완료되었습니다.');
} }
/**
* 주문접수 관리 — 입금확인(so_paid) 수동 토글(JSON 응답).
* 입금확인이 완료되어야 포장(스캔)이 가능하다.
*/
public function phoneOrderMarkPaid()
{
$lgIdx = $this->lgIdx();
if (! $lgIdx) {
return $this->response->setJSON(['ok' => false, 'message' => '지자체를 선택해 주세요.', 'csrf' => csrf_hash()]);
}
$soIdx = (int) ($this->request->getPost('so_idx') ?? 0);
$paid = ((int) ($this->request->getPost('paid') ?? 0)) === 1 ? 1 : 0;
if ($soIdx <= 0) {
return $this->response->setJSON(['ok' => false, 'message' => '주문 값이 올바르지 않습니다.', 'csrf' => csrf_hash()]);
}
$orderModel = model(ShopOrderModel::class);
$order = $orderModel->where('so_idx', $soIdx)->where('so_lg_idx', $lgIdx)->first();
if (! $order) {
return $this->response->setJSON(['ok' => false, 'message' => '선택한 주문을 찾을 수 없습니다.', 'csrf' => csrf_hash()]);
}
if ((string) ($order->so_status ?? '') === 'cancelled') {
return $this->response->setJSON(['ok' => false, 'message' => '취소된 주문은 입금상태를 변경할 수 없습니다.', 'csrf' => csrf_hash()]);
}
$orderModel->update($soIdx, ['so_paid' => $paid]);
return $this->response->setJSON(['ok' => true, 'so_idx' => $soIdx, 'paid' => $paid, 'csrf' => csrf_hash()]);
}
/** /**
* 전화접수 관리 — 봉투코드 스캔으로 즉시 판매·포장 처리(JSON 응답). * 전화접수 관리 — 봉투코드 스캔으로 즉시 판매·포장 처리(JSON 응답).
* 선택된 주문의 판매소로 판매 기록(bag_sale) + 재고 차감 + 팩코드 sold 전환 + * 선택된 주문의 판매소로 판매 기록(bag_sale) + 재고 차감 + 팩코드 sold 전환 +
@@ -7585,6 +8016,11 @@ SQL;
return $this->response->setJSON(['ok' => false, 'message' => '선택한 주문을 사용할 수 없습니다.', 'csrf' => csrf_hash()]); return $this->response->setJSON(['ok' => false, 'message' => '선택한 주문을 사용할 수 없습니다.', 'csrf' => csrf_hash()]);
} }
// 입금확인(so_paid=1)이 안 된 주문은 포장(판매) 스캔 불가.
if ((int) ($order->so_paid ?? 0) !== 1) {
return $this->response->setJSON(['ok' => false, 'message' => '입금확인이 완료되지 않은 주문입니다. 입금확인 후 포장하세요.', 'csrf' => csrf_hash()]);
}
$scan = $this->resolveDesignatedSaleBarcode($lgIdx, $barcode); $scan = $this->resolveDesignatedSaleBarcode($lgIdx, $barcode);
if (! ($scan['ok'] ?? false)) { if (! ($scan['ok'] ?? false)) {
return $this->response->setJSON(array_merge($scan, ['csrf' => csrf_hash()])); return $this->response->setJSON(array_merge($scan, ['csrf' => csrf_hash()]));

View File

@@ -44,7 +44,11 @@ class Home extends BaseController
} }
helper('admin'); helper('admin');
return view('bag/layout/workspace'); // 계정별 저장된 탭 상태(자동 복원). 없으면 'null'.
$savedWorkspaceJson = (new \App\Controllers\Bag())
->loadWorkspaceTabsState((int) (session()->get('mb_idx') ?? 0));
return view('bag/layout/workspace', ['savedWorkspaceJson' => $savedWorkspaceJson]);
} }
/** /**

View File

@@ -229,6 +229,37 @@ if (! function_exists('current_nav_request_path')) {
} }
} }
if (! function_exists('member_font_scale_for')) {
/**
* 로그인 계정의 특정 메뉴(화면 경로)에 저장된 글자크기(zoom %)를 반환.
* 저장값이 없거나 범위(70~150) 밖이면 100.
*/
function member_font_scale_for(string $menuKey): int
{
$mbIdx = (int) (session()->get('mb_idx') ?? 0);
$menuKey = trim($menuKey);
if ($mbIdx <= 0 || $menuKey === '') {
return 100;
}
try {
$db = \Config\Database::connect();
if (! $db->tableExists('member_menu_font_scale')) {
return 100;
}
$row = $db->table('member_menu_font_scale')
->select('mmf_scale')
->where('mmf_mb_idx', $mbIdx)
->where('mmf_menu_key', $menuKey)
->get()->getRow();
} catch (\Throwable $e) {
return 100;
}
$s = $row ? (int) $row->mmf_scale : 100;
return ($s >= 70 && $s <= 150) ? $s : 100;
}
}
if (! function_exists('normalize_menu_link_for_url')) { if (! function_exists('normalize_menu_link_for_url')) {
/** /**
* menu.mm_link 를 base_url() 인자로 쓸 수 있는 상대 경로로 정규화합니다. * menu.mm_link 를 base_url() 인자로 쓸 수 있는 상대 경로로 정규화합니다.

View File

@@ -70,3 +70,246 @@ if (! function_exists('pii_decrypt')) {
if (! defined('PII_ENCRYPTED_FIELDS')) { if (! defined('PII_ENCRYPTED_FIELDS')) {
define('PII_ENCRYPTED_FIELDS', ['mb_phone', 'mb_email']); define('PII_ENCRYPTED_FIELDS', ['mb_phone', 'mb_email']);
} }
/* =========================================================================
* 개인정보 마스킹(비식별화) 헬퍼 — 표시 계층에서만 사용(원본 데이터는 그대로).
* ======================================================================= */
if (! function_exists('pii_mask_name')) {
/** 이름 마스킹: 홍길동→홍*동, 홍길→홍*, 홍→홍 */
function pii_mask_name(?string $v): string
{
$v = trim((string) $v);
if ($v === '') {
return '';
}
$len = mb_strlen($v, 'UTF-8');
if ($len === 1) {
return $v;
}
if ($len === 2) {
return mb_substr($v, 0, 1, 'UTF-8') . '*';
}
return mb_substr($v, 0, 1, 'UTF-8') . str_repeat('*', $len - 2) . mb_substr($v, $len - 1, 1, 'UTF-8');
}
}
if (! function_exists('pii_mask_phone')) {
/** 전화 마스킹: 앞 국번 + 마지막 4자리만 노출, 가운데 마스킹. 010-1234-5678→010-****-5678 */
function pii_mask_phone(?string $v): string
{
$v = trim((string) $v);
if ($v === '') {
return '';
}
// 하이픈 형식이면 가운데 그룹만 마스킹
if (preg_match('/^(\d{2,4})-(\d{3,4})-(\d{4})$/', $v, $m) === 1) {
return $m[1] . '-' . str_repeat('*', strlen($m[2])) . '-' . $m[3];
}
// 그 외: 숫자만 추출해 앞3·뒤4 노출
$digits = preg_replace('/\D/', '', $v);
$n = strlen($digits);
if ($n <= 4) {
return str_repeat('*', $n);
}
$head = substr($digits, 0, 3);
$tail = substr($digits, -4);
return $head . str_repeat('*', max(1, $n - 7)) . $tail;
}
}
if (! function_exists('pii_mask_email')) {
/** 이메일 마스킹: 앞부분 첫 글자만 노출. hong@a.com→h***@a.com */
function pii_mask_email(?string $v): string
{
$v = trim((string) $v);
if ($v === '' || strpos($v, '@') === false) {
return $v === '' ? '' : pii_mask_name($v);
}
[$local, $domain] = explode('@', $v, 2);
$llen = mb_strlen($local, 'UTF-8');
$maskedLocal = $llen <= 1 ? '*' : mb_substr($local, 0, 1, 'UTF-8') . str_repeat('*', $llen - 1);
return $maskedLocal . '@' . $domain;
}
}
if (! function_exists('pii_mask_account')) {
/** 계좌/번호 마스킹: 마지막 4자리만 노출 */
function pii_mask_account(?string $v): string
{
$v = trim((string) $v);
if ($v === '') {
return '';
}
$len = mb_strlen($v, 'UTF-8');
if ($len <= 4) {
return str_repeat('*', $len);
}
return str_repeat('*', $len - 4) . mb_substr($v, $len - 4, 4, 'UTF-8');
}
}
if (! function_exists('pii_blind_index')) {
/**
* 블라인드 인덱스(정확일치 검색용): HMAC-SHA256(정규화값, 별도 인덱스키).
* 키(pii.blindIndexKey)가 없으면 '' 반환(검색 인덱스 비활성).
*/
function pii_blind_index(?string $value): string
{
$value = trim((string) $value);
if ($value === '') {
return '';
}
try {
$key = (string) (config('Pii')->blindIndexKey ?? '');
} catch (Throwable $e) {
$key = '';
}
if ($key === '') {
return '';
}
// 정규화: 공백 제거 + 소문자 (전화는 숫자만)
$norm = preg_replace('/\s+/u', '', $value);
if (preg_match('/^[\d\-()+ ]+$/', $value) === 1) {
$norm = preg_replace('/\D/', '', $value);
} else {
$norm = mb_strtolower($norm, 'UTF-8');
}
return hash_hmac('sha256', $norm, $key);
}
}
/* =========================================================================
* 지정판매소 PII 열람 게이트 — canViewShopPii (단일 판별 지점).
* ======================================================================= */
if (! function_exists('pii_shop_protection_enabled')) {
/** 지정판매소 PII 보호 마스터 플래그 (OFF면 현재 동작=항상 원문) */
function pii_shop_protection_enabled(): bool
{
try {
return (bool) (config('Pii')->designatedShopProtection ?? false);
} catch (Throwable $e) {
return false;
}
}
}
if (! function_exists('pii_ip_in_cidr')) {
/** IP가 CIDR/단일IP 목록(쉼표·공백 구분)에 포함되는지 (IPv4) */
function pii_ip_in_cidr(string $ip, string $list): bool
{
$ip = trim($ip);
if ($ip === '' || $list === '') {
return false;
}
$ipLong = ip2long($ip);
if ($ipLong === false) {
return false;
}
foreach (preg_split('/[\s,]+/', $list, -1, PREG_SPLIT_NO_EMPTY) as $entry) {
if (strpos($entry, '/') !== false) {
[$subnet, $bits] = explode('/', $entry, 2);
$subnetLong = ip2long(trim($subnet));
$bits = (int) $bits;
if ($subnetLong === false || $bits < 0 || $bits > 32) {
continue;
}
$mask = $bits === 0 ? 0 : (-1 << (32 - $bits)) & 0xFFFFFFFF;
if (($ipLong & $mask) === ($subnetLong & $mask)) {
return true;
}
} elseif (ip2long(trim($entry)) === $ipLong) {
return true;
}
}
return false;
}
}
if (! function_exists('pii_request_ip_in_lg_range')) {
/** 현재 요청 IP가 해당 지자체 허용 IP 대역에 속하는지 (local_government.lg_allow_ips) */
function pii_request_ip_in_lg_range(int $lgIdx): bool
{
if ($lgIdx <= 0) {
return false;
}
try {
$db = \Config\Database::connect();
if (! $db->fieldExists('lg_allow_ips', 'local_government')) {
return false; // 컬럼 없으면 대역 미설정 → 자동 원문 불가(마스킹+step-up)
}
$row = $db->table('local_government')->select('lg_allow_ips')->where('lg_idx', $lgIdx)->get()->getRowArray();
$list = (string) ($row['lg_allow_ips'] ?? '');
if ($list === '') {
return false;
}
return pii_ip_in_cidr((string) service('request')->getIPAddress(), $list);
} catch (Throwable $e) {
return false;
}
}
}
if (! function_exists('pii_stepup_granted')) {
/** 이 세션에서 step-up 재인증으로 원문 열람이 승인됐는지 (P2에서 세팅) */
function pii_stepup_granted(int $dsLgIdx): bool
{
$g = session('pii_stepup_grant');
if (! is_array($g)) {
return false;
}
$until = (int) ($g['until'] ?? 0);
$lg = (int) ($g['lg_idx'] ?? -1);
return $until > time() && ($lg === 0 || $lg === $dsLgIdx);
}
}
if (! function_exists('can_view_shop_pii')) {
/**
* 지정판매소 개인정보 원문 열람 가능 여부(단일 판별 지점).
* 보호 OFF → 항상 true(현재 동작). 보호 ON → 조건 판별.
*/
function can_view_shop_pii(int $dsLgIdx): bool
{
if (! pii_shop_protection_enabled()) {
return true;
}
$level = (int) session('mb_level');
$adminLg = (int) session('admin_lg_idx');
$twofa = (bool) session('auth_2fa_verified');
// 정상(자동 원문): 지자체관리자 + 본인 지자체 + 2FA + 지자체 IP대역
if ($level === \Config\Roles::LEVEL_LOCAL_ADMIN
&& $adminLg === $dsLgIdx && $adminLg > 0
&& $twofa
&& pii_request_ip_in_lg_range($dsLgIdx)) {
return true;
}
// 그 외(Super Admin·본부 포함, IP 밖 등): step-up 재인증으로 승인된 경우만
return pii_stepup_granted($dsLgIdx);
}
}
if (! function_exists('mask_shop_field')) {
/** 필드명 기준 마스킹 라우팅 (원문 표시 불가일 때 사용) */
function mask_shop_field(string $field, ?string $value): string
{
$value = (string) $value;
switch ($field) {
case 'ds_rep_name':
return pii_mask_name($value);
case 'ds_tel':
case 'ds_rep_phone':
return pii_mask_phone($value);
case 'ds_email':
return pii_mask_email($value);
case 'ds_va_account':
case 'ds_va_number':
return pii_mask_account($value);
default:
return $value === '' ? '' : pii_mask_name($value);
}
}
}

View File

@@ -41,6 +41,118 @@ class DesignatedShopModel extends Model
'ds_state_changed_at', 'ds_state_changed_at',
'ds_change_reason', 'ds_change_reason',
'ds_regdate', 'ds_regdate',
// 블라인드 인덱스(정확일치 검색용). 컬럼이 있을 때만 실제로 기록됨.
'ds_tel_bidx',
'ds_rep_name_bidx',
]; ];
// PII 암복호화·블라인드인덱스 콜백 (설계안 P3·P4)
protected $beforeInsert = ['piiBlindIndex', 'piiEncrypt'];
protected $beforeUpdate = ['piiBlindIndex', 'piiEncrypt'];
protected $afterFind = ['piiDecrypt'];
private ?bool $telBidxExists = null;
private ?bool $nameBidxExists = null;
/** 저장 계층 암호화 사용 여부 (Config\Pii.encryptAtRest) */
private function encryptAtRest(): bool
{
try {
return (bool) (config('Pii')->encryptAtRest ?? false);
} catch (\Throwable $e) {
return false;
}
}
/** @return list<string> 암호화·마스킹 대상 PII 필드 */
private function piiFields(): array
{
try {
$f = config('Pii')->designatedShopPiiFields ?? [];
return is_array($f) && $f !== [] ? $f : ['ds_rep_name', 'ds_tel', 'ds_rep_phone', 'ds_email', 'ds_va_account'];
} catch (\Throwable $e) {
return ['ds_rep_name', 'ds_tel', 'ds_rep_phone', 'ds_email', 'ds_va_account'];
}
}
/** 저장 전 PII 필드 암호화 (encryptAtRest ON일 때만, 이미 ENC:면 건너뜀) */
protected function piiEncrypt(array $data): array
{
if (! $this->encryptAtRest() || empty($data['data']) || ! is_array($data['data'])) {
return $data;
}
helper('pii_encryption');
foreach ($this->piiFields() as $f) {
if (array_key_exists($f, $data['data']) && $data['data'][$f] !== null && $data['data'][$f] !== '') {
$v = (string) $data['data'][$f];
if (strpos($v, 'ENC:') !== 0) {
$data['data'][$f] = pii_encrypt($v);
}
}
}
return $data;
}
/** 저장 전 블라인드 인덱스(전화·이름) 기록 — 컬럼과 인덱스키가 있을 때만 */
protected function piiBlindIndex(array $data): array
{
if (empty($data['data']) || ! is_array($data['data'])) {
return $data;
}
helper('pii_encryption');
$db = $this->db;
if ($this->telBidxExists === null) {
$this->telBidxExists = $db->fieldExists('ds_tel_bidx', $this->table);
}
if ($this->nameBidxExists === null) {
$this->nameBidxExists = $db->fieldExists('ds_rep_name_bidx', $this->table);
}
// 원본값이 넘어온 경우에만 인덱스 갱신(암호화 콜백이 먼저 돌면 ENC:라 인덱스 불가 → piiBlindIndex를 piiEncrypt보다 먼저 두지 않음에 주의)
if ($this->telBidxExists && array_key_exists('ds_tel', $data['data'])) {
$raw = (string) $data['data']['ds_tel'];
$data['data']['ds_tel_bidx'] = strpos($raw, 'ENC:') === 0 ? '' : pii_blind_index($raw);
}
if ($this->nameBidxExists && array_key_exists('ds_rep_name', $data['data'])) {
$raw = (string) $data['data']['ds_rep_name'];
$data['data']['ds_rep_name_bidx'] = strpos($raw, 'ENC:') === 0 ? '' : pii_blind_index($raw);
}
return $data;
}
/** 조회 후 PII 필드 복호화 (ENC: 값만; 평문은 그대로) */
protected function piiDecrypt(array $data): array
{
if (empty($data['data'])) {
return $data;
}
helper('pii_encryption');
$fields = $this->piiFields();
$decodeOne = static function ($row) use ($fields) {
if (is_object($row)) {
foreach ($fields as $f) {
if (isset($row->$f) && is_string($row->$f) && strpos($row->$f, 'ENC:') === 0) {
$row->$f = pii_decrypt($row->$f);
}
}
} elseif (is_array($row)) {
foreach ($fields as $f) {
if (isset($row[$f]) && is_string($row[$f]) && strpos($row[$f], 'ENC:') === 0) {
$row[$f] = pii_decrypt($row[$f]);
}
}
}
return $row;
};
if (is_array($data['data'])) {
// findAll 등: 결과 배열
foreach ($data['data'] as $i => $row) {
$data['data'][$i] = $decodeOne($row);
}
} else {
// find/first: 단일
$data['data'] = $decodeOne($data['data']);
}
return $data;
}
} }

View File

@@ -270,6 +270,13 @@ class MenuModel extends Model
->get() ->get()
->getResultArray(); ->getResultArray();
// 깊이(dep) 오름차순으로 그룹핑 — 상위부터 삽입해 자식의 mm_pidx를 새 ID로 재매핑
$byDep = [];
foreach ($source as $row) {
$byDep[(int) ($row->mm_dep ?? 0)][] = $row;
}
ksort($byDep);
foreach ($lgRows as $lgRow) { foreach ($lgRows as $lgRow) {
$destLg = (int) ($lgRow['lg_idx'] ?? 0); $destLg = (int) ($lgRow['lg_idx'] ?? 0);
if ($destLg <= 0 || $destLg === $sourceLg) { if ($destLg <= 0 || $destLg === $sourceLg) {
@@ -281,28 +288,41 @@ class MenuModel extends Model
->where('lg_idx', $destLg) ->where('lg_idx', $destLg)
->delete(); ->delete();
// 원격 DB 왕복 최소화: 레벨(dep)별로 batch insert 후, 삽입 순서(mm_idx ASC)로 old→new ID 매핑
$idMap = []; $idMap = [];
foreach ($source as $row) { foreach ($byDep as $dep => $levelRows) {
$oldId = (int) ($row->mm_idx ?? 0); $batch = [];
foreach ($levelRows as $row) {
$oldP = (int) ($row->mm_pidx ?? 0); $oldP = (int) ($row->mm_pidx ?? 0);
$newPidx = 0; $newPidx = ($oldP > 0 && isset($idMap[$oldP])) ? (int) $idMap[$oldP] : 0;
if ($oldP > 0 && isset($idMap[$oldP])) { $batch[] = [
$newPidx = (int) $idMap[$oldP];
}
$this->insert([
'mt_idx' => $mtIdx, 'mt_idx' => $mtIdx,
'lg_idx' => $destLg, 'lg_idx' => $destLg,
'mm_name' => (string) ($row->mm_name ?? ''), 'mm_name' => (string) ($row->mm_name ?? ''),
'mm_link' => (string) ($row->mm_link ?? ''), 'mm_link' => (string) ($row->mm_link ?? ''),
'mm_pidx' => $newPidx, 'mm_pidx' => $newPidx,
'mm_dep' => (int) ($row->mm_dep ?? 0), 'mm_dep' => (int) $dep,
'mm_num' => (int) ($row->mm_num ?? 0), 'mm_num' => (int) ($row->mm_num ?? 0),
'mm_cnode' => (int) ($row->mm_cnode ?? 0), 'mm_cnode' => (int) ($row->mm_cnode ?? 0),
'mm_level' => (string) ($row->mm_level ?? ''), 'mm_level' => (string) ($row->mm_level ?? ''),
'mm_is_view' => (string) ($row->mm_is_view ?? 'Y'), 'mm_is_view' => (string) ($row->mm_is_view ?? 'Y'),
]); ];
$idMap[$oldId] = (int) $this->getInsertID(); }
if ($batch === []) {
continue;
}
$this->insertBatch($batch);
// 방금 삽입한 이 레벨의 행을 삽입 순서(mm_idx ASC)대로 조회해 원본과 1:1 매핑
$inserted = $this->where('mt_idx', $mtIdx)
->where('lg_idx', $destLg)
->where('mm_dep', (int) $dep)
->orderBy('mm_idx', 'ASC')
->findAll();
foreach ($levelRows as $i => $row) {
if (isset($inserted[$i])) {
$idMap[(int) ($row->mm_idx ?? 0)] = (int) $inserted[$i]->mm_idx;
}
}
} }
$this->db->transComplete(); $this->db->transComplete();
} }

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Models;
use CodeIgniter\Model;
class ReportPresetModel extends Model
{
protected $table = 'report_preset';
protected $primaryKey = 'rp_idx';
protected $returnType = 'object';
protected $useTimestamps = true;
protected $createdField = 'rp_regdate';
protected $updatedField = 'rp_updated';
protected $allowedFields = [
'rp_lg_idx', 'rp_mb_idx', 'rp_name', 'rp_title',
'rp_category', 'rp_mode', 'rp_columns',
];
}

View File

@@ -1,5 +1,9 @@
<?php <?php
helper('admin'); helper(['admin', 'pii_encryption']);
$repDisp = static function ($row): string {
$v = (string) ($row->ds_rep_name ?? '');
return can_view_shop_pii((int) ($row->ds_lg_idx ?? 0)) ? $v : mask_shop_field('ds_rep_name', $v);
};
$currentPath = current_nav_request_path(); $currentPath = current_nav_request_path();
if ($currentPath === 'bag/designated-shops') { if ($currentPath === 'bag/designated-shops') {
$readOnly = false; $readOnly = false;
@@ -213,6 +217,10 @@ $listBasePath = $readOnly ? 'designated-shops/browse' : 'designated-shops';
<option value="<?= esc($gCode) ?>" <?= ($dsGugunCode ?? '') === $gCode ? 'selected' : '' ?>><?= esc((string) (($gugunNameMap[$gCode] ?? '') !== '' ? $gugunNameMap[$gCode] : $gCode)) ?></option> <option value="<?= esc($gCode) ?>" <?= ($dsGugunCode ?? '') === $gCode ? 'selected' : '' ?>><?= esc((string) (($gugunNameMap[$gCode] ?? '') !== '' ? $gugunNameMap[$gCode] : $gCode)) ?></option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
<label class="text-sm text-gray-600">대표자명</label>
<input type="text" name="ds_rep" value="<?= esc($dsRep ?? '') ?>" placeholder="정확히 일치" class="border border-gray-300 rounded px-2 py-1 text-sm w-28" title="개인정보 보호를 위해 정확일치 검색만 지원합니다"/>
<label class="text-sm text-gray-600">전화</label>
<input type="text" name="ds_tel" value="<?= esc($dsTel ?? '') ?>" placeholder="정확히 일치" class="border border-gray-300 rounded px-2 py-1 text-sm w-32" title="개인정보 보호를 위해 정확일치 검색만 지원합니다"/>
<label class="text-sm text-gray-600">상태</label> <label class="text-sm text-gray-600">상태</label>
<select name="ds_state" class="border border-gray-300 rounded px-2 py-1 text-sm"> <select name="ds_state" class="border border-gray-300 rounded px-2 py-1 text-sm">
<option value="">전체</option> <option value="">전체</option>
@@ -282,14 +290,14 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
<tr class="ds-list-row cursor-pointer border-b border-gray-200 last:border-0 hover:bg-blue-50/60" <tr class="ds-list-row cursor-pointer border-b border-gray-200 last:border-0 hover:bg-blue-50/60"
data-row-index="<?= (int) $i ?>" role="button" tabindex="0" data-row-index="<?= (int) $i ?>" role="button" tabindex="0"
data-sort-no="<?= esc((string) (preg_match('/\d+/', $shortNo, $mn) ? (int) $mn[0] : 0), 'attr') ?>" data-sort-no="<?= esc((string) (preg_match('/\d+/', $shortNo, $mn) ? (int) $mn[0] : 0), 'attr') ?>"
data-sort-rep="<?= esc($row->ds_rep_name ?? '', 'attr') ?>" data-sort-rep="<?= esc($repDisp($row), 'attr') ?>"
data-sort-name="<?= esc($row->ds_name ?? '', 'attr') ?>" data-sort-name="<?= esc($row->ds_name ?? '', 'attr') ?>"
data-sort-dong="<?= esc($dongDisp, 'attr') ?>" data-sort-dong="<?= esc($dongDisp, 'attr') ?>"
data-sort-designated="<?= esc($daDisp, 'attr') ?>" data-sort-designated="<?= esc($daDisp, 'attr') ?>"
data-sort-zone="<?= esc($zone, 'attr') ?>" data-sort-zone="<?= esc($zone, 'attr') ?>"
data-sort-state="<?= (int) $st ?>"> data-sort-state="<?= (int) $st ?>">
<td class="py-2.5 px-2 text-left font-mono text-gray-700"><?= esc($shortNo) ?></td> <td class="py-2.5 px-2 text-left font-mono text-gray-700"><?= esc($shortNo) ?></td>
<td class="py-2.5 px-2 text-gray-600 ds-col-tight" title="<?= esc($row->ds_rep_name ?? '') ?>"><?= esc($row->ds_rep_name ?? '') ?></td> <td class="py-2.5 px-2 text-gray-600 ds-col-tight" title="<?= esc($repDisp($row)) ?>"><?= esc($repDisp($row)) ?></td>
<td class="py-2.5 px-2 font-medium text-gray-900 ds-col-tight" title="<?= esc($row->ds_name ?? '') ?>"><?= esc($row->ds_name ?? '') ?></td> <td class="py-2.5 px-2 font-medium text-gray-900 ds-col-tight" title="<?= esc($row->ds_name ?? '') ?>"><?= esc($row->ds_name ?? '') ?></td>
<td class="py-2.5 px-2 text-gray-600 font-mono" title="<?= esc($dongDisp) ?>"><?= esc($dongDisp) ?></td> <td class="py-2.5 px-2 text-gray-600 font-mono" title="<?= esc($dongDisp) ?>"><?= esc($dongDisp) ?></td>
<td class="py-2.5 px-2 text-left text-gray-500 text-[12px]"><?= esc($daDisp) ?></td> <td class="py-2.5 px-2 text-left text-gray-500 text-[12px]"><?= esc($daDisp) ?></td>
@@ -312,6 +320,24 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
<div class="ds-panel-title shrink-0">지정판매소 정보</div> <div class="ds-panel-title shrink-0">지정판매소 정보</div>
<div class="ds-detail-inner" id="ds-detail-box"> <div class="ds-detail-inner" id="ds-detail-box">
<p id="ds-detail-placeholder" class="text-sm text-gray-500 py-6 text-center">위 목록에서 행을 선택하세요.</p> <p id="ds-detail-placeholder" class="text-sm text-gray-500 py-6 text-center">위 목록에서 행을 선택하세요.</p>
<!-- 개인정보 마스킹 안내 + 원문 보기(2단계 인증) -->
<div id="ds-pii-reveal" class="hidden mb-2 border border-amber-300 bg-amber-50 rounded p-2 text-[12px] no-print">
<div class="flex items-center justify-between gap-2">
<span class="text-amber-800">개인정보가 마스킹되어 있습니다.</span>
<button type="button" id="ds-pii-reveal-btn" class="border border-amber-500 text-amber-800 px-2 py-0.5 rounded hover:bg-amber-100">원문 보기</button>
</div>
<div id="ds-pii-reveal-form" class="hidden mt-2 space-y-1">
<input type="text" id="ds-pii-otp" inputmode="numeric" maxlength="6" placeholder="인증코드 6자리(OTP)" class="border border-gray-300 rounded px-2 py-1 w-full"/>
<input type="text" id="ds-pii-reason" maxlength="200" placeholder="열람 사유" class="border border-gray-300 rounded px-2 py-1 w-full"/>
<div class="flex gap-1">
<button type="button" id="ds-pii-reveal-submit" class="bg-[#243a5e] text-white px-3 py-1 rounded text-[12px]">인증 후 열람</button>
<button type="button" id="ds-pii-reveal-cancel" class="bg-gray-200 text-gray-700 px-3 py-1 rounded text-[12px]">취소</button>
</div>
<p id="ds-pii-reveal-msg" class="text-red-600"></p>
</div>
</div>
<div id="ds-detail-fields" class="hidden"> <div id="ds-detail-fields" class="hidden">
<!-- 제목 왼쪽 · 내용 오른쪽 (라벨/값) 폼 형태 --> <!-- 제목 왼쪽 · 내용 오른쪽 (라벨/값) 폼 형태 -->
<div class="ds-detail-form" id="ds-detail-info-table" aria-label="지정판매소 상세"> <div class="ds-detail-form" id="ds-detail-info-table" aria-label="지정판매소 상세">
@@ -478,6 +504,14 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
fieldsWrap.classList.remove('hidden'); fieldsWrap.classList.remove('hidden');
fillDetailInfoTable(d); fillDetailInfoTable(d);
// 마스킹된 행이면 "원문 보기" 안내 노출
var revealBox = document.getElementById('ds-pii-reveal');
if (revealBox) {
revealBox.classList.toggle('hidden', !d.pii_masked);
var rf = document.getElementById('ds-pii-reveal-form');
if (rf) rf.classList.add('hidden');
}
if (!readOnly && editLink && delForm && delBtn) { if (!readOnly && editLink && delForm && delBtn) {
var id = d.ds_idx; var id = d.ds_idx;
editLink.href = editBase + '/' + id; editLink.href = editBase + '/' + id;
@@ -569,6 +603,38 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
delBtn.disabled = true; delBtn.disabled = true;
delBtn.classList.add('pointer-events-none', 'opacity-40'); delBtn.classList.add('pointer-events-none', 'opacity-40');
} }
// ── 개인정보 원문 보기(2단계 인증 step-up) ──
(function () {
var box = document.getElementById('ds-pii-reveal');
if (!box) return;
var btn = document.getElementById('ds-pii-reveal-btn');
var form = document.getElementById('ds-pii-reveal-form');
var otp = document.getElementById('ds-pii-otp');
var reason = document.getElementById('ds-pii-reason');
var submit = document.getElementById('ds-pii-reveal-submit');
var cancel = document.getElementById('ds-pii-reveal-cancel');
var msg = document.getElementById('ds-pii-reveal-msg');
var endpoint = new URL('<?= mgmt_url('designated-shops/reveal-pii') ?>', window.location.href).pathname;
var csrfName = '<?= csrf_token() ?>';
btn.addEventListener('click', function () { form.classList.remove('hidden'); otp.focus(); });
cancel.addEventListener('click', function () { form.classList.add('hidden'); msg.textContent = ''; });
submit.addEventListener('click', function () {
msg.textContent = '';
var body = new URLSearchParams();
body.set('totp_code', (otp.value || '').trim());
body.set('reason', (reason.value || '').trim());
var t = document.querySelector('meta[name="' + csrfName + '"]');
body.set(csrfName, '<?= csrf_hash() ?>');
fetch(endpoint, { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' }, body: body })
.then(function (r) { return r.json(); })
.then(function (d) {
if (d && d.ok) { location.reload(); }
else { msg.textContent = (d && d.error) || '열람 승인 실패'; }
})
.catch(function () { msg.textContent = '통신 오류'; });
});
})();
})(); })();
</script> </script>
@@ -630,7 +696,7 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
<td class="text-left"><?= esc($dongDispP) ?></td> <td class="text-left"><?= esc($dongDispP) ?></td>
<td class="text-center"><?= esc($daDispP) ?></td> <td class="text-center"><?= esc($daDispP) ?></td>
<td class="text-left"><?= esc($row->ds_zone_code ?? '') ?></td> <td class="text-left"><?= esc($row->ds_zone_code ?? '') ?></td>
<td class="text-left"><?= esc($row->ds_rep_name ?? '') ?></td> <td class="text-left"><?= esc($repDisp($row)) ?></td>
<td class="text-left"><?= esc($row->ds_name ?? '') ?></td> <td class="text-left"><?= esc($row->ds_name ?? '') ?></td>
<td class="text-left"><?= esc($zipP) ?></td> <td class="text-left"><?= esc($zipP) ?></td>
<td class="text-left"><?= esc($addrCombinedP) ?></td> <td class="text-left"><?= esc($addrCombinedP) ?></td>

View File

@@ -14,7 +14,7 @@ $badge = static function (string $s): string {
$map = [ $map = [
'new' => 'bg-blue-50 text-blue-700', 'new' => 'bg-blue-50 text-blue-700',
'in_progress' => 'bg-amber-50 text-amber-700', 'in_progress' => 'bg-amber-50 text-amber-700',
'answered' => 'bg-teal-50 text-teal-700', 'answered' => 'bg-gray-100 text-gray-500',
'done' => 'bg-emerald-50 text-emerald-700', 'done' => 'bg-emerald-50 text-emerald-700',
'hold' => 'bg-gray-100 text-gray-500', 'hold' => 'bg-gray-100 text-gray-500',
]; ];
@@ -22,6 +22,7 @@ $badge = static function (string $s): string {
return '<span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium ' . $c . '">' . esc(FeedbackModel::statusLabel($s)) . '</span>'; return '<span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium ' . $c . '">' . esc(FeedbackModel::statusLabel($s)) . '</span>';
}; };
?> ?>
<div class="fb-selectable">
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel flex flex-wrap items-center justify-between gap-2"> <section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel flex flex-wrap items-center justify-between gap-2">
<span class="text-sm font-bold text-gray-700">사용자 의견 · 전체보기 (<?= count($list) ?>건)</span> <span class="text-sm font-bold text-gray-700">사용자 의견 · 전체보기 (<?= count($list) ?>건)</span>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -63,9 +64,20 @@ $badge = static function (string $s): string {
<span class="text-gray-400 text-[12px]">·</span> <span class="text-gray-400 text-[12px]">·</span>
<span class="text-gray-600 text-[12px]"><?= esc((string) ($row->fb_mb_name ?? '')) ?></span> <span class="text-gray-600 text-[12px]"><?= esc((string) ($row->fb_mb_name ?? '')) ?></span>
</div> </div>
<?php
$fbUrlRaw = (string) ($row->fb_page_url ?? '');
$fbUrlOpen = $fbUrlRaw;
if ($fbUrlOpen !== '' && ! preg_match('#^https?://#i', $fbUrlOpen)) {
$fbUrlOpen = (string) preg_replace('/([?&])embed=1(?=&|$)/', '$1', $fbUrlOpen);
$fbUrlOpen = rtrim($fbUrlOpen, '?&');
$fbUrlOpen = base_url(ltrim($fbUrlOpen, '/'));
}
?>
<div class="text-[12px] text-gray-500"> <div class="text-[12px] text-gray-500">
<?= esc((string) ($row->fb_page_title ?: '-')) ?> <?= esc((string) ($row->fb_page_title ?: '-')) ?>
<span class="text-gray-400"> · <?= esc((string) ($row->fb_page_url ?: '')) ?></span> <?php if ($fbUrlRaw !== ''): ?>
<span class="text-gray-400"> · </span><a href="<?= esc($fbUrlOpen, 'attr') ?>" target="_blank" rel="noopener" class="text-blue-600 hover:underline break-all"><?= esc($fbUrlRaw) ?></a>
<?php endif; ?>
</div> </div>
</div> </div>
@@ -104,3 +116,4 @@ $badge = static function (string $s): string {
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
</div><!-- /.fb-selectable -->

View File

@@ -12,7 +12,7 @@ $badge = static function (string $s): string {
$map = [ $map = [
'new' => 'bg-blue-50 text-blue-700', 'new' => 'bg-blue-50 text-blue-700',
'in_progress' => 'bg-amber-50 text-amber-700', 'in_progress' => 'bg-amber-50 text-amber-700',
'answered' => 'bg-teal-50 text-teal-700', 'answered' => 'bg-gray-100 text-gray-500',
'done' => 'bg-emerald-50 text-emerald-700', 'done' => 'bg-emerald-50 text-emerald-700',
'hold' => 'bg-gray-100 text-gray-500', 'hold' => 'bg-gray-100 text-gray-500',
]; ];
@@ -20,6 +20,7 @@ $badge = static function (string $s): string {
return '<span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium ' . $c . '">' . esc(FeedbackModel::statusLabel($s)) . '</span>'; return '<span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium ' . $c . '">' . esc(FeedbackModel::statusLabel($s)) . '</span>';
}; };
?> ?>
<div class="fb-selectable">
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel flex flex-wrap items-center justify-between gap-2"> <section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel flex flex-wrap items-center justify-between gap-2">
<span class="text-sm font-bold text-gray-700">사용자 의견</span> <span class="text-sm font-bold text-gray-700">사용자 의견</span>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -53,7 +54,7 @@ $badge = static function (string $s): string {
<tr><td colspan="7" class="text-center text-gray-400 py-6">접수된 의견이 없습니다.</td></tr> <tr><td colspan="7" class="text-center text-gray-400 py-6">접수된 의견이 없습니다.</td></tr>
<?php else: ?> <?php else: ?>
<?php foreach ($list as $row): ?> <?php foreach ($list as $row): ?>
<tr class="cursor-pointer hover:bg-blue-50/60" onclick="window.location.href='<?= site_url('admin/feedback/' . (int) $row->fb_idx) ?>'"> <tr class="cursor-pointer hover:bg-blue-50/60" onclick="if(!window.getSelection().toString()){window.location.href='<?= site_url('admin/feedback/' . (int) $row->fb_idx) ?>';}">
<td class="text-left text-gray-500"><?= (int) $row->fb_idx ?></td> <td class="text-left text-gray-500"><?= (int) $row->fb_idx ?></td>
<td class="text-left text-gray-500 text-[12px]"><?= esc((string) ($row->fb_regdate ?? '')) ?></td> <td class="text-left text-gray-500 text-[12px]"><?= esc((string) ($row->fb_regdate ?? '')) ?></td>
<td class="text-left"><?= $badge((string) ($row->fb_status ?? 'new')) ?></td> <td class="text-left"><?= $badge((string) ($row->fb_status ?? 'new')) ?></td>
@@ -68,3 +69,4 @@ $badge = static function (string $s): string {
</table> </table>
</div> </div>
<?php if (isset($pager)): ?><div class="mt-3"><?= $pager->links() ?></div><?php endif; ?> <?php if (isset($pager)): ?><div class="mt-3"><?= $pager->links() ?></div><?php endif; ?>
</div><!-- /.fb-selectable -->

View File

@@ -20,12 +20,45 @@ use App\Models\FeedbackModel;
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3 mt-2"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-3 mt-2">
<section class="border border-gray-300 rounded-lg overflow-hidden"> <section class="border border-gray-300 rounded-lg overflow-hidden">
<div class="px-3 py-2 border-b bg-gray-50 text-sm font-semibold text-gray-700">의견 내용</div> <div class="px-3 py-2 border-b bg-gray-50 text-sm font-semibold text-gray-700 flex items-center justify-between">
<span>의견 내용</span>
<button type="button" id="fb-content-edit-btn" class="text-xs font-medium text-blue-600 hover:underline">수정</button>
</div>
<div class="p-3 space-y-2 text-sm"> <div class="p-3 space-y-2 text-sm">
<!-- 보기 모드 -->
<div id="fb-content-view">
<p class="whitespace-pre-wrap text-gray-800"><?= esc((string) $row->fb_content) ?></p> <p class="whitespace-pre-wrap text-gray-800"><?= esc((string) $row->fb_content) ?></p>
</div>
<!-- 수정 모드 -->
<form id="fb-content-edit" action="<?= site_url('admin/feedback/' . (int) $row->fb_idx . '/content') ?>" method="post" class="hidden">
<?= csrf_field() ?>
<textarea name="fb_content" rows="6" maxlength="2000" class="w-full border border-gray-300 rounded px-2 py-1.5 text-sm"><?= esc((string) $row->fb_content) ?></textarea>
<div class="flex gap-2 mt-2">
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm">수정 저장</button>
<button type="button" id="fb-content-cancel" class="bg-gray-200 text-gray-700 px-4 py-1.5 rounded-sm text-sm hover:bg-gray-300">취소</button>
</div>
</form>
<hr class="my-2"/> <hr class="my-2"/>
<?php
// 화면 URL — embed 파라미터를 떼고 전체 화면 절대 URL로 변환(클릭 시 해당 화면으로 바로 이동).
$fbUrlRaw = (string) ($row->fb_page_url ?? '');
$fbUrlOpen = $fbUrlRaw;
if ($fbUrlOpen !== '' && ! preg_match('#^https?://#i', $fbUrlOpen)) {
$fbUrlOpen = (string) preg_replace('/([?&])embed=1(?=&|$)/', '$1', $fbUrlOpen);
$fbUrlOpen = rtrim($fbUrlOpen, '?&');
$fbUrlOpen = base_url(ltrim($fbUrlOpen, '/'));
}
?>
<dl class="grid grid-cols-[5rem_1fr] gap-y-1 text-[12px] text-gray-600"> <dl class="grid grid-cols-[5rem_1fr] gap-y-1 text-[12px] text-gray-600">
<dt class="font-semibold">화면</dt><dd><?= esc((string) ($row->fb_page_title ?: '-')) ?><br><span class="text-gray-400"><?= esc((string) ($row->fb_page_url ?: '')) ?></span></dd> <dt class="font-semibold">화면</dt>
<dd>
<?= esc((string) ($row->fb_page_title ?: '-')) ?><br>
<?php if ($fbUrlRaw !== ''): ?>
<a href="<?= esc($fbUrlOpen, 'attr') ?>" target="_blank" rel="noopener" class="text-blue-600 hover:underline break-all"><?= esc($fbUrlRaw) ?></a>
<?php else: ?>
<span class="text-gray-400">-</span>
<?php endif; ?>
</dd>
<dt class="font-semibold">작성자</dt><dd><?= esc((string) ($row->fb_mb_name ?? '')) ?> (idx <?= (int) $row->fb_mb_idx ?>, level <?= (int) $row->fb_mb_level ?>)</dd> <dt class="font-semibold">작성자</dt><dd><?= esc((string) ($row->fb_mb_name ?? '')) ?> (idx <?= (int) $row->fb_mb_idx ?>, level <?= (int) $row->fb_mb_level ?>)</dd>
<dt class="font-semibold">지자체</dt><dd><?= (int) ($row->fb_lg_idx ?? 0) ?></dd> <dt class="font-semibold">지자체</dt><dd><?= (int) ($row->fb_lg_idx ?? 0) ?></dd>
<dt class="font-semibold">일시</dt><dd><?= esc((string) ($row->fb_regdate ?? '')) ?></dd> <dt class="font-semibold">일시</dt><dd><?= esc((string) ($row->fb_regdate ?? '')) ?></dd>
@@ -73,3 +106,21 @@ use App\Models\FeedbackModel;
</section> </section>
<?php endif; ?> <?php endif; ?>
</div><!-- /.fb-selectable --> </div><!-- /.fb-selectable -->
<script>
(function () {
var btn = document.getElementById('fb-content-edit-btn');
var view = document.getElementById('fb-content-view');
var edit = document.getElementById('fb-content-edit');
var cancel = document.getElementById('fb-content-cancel');
if (!btn || !view || !edit) return;
var show = function (editing) {
view.classList.toggle('hidden', editing);
edit.classList.toggle('hidden', !editing);
btn.classList.toggle('hidden', editing);
if (editing) { var ta = edit.querySelector('textarea'); if (ta) ta.focus(); }
};
btn.addEventListener('click', function () { show(true); });
if (cancel) cancel.addEventListener('click', function () { show(false); });
})();
</script>

View File

@@ -62,6 +62,9 @@ $navPartial = [
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/> <meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title><?= esc($title ?? '관리자') ?> - GBLS</title> <title><?= esc($title ?? '관리자') ?> - GBLS</title>
<link rel="icon" type="image/svg+xml" href="<?= base_url('favicon.svg') ?>"/>
<link rel="alternate icon" href="<?= base_url('favicon.ico') ?>"/>
<link rel="apple-touch-icon" href="<?= base_url('favicon.svg') ?>"/>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"/> <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"/> <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"/>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script> <script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
@@ -91,6 +94,13 @@ tailwind.config = {
.data-table tbody td { color: #374151; } .data-table tbody td { color: #374151; }
.data-table tbody tr:last-child td { border-bottom: 0; } .data-table tbody tr:last-child td { border-bottom: 0; }
.data-table tbody tr:hover td { background-color: #f9fafb; } .data-table tbody tr:hover td { background-color: #f9fafb; }
/* body의 select-none 상속을 무효화해 본문 텍스트를 드래그·복사할 수 있게 하는 opt-in 클래스 */
.fb-selectable, .fb-selectable * {
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
user-select: text;
}
@media print { @media print {
.portal-header, .sidebar, .portal-footer, .no-print, nav.portal-top-nav { display: none !important; } .portal-header, .sidebar, .portal-footer, .no-print, nav.portal-top-nav { display: none !important; }
body.gov-portal-shell { background: #fff; display: block; } body.gov-portal-shell { background: #fff; display: block; }
@@ -167,27 +177,40 @@ tailwind.config = {
window.addEventListener('pagehide', closeStuckOverlays); window.addEventListener('pagehide', closeStuckOverlays);
})(); })();
// 글씨 크기 조절(A/A+) — 본문 + 상단 대메뉴 + 좌측 사이드바에 zoom 적용. 사이트/워크스페이스와 배율 공유. // 글씨 크기 조절(A/A+) — 계정별·메뉴별 저장(서버). 현재 화면 경로 기준으로 개별 적용.
<?php $__fmk = function_exists('current_nav_request_path') ? current_nav_request_path() : ''; $__fsc = function_exists('member_font_scale_for') ? member_font_scale_for($__fmk) : 100; ?>
(function () { (function () {
var FONT_KEY = 'jrj_font_scale'; var MENU_KEY = <?= json_encode($__fmk) ?>;
// 상단 헤더는 제외 → A/A+ 버튼이 커지거나 밀리지 않아 연속 클릭이 편하다. var scale = <?= (int) $__fsc ?>;
var SAVE_URL = <?= json_encode(site_url('bag/pref/font-scale')) ?>;
var CSRF_NAME = <?= json_encode(csrf_token()) ?>;
var csrfHash = <?= json_encode(csrf_hash()) ?>;
var scaleSelectors = ['.sidebar', '.work-main']; var scaleSelectors = ['.sidebar', '.work-main'];
function curScale() { var s = parseInt(localStorage.getItem(FONT_KEY) || '100', 10); return (s >= 70 && s <= 150) ? s : 100; } var saveTimer = null;
function applyScale(s) { function persist(s) {
try { localStorage.setItem('jrj_font_scale::' + MENU_KEY, String(s)); } catch (e) {}
if (!MENU_KEY) return;
clearTimeout(saveTimer);
saveTimer = setTimeout(function () {
var b = new URLSearchParams(); b.set(CSRF_NAME, csrfHash); b.set('menu_key', MENU_KEY); b.set('scale', String(s));
fetch(SAVE_URL, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: b.toString() })
.then(function (r) { return r.json(); }).then(function (d) { if (d && d.csrf) csrfHash = d.csrf; }).catch(function () {});
}, 400);
}
function applyScale(s, save) {
s = Math.min(150, Math.max(70, s)); s = Math.min(150, Math.max(70, s));
try { localStorage.setItem(FONT_KEY, String(s)); } catch (e) {} scale = s;
var z = s / 100; var z = s / 100;
scaleSelectors.forEach(function (sel) { var el = document.querySelector(sel); if (el) el.style.zoom = z; }); scaleSelectors.forEach(function (sel) { var el = document.querySelector(sel); if (el) el.style.zoom = z; });
// 상단 대메뉴는 font-size 만 키운다(바 높이 48px 고정 → A/A+ 버튼이 안 밀림).
var topnav = document.querySelector('.portal-top-nav'); var topnav = document.querySelector('.portal-top-nav');
if (topnav) topnav.style.fontSize = (0.875 * z).toFixed(4) + 'rem'; if (topnav) topnav.style.fontSize = (0.875 * z).toFixed(4) + 'rem';
var pct = document.getElementById('wsFontPct'); if (pct) pct.textContent = s + '%'; var pct = document.getElementById('wsFontPct'); if (pct) pct.textContent = s + '%';
if (save) persist(s);
} }
applyScale(curScale()); applyScale(scale, false);
var plus = document.getElementById('wsFontPlus'), minus = document.getElementById('wsFontMinus'); var plus = document.getElementById('wsFontPlus'), minus = document.getElementById('wsFontMinus');
if (plus) plus.addEventListener('click', function () { applyScale(curScale() + 10); }); if (plus) plus.addEventListener('click', function () { applyScale(scale + 10, true); });
if (minus) minus.addEventListener('click', function () { applyScale(curScale() - 10); }); if (minus) minus.addEventListener('click', function () { applyScale(scale - 10, true); });
window.addEventListener('storage', function (e) { if (e.key === FONT_KEY) applyScale(curScale()); });
})(); })();
</script> </script>
<script> <script>

View File

@@ -145,7 +145,8 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
data-link="<?= esc($row->mm_link) ?>" data-link="<?= esc($row->mm_link) ?>"
data-level="<?= esc($row->mm_level) ?>" data-level="<?= esc($row->mm_level) ?>"
data-view="<?= (string) $row->mm_is_view ?>" data-view="<?= (string) $row->mm_is_view ?>"
data-dep="<?= (int) $row->mm_dep ?>"> data-dep="<?= (int) $row->mm_dep ?>"
data-pidx="<?= (int) $row->mm_pidx ?>">
수정 수정
</button> </button>
<?php if ($dep === 0): ?> <?php if ($dep === 0): ?>
@@ -190,6 +191,26 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
<label class="block text-sm font-medium text-gray-700">링크</label> <label class="block text-sm font-medium text-gray-700">링크</label>
<input type="text" name="mm_link" id="mm_link" class="border border-gray-300 rounded px-2 py-1 w-full text-sm" placeholder="예: admin/users"/> <input type="text" name="mm_link" id="mm_link" class="border border-gray-300 rounded px-2 py-1 w-full text-sm" placeholder="예: admin/users"/>
</div> </div>
<div class="space-y-2 mb-3" id="menu-position-wrap">
<label class="block text-sm font-medium text-gray-700">메뉴 위치</label>
<div class="flex flex-wrap gap-x-4 gap-y-1">
<label class="inline-flex items-center gap-1 cursor-pointer">
<input type="radio" name="pos_type" value="top" id="pos-top" checked/>
<span class="text-sm">상위 메뉴로 등록</span>
</label>
<label class="inline-flex items-center gap-1 cursor-pointer">
<input type="radio" name="pos_type" value="child" id="pos-child"/>
<span class="text-sm">하위 메뉴로 등록</span>
</label>
</div>
<select id="mm-parent-select" class="border border-gray-300 rounded px-2 py-1 w-full text-sm mt-1 hidden">
<option value="">— 상위 메뉴 선택 —</option>
<?php foreach ($list as $prow): ?>
<?php if ((int) $prow->mm_dep !== 0) { continue; } ?>
<option value="<?= (int) $prow->mm_idx ?>" data-dep="<?= (int) $prow->mm_dep ?>"><?= esc($prow->mm_name) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="space-y-2 mb-3"> <div class="space-y-2 mb-3">
<label class="block text-sm font-medium text-gray-700">노출 대상</label> <label class="block text-sm font-medium text-gray-700">노출 대상</label>
<?php if ($mtCode === 'admin'): ?> <?php if ($mtCode === 'admin'): ?>
@@ -259,6 +280,50 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
const editIdInput = document.getElementById('mm_idx_edit'); const editIdInput = document.getElementById('mm_idx_edit');
const mmPidxInput = form.querySelector('[name="mm_pidx"]'); const mmPidxInput = form.querySelector('[name="mm_pidx"]');
const mmDepInput = form.querySelector('[name="mm_dep"]'); const mmDepInput = form.querySelector('[name="mm_dep"]');
const posWrap = document.getElementById('menu-position-wrap');
const posTop = document.getElementById('pos-top');
const posChild = document.getElementById('pos-child');
const parentSelect = document.getElementById('mm-parent-select');
// 상위/하위 위치 선택 → 숨은 mm_pidx / mm_dep 동기화
function applyParentFromSelect() {
const opt = parentSelect.options[parentSelect.selectedIndex];
if (opt && opt.value) {
mmPidxInput.value = opt.value;
mmDepInput.value = String((parseInt(opt.dataset.dep || '0', 10)) + 1);
} else {
mmPidxInput.value = '0';
mmDepInput.value = '1';
}
}
function setPosition(type, parentId) {
if (type === 'child') {
if (posChild) posChild.checked = true;
parentSelect.classList.remove('hidden');
if (parentId != null) parentSelect.value = String(parentId);
applyParentFromSelect();
} else {
if (posTop) posTop.checked = true;
parentSelect.classList.add('hidden');
mmPidxInput.value = '0';
mmDepInput.value = '0';
}
}
if (posTop) posTop.addEventListener('change', function () { if (posTop.checked) setPosition('top'); });
if (posChild) posChild.addEventListener('change', function () { if (posChild.checked) setPosition('child', parentSelect.value); });
if (parentSelect) parentSelect.addEventListener('change', applyParentFromSelect);
// 하위 선택인데 상위 미지정이면 등록 차단
function enableAllParentOptions() {
if (!parentSelect) return;
Array.prototype.forEach.call(parentSelect.options, function (o) { o.disabled = false; });
}
form.addEventListener('submit', function (e) {
if (posChild && posChild.checked && !parentSelect.value) {
e.preventDefault();
alert('하위 메뉴로 지정하려면 상위 메뉴를 선택해 주세요.');
}
});
const levelAll = document.getElementById('mm_level_all'); const levelAll = document.getElementById('mm_level_all');
const levelCbs = document.querySelectorAll('.mm-level-cb'); const levelCbs = document.querySelectorAll('.mm-level-cb');
const isAdminType = '<?= esc($mtCode) ?>' === 'admin'; const isAdminType = '<?= esc($mtCode) ?>' === 'admin';
@@ -298,11 +363,23 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
const level = (this.dataset.level || '').toString().trim(); const level = (this.dataset.level || '').toString().trim();
const view = this.dataset.view || 'Y'; const view = this.dataset.view || 'Y';
const dep = parseInt(this.dataset.dep || '0', 10); const dep = parseInt(this.dataset.dep || '0', 10);
const pidx = parseInt(this.dataset.pidx || '0', 10);
form.action = '<?= base_url('admin/menus/update/') ?>' + id; form.action = '<?= base_url('admin/menus/update/') ?>' + id;
form.querySelector('[name="mm_name"]').value = name; form.querySelector('[name="mm_name"]').value = name;
form.querySelector('[name="mm_link"]').value = link; form.querySelector('[name="mm_link"]').value = link;
mmPidxInput.value = '0'; // 위치(상위/하위) 선택 UI를 현재 위치로 표시 — 수정 시에도 상위↔하위 이동 가능
mmDepInput.value = String(dep); if (posWrap) posWrap.style.display = '';
// 자기 자신은 상위 후보에서 제외
if (parentSelect) {
Array.prototype.forEach.call(parentSelect.options, function (o) {
o.disabled = (o.value === String(id));
});
}
if (pidx > 0) {
setPosition('child', pidx);
} else {
setPosition('top');
}
if (!isAdminType && levelAll) { if (!isAdminType && levelAll) {
levelAll.checked = (level === ''); levelAll.checked = (level === '');
levelCbs.forEach(function(cb){ levelCbs.forEach(function(cb){
@@ -340,6 +417,10 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
formTitle.textContent = '하위 메뉴 등록'; formTitle.textContent = '하위 메뉴 등록';
btnSubmit.textContent = '등록'; btnSubmit.textContent = '등록';
btnCancel.style.display = 'inline-block'; btnCancel.style.display = 'inline-block';
// 위치 선택 UI를 '하위'로 반영(선택한 상위 메뉴 표시)
if (posWrap) posWrap.style.display = '';
enableAllParentOptions();
setPosition('child', parentId);
// 노출 기본값 재설정 // 노출 기본값 재설정
form.querySelector('[name="mm_is_view"]').checked = true; form.querySelector('[name="mm_is_view"]').checked = true;
if (!isAdminType && levelAll) { if (!isAdminType && levelAll) {
@@ -368,6 +449,10 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
formTitle.textContent = '메뉴 등록'; formTitle.textContent = '메뉴 등록';
btnSubmit.textContent = '등록'; btnSubmit.textContent = '등록';
btnCancel.style.display = 'none'; btnCancel.style.display = 'none';
// 위치 선택 UI 복원 → 기본 '상위'
if (posWrap) posWrap.style.display = '';
enableAllParentOptions();
setPosition('top');
}); });
// 메뉴 목록 행 드래그 정렬 (마우스 이벤트 기반) // 메뉴 목록 행 드래그 정렬 (마우스 이벤트 기반)

View File

@@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
/**
* @var array<string, array{label:string,type:string}> $columnCatalog
* @var list<string> $selected
* @var string $title
* @var string $category
* @var string $mode
* @var string $startDate
* @var string $endDate
* @var list<array<string,mixed>> $rows
* @var array<string,int> $totals
* @var list<object> $presets
* @var int $activePresetIdx
*/
$catLabels = ['all' => '전체', 'envelope' => '봉투', 'food' => '음식물', 'waste' => '폐기물'];
$printTitle = $title !== '' ? $title : '맞춤 집계표';
$nf = static fn ($n): string => number_format((int) $n);
?>
<?= view('components/print_header', [
'printTitle' => $printTitle,
'printDate' => date('Y-m-d'),
'printExtraLines' => ['조회기간: ' . $startDate . ' ~ ' . $endDate . ' · 품목구분: ' . ($catLabels[$category] ?? '전체') . ' · (단위: 매 / 원)'],
]) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel no-print flex flex-wrap items-center justify-between gap-2">
<span class="text-sm font-bold text-gray-700">맞춤 집계표</span>
<button type="button" onclick="window.print()" class="border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
</section>
<!-- 조회/구성 폼 -->
<section class="p-3 bg-white border-b border-gray-200 no-print">
<form method="get" action="<?= mgmt_url('reports/custom') ?>" id="custom-report-form" class="space-y-3">
<input type="hidden" name="applied" value="1"/>
<input type="hidden" name="export" id="cr-export" value="0"/>
<div class="flex flex-wrap items-end gap-3 text-sm">
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">표 제목</label>
<input type="text" name="title" value="<?= esc($title, 'attr') ?>" maxlength="200" placeholder="예: 2026년 음식물 봉투 판매 집계" class="border border-gray-300 rounded px-2 py-1 w-72"/>
</div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">시작일</label>
<input type="date" name="start_date" value="<?= esc($startDate, 'attr') ?>" class="border border-gray-300 rounded px-2 py-1"/>
</div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">종료일</label>
<input type="date" name="end_date" value="<?= esc($endDate, 'attr') ?>" class="border border-gray-300 rounded px-2 py-1"/>
</div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">품목구분(종류)</label>
<select name="category" class="border border-gray-300 rounded px-2 py-1 w-32">
<?php foreach ($catLabels as $ck => $cl): ?>
<option value="<?= esc($ck, 'attr') ?>" <?= $category === $ck ? 'selected' : '' ?>><?= esc($cl) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">집계 방식</label>
<select name="mode" class="border border-gray-300 rounded px-2 py-1 w-40">
<option value="detail" <?= $mode === 'detail' ? 'selected' : '' ?>>상세(건별)</option>
<option value="summary" <?= $mode === 'summary' ? 'selected' : '' ?>>품목별 집계</option>
</select>
</div>
<button type="submit" onclick="document.getElementById('cr-export').value='0'" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm shadow hover:opacity-90">조회</button>
<button type="submit" onclick="document.getElementById('cr-export').value='1'" class="border border-btn-excel-border text-btn-excel-text px-4 py-1.5 rounded-sm text-sm hover:bg-green-50 transition">엑셀저장</button>
</div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">표시할 컬럼 선택</label>
<div class="flex flex-wrap gap-x-4 gap-y-1">
<?php foreach ($columnCatalog as $key => $meta): ?>
<label class="inline-flex items-center gap-1 text-sm cursor-pointer">
<input type="checkbox" name="cols[]" value="<?= esc($key, 'attr') ?>" <?= in_array($key, $selected, true) ? 'checked' : '' ?>/>
<span><?= esc($meta['label']) ?></span>
</label>
<?php endforeach; ?>
</div>
<p class="text-[11px] text-gray-500 mt-1">집계 방식이 「품목별 집계」이면 일자·판매소 등 건별 컬럼은 빈 값으로 표시됩니다.</p>
</div>
</form>
</section>
<!-- 프리셋 (계정별 저장) -->
<?php if (! empty($presetsEnabled)): ?>
<section class="p-3 bg-gray-50 border-b border-gray-200 no-print">
<div class="flex flex-wrap items-end gap-3 text-sm">
<form method="get" action="<?= mgmt_url('reports/custom') ?>" class="flex items-end gap-2">
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">저장된 프리셋 불러오기</label>
<select name="preset" class="border border-gray-300 rounded px-2 py-1 min-w-[14rem]" onchange="this.form.submit()">
<option value="">— 프리셋 선택 —</option>
<?php foreach ($presets as $p): ?>
<option value="<?= (int) $p->rp_idx ?>" <?= $activePresetIdx === (int) $p->rp_idx ? 'selected' : '' ?>><?= esc((string) $p->rp_name) ?></option>
<?php endforeach; ?>
</select>
</div>
<input type="hidden" name="start_date" value="<?= esc($startDate, 'attr') ?>"/>
<input type="hidden" name="end_date" value="<?= esc($endDate, 'attr') ?>"/>
</form>
<?php if ($activePresetIdx > 0): ?>
<form method="post" action="<?= mgmt_url('reports/custom/preset/delete') ?>" onsubmit="return confirm('이 프리셋을 삭제할까요?');" class="flex items-end">
<?= csrf_field() ?>
<input type="hidden" name="rp_idx" value="<?= (int) $activePresetIdx ?>"/>
<button type="submit" class="text-red-600 hover:underline text-xs pb-1">현재 프리셋 삭제</button>
</form>
<?php endif; ?>
<!-- 현재 화면 구성 그대로 저장 (JS로 현재 폼 값 복사) -->
<form method="post" action="<?= mgmt_url('reports/custom/preset/save') ?>" id="preset-save-form" class="flex items-end gap-2">
<?= csrf_field() ?>
<input type="hidden" name="rp_idx" value="<?= (int) $activePresetIdx ?>"/>
<input type="hidden" name="rp_title" id="ps-title"/>
<input type="hidden" name="rp_category" id="ps-category"/>
<input type="hidden" name="rp_mode" id="ps-mode"/>
<input type="hidden" name="start_date" value="<?= esc($startDate, 'attr') ?>"/>
<input type="hidden" name="end_date" value="<?= esc($endDate, 'attr') ?>"/>
<div id="ps-cols-holder"></div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">프리셋 이름</label>
<input type="text" name="rp_name" maxlength="100" placeholder="예: 음식물 담당 월집계" class="border border-gray-300 rounded px-2 py-1 w-56" required/>
</div>
<button type="submit" class="bg-[#243a5e] text-white px-4 py-1.5 rounded-sm text-sm shadow hover:opacity-90">현재 구성 저장</button>
</form>
</div>
</section>
<?php endif; ?>
<!-- 결과 표 -->
<div class="p-3">
<div class="mb-2 text-center no-print">
<h1 class="text-lg font-bold m-0"><?= esc($printTitle) ?></h1>
<p class="text-xs text-gray-500 mt-0.5">조회기간 <?= esc($startDate) ?> ~ <?= esc($endDate) ?> · 품목구분 <?= esc($catLabels[$category] ?? '전체') ?> · 건수 <?= $nf(count($rows)) ?></p>
</div>
<div class="overflow-auto">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-12 text-center">No</th>
<?php foreach ($selected as $key): ?>
<th class="<?= ($columnCatalog[$key]['type'] ?? '') === 'num' ? 'text-right' : 'text-left' ?> px-2"><?= esc($columnCatalog[$key]['label']) ?></th>
<?php endforeach; ?>
</tr>
</thead>
<tbody>
<?php if ($rows === []): ?>
<tr><td colspan="<?= count($selected) + 1 ?>" class="text-center text-gray-400 py-6">조회된 데이터가 없습니다.</td></tr>
<?php else: ?>
<?php foreach ($rows as $i => $row): ?>
<tr>
<td class="text-center text-gray-500"><?= $i + 1 ?></td>
<?php foreach ($selected as $key): ?>
<?php $isNum = ($columnCatalog[$key]['type'] ?? '') === 'num'; ?>
<td class="<?= $isNum ? 'text-right' : 'text-left' ?> px-2"><?= $isNum ? $nf($row[$key] ?? 0) : esc((string) ($row[$key] ?? '')) ?></td>
<?php endforeach; ?>
</tr>
<?php endforeach; ?>
<tr class="bg-amber-50 font-bold">
<td class="text-center">합계</td>
<?php foreach ($selected as $key): ?>
<?php $isNum = ($columnCatalog[$key]['type'] ?? '') === 'num'; ?>
<td class="<?= $isNum ? 'text-right' : '' ?> px-2"><?= $isNum ? $nf($totals[$key] ?? 0) : '' ?></td>
<?php endforeach; ?>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<script>
(function () {
// "현재 구성 저장" 시, 조회 폼의 현재 값(제목/종류/방식/컬럼)을 프리셋 저장 폼으로 복사
var mainForm = document.getElementById('custom-report-form');
var saveForm = document.getElementById('preset-save-form');
if (!mainForm || !saveForm) return;
saveForm.addEventListener('submit', function () {
document.getElementById('ps-title').value = mainForm.querySelector('[name="title"]').value;
document.getElementById('ps-category').value = mainForm.querySelector('[name="category"]').value;
document.getElementById('ps-mode').value = mainForm.querySelector('[name="mode"]').value;
var holder = document.getElementById('ps-cols-holder');
holder.innerHTML = '';
mainForm.querySelectorAll('input[name="cols[]"]:checked').forEach(function (cb) {
var h = document.createElement('input');
h.type = 'hidden'; h.name = 'cols[]'; h.value = cb.value;
holder.appendChild(h);
});
});
})();
</script>

View File

@@ -15,6 +15,9 @@ $subtitle = $subtitle ?? '종량제 쓰레기봉투 물류시스템';
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/> <meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title><?= esc($pageTitle ?? 'GBLS') ?></title> <title><?= esc($pageTitle ?? 'GBLS') ?></title>
<link rel="icon" type="image/svg+xml" href="<?= base_url('favicon.svg') ?>"/>
<link rel="alternate icon" href="<?= base_url('favicon.ico') ?>"/>
<link rel="apple-touch-icon" href="<?= base_url('favicon.svg') ?>"/>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script> <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 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"/> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"/>
@@ -41,7 +44,7 @@ if (window.top !== window.self) { try { window.top.location.href = <?= json_enco
<header class="bg-navy text-white h-12 flex items-center justify-between px-4 shrink-0 shadow"> <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 tracking-tight hover:opacity-90" title="GBLS (Garbage Bag Logistics System)"> <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"> <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"/> <path fill="currentColor" fill-rule="evenodd" d="M6 7.5 H18 V19.5 A1.5 1.5 0 0 1 16.5 21 H7.5 A1.5 1.5 0 0 1 6 19.5 Z M6 7.5 L8.5 4.2 H15.5 L18 7.5 Z M10.2 5.2 h3.6 a0.5 0.5 0 0 1 0 1 h-3.6 a0.5 0.5 0 0 1 0 -1 z"/>
</svg> </svg>
<span class="leading-none flex flex-col"> <span class="leading-none flex flex-col">
<strong class="text-base font-extrabold tracking-wide">GBLS</strong> <strong class="text-base font-extrabold tracking-wide">GBLS</strong>

View File

@@ -788,6 +788,16 @@
} }
}); });
// 박스수량 입력 중 Enter → 다음 봉투의 박스수량 입력칸으로 이동(#39)
selectedBody.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' || !event.target.classList.contains('item-qty-box')) return;
event.preventDefault();
const inputs = Array.from(selectedBody.querySelectorAll('.item-qty-box'));
const next = inputs[inputs.indexOf(event.target) + 1];
if (next) { next.focus(); next.select(); }
else { event.target.blur(); }
});
referenceRows.forEach((row) => { referenceRows.forEach((row) => {
row.addEventListener('click', (event) => { row.addEventListener('click', (event) => {
const button = event.target.closest('.js-toggle-bag'); const button = event.target.closest('.js-toggle-bag');

View File

@@ -0,0 +1,271 @@
<?php
/**
* 실사 재고 바코드 리스트 — 현재 창고에 있어야 하는 봉투 번호(바코드) 목록.
* 초기 1페이지만 렌더하고, 스크롤 시 AJAX 로 다음 청크를 이어 붙인다(레이지 로딩).
* @var string $bagCode
* @var string $keyword
* @var list<array<string,mixed>> $itemOptions
* @var list<array<string,mixed>> $rows 초기 청크
* @var int $totalBoxes
* @var int $totalPacks
* @var int $totalSheets
* @var int $pageSize
* @var int $loadedCount
*/
$bagCode = (string) ($bagCode ?? '');
$keyword = (string) ($keyword ?? '');
$itemOptions = is_array($itemOptions ?? null) ? $itemOptions : [];
$rows = is_array($rows ?? null) ? $rows : [];
$totalBoxes = (int) ($totalBoxes ?? 0);
$totalPacks = (int) ($totalPacks ?? 0);
$totalSheets = (int) ($totalSheets ?? 0);
$pageSize = (int) ($pageSize ?? 200);
$loadedCount = (int) ($loadedCount ?? count($rows));
$baseUrl = site_url('bag/inventory/stock-barcode-list');
$exportQs = http_build_query(['bag_code' => $bagCode, 'keyword' => $keyword, 'export' => 1]);
$hasMore = $loadedCount < $totalPacks;
?>
<div class="space-y-2">
<section class="border border-gray-300 rounded-lg bg-white p-3 no-print">
<div class="flex items-start justify-between gap-2 mb-2">
<div>
<h2 class="text-base font-bold text-gray-800">실사 재고 바코드 리스트</h2>
<p class="text-xs text-gray-500 mt-0.5">현재 창고에 남아있어야 하는(재고 상태) 봉투 번호를 뽑아 실사 시 실물과 대조하세요.</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<button type="button" id="sbl-print" class="border border-gray-300 text-gray-600 px-3 py-1.5 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
<a href="<?= esc($baseUrl . '?' . $exportQs) ?>" class="border border-green-500 text-green-700 px-3 py-1.5 rounded-sm text-sm hover:bg-green-50 transition">엑셀저장</a>
</div>
</div>
<form method="get" action="<?= esc($baseUrl) ?>" class="flex flex-wrap items-end gap-2 text-sm">
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">품목</label>
<select name="bag_code" class="border border-gray-300 rounded px-2 py-1 w-56">
<option value="">— 전체 품목 —</option>
<?php foreach ($itemOptions as $opt): ?>
<?php $oc = (string) ($opt['brpc_bag_code'] ?? ''); ?>
<option value="<?= esc($oc, 'attr') ?>" <?= $oc === $bagCode ? 'selected' : '' ?>>
<?= esc(($opt['bag_name'] ?? '') !== '' ? $opt['bag_name'] : $oc) ?>
(팩 <?= number_format((int) ($opt['pack_cnt'] ?? 0)) ?> · <?= number_format((int) ($opt['sheet_qty'] ?? 0)) ?>매)
</option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">번호 검색</label>
<input type="text" name="keyword" value="<?= esc($keyword, 'attr') ?>" placeholder="박스/팩/시트번호/LOT" class="border border-gray-300 rounded px-2 py-1 w-56"/>
</div>
<button type="submit" class="text-white px-4 py-1.5 rounded-sm text-sm hover:opacity-90 transition" style="background:#1f2b4d;">조회</button>
<?php if ($bagCode !== '' || $keyword !== ''): ?>
<a href="<?= esc($baseUrl) ?>" class="text-gray-500 hover:underline text-sm pb-1">초기화</a>
<?php endif; ?>
</form>
</section>
<section class="border border-gray-300 rounded-lg bg-white p-3">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm mb-2 print-title">
<span class="font-bold text-gray-800">창고 재고 바코드</span>
<span class="text-gray-600">박스 <b class="text-gray-900"><?= number_format($totalBoxes) ?></b></span>
<span class="text-gray-600">팩 <b class="text-gray-900"><?= number_format($totalPacks) ?></b></span>
<span class="text-gray-600">총 매수 <b class="text-gray-900"><?= number_format($totalSheets) ?></b></span>
<span class="text-gray-400 text-xs ml-auto no-print"><?= esc(date('Y-m-d H:i')) ?> 기준 · 표시 <b id="sbl-shown"><?= number_format($loadedCount) ?></b>/<?= number_format($totalPacks) ?></span>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm border-collapse"
id="sbl-table"
data-endpoint="<?= esc($baseUrl, 'attr') ?>"
data-bag-code="<?= esc($bagCode, 'attr') ?>"
data-keyword="<?= esc($keyword, 'attr') ?>"
data-offset="<?= (int) $loadedCount ?>"
data-page-size="<?= (int) $pageSize ?>"
data-total="<?= (int) $totalPacks ?>"
data-has-more="<?= $hasMore ? '1' : '0' ?>">
<thead>
<tr class="bg-gray-100 text-gray-700">
<th class="border border-gray-300 px-2 py-1 text-center w-12">No</th>
<th class="border border-gray-300 px-2 py-1 text-left">품목</th>
<th class="border border-gray-300 px-2 py-1 text-left w-24">LOT</th>
<th class="border border-gray-300 px-2 py-1 text-left">박스코드</th>
<th class="border border-gray-300 px-2 py-1 text-left">팩코드</th>
<th class="border border-gray-300 px-2 py-1 text-left">시작번호</th>
<th class="border border-gray-300 px-2 py-1 text-left">끝번호</th>
<th class="border border-gray-300 px-2 py-1 text-right w-20">매수</th>
</tr>
</thead>
<tbody id="sbl-tbody">
<?php if ($rows === []): ?>
<tr id="sbl-empty"><td colspan="8" class="border border-gray-300 px-2 py-6 text-center text-gray-400">재고로 남아있는 바코드가 없습니다.</td></tr>
<?php else: ?>
<?php foreach ($rows as $i => $r): ?>
<tr class="hover:bg-blue-50/40">
<td class="border border-gray-300 px-2 py-1 text-center text-gray-500"><?= $i + 1 ?></td>
<td class="border border-gray-300 px-2 py-1"><?= esc(($r['brpc_bag_name'] ?? '') !== '' ? $r['brpc_bag_name'] : ($r['brpc_bag_code'] ?? '')) ?></td>
<td class="border border-gray-300 px-2 py-1"><?= esc((string) ($r['brpc_lot_no'] ?? '')) ?></td>
<td class="border border-gray-300 px-2 py-1 font-mono text-xs"><?= esc((string) ($r['brpc_box_code'] ?? '')) ?></td>
<td class="border border-gray-300 px-2 py-1 font-mono text-xs"><?= esc((string) ($r['brpc_pack_code'] ?? '')) ?></td>
<td class="border border-gray-300 px-2 py-1 font-mono text-xs"><?= esc((string) ($r['brpc_sheet_start_code'] ?? '')) ?></td>
<td class="border border-gray-300 px-2 py-1 font-mono text-xs"><?= esc((string) ($r['brpc_sheet_end_code'] ?? '')) ?></td>
<td class="border border-gray-300 px-2 py-1 text-right"><?= number_format((int) ($r['brpc_sheet_qty'] ?? 0)) ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
<?php if ($rows !== []): ?>
<tfoot>
<tr class="bg-gray-50 font-bold text-gray-800">
<td class="border border-gray-300 px-2 py-1 text-center" colspan="3">합계</td>
<td class="border border-gray-300 px-2 py-1">박스 <?= number_format($totalBoxes) ?></td>
<td class="border border-gray-300 px-2 py-1" colspan="3">팩 <?= number_format($totalPacks) ?></td>
<td class="border border-gray-300 px-2 py-1 text-right"><?= number_format($totalSheets) ?></td>
</tr>
</tfoot>
<?php endif; ?>
</table>
</div>
<!-- 레이지 로딩 감시 지점 + 수동 더보기 폴백 -->
<div id="sbl-sentinel" class="no-print py-3 text-center text-sm text-gray-400" <?= $hasMore ? '' : 'style="display:none;"' ?>>
<span id="sbl-loading" style="display:none;">불러오는 중…</span>
<button type="button" id="sbl-more" class="border border-gray-300 text-gray-600 px-4 py-1.5 rounded-sm hover:bg-gray-50 transition">더 보기</button>
</div>
</section>
</div>
<style>
@media print {
.no-print { display: none !important; }
.print-title { margin-bottom: 6px; }
table { font-size: 11px; }
}
</style>
<script>
(function () {
var table = document.getElementById('sbl-table');
if (!table) return;
var tbody = document.getElementById('sbl-tbody');
var sentinel = document.getElementById('sbl-sentinel');
var loading = document.getElementById('sbl-loading');
var moreBtn = document.getElementById('sbl-more');
var shownEl = document.getElementById('sbl-shown');
var printBtn = document.getElementById('sbl-print');
// baseURL(예: 로컬 :8080)과 실제 접속 origin(:8045 등)이 달라도 항상 현재 origin 기준으로 호출
var endpoint = table.getAttribute('data-endpoint');
try { endpoint = location.origin + new URL(endpoint, location.href).pathname; }
catch (e) { endpoint = location.pathname; }
var bagCode = table.getAttribute('data-bag-code') || '';
var keyword = table.getAttribute('data-keyword') || '';
var pageSize = parseInt(table.getAttribute('data-page-size'), 10) || 200;
var offset = parseInt(table.getAttribute('data-offset'), 10) || 0;
var total = parseInt(table.getAttribute('data-total'), 10) || 0;
var hasMore = table.getAttribute('data-has-more') === '1';
var busy = false;
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function fmt(n) { return (n || 0).toLocaleString(); }
function rowHtml(r, no) {
return '<tr class="hover:bg-blue-50/40">'
+ '<td class="border border-gray-300 px-2 py-1 text-center text-gray-500">' + no + '</td>'
+ '<td class="border border-gray-300 px-2 py-1">' + esc(r.name) + '</td>'
+ '<td class="border border-gray-300 px-2 py-1">' + esc(r.lot) + '</td>'
+ '<td class="border border-gray-300 px-2 py-1 font-mono text-xs">' + esc(r.box) + '</td>'
+ '<td class="border border-gray-300 px-2 py-1 font-mono text-xs">' + esc(r.pack) + '</td>'
+ '<td class="border border-gray-300 px-2 py-1 font-mono text-xs">' + esc(r.start) + '</td>'
+ '<td class="border border-gray-300 px-2 py-1 font-mono text-xs">' + esc(r.end) + '</td>'
+ '<td class="border border-gray-300 px-2 py-1 text-right">' + fmt(r.qty) + '</td>'
+ '</tr>';
}
function buildUrl(off, lim) {
var p = new URLSearchParams();
p.set('ajax', '1');
p.set('offset', off);
p.set('limit', lim);
if (bagCode) p.set('bag_code', bagCode);
if (keyword) p.set('keyword', keyword);
return endpoint + '?' + p.toString();
}
function appendRows(rows) {
if (!rows || !rows.length) return;
var html = '';
for (var i = 0; i < rows.length; i++) {
html += rowHtml(rows[i], offset + i + 1);
}
tbody.insertAdjacentHTML('beforeend', html);
offset += rows.length;
if (shownEl) shownEl.textContent = fmt(offset);
}
// 다음 청크 한 번 로드
function loadMore() {
if (busy || !hasMore) return Promise.resolve();
busy = true;
if (loading) loading.style.display = 'inline';
if (moreBtn) moreBtn.style.display = 'none';
return fetch(buildUrl(offset, pageSize), { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function (res) { return res.json(); })
.then(function (data) {
appendRows(data.rows);
hasMore = !!data.hasMore;
if (!hasMore && sentinel) sentinel.style.display = 'none';
})
.catch(function () { /* 네트워크 오류 시 더보기 버튼 유지 */ })
.finally(function () {
busy = false;
if (loading) loading.style.display = 'none';
if (hasMore && moreBtn) moreBtn.style.display = '';
});
}
// 남은 전체를 한 번에 로드(인쇄용)
function loadAll() {
if (!hasMore) return Promise.resolve();
busy = true;
if (loading) loading.style.display = 'inline';
if (moreBtn) moreBtn.style.display = 'none';
return fetch(buildUrl(offset, 5000), { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function (res) { return res.json(); })
.then(function (data) {
appendRows(data.rows);
hasMore = !!data.hasMore;
busy = false;
if (hasMore) return loadAll();
if (sentinel) sentinel.style.display = 'none';
if (loading) loading.style.display = 'none';
});
}
// 스크롤 자동 로딩(IntersectionObserver) — 미지원/iframe 대비 더보기 버튼 폴백 병행
if (hasMore && 'IntersectionObserver' in window) {
var io = new IntersectionObserver(function (entries) {
if (entries[0] && entries[0].isIntersecting) loadMore();
}, { rootMargin: '300px' });
io.observe(sentinel);
}
if (moreBtn) moreBtn.addEventListener('click', loadMore);
// 인쇄 — 전체를 먼저 불러온 뒤 출력(부분만 인쇄되는 것 방지)
if (printBtn) {
printBtn.addEventListener('click', function () {
if (!hasMore) { window.print(); return; }
printBtn.disabled = true;
var orig = printBtn.textContent;
printBtn.textContent = '전체 불러오는 중…';
loadAll().then(function () {
printBtn.disabled = false;
printBtn.textContent = orig;
window.print();
});
});
}
})();
</script>

View File

@@ -175,12 +175,13 @@ tailwind.config = {
document.addEventListener('mouseup', function () { if (!dragging) return; dragging = false; ov.style.display = 'none'; document.body.style.userSelect = ''; }); document.addEventListener('mouseup', function () { if (!dragging) return; dragging = false; ov.style.display = 'none'; document.body.style.userSelect = ''; });
})(); })();
// 글씨 크기(zoom) — 상단바에서 조절한 값을 적용. localStorage 공유 + storage 이벤트로 실시간 반영. // 글씨 크기(zoom) — 계정별·메뉴별 저장값(서버 주입)을 이 화면에 적용.
function applyFontScale() { <?php $__fmk = function_exists('current_nav_request_path') ? current_nav_request_path() : ''; $__fsc = function_exists('member_font_scale_for') ? member_font_scale_for($__fmk) : 100; ?>
try { var s = parseInt(localStorage.getItem('jrj_font_scale') || '100', 10); if (!(s >= 70 && s <= 150)) s = 100; document.documentElement.style.zoom = (s / 100); } catch (e) {} (function () {
} var s = <?= (int) $__fsc ?>;
applyFontScale(); if (!(s >= 70 && s <= 150)) s = 100;
window.addEventListener('storage', function (e) { if (e.key === 'jrj_font_scale') applyFontScale(); }); try { document.documentElement.style.zoom = (s / 100); } catch (e) {}
})();
document.addEventListener('click', function (e) { document.addEventListener('click', function (e) {
var a = e.target.closest ? e.target.closest('a[href]') : null; var a = e.target.closest ? e.target.closest('a[href]') : null;

View File

@@ -264,25 +264,39 @@ tailwind.config = {
document.addEventListener('mousemove', function (e) { if (!dragging) return; var w = window.innerWidth - e.clientX; drawer.style.width = Math.min(window.innerWidth * 0.92, Math.max(300, w)) + 'px'; }); document.addEventListener('mousemove', function (e) { if (!dragging) return; var w = window.innerWidth - e.clientX; drawer.style.width = Math.min(window.innerWidth * 0.92, Math.max(300, w)) + 'px'; });
document.addEventListener('mouseup', function () { if (!dragging) return; dragging = false; ov.style.display = 'none'; document.body.style.userSelect = ''; }); document.addEventListener('mouseup', function () { if (!dragging) return; dragging = false; ov.style.display = 'none'; document.body.style.userSelect = ''; });
// 글씨 크기 조절 — 본문 + 좌측 사이드바에 zoom 적용. // 글씨 크기 조절 — 계정별·메뉴별 저장(서버). 현재 화면 경로 기준으로 개별 적용.
// 상단 헤더는 제외 → A/A+ 버튼이 커지거나 밀리지 않아 연속 클릭이 편하다. <?php $__fmk = function_exists('current_nav_request_path') ? current_nav_request_path() : ''; $__fsc = function_exists('member_font_scale_for') ? member_font_scale_for($__fmk) : 100; ?>
var FONT_KEY = 'jrj_font_scale'; var MENU_KEY = <?= json_encode($__fmk) ?>;
var scale = <?= (int) $__fsc ?>;
var SAVE_URL = <?= json_encode(site_url('bag/pref/font-scale')) ?>;
var CSRF_NAME = <?= json_encode(csrf_token()) ?>;
var csrfHash = <?= json_encode(csrf_hash()) ?>;
var scaleSelectors = ['.sidebar', '.work-main']; var scaleSelectors = ['.sidebar', '.work-main'];
function curScale() { var s = parseInt(localStorage.getItem(FONT_KEY) || '100', 10); return (s >= 70 && s <= 150) ? s : 100; } var saveTimer = null;
function applyScale(s) { function persist(s) {
try { localStorage.setItem('jrj_font_scale::' + MENU_KEY, String(s)); } catch (e) {}
if (!MENU_KEY) return;
clearTimeout(saveTimer);
saveTimer = setTimeout(function () {
var b = new URLSearchParams(); b.set(CSRF_NAME, csrfHash); b.set('menu_key', MENU_KEY); b.set('scale', String(s));
fetch(SAVE_URL, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: b.toString() })
.then(function (r) { return r.json(); }).then(function (d) { if (d && d.csrf) csrfHash = d.csrf; }).catch(function () {});
}, 400);
}
function applyScale(s, save) {
s = Math.min(150, Math.max(70, s)); s = Math.min(150, Math.max(70, s));
try { localStorage.setItem(FONT_KEY, String(s)); } catch (e) {} scale = s;
var z = s / 100; var z = s / 100;
scaleSelectors.forEach(function (sel) { var el = document.querySelector(sel); if (el) el.style.zoom = z; }); scaleSelectors.forEach(function (sel) { var el = document.querySelector(sel); if (el) el.style.zoom = z; });
// 상단 대메뉴는 font-size 만 키운다(바 높이 48px 고정 → A/A+ 버튼이 안 밀림).
var topnav = document.querySelector('.portal-top-nav'); var topnav = document.querySelector('.portal-top-nav');
if (topnav) topnav.style.fontSize = (0.875 * z).toFixed(4) + 'rem'; if (topnav) topnav.style.fontSize = (0.875 * z).toFixed(4) + 'rem';
var pct = document.getElementById('wsFontPct'); if (pct) pct.textContent = s + '%'; var pct = document.getElementById('wsFontPct'); if (pct) pct.textContent = s + '%';
if (save) persist(s);
} }
applyScale(curScale()); applyScale(scale, false);
var plus = document.getElementById('wsFontPlus'), minus = document.getElementById('wsFontMinus'); var plus = document.getElementById('wsFontPlus'), minus = document.getElementById('wsFontMinus');
if (plus) plus.addEventListener('click', function () { applyScale(curScale() + 10); }); if (plus) plus.addEventListener('click', function () { applyScale(scale + 10, true); });
if (minus) minus.addEventListener('click', function () { applyScale(curScale() - 10); }); if (minus) minus.addEventListener('click', function () { applyScale(scale - 10, true); });
})(); })();
</script> </script>
<script> <script>

View File

@@ -191,9 +191,12 @@ if ($effectiveLgIdx) {
var STORE_KEY = 'jrj_ws_tabs'; var STORE_KEY = 'jrj_ws_tabs';
var WS_OWNER = '<?= (string) (session()->get('mb_idx') ?? '') ?>'; // 탭 저장 소유자(로그인 사용자) 식별 var WS_OWNER = '<?= (string) (session()->get('mb_idx') ?? '') ?>'; // 탭 저장 소유자(로그인 사용자) 식별
function persist() { var WS_SAVE_URL = '<?= site_url('bag/pref/workspace-tabs') ?>';
try { var WS_CSRF_NAME = '<?= csrf_token() ?>';
sessionStorage.setItem(STORE_KEY, JSON.stringify({ var wsCsrfHash = '<?= csrf_hash() ?>';
var wsSaveTimer = null;
function wsStateObj() {
return {
owner: WS_OWNER, owner: WS_OWNER,
tabs: order.map(function (id) { return { url: tabs[id].url, title: tabs[id].title }; }), tabs: order.map(function (id) { return { url: tabs[id].url, title: tabs[id].title }; }),
layout: layout, layout: layout,
@@ -201,8 +204,20 @@ if ($effectiveLgIdx) {
vRatio: vRatio, vRatio: vRatio,
hRatio: hRatio, hRatio: hRatio,
slots: slots.map(function (id) { return (id && tabs[id]) ? tabs[id].url : null; }) slots: slots.map(function (id) { return (id && tabs[id]) ? tabs[id].url : null; })
})); };
} catch (e) {} }
function persist() {
var obj = wsStateObj();
var json = JSON.stringify(obj);
try { sessionStorage.setItem(STORE_KEY, json); } catch (e) {}
// 계정(DB)에 저장 → 다음 로그인/다른 기기에서도 자동 복원 (연속 변경은 디바운스)
clearTimeout(wsSaveTimer);
wsSaveTimer = setTimeout(function () {
var b = new URLSearchParams();
b.set(WS_CSRF_NAME, wsCsrfHash); b.set('state', json);
fetch(WS_SAVE_URL, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: b.toString() })
.then(function (r) { return r.json(); }).then(function (d) { if (d && d.csrf) wsCsrfHash = d.csrf; }).catch(function () {});
}, 500);
} }
// 분할 칸 헤더·빈칸 안내 요소 4개 미리 생성 // 분할 칸 헤더·빈칸 안내 요소 4개 미리 생성
@@ -331,7 +346,7 @@ if ($effectiveLgIdx) {
persist(); persist();
} }
function focusSlot(i) { focused = i; render(); } function focusSlot(i) { focused = i; render(); try { updateFontPct(); } catch (e) {} }
// 포커스 칸에 탭 배치 (이미 다른 칸에 있으면 두 칸을 맞바꿈) // 포커스 칸에 탭 배치 (이미 다른 칸에 있으면 두 칸을 맞바꿈)
function placeInFocused(id) { function placeInFocused(id) {
@@ -343,6 +358,7 @@ if ($effectiveLgIdx) {
else { slots[focused] = id; } else { slots[focused] = id; }
} }
render(); render();
try { updateFontPct(); } catch (e) {}
} }
function setLayout(mode) { function setLayout(mode) {
@@ -423,6 +439,7 @@ if ($effectiveLgIdx) {
d.addEventListener('keydown', handleShortcut); d.addEventListener('keydown', handleShortcut);
d.addEventListener('mousedown', function () { var j = slots.indexOf(id); if (j >= 0 && j !== focused) focusSlot(j); }, true); d.addEventListener('mousedown', function () { var j = slots.indexOf(id); if (j >= 0 && j !== focused) focusSlot(j); }, true);
} catch (e) {} } catch (e) {}
try { readTabScale(id); } catch (e) {}
}); });
panels.appendChild(frame); panels.appendChild(frame);
@@ -479,29 +496,62 @@ if ($effectiveLgIdx) {
} }
document.addEventListener('keydown', handleShortcut); document.addEventListener('keydown', handleShortcut);
// 글씨 크기 조절 — 각 탭(iframe) 내용에 zoom 적용. localStorage 공유 + storage 이벤트로 새 탭/실시간 반영. // 글씨 크기 조절 — 포커스된 탭(메뉴) 내용에만 적용하고, 계정·메뉴별로 서버에 저장한다.
var FONT_KEY = 'jrj_font_scale'; // (예전엔 모든 탭·셸에 일괄 적용돼 "모든 메뉴가 함께 바뀌는" 문제가 있었음)
function curFontScale() { var s = parseInt(localStorage.getItem(FONT_KEY) || '100', 10); return (s >= 70 && s <= 150) ? s : 100; } var FONT_MIN = 70, FONT_MAX = 150;
function setFontScale(s) { var FONT_SAVE_URL = '<?= site_url('bag/pref/font-scale') ?>';
s = Math.min(150, Math.max(70, s)); var FONT_CSRF_NAME = '<?= csrf_token() ?>';
try { localStorage.setItem(FONT_KEY, String(s)); } catch (e) {} var fontCsrfHash = '<?= csrf_hash() ?>';
var z = s / 100; function menuKeyOf(id) {
var u = (tabs[id] && tabs[id].url) ? tabs[id].url : id;
try { return new URL(u, location.href).pathname.replace(/^\/+/, '').replace(/^index\.php\//, ''); } catch (e) { return String(u); }
}
function updateFontPct() {
var fid = slots[focused];
var s = (fid && tabs[fid] && tabs[fid].scale) ? tabs[fid].scale : 100;
var pct = document.getElementById('wsFontPct'); if (pct) pct.textContent = s + '%'; var pct = document.getElementById('wsFontPct'); if (pct) pct.textContent = s + '%';
// 탭(iframe) 내용 }
Object.keys(tabs).forEach(function (k) { try { tabs[k].frame.contentDocument.documentElement.style.zoom = z; } catch (e) {} }); // 새 탭 로드 시: embed 레이아웃이 적용한 저장 배율을 읽어 탭 상태로 보관.
// 좌측 사이드바 + 탭 바 텍스트도 함께 확대. function readTabScale(id) {
['.sidebar', '#wsTabBar'].forEach(function (sel) { var s = 100;
var el = document.querySelector(sel); if (el) el.style.zoom = z; try { var z = parseFloat(tabs[id].frame.contentDocument.documentElement.style.zoom || '1'); if (z > 0) s = Math.round(z * 100); } catch (e) {}
}); if (!(s >= FONT_MIN && s <= FONT_MAX)) s = 100;
// 상단 대메뉴는 font-size 만 키운다(바 높이 48px 고정 → A/A+ 버튼이 안 밀림). if (tabs[id]) tabs[id].scale = s;
var topnav = document.querySelector('.portal-top-nav'); if (slots[focused] === id) updateFontPct();
if (topnav) topnav.style.fontSize = (0.875 * z).toFixed(4) + 'rem'; }
var fontSaveTimer = null, fontSavePending = null;
function flushFontSave() {
if (!fontSavePending) return;
var p = fontSavePending; fontSavePending = null;
var b = new URLSearchParams();
b.set(FONT_CSRF_NAME, fontCsrfHash); b.set('menu_key', p.key); b.set('scale', String(p.scale));
fetch(FONT_SAVE_URL, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: b.toString() })
.then(function (r) { return r.json(); }).then(function (d) { if (d && d.csrf) fontCsrfHash = d.csrf; }).catch(function () {});
}
function applyTabFont(id, s, save) {
if (!tabs[id]) return;
s = Math.min(FONT_MAX, Math.max(FONT_MIN, s));
tabs[id].scale = s;
try { tabs[id].frame.contentDocument.documentElement.style.zoom = (s / 100); } catch (e) {}
if (slots[focused] === id) updateFontPct();
if (save) {
var key = menuKeyOf(id);
try { localStorage.setItem('jrj_font_scale::' + key, String(s)); } catch (e) {}
// 연속 클릭 시 CSRF 토큰 회전 경합 방지 → 디바운스로 마지막 값 1회만 저장
fontSavePending = { key: key, scale: s };
clearTimeout(fontSaveTimer);
fontSaveTimer = setTimeout(flushFontSave, 400);
}
} }
(function () { (function () {
setFontScale(curFontScale()); // 저장된 배율을 셸 메뉴에도 적용(초기 로드)
var plus = document.getElementById('wsFontPlus'), minus = document.getElementById('wsFontMinus'); var plus = document.getElementById('wsFontPlus'), minus = document.getElementById('wsFontMinus');
if (plus) plus.addEventListener('click', function () { setFontScale(curFontScale() + 10); }); function bump(delta) {
if (minus) minus.addEventListener('click', function () { setFontScale(curFontScale() - 10); }); var fid = slots[focused];
if (!fid || !tabs[fid]) { showToast('먼저 메뉴 탭을 여세요.'); return; }
applyTabFont(fid, (tabs[fid].scale || 100) + delta, true);
}
if (plus) plus.addEventListener('click', function () { bump(10); });
if (minus) minus.addEventListener('click', function () { bump(-10); });
})(); })();
// 좌측 사이드바 소메뉴 클릭 → 포커스 칸에 열기 // 좌측 사이드바 소메뉴 클릭 → 포커스 칸에 열기
@@ -525,11 +575,19 @@ if ($effectiveLgIdx) {
}); });
}); });
// 첫 화면: 세션 저장 복원(레이아웃·분할 배치 포함), 없으면 대시보드 1분할 // 첫 화면: ① 계정(DB) 저장 자동복원 → ② 세션 저장 → ③ 없으면 대시보드 1분할
var SERVER_STATE = <?= $savedWorkspaceJson ?? 'null' ?>;
(function restore() { (function restore() {
var saved = null; var saved = null;
// ① 계정 DB에 저장된 상태 우선(다음 로그인·다른 기기에서도 복원)
if (SERVER_STATE && SERVER_STATE.tabs && SERVER_STATE.tabs.length) {
saved = SERVER_STATE;
}
// ② 없으면 이 브라우저 세션 저장 사용
if (!saved) {
try { saved = JSON.parse(sessionStorage.getItem(STORE_KEY) || 'null'); } catch (e) {} 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.owner !== WS_OWNER) { try { sessionStorage.removeItem(STORE_KEY); } catch (e) {} saved = null; }
}
if (saved && saved.tabs && saved.tabs.length) { if (saved && saved.tabs && saved.tabs.length) {
saved.tabs.forEach(function (t) { if (t && t.url) openTab(t.url, t.title, { noFocus: true }); }); 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'; layout = (saved.layout === 'lr' || saved.layout === 'tb' || saved.layout === 'quad') ? saved.layout : 'single';

View File

@@ -187,7 +187,7 @@ unset($g);
<th class="text-right">금액</th> <th class="text-right">금액</th>
<th class="text-right">수량</th> <th class="text-right">수량</th>
<th class="text-right">금액</th> <th class="text-right">금액</th>
<th class="text-right">판매수량</th> <th class="text-right">판매수량(팩)</th>
<th class="text-right">금액</th> <th class="text-right">금액</th>
<th class="text-center">B</th> <th class="text-center">B</th>
<th class="text-center">P</th> <th class="text-center">P</th>
@@ -212,7 +212,7 @@ unset($g);
<td class="text-right px-2"><?= number_format($r['box']) ?></td> <td class="text-right px-2"><?= number_format($r['box']) ?></td>
<td class="text-right px-2"><?= number_format($boxPrice) ?></td> <td class="text-right px-2"><?= number_format($boxPrice) ?></td>
<td class="text-right px-2"><?= number_format($r['price']) ?></td> <td class="text-right px-2"><?= number_format($r['price']) ?></td>
<td><input class="border border-gray-300 rounded px-2 py-1 text-sm w-full text-right item-qty-input" name="item_qty[]" type="number" min="0" value="0"/></td> <td><input class="border border-gray-300 rounded px-2 py-1 text-sm w-full text-right item-qty-input item-pack-input" type="number" min="0" value="0"/><input type="hidden" name="item_qty[]" class="item-qty-hidden" value="0"/></td>
<td class="text-right px-2 item-amount-cell">0</td> <td class="text-right px-2 item-amount-cell">0</td>
<td class="text-center item-box-cell">0</td> <td class="text-center item-box-cell">0</td>
<td class="text-center item-pack-cell">0</td> <td class="text-center item-pack-cell">0</td>
@@ -330,12 +330,18 @@ unset($g);
} }
function calcRow(row) { function calcRow(row) {
const qtyInput = row.querySelector('.item-qty-input'); const qtyInput = row.querySelector('.item-pack-input');
const qty = parseInt(qtyInput.value || '0', 10) || 0; const hiddenQty = row.querySelector('.item-qty-hidden');
const packQty = parseInt(qtyInput.value || '0', 10) || 0; // 입력값 = 팩 수
const unitPrice = parseInt(row.dataset.unitPrice || '0', 10) || 0; const unitPrice = parseInt(row.dataset.unitPrice || '0', 10) || 0;
const boxSheets = parseInt(row.dataset.boxSheets || '0', 10) || 0; const boxSheets = parseInt(row.dataset.boxSheets || '0', 10) || 0;
const packSheets = parseInt(row.dataset.packSheets || '0', 10) || 0; const packSheets = parseInt(row.dataset.packSheets || '0', 10) || 0;
// 팩당 낱장 수(팩 매수 미설정 봉투는 1:1로 처리)
const mult = packSheets > 0 ? packSheets : 1;
const qty = packQty * mult; // 실제 낱장 수 → 서버로 전송
if (hiddenQty) hiddenQty.value = String(qty);
let box = 0; let box = 0;
let pack = 0; let pack = 0;
let sheet = qty; let sheet = qty;
@@ -359,14 +365,14 @@ unset($g);
row.querySelector('.item-pack-cell').textContent = pack ? nf(pack) : ''; row.querySelector('.item-pack-cell').textContent = pack ? nf(pack) : '';
row.querySelector('.item-sheet-cell').textContent = sheet ? nf(sheet) : ''; row.querySelector('.item-sheet-cell').textContent = sheet ? nf(sheet) : '';
return { qty, amount, box, pack, sheet }; return { packQty, amount, box, pack, sheet };
} }
function recalcAllRows() { function recalcAllRows() {
let sumQty = 0, sumAmount = 0, sumBox = 0, sumPack = 0, sumSheet = 0; let sumQty = 0, sumAmount = 0, sumBox = 0, sumPack = 0, sumSheet = 0;
document.querySelectorAll('.order-row').forEach((row) => { document.querySelectorAll('.order-row').forEach((row) => {
const r = calcRow(row); const r = calcRow(row);
sumQty += r.qty; sumQty += r.packQty;
sumAmount += r.amount; sumAmount += r.amount;
sumBox += r.box; sumBox += r.box;
sumPack += r.pack; sumPack += r.pack;

View File

@@ -69,12 +69,14 @@
<input type="date" lang="ko" name="so_delivery_date" id="detail-delivery-date-input" class="border border-gray-300 rounded px-2 py-0.5 text-sm"/> <input type="date" lang="ko" name="so_delivery_date" id="detail-delivery-date-input" class="border border-gray-300 rounded px-2 py-0.5 text-sm"/>
</div> </div>
<div><span class="font-semibold text-gray-700 inline-block w-20">상태</span> <span id="detail-status">-</span></div> <div><span class="font-semibold text-gray-700 inline-block w-20">상태</span> <span id="detail-status">-</span></div>
<div><span class="font-semibold text-gray-700 inline-block w-20">입금상태</span> <span id="detail-paid-status" class="font-semibold">-</span></div>
</div> </div>
</div> </div>
<div class="flex flex-wrap items-center justify-between gap-2 px-1"> <div class="flex flex-wrap items-center justify-between gap-2 px-1">
<span class="text-sm font-semibold text-gray-700">접수 품목 내역</span> <span class="text-sm font-semibold text-gray-700">접수 품목 내역</span>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button type="button" id="btn-toggle-paid" class="border border-emerald-300 text-emerald-700 px-3 py-1 rounded-sm text-sm hover:bg-emerald-50" disabled>입금확인</button>
<button type="submit" id="btn-save" class="bg-btn-search text-white px-3 py-1 rounded-sm text-sm shadow hover:opacity-90" disabled>주문 수정 저장</button> <button type="submit" id="btn-save" class="bg-btn-search text-white px-3 py-1 rounded-sm text-sm shadow hover:opacity-90" disabled>주문 수정 저장</button>
<button type="button" id="btn-receipt" class="border border-gray-300 text-gray-700 px-3 py-1 rounded-sm text-sm hover:bg-gray-50" disabled>입금자 영수증 출력</button> <button type="button" id="btn-receipt" class="border border-gray-300 text-gray-700 px-3 py-1 rounded-sm text-sm hover:bg-gray-50" disabled>입금자 영수증 출력</button>
<button type="submit" id="btn-cancel-order" form="order-cancel-form" class="border border-red-300 text-red-600 px-3 py-1 rounded-sm text-sm hover:bg-red-50" disabled>주문 취소</button> <button type="submit" id="btn-cancel-order" form="order-cancel-form" class="border border-red-300 text-red-600 px-3 py-1 rounded-sm text-sm hover:bg-red-50" disabled>주문 취소</button>
@@ -121,26 +123,25 @@
<div class="mt-3"> <div class="mt-3">
<div class="flex items-center justify-between px-1 mb-1 text-sm"> <div class="flex items-center justify-between px-1 mb-1 text-sm">
<span class="font-semibold text-gray-700">포장 명세</span> <span class="font-semibold text-gray-700">스캔 현황</span>
<span class="flex items-center gap-2">
<span class="text-gray-600">포장량: <span id="packing-total" class="font-bold text-blue-700">0</span> 개</span> <span class="text-gray-600">포장량: <span id="packing-total" class="font-bold text-blue-700">0</span> 개</span>
<button type="button" id="btn-packing-modal" class="border border-gray-300 text-gray-600 px-2 py-0.5 rounded-sm text-xs hover:bg-gray-50 disabled:opacity-50" disabled>포장 명세 보기</button>
</span>
</div> </div>
<div id="packing-scroll" class="border border-gray-300 rounded-lg overflow-auto max-h-[179px]"> <div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<table class="w-full data-table text-sm"> <!-- 최근 스캔 2건 -->
<thead> <div class="border border-gray-300 rounded-lg overflow-hidden">
<tr> <div class="px-2 py-1 bg-gray-50 border-b border-gray-200 text-xs font-semibold text-gray-600">최근 스캔</div>
<th class="w-10 text-center sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">No</th> <div id="recent-scans" class="p-2 text-xs text-gray-400 min-h-[3.5rem]">스캔 내역이 없습니다.</div>
<th class="w-40 text-left sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">봉투종류</th>
<th class="text-left sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">봉투코드</th>
<th class="w-16 text-right sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">수량</th>
<th class="w-16 text-center sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">포장</th>
</tr>
</thead>
<tbody id="packing-body">
<tr><td colspan="5" class="text-center py-6 text-gray-400">주문을 선택해 주세요.</td></tr>
</tbody>
</table>
</div> </div>
</div><!-- /포장 명세 --> <!-- 앞으로 찍을 목록(품목별 남은 수량) -->
<div class="border border-gray-300 rounded-lg overflow-hidden">
<div class="px-2 py-1 bg-amber-50 border-b border-amber-200 text-xs font-semibold text-amber-800">앞으로 찍을 목록 (남은 수량)</div>
<div id="remaining-list" class="p-2 text-xs text-gray-400 max-h-[9rem] overflow-auto min-h-[3.5rem]">주문을 선택해 주세요.</div>
</div>
</div>
</div><!-- /스캔 현황 -->
</form> </form>
<!-- 주문 취소 폼 — 버튼은 접수 품목 내역 타이틀 옆(form 속성으로 연결) --> <!-- 주문 취소 폼 — 버튼은 접수 품목 내역 타이틀 옆(form 속성으로 연결) -->
@@ -150,6 +151,36 @@
</section> </section>
</div> </div>
<!-- 포장 명세(전체 봉투코드) 팝업 -->
<div id="packing-modal-overlay" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/40 p-4">
<div class="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[85vh] flex flex-col">
<div class="flex items-center justify-between border-b border-gray-200 px-4 py-2">
<span class="text-sm font-bold text-gray-800">포장 명세 — <span id="packing-modal-title">-</span></span>
<button type="button" id="packing-modal-close" class="text-gray-400 hover:text-gray-700 text-lg leading-none">&times;</button>
</div>
<div class="p-3 overflow-auto">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-10 text-center" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">No</th>
<th class="w-40 text-left" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">봉투종류</th>
<th class="text-left" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">봉투코드</th>
<th class="w-16 text-right" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">수량</th>
<th class="w-16 text-center" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">포장</th>
</tr>
</thead>
<tbody id="packing-modal-body">
<tr><td colspan="5" class="text-center py-6 text-gray-400">스캔된 포장 내역이 없습니다.</td></tr>
</tbody>
</table>
</div>
<div class="border-t border-gray-200 px-4 py-2 flex items-center justify-between">
<span class="text-xs text-gray-500">총 <span id="packing-modal-count">0</span>건 · 포장량 <span id="packing-modal-total">0</span></span>
<button type="button" id="packing-modal-close2" class="border border-gray-300 text-gray-600 px-3 py-1.5 rounded-sm text-sm hover:bg-gray-50">닫기</button>
</div>
</div>
</div>
<!-- [개발용 임시] 선택 주문 판매소 기준 스캔 가능 바코드 후보 — 페이지 맨 아래 배치 (개발 완료 후 이 블록과 API 제거) --> <!-- [개발용 임시] 선택 주문 판매소 기준 스캔 가능 바코드 후보 — 페이지 맨 아래 배치 (개발 완료 후 이 블록과 API 제거) -->
<div id="dev-saleable-panel" class="mt-3 hidden border border-amber-400 bg-amber-50/50 p-2 rounded-sm"> <div id="dev-saleable-panel" class="mt-3 hidden border border-amber-400 bg-amber-50/50 p-2 rounded-sm">
<p class="text-[11px] text-amber-900 mb-1 leading-relaxed"> <p class="text-[11px] text-amber-900 mb-1 leading-relaxed">
@@ -209,6 +240,8 @@
const btnSave = document.getElementById('btn-save'); const btnSave = document.getElementById('btn-save');
const btnCancel = document.getElementById('btn-cancel-order'); const btnCancel = document.getElementById('btn-cancel-order');
const btnReceipt = document.getElementById('btn-receipt'); const btnReceipt = document.getElementById('btn-receipt');
const btnTogglePaid = document.getElementById('btn-toggle-paid');
const paidApi = new URL('<?= base_url('bag/order/phone/manage/paid') ?>', window.location.href).pathname;
const receiptBase = '<?= base_url('bag/order/phone/receipt') ?>'; const receiptBase = '<?= base_url('bag/order/phone/receipt') ?>';
btnReceipt?.addEventListener('click', () => { btnReceipt?.addEventListener('click', () => {
const id = inputSoIdx.value; const id = inputSoIdx.value;
@@ -223,6 +256,43 @@
const nf = (n) => new Intl.NumberFormat('ko-KR').format(n || 0); const nf = (n) => new Intl.NumberFormat('ko-KR').format(n || 0);
function paintPaidButton(order) {
if (!btnTogglePaid) return;
const paid = order && Number(order.so_paid) === 1;
btnTogglePaid.textContent = paid ? '입금확인 취소' : '입금확인';
btnTogglePaid.className = paid
? 'border border-gray-300 text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50'
: 'border border-emerald-300 text-emerald-700 px-3 py-1 rounded-sm text-sm hover:bg-emerald-50';
}
btnTogglePaid?.addEventListener('click', async () => {
const id = inputSoIdx.value;
if (!id) return;
const o = orderMap.get(String(id));
if (!o) return;
const nextPaid = Number(o.so_paid) === 1 ? 0 : 1;
if (nextPaid === 0 && !confirm('입금확인을 취소하면 이 주문은 포장(스캔)이 차단됩니다.\n계속할까요?')) return;
const body = new URLSearchParams();
body.set(csrfName, csrfHash);
body.set('so_idx', String(id));
body.set('paid', String(nextPaid));
btnTogglePaid.disabled = true;
try {
const res = await fetch(paidApi, { method: 'POST', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' }, body });
const data = await res.json();
if (data && data.csrf) csrfHash = data.csrf;
if (!data || !data.ok) { alert((data && data.message) || '처리에 실패했습니다.'); btnTogglePaid.disabled = false; return; }
o.so_paid = nextPaid;
setHeader(o);
paintPaidButton(o);
btnTogglePaid.disabled = false;
renderList(); // 리스트의 입금 표시 갱신
} catch (e) {
alert('네트워크 오류로 처리하지 못했습니다.');
btnTogglePaid.disabled = false;
}
});
function setHeader(order) { function setHeader(order) {
var t = function (id, v) { document.getElementById(id).textContent = (v === undefined || v === null || v === '') ? '-' : v; }; var t = function (id, v) { document.getElementById(id).textContent = (v === undefined || v === null || v === '') ? '-' : v; };
t('detail-so-no', order ? displayNo(order) : ''); t('detail-so-no', order ? displayNo(order) : '');
@@ -234,6 +304,12 @@
t('detail-shop-addr', order ? order.ds_addr : ''); t('detail-shop-addr', order ? order.ds_addr : '');
t('detail-order-date', order ? order.so_order_date : ''); t('detail-order-date', order ? order.so_order_date : '');
t('detail-status', order ? ((order.so_status === 'cancelled') ? '취소' : '정상') : ''); t('detail-status', order ? ((order.so_status === 'cancelled') ? '취소' : '정상') : '');
var ps = document.getElementById('detail-paid-status');
if (ps) {
var paid = order && Number(order.so_paid) === 1;
ps.textContent = order ? (paid ? '입금완료' : '미입금') : '-';
ps.style.color = order ? (paid ? '#059669' : '#e11d48') : '';
}
// 배달일 — 수정 가능한 date 입력 // 배달일 — 수정 가능한 date 입력
var dd = document.getElementById('detail-delivery-date-input'); var dd = document.getElementById('detail-delivery-date-input');
if (dd) { if (dd) {
@@ -317,6 +393,7 @@
btnSave.disabled = isCancelled; btnSave.disabled = isCancelled;
btnCancel.disabled = isCancelled; btnCancel.disabled = isCancelled;
if (btnReceipt) btnReceipt.disabled = false; if (btnReceipt) btnReceipt.disabled = false;
if (btnTogglePaid) { btnTogglePaid.disabled = isCancelled; paintPaidButton(order); }
renderPacking(order); // 포장 명세 탭 동기화 renderPacking(order); // 포장 명세 탭 동기화
@@ -464,9 +541,6 @@
// 현재 이 주문을 보고 있으면 상세·포장명세 즉시 갱신 // 현재 이 주문을 보고 있으면 상세·포장명세 즉시 갱신
if (String(selectedId) === String(data.so_idx)) { if (String(selectedId) === String(data.so_idx)) {
renderPacking(order); renderPacking(order);
// 새로 스캔한 봉투가 항상 맨 위(최신)에 보이도록 스크롤을 맨 위로 이동
const packingScrollEl = document.getElementById('packing-scroll');
if (packingScrollEl) packingScrollEl.scrollTop = 0;
const cell = detailBody.querySelector('.item-packed-cell[data-bag-code="' + (window.CSS && CSS.escape ? CSS.escape(String(data.bag_code)) : String(data.bag_code)) + '"]'); const cell = detailBody.querySelector('.item-packed-cell[data-bag-code="' + (window.CSS && CSS.escape ? CSS.escape(String(data.bag_code)) : String(data.bag_code)) + '"]');
if (cell) { if (cell) {
cell.textContent = nf(Number(data.packed_qty || 0)); cell.textContent = nf(Number(data.packed_qty || 0));
@@ -580,32 +654,93 @@
applySplit(currentPct()); applySplit(currentPct());
} }
// ── 포장 명세 탭: 주문 품목을 박스/팩/낱장 단위 행으로 펼침 ────────── // ── 스캔 현황(최근 2건 + 앞으로 찍을 목록) + 포장 명세 팝업 ──────────
const packingBody = document.getElementById('packing-body');
const packingTotal = document.getElementById('packing-total'); const packingTotal = document.getElementById('packing-total');
const recentScansEl = document.getElementById('recent-scans');
const remainingListEl = document.getElementById('remaining-list');
const btnPackingModal = document.getElementById('btn-packing-modal');
const packingModalOverlay = document.getElementById('packing-modal-overlay');
const packingModalBody = document.getElementById('packing-modal-body');
const packingModalTitle = document.getElementById('packing-modal-title');
const packingModalCount = document.getElementById('packing-modal-count');
const packingModalTotal = document.getElementById('packing-modal-total');
let packingModalOrderId = null;
function packEsc(s) { return String(s == null ? '' : s).replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c])); } function packEsc(s) { return String(s == null ? '' : s).replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c])); }
// 포장 명세 = 실제 스캔(=판매/포장)된 봉투코드 내역. 최근 스캔이 위로 오도록 역순 표시.
function renderPacking(order) { // 스캔 코드 전체 → 표 행(HTML) + 건수/합계 (팝업용). 최근이 위로.
if (!packingBody) return; function scanFullRows(order) {
const scans = Array.isArray(order.scans) ? order.scans.slice().reverse() : []; const scans = Array.isArray(order.scans) ? order.scans.slice().reverse() : [];
let no = 0; let no = 0, total = 0;
let totalQty = 0;
const rows = scans.map((s) => { const rows = scans.map((s) => {
no++; no++;
const code = s.bag_code || ''; const code = s.bag_code || '', name = s.bag_name || '', qty = parseInt(s.qty || 0, 10) || 0;
const name = s.bag_name || ''; total += qty;
const qty = parseInt(s.qty || 0, 10) || 0;
totalQty += qty;
return '<tr><td class="text-center">' + no + '</td>' return '<tr><td class="text-center">' + no + '</td>'
+ '<td class="text-left pl-2 truncate" title="' + packEsc(code) + ' ' + packEsc(name) + '">' + packEsc(code) + ' ' + packEsc(name) + '</td>' + '<td class="text-left pl-2 truncate" title="' + packEsc(code) + ' ' + packEsc(name) + '">' + packEsc(code) + ' ' + packEsc(name) + '</td>'
+ '<td class="text-left pl-2 font-mono text-gray-600">' + packEsc(s.code || '') + '</td>' + '<td class="text-left pl-2 font-mono text-gray-600">' + packEsc(s.code || '') + '</td>'
+ '<td class="text-right pr-2">' + nf(qty) + '</td>' + '<td class="text-right pr-2">' + nf(qty) + '</td>'
+ '<td class="text-center">' + packEsc(s.unit || '') + '</td></tr>'; + '<td class="text-center">' + packEsc(s.unit || '') + '</td></tr>';
}); });
packingBody.innerHTML = rows.length ? rows.join('') return { html: rows.join(''), count: no, total: total };
: '<tr><td colspan="5" class="text-center py-6 text-gray-400">스캔된 포장 내역이 없습니다.</td></tr>';
if (packingTotal) packingTotal.textContent = nf(totalQty);
} }
function fillPackingModal(order) {
const full = scanFullRows(order);
if (packingModalBody) packingModalBody.innerHTML = full.count ? full.html
: '<tr><td colspan="5" class="text-center py-6 text-gray-400">스캔된 포장 내역이 없습니다.</td></tr>';
if (packingModalCount) packingModalCount.textContent = nf(full.count);
if (packingModalTotal) packingModalTotal.textContent = nf(full.total);
}
// 앞으로 찍을 목록 = 품목별 남은(접수량 포장량)
function renderRemaining(order) {
if (!remainingListEl) return;
const items = Array.isArray(order.items) ? order.items : [];
const rows = items.map((it) => {
const qty = parseInt(it.soi_qty || 0, 10) || 0;
const packed = parseInt(it.soi_packed_qty || 0, 10) || 0;
if (qty <= 0) return '';
const remain = Math.max(0, qty - packed);
const name = (String(it.soi_bag_code || '') + ' ' + String(it.soi_bag_name || '')).trim();
if (remain <= 0) {
return '<div class="flex items-center justify-between text-emerald-600 py-0.5"><span class="truncate mr-2">' + packEsc(name) + '</span><span class="whitespace-nowrap">✓ 완료</span></div>';
}
return '<div class="flex items-center justify-between text-gray-700 py-0.5"><span class="truncate mr-2">' + packEsc(name) + '</span><span class="whitespace-nowrap font-semibold text-amber-700">' + nf(remain) + ' 매</span></div>';
}).filter(Boolean);
remainingListEl.innerHTML = rows.length ? rows.join('') : '<span class="text-gray-400">남은 수량이 없습니다.</span>';
}
// 스캔 현황 갱신(최근 2건 + 남은 목록 + 포장량 + 열린 팝업)
function renderPacking(order) {
const scans = Array.isArray(order.scans) ? order.scans : [];
const full = scanFullRows(order);
if (packingTotal) packingTotal.textContent = nf(full.total);
if (btnPackingModal) btnPackingModal.disabled = false;
if (recentScansEl) {
const last = scans.slice(-2).reverse();
recentScansEl.innerHTML = last.length
? last.map((s, i) => '<div class="flex items-center justify-between py-0.5 ' + (i === 0 ? 'font-semibold text-emerald-700' : 'text-gray-500') + '">'
+ '<span class="truncate mr-2 font-mono">' + (i === 0 ? '▶ ' : '') + packEsc(s.code || '') + '</span>'
+ '<span class="whitespace-nowrap text-gray-500">' + packEsc(s.bag_name || '') + ' · ' + nf(parseInt(s.qty || 0, 10) || 0) + '</span></div>').join('')
: '<span class="text-gray-400">스캔 내역이 없습니다.</span>';
}
renderRemaining(order);
if (packingModalOverlay && !packingModalOverlay.classList.contains('hidden') && String(packingModalOrderId) === String(order.so_idx)) {
fillPackingModal(order);
}
}
// 포장 명세 팝업 열기/닫기
function openPackingModal() {
const order = orderMap.get(String(selectedId));
if (!order) return;
packingModalOrderId = selectedId;
if (packingModalTitle) packingModalTitle.textContent = '접수 #' + displayNo(order) + ' · ' + (order.so_ds_name || '');
fillPackingModal(order);
packingModalOverlay.classList.remove('hidden');
packingModalOverlay.classList.add('flex');
}
function closePackingModal() { packingModalOverlay.classList.add('hidden'); packingModalOverlay.classList.remove('flex'); }
if (btnPackingModal) btnPackingModal.addEventListener('click', openPackingModal);
document.getElementById('packing-modal-close')?.addEventListener('click', closePackingModal);
document.getElementById('packing-modal-close2')?.addEventListener('click', closePackingModal);
if (packingModalOverlay) packingModalOverlay.addEventListener('click', (e) => { if (e.target === packingModalOverlay) closePackingModal(); });
// ── 접수 리스트 렌더 + 헤더 클릭 정렬 ────────────────────────── // ── 접수 리스트 렌더 + 헤더 클릭 정렬 ──────────────────────────
let sortKey = 'so_idx', sortDir = 'desc', selectedId = null; let sortKey = 'so_idx', sortDir = 'desc', selectedId = null;
@@ -668,6 +803,7 @@
listBody.querySelectorAll('.order-list-row').forEach((row) => row.classList.remove('bg-blue-100')); listBody.querySelectorAll('.order-list-row').forEach((row) => row.classList.remove('bg-blue-100'));
tr.classList.add('bg-blue-100'); tr.classList.add('bg-blue-100');
renderDetail(selectedId); renderDetail(selectedId);
openPackingModal(); // 행 클릭 시 해당 주문 봉투코드 팝업 즉시 표시
}); });
detailBody?.addEventListener('input', (e) => { detailBody?.addEventListener('input', (e) => {

View File

@@ -1,148 +1,360 @@
<?php
$mode = (string) ($mode ?? 'scanner');
$isBatch = (bool) ($isBatch ?? false);
$tab = (string) ($tab ?? 'company');
$companies = is_array($companies ?? null) ? $companies : [];
$agencies = is_array($agencies ?? null) ? $agencies : [];
$rows = is_array($rows ?? null) ? $rows : [];
$monthOptions = is_array($monthOptions ?? null) ? $monthOptions : [];
$receiverOptions = is_array($receiverOptions ?? null) ? $receiverOptions : [];
$senders = is_array($senders ?? null) ? $senders : [];
$companyIdx = (int) ($companyIdx ?? 0);
$agencyIdx = (int) ($agencyIdx ?? 0);
$baseUrl = $isBatch ? base_url('bag/receiving/batch') : base_url('bag/receiving/scanner');
$otherUrl = $isBatch ? base_url('bag/receiving/scanner') : base_url('bag/receiving/batch');
$otherLabel = $isBatch ? '발주 입고(스캐너)' : '일괄입고(음식물,폐기물)';
$tabUrl = static function (string $t) use ($baseUrl): string {
return $baseUrl . '?' . http_build_query(['tab' => $t]);
};
?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel flex flex-wrap items-center justify-between gap-2"> <section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel flex flex-wrap items-center justify-between gap-2">
<span class="text-sm font-bold text-gray-700">발주 입고(스캐너 대체 수동입력)</span> <span class="text-sm font-bold text-gray-700"><?= esc((string) ($workspaceTitle ?? '발주 입고')) ?></span>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<a href="<?= base_url('bag/receiving/batch') ?>" class="border border-gray-300 text-gray-700 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">일괄 입고</a> <a href="<?= esc($otherUrl) ?>" class="border border-gray-300 text-gray-700 px-3 py-1 rounded-sm text-sm hover:bg-gray-50"><?= esc($otherLabel) ?></a>
<a href="<?= base_url('bag/receiving/status') ?>" class="border border-gray-300 text-gray-700 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">입고 현황</a> <a href="<?= base_url('bag/receiving/status') ?>" class="border border-gray-300 text-gray-700 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">입고 현황</a>
</div> </div>
</section> </section>
<?php /* 플래시 메시지는 레이아웃(embed/portal)에서 한 번만 표시 — 중복 출력 방지 */ ?> <div class="grid grid-cols-1 xl:grid-cols-12 gap-3 mt-2">
<!-- 좌: 발주 현황 -->
<section class="xl:col-span-7 border border-gray-300 rounded-lg bg-white overflow-hidden flex flex-col">
<div class="px-3 py-2 border-b border-gray-200 bg-gray-50 text-sm font-semibold text-gray-700">발주 현황</div>
<section class="p-2 bg-white border border-gray-300 rounded-lg mt-2"> <!-- 조회 기준 탭 -->
<form method="get" action="<?= base_url('bag/receiving/scanner') ?>" class="flex flex-wrap items-end gap-2"> <div class="flex border-b border-gray-200 text-sm">
<div class="flex flex-col min-w-[14rem] max-w-[22rem]"> <a href="<?= esc($tabUrl('company')) ?>" class="px-4 py-2 border-b-2 <?= $tab === 'company' ? 'border-blue-600 text-blue-700 font-semibold' : 'border-transparent text-gray-500 hover:text-gray-700' ?>">제작업체 · 발주기간</a>
<label class="text-xs text-gray-500 mb-1">제작업체</label> <a href="<?= esc($tabUrl('agency')) ?>" class="px-4 py-2 border-b-2 <?= $tab === 'agency' ? 'border-blue-600 text-blue-700 font-semibold' : 'border-transparent text-gray-500 hover:text-gray-700' ?>">인수자 · 인계자</a>
<select name="company_idx" class="border border-gray-300 rounded px-2 py-1.5 text-sm bg-white"> </div>
<option value="0">제작업체 선택</option>
<?php foreach (($companies ?? []) as $company): ?> <!-- 조회 필터 -->
<option value="<?= (int) ($company->cp_idx ?? 0) ?>" <?= (int) ($companyIdx ?? 0) === (int) ($company->cp_idx ?? 0) ? 'selected' : '' ?>> <form method="get" action="<?= esc($baseUrl) ?>" class="p-2 flex flex-wrap items-end gap-2 border-b border-gray-100 text-sm">
<?= esc((string) ($company->cp_name ?? '')) ?> <input type="hidden" name="tab" value="<?= esc($tab) ?>"/>
</option> <?php if ($tab === 'company'): ?>
<div class="flex flex-col">
<label class="text-xs text-gray-500 mb-0.5">제작업체</label>
<select name="company_idx" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[10rem]">
<option value="0">전체</option>
<?php foreach ($companies as $c): ?>
<option value="<?= (int) ($c->cp_idx ?? 0) ?>" <?= $companyIdx === (int) ($c->cp_idx ?? 0) ? 'selected' : '' ?>><?= esc((string) ($c->cp_name ?? '')) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="flex flex-col">
<label class="text-xs text-gray-500 mb-0.5">발주기간(시작월)</label>
<select name="start_month" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[8rem]">
<option value="">전체</option>
<?php foreach ($monthOptions as $opt): ?>
<option value="<?= esc((string) $opt['value']) ?>" <?= (string) ($startMonth ?? '') === (string) $opt['value'] ? 'selected' : '' ?>><?= esc((string) $opt['label']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="flex flex-col">
<label class="text-xs text-gray-500 mb-0.5">종료월</label>
<select name="end_month" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[8rem]">
<option value="">전체</option>
<?php foreach ($monthOptions as $opt): ?>
<option value="<?= esc((string) $opt['value']) ?>" <?= (string) ($endMonth ?? '') === (string) $opt['value'] ? 'selected' : '' ?>><?= esc((string) $opt['label']) ?></option>
<?php endforeach; ?>
</select>
</div>
<?php else: ?>
<div class="flex flex-col">
<label class="text-xs text-gray-500 mb-0.5">인수자(대행소)</label>
<select name="agency_idx" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[10rem]">
<option value="0">전체</option>
<?php foreach ($agencies as $a): ?>
<option value="<?= (int) ($a->sa_idx ?? 0) ?>" <?= $agencyIdx === (int) ($a->sa_idx ?? 0) ? 'selected' : '' ?>><?= esc((string) ($a->sa_name ?? '')) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="flex flex-col">
<label class="text-xs text-gray-500 mb-0.5">인계자(제작업체)</label>
<select name="company_idx" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[10rem]">
<option value="0">전체</option>
<?php foreach ($companies as $c): ?>
<option value="<?= (int) ($c->cp_idx ?? 0) ?>" <?= $companyIdx === (int) ($c->cp_idx ?? 0) ? 'selected' : '' ?>><?= esc((string) ($c->cp_name ?? '')) ?></option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</div> </div>
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm shrink-0">조회</button>
</form>
<?php if ((int) ($companyIdx ?? 0) <= 0): ?>
<p class="text-xs text-gray-600 mt-2">제작업체를 선택하면 해당 업체 발주 중 미입고 내역을 조회합니다.</p>
<?php elseif (empty($rows ?? [])): ?>
<p class="text-xs text-amber-700 mt-2">미입고 잔량이 있는 발주가 없습니다. 발주 등록 후 다시 확인해 주세요.</p>
<?php endif; ?> <?php endif; ?>
</section> <button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
</form>
<form action="<?= base_url('bag/receiving/scanner/store') ?>" method="post" class="mt-2 space-y-2"> <div class="overflow-auto max-h-[60vh]">
<?= csrf_field() ?> <table class="w-full data-table text-sm" id="order-status-table">
<input type="hidden" name="company_idx" value="<?= (int) ($companyIdx ?? 0) ?>" />
<section class="p-2 bg-white border border-gray-300 rounded-lg">
<div class="grid grid-cols-1 xl:grid-cols-12 gap-2 items-end">
<div class="xl:col-span-3 flex items-center gap-2">
<label class="text-sm text-gray-600 shrink-0 w-28">인수자 (대행소)</label>
<select name="br_receiver_ref" class="border border-gray-300 rounded px-2 py-1 text-sm w-full" required>
<?php foreach (($receiverOptions ?? []) as $opt): ?>
<option value="<?= esc((string) ($opt['ref'] ?? '')) ?>" <?= (string) ($receiverRef ?? '') === (string) ($opt['ref'] ?? '') ? 'selected' : '' ?>>
<?= esc((string) ($opt['label'] ?? '')) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="xl:col-span-3 flex items-center gap-2">
<label class="text-sm text-gray-600 shrink-0 w-28">인계자 (제작업체)</label>
<select name="br_sender_idx" class="border border-gray-300 rounded px-2 py-1 text-sm w-full">
<?php foreach (($senders ?? []) as $sender): ?>
<option value="<?= (int) ($sender->mg_idx ?? 0) ?>" <?= (int) ($senderIdx ?? 0) === (int) ($sender->mg_idx ?? 0) ? 'selected' : '' ?>>
<?= esc((string) ($sender->mg_name ?? '')) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="xl:col-span-2 flex items-center gap-2">
<label class="text-sm text-gray-600 w-16">입고일</label>
<input type="date" name="br_receive_date" value="<?= esc((string) old('br_receive_date', date('Y-m-d'))) ?>" class="border border-gray-300 rounded px-2 py-1 text-sm w-full" required />
</div>
<div class="xl:col-span-2">
<button type="submit" class="w-full border border-blue-600 text-blue-700 px-2 py-1 rounded-sm text-sm hover:bg-blue-50">입고 처리</button>
</div>
<div class="xl:col-span-2 text-xs text-gray-500">상단에서 제작업체를 조회한 뒤, 아래에서 입고량(매)을 입력해 저장합니다.</div>
</div>
</section>
<div class="border border-gray-300 rounded-lg p-4 overflow-auto bg-white">
<table class="w-full data-table text-sm">
<thead> <thead>
<tr> <tr>
<th class="text-center">발주일자</th> <th class="text-center sort-th cursor-pointer select-none" data-sort="order_date" data-type="str" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">발주일자<span class="sort-ind"></span></th>
<th>봉투종류</th> <th class="text-left sort-th cursor-pointer select-none" data-sort="bag_name" data-type="str" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">봉투종류<span class="sort-ind"></span></th>
<th class="text-right">발주량(매)</th> <th class="text-right sort-th cursor-pointer select-none" data-sort="order_qty" data-type="num" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">발주량<span class="sort-ind"></span></th>
<th class="text-right">미입고량(매)</th> <th class="text-right sort-th cursor-pointer select-none" data-sort="pending" data-type="num" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">미입고량<span class="sort-ind"></span></th>
<th class="text-right">입고량(매)</th> <th class="text-right sort-th cursor-pointer select-none" data-sort="received" data-type="num" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">입고량<span class="sort-ind"></span></th>
<th>제작업체</th> <th class="text-left" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">제작업체</th>
<th class="text-center">LOT NO</th> <th class="text-center" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">LOT</th>
<th class="text-center">발주NO</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody id="order-status-body">
<?php foreach (($rows ?? []) as $row): ?> <?php foreach ($rows as $row): $k = (string) ($row['row_key'] ?? ''); ?>
<?php $k = (string) ($row['row_key'] ?? ''); ?> <tr class="os-row cursor-pointer hover:bg-blue-50"
<tr data-row-key="<?= esc($k) ?>" data-lot-no="<?= esc((string) ($row['lot_no'] ?? '')) ?>" data-bag-code="<?= esc((string) ($row['bag_code'] ?? '')) ?>" data-total-per-box="<?= (int) ($row['total_per_box'] ?? 1) ?>" data-pack-per-sheet="<?= (int) ($row['pack_per_sheet'] ?? 1) ?>" data-pending-original="<?= (int) ($row['pending_qty_sheet'] ?? 0) ?>"> data-row-key="<?= esc($k, 'attr') ?>"
data-bag-name="<?= esc((string) ($row['bag_name'] ?? ''), 'attr') ?>"
data-company="<?= esc((string) ($row['company_name'] ?? ''), 'attr') ?>"
data-lot="<?= esc((string) ($row['lot_no'] ?? ''), 'attr') ?>"
data-order-qty="<?= (int) ($row['order_qty_sheet'] ?? 0) ?>"
data-pending="<?= (int) ($row['pending_qty_sheet'] ?? 0) ?>"
data-received="<?= (int) ($row['received_qty_sheet'] ?? 0) ?>"
data-total-per-box="<?= (int) ($row['total_per_box'] ?? 1) ?>">
<td class="text-center"><?= esc((string) ($row['order_date'] ?? '')) ?></td> <td class="text-center"><?= esc((string) ($row['order_date'] ?? '')) ?></td>
<td class="text-left pl-2"><?= esc((string) ($row['bag_name'] ?? '')) ?></td> <td class="text-left pl-2"><?= esc((string) ($row['bag_name'] ?? '')) ?></td>
<td class="text-right"><?= number_format((int) ($row['order_qty_sheet'] ?? 0)) ?></td> <td class="text-right pr-2"><?= number_format((int) ($row['order_qty_sheet'] ?? 0)) ?></td>
<td class="text-right pending-cell"><?= number_format((int) ($row['pending_qty_sheet'] ?? 0)) ?></td> <td class="text-right pr-2 cell-pending"><?= number_format((int) ($row['pending_qty_sheet'] ?? 0)) ?></td>
<td class="text-right"> <td class="text-right pr-2 cell-received"><?= number_format((int) ($row['received_qty_sheet'] ?? 0)) ?></td>
<input type="number" min="0" max="<?= (int) ($row['pending_qty_sheet'] ?? 0) ?>" name="receive_qty_sheet[<?= esc($k) ?>]" value="<?= esc((string) old('receive_qty_sheet.' . $k, '0')) ?>" class="w-24 border border-gray-300 rounded px-1 py-0.5 text-sm text-right receive-input" />
</td>
<td class="text-left pl-2"><?= esc((string) ($row['company_name'] ?? '')) ?></td> <td class="text-left pl-2"><?= esc((string) ($row['company_name'] ?? '')) ?></td>
<td class="text-center font-mono"><?= esc((string) ($row['lot_no'] ?? '')) ?></td> <td class="text-center font-mono"><?= esc((string) ($row['lot_no'] ?? '')) ?></td>
<td class="text-center"><?= esc((string) ($row['order_no'] ?? '')) ?></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($rows ?? [])): ?> <?php if (empty($rows)): ?>
<tr><td colspan="8" class="text-center text-gray-400 py-4"><?php <tr><td colspan="7" class="text-center text-gray-400 py-6">조회 조건에 맞는 미입고 발주가 없습니다.</td></tr>
if ((int) ($companyIdx ?? 0) <= 0) {
echo '제작업체를 선택하고 조회해 주세요.';
} else {
echo '해당 제작업체의 미입고 발주 내역이 없습니다.';
}
?></td></tr>
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
</table> </table>
</div> </div>
</section>
<div class="flex items-center gap-2"> <!-- 우: 선택 발주 상세 + 스캔/수량 입고 + 입고 세부내역 -->
<a href="<?= base_url('bag/purchase-inbound') ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm">취소</a> <section class="xl:col-span-5 flex flex-col gap-3">
<div class="border border-gray-300 rounded-lg bg-white overflow-hidden">
<div class="px-3 py-2 border-b border-gray-200 bg-gray-50 text-sm font-semibold text-gray-700">선택 발주 · 입고 처리</div>
<div class="p-3 space-y-3 text-sm">
<div id="recv-empty" class="text-gray-400 py-6 text-center">좌측 발주 현황에서 발주를 선택하세요.</div>
<div id="recv-detail" class="hidden space-y-3">
<dl class="grid grid-cols-[5rem_1fr] gap-y-1 text-[13px] border border-gray-200 rounded p-2 bg-gray-50">
<dt class="font-semibold text-gray-600">봉투종류</dt><dd id="sel-bag"></dd>
<dt class="font-semibold text-gray-600">제작업체</dt><dd id="sel-company"></dd>
<dt class="font-semibold text-gray-600">발주량</dt><dd><span id="sel-orderqty">0</span> 매</dd>
<dt class="font-semibold text-gray-600">입고량</dt><dd><span id="sel-received" class="font-bold text-blue-700">0</span> 매</dd>
<dt class="font-semibold text-gray-600">미입고량</dt><dd><span id="sel-pending" class="font-bold text-red-600">0</span> 매</dd>
</dl>
<div class="grid grid-cols-2 gap-2">
<label class="flex flex-col text-xs text-gray-500">인수자(대행소)
<select id="br_receiver_ref" class="mt-0.5 border border-gray-300 rounded px-2 py-1 text-sm">
<?php foreach ($receiverOptions as $opt): ?>
<option value="<?= esc((string) ($opt['ref'] ?? ''), 'attr') ?>" <?= (string) ($receiverRef ?? '') === (string) ($opt['ref'] ?? '') ? 'selected' : '' ?>><?= esc((string) ($opt['label'] ?? '')) ?></option>
<?php endforeach; ?>
</select>
</label>
<label class="flex flex-col text-xs text-gray-500">인계자(제작업체)
<select id="br_sender_idx" class="mt-0.5 border border-gray-300 rounded px-2 py-1 text-sm">
<?php foreach ($senders as $s): ?>
<option value="<?= (int) ($s->mg_idx ?? 0) ?>" <?= (int) ($senderIdx ?? 0) === (int) ($s->mg_idx ?? 0) ? 'selected' : '' ?>><?= esc((string) ($s->mg_name ?? '')) ?></option>
<?php endforeach; ?>
</select>
</label>
</div> </div>
</form> <label class="flex flex-col text-xs text-gray-500">입고일
<input type="date" id="br_receive_date" value="<?= esc(date('Y-m-d'), 'attr') ?>" class="mt-0.5 border border-gray-300 rounded px-2 py-1 text-sm w-40"/>
</label>
<div class="border border-emerald-300 rounded-lg p-2 bg-emerald-50/40">
<label class="block text-xs font-semibold text-emerald-800 mb-1"><i class="fa-solid fa-barcode mr-1"></i>박스 바코드 스캔 (스캔 1회 = 1박스 입고)</label>
<input type="text" id="scan-input" autocomplete="off" placeholder="박스 바코드를 스캔 후 Enter" class="border border-gray-300 rounded px-2 py-1 text-sm w-full" disabled/>
<div class="flex items-center gap-2 mt-2">
<span class="text-xs text-gray-500 shrink-0">또는 수량 직접입력(매)</span>
<input type="number" id="qty-input" min="1" placeholder="매수" class="border border-gray-300 rounded px-2 py-1 text-sm w-28 text-right" disabled/>
<button type="button" id="btn-receive" class="border border-blue-600 text-blue-700 px-3 py-1 rounded-sm text-sm hover:bg-blue-50 disabled:opacity-50" disabled>입고 처리</button>
</div>
<p id="scan-msg" class="text-xs text-gray-500 mt-1"></p>
</div>
</div>
</div>
</div>
<div class="border border-gray-300 rounded-lg bg-white overflow-hidden">
<div class="px-3 py-2 border-b border-gray-200 bg-gray-50 text-sm font-semibold text-gray-700 flex items-center justify-between">
<span>입고 세부내역 (박스코드)</span>
<span class="text-gray-500 text-xs">박스 <span id="detail-count" class="font-bold text-blue-700">0</span></span>
</div>
<div class="overflow-auto max-h-[36vh]">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-10 text-center" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">No</th>
<th class="text-left" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">봉투종류</th>
<th class="text-left" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">박스코드</th>
<th class="w-16 text-right" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">수량</th>
</tr>
</thead>
<tbody id="detail-body">
<tr><td colspan="4" class="text-center text-gray-400 py-6">스캔/입고 시 박스코드가 추가됩니다.</td></tr>
</tbody>
</table>
</div>
</div>
</section>
</div>
<script> <script>
(() => { (() => {
const parseIntSafe = (v) => { const mode = <?= json_encode($mode) ?>;
const n = Number(String(v ?? '').replace(/,/g, '')); const scanApi = new URL('<?= base_url('bag/receiving/scan-box') ?>', window.location.href).pathname;
return Number.isFinite(n) ? Math.max(0, Math.floor(n)) : 0; const csrfName = '<?= csrf_token() ?>';
}; let csrfHash = '<?= csrf_hash() ?>';
const nf = (n) => new Intl.NumberFormat('ko-KR').format(Number(n) || 0);
const esc = (s) => String(s == null ? '' : s).replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
const refreshPending = (row) => { const tbody = document.getElementById('order-status-body');
const pendingCell = row.querySelector('.pending-cell'); const detailBody = document.getElementById('detail-body');
const input = row.querySelector('.receive-input'); const detailCountEl = document.getElementById('detail-count');
if (!pendingCell || !input) return; const scanInput = document.getElementById('scan-input');
const original = parseIntSafe(row.getAttribute('data-pending-original')); const qtyInput = document.getElementById('qty-input');
const current = parseIntSafe(input.value); const btnReceive = document.getElementById('btn-receive');
const remain = Math.max(0, original - current); const scanMsg = document.getElementById('scan-msg');
pendingCell.textContent = Number(remain || 0).toLocaleString('ko-KR'); let selectedRow = null;
}; let detailNo = 0;
document.querySelectorAll('.receive-input').forEach((input) => { // ── 정렬 ──
input.addEventListener('input', (e) => { let sortKey = '', sortDir = 'asc';
const row = e.target.closest('tr'); const numOf = (tr, key) => {
if (!row) return; const map = { order_qty: 'data-order-qty', pending: 'data-pending', received: 'data-received' };
const original = parseIntSafe(row.getAttribute('data-pending-original')); if (map[key]) return Number(tr.getAttribute(map[key]) || 0);
const current = Math.min(parseIntSafe(input.value), original); return 0;
input.value = String(current); };
refreshPending(row); const strOf = (tr, key) => {
if (key === 'order_date') return tr.children[0].textContent.trim();
if (key === 'bag_name') return tr.getAttribute('data-bag-name') || '';
return '';
};
document.querySelectorAll('#order-status-table .sort-th').forEach((th) => {
th.addEventListener('click', () => {
const key = th.dataset.sort, type = th.dataset.type;
if (sortKey === key) { sortDir = sortDir === 'asc' ? 'desc' : 'asc'; } else { sortKey = key; sortDir = 'asc'; }
const rows = Array.prototype.slice.call(tbody.querySelectorAll('tr.os-row'));
rows.sort((a, b) => {
let va, vb;
if (type === 'num') { va = numOf(a, key); vb = numOf(b, key); return sortDir === 'asc' ? va - vb : vb - va; }
va = strOf(a, key); vb = strOf(b, key);
return sortDir === 'asc' ? va.localeCompare(vb, 'ko') : vb.localeCompare(va, 'ko');
}); });
const row = input.closest('tr'); rows.forEach((r) => tbody.appendChild(r));
if (row) refreshPending(row); document.querySelectorAll('#order-status-table .sort-ind').forEach((s) => { s.textContent = ''; });
const ind = th.querySelector('.sort-ind');
if (ind) ind.textContent = sortDir === 'asc' ? ' ▲' : ' ▼';
});
});
// ── 행 선택 ──
function selectRow(tr) {
if (selectedRow) selectedRow.classList.remove('bg-blue-100');
selectedRow = tr;
tr.classList.add('bg-blue-100');
document.getElementById('recv-empty').classList.add('hidden');
document.getElementById('recv-detail').classList.remove('hidden');
document.getElementById('sel-bag').textContent = tr.getAttribute('data-bag-name') || '';
document.getElementById('sel-company').textContent = tr.getAttribute('data-company') || '-';
document.getElementById('sel-orderqty').textContent = nf(tr.getAttribute('data-order-qty'));
document.getElementById('sel-received').textContent = nf(tr.getAttribute('data-received'));
document.getElementById('sel-pending').textContent = nf(tr.getAttribute('data-pending'));
const pending = Number(tr.getAttribute('data-pending') || 0);
const enabled = pending > 0;
scanInput.disabled = !enabled;
qtyInput.disabled = !enabled;
btnReceive.disabled = !enabled;
scanMsg.textContent = enabled ? '' : '이미 전량 입고된 발주입니다.';
scanMsg.className = 'text-xs mt-1 ' + (enabled ? 'text-gray-500' : 'text-amber-700');
if (enabled) scanInput.focus();
}
tbody.addEventListener('click', (e) => {
const tr = e.target.closest('tr.os-row');
if (tr) selectRow(tr);
});
// ── 입고 처리(스캔/수량) AJAX ──
async function doReceive(barcode, qtySheet) {
if (!selectedRow) return;
const body = new URLSearchParams();
body.set(csrfName, csrfHash);
body.set('mode', mode);
body.set('row_key', selectedRow.getAttribute('data-row-key'));
body.set('br_receiver_ref', document.getElementById('br_receiver_ref').value || '');
body.set('br_sender_idx', document.getElementById('br_sender_idx').value || '0');
body.set('br_receive_date', document.getElementById('br_receive_date').value || '');
body.set('barcode', barcode || '');
body.set('qty_sheet', String(qtySheet || 0));
let data;
try {
const res = await fetch(scanApi, {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: body.toString(),
});
data = await res.json();
} catch (err) {
scanMsg.textContent = '통신 오류가 발생했습니다.'; scanMsg.className = 'text-xs mt-1 text-red-600';
return;
}
if (data && data.csrf) csrfHash = data.csrf;
if (!data || !data.ok) {
scanMsg.textContent = (data && data.message) || '입고 처리 실패';
scanMsg.className = 'text-xs mt-1 text-red-600 font-semibold';
return;
}
// 선택 행 · 상세 수치 갱신
selectedRow.setAttribute('data-received', String(data.received_qty_sheet));
selectedRow.setAttribute('data-pending', String(data.pending_qty_sheet));
selectedRow.querySelector('.cell-received').textContent = nf(data.received_qty_sheet);
selectedRow.querySelector('.cell-pending').textContent = nf(data.pending_qty_sheet);
document.getElementById('sel-received').textContent = nf(data.received_qty_sheet);
document.getElementById('sel-pending').textContent = nf(data.pending_qty_sheet);
// 입고 세부내역에 박스코드 추가(최신이 위로)
(data.boxes || []).forEach((b) => {
detailNo++;
if (detailNo === 1) detailBody.innerHTML = '';
const tr = document.createElement('tr');
tr.innerHTML = '<td class="text-center">' + detailNo + '</td>'
+ '<td class="text-left pl-2">' + esc(data.bag_name) + '</td>'
+ '<td class="text-left pl-2 font-mono text-gray-600">' + esc(b.box_code) + '</td>'
+ '<td class="text-right pr-2">' + nf(b.sheet) + '</td>';
detailBody.insertBefore(tr, detailBody.firstChild);
});
detailCountEl.textContent = nf(detailNo);
scanMsg.textContent = '입고: ' + data.bag_name + ' / ' + nf(data.qty_sheet) + '매' + (data.qty_box ? ' (' + data.qty_box + '박스)' : '') + ' · 잔량 ' + nf(data.pending_qty_sheet);
scanMsg.className = 'text-xs mt-1 text-emerald-700';
// 전량 입고되면 입력 잠금
if (Number(data.pending_qty_sheet) <= 0) {
scanInput.disabled = true; qtyInput.disabled = true; btnReceive.disabled = true;
}
}
scanInput.addEventListener('keydown', (e) => {
if (e.key !== 'Enter') return;
e.preventDefault();
const code = (scanInput.value || '').trim();
if (!code) return;
doReceive(code, 0);
scanInput.value = '';
scanInput.focus();
});
btnReceive.addEventListener('click', () => {
const q = Math.floor(Number(qtyInput.value || 0));
if (!(q > 0)) { scanMsg.textContent = '입고 수량(매)을 입력하세요.'; scanMsg.className = 'text-xs mt-1 text-amber-700'; return; }
doReceive('', q);
qtyInput.value = '';
}); });
})(); })();
</script> </script>

View File

@@ -8,7 +8,7 @@ $linkClass = $linkClass ?? 'app-brand flex items-center gap-2 shrink-0 text-base
?> ?>
<a href="<?= esc($href) ?>" class="<?= esc($linkClass, 'attr') ?>" title="GBLS (Garbage Bag Logistics System)"> <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"> <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"/> <path fill="currentColor" fill-rule="evenodd" d="M6 7.5 H18 V19.5 A1.5 1.5 0 0 1 16.5 21 H7.5 A1.5 1.5 0 0 1 6 19.5 Z M6 7.5 L8.5 4.2 H15.5 L18 7.5 Z M10.2 5.2 h3.6 a0.5 0.5 0 0 1 0 1 h-3.6 a0.5 0.5 0 0 1 0 -1 z"/>
</svg> </svg>
<span class="leading-none flex flex-col"> <span class="leading-none flex flex-col">
<strong class="font-extrabold tracking-wide">GBLS</strong> <strong class="font-extrabold tracking-wide">GBLS</strong>

View File

@@ -1,38 +1,109 @@
<?php <?php
/** /**
* Custom Tailwind CSS Pagination View for CodeIgniter 4 * 공용 리스트 페이저 → Lazy Loading(무한 스크롤) 표준 컴포넌트.
*
* CI4 Pager 템플릿(Config\Pager: default_full/default_simple)으로 등록돼 있어,
* 모든 리스트 화면의 `<?= $pager->links() ?>` 호출이 이 컴포넌트를 렌더한다.
* 페이지 번호 대신 센티넬 + '더보기' 버튼을 두고, 화면 하단 도달 시(IntersectionObserver)
* 다음 페이지 HTML을 받아 바로 위 리스트 표(tbody)에 행을 이어붙인다.
* *
* @var \CodeIgniter\Pager\PagerRenderer $pager * @var \CodeIgniter\Pager\PagerRenderer $pager
*/ */
$pager->setSurroundCount(2); $hasNext = $pager->hasNextPage();
$nextUrl = $hasNext ? (string) $pager->getNextPage() : '';
$curPage = (int) $pager->getCurrent();
$pageCount = (int) $pager->getPageCount();
?> ?>
<?php if ($pageCount > 1): ?>
<div class="lazy-pager no-print mt-3 mb-2 flex flex-col items-center gap-1"
data-next-url="<?= esc($nextUrl, 'attr') ?>"
data-has-next="<?= $hasNext ? '1' : '0' ?>">
<button type="button" class="lazy-more px-4 py-1.5 text-xs border border-gray-300 rounded-full hover:bg-gray-50 text-gray-600<?= $hasNext ? '' : ' hidden' ?>">더보기</button>
<span class="lazy-status text-[11px] text-gray-400"></span>
</div>
<script>
(function () {
if (window.__lazyPagerInit) { window.__lazyPagerInit(); return; }
<?php if ($pager->hasPreviousPage() || $pager->hasNextPage()): ?> function lastTableBefore(el, root) {
<nav aria-label="Page navigation" class="flex items-center justify-center gap-1 mt-3 mb-2 no-print"> var tables = root.querySelectorAll('table');
<?php if ($pager->hasPreviousPage()): ?> var found = null;
<a href="<?= $pager->getFirst() ?>" class="px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-100 text-gray-600" title="처음">&laquo;</a> for (var i = 0; i < tables.length; i++) {
<a href="<?= $pager->getPreviousPage() ?>" class="px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-100 text-gray-600" title="이전">&lsaquo;</a> if (el.compareDocumentPosition(tables[i]) & Node.DOCUMENT_POSITION_PRECEDING) found = tables[i];
<?php else: ?> }
<span class="px-2 py-1 text-xs border border-gray-200 rounded text-gray-300">&laquo;</span> return found;
<span class="px-2 py-1 text-xs border border-gray-200 rounded text-gray-300">&lsaquo;</span> }
<?php endif; ?> function bodyOf(table) { return table ? (table.tBodies[0] || table.querySelector('tbody')) : null; }
<?php foreach ($pager->links() as $link): ?> function initOne(sentinel) {
<?php if ($link['active']): ?> if (sentinel.getAttribute('data-lazy-init')) return;
<span class="px-3 py-1 text-xs border border-btn-search rounded bg-btn-search text-white font-bold"><?= $link['title'] ?></span> sentinel.setAttribute('data-lazy-init', '1');
<?php else: ?> var tbody = bodyOf(lastTableBefore(sentinel, document));
<a href="<?= $link['uri'] ?>" class="px-3 py-1 text-xs border border-gray-300 rounded hover:bg-gray-100 text-gray-700"><?= $link['title'] ?></a> if (!tbody) return;
<?php endif; ?> var moreBtn = sentinel.querySelector('.lazy-more');
<?php endforeach; ?> var statusEl = sentinel.querySelector('.lazy-status');
var loading = false;
var io = null;
var setStatus = function (t) { if (statusEl) statusEl.textContent = t || ''; };
<?php if ($pager->hasNextPage()): ?> async function loadNext() {
<a href="<?= $pager->getNextPage() ?>" class="px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-100 text-gray-600" title="다음">&rsaquo;</a> if (loading) return;
<a href="<?= $pager->getLast() ?>" class="px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-100 text-gray-600" title="마지막">&raquo;</a> if (sentinel.getAttribute('data-has-next') !== '1') return;
<?php else: ?> var url = sentinel.getAttribute('data-next-url');
<span class="px-2 py-1 text-xs border border-gray-200 rounded text-gray-300">&rsaquo;</span> if (!url) return;
<span class="px-2 py-1 text-xs border border-gray-200 rounded text-gray-300">&raquo;</span> loading = true; setStatus('불러오는 중…');
<?php endif; ?> try {
</nav> var res = await fetch(url, { credentials: 'same-origin', headers: { 'X-Requested-With': 'fetch' } });
var html = await res.text();
var doc = new DOMParser().parseFromString(html, 'text/html');
var fSentinel = doc.querySelector('.lazy-pager');
var fBody = bodyOf(fSentinel ? lastTableBefore(fSentinel, doc) : null);
var added = 0;
if (fBody) {
fBody.querySelectorAll('tr').forEach(function (tr) {
if (tr.children.length === 1 && tr.children[0].hasAttribute('colspan')) return; // 빈 목록 placeholder 제외
tbody.appendChild(document.importNode(tr, true));
added++;
});
}
if (fSentinel && fSentinel.getAttribute('data-has-next') === '1') {
sentinel.setAttribute('data-next-url', fSentinel.getAttribute('data-next-url'));
setStatus('');
} else {
sentinel.setAttribute('data-has-next', '0');
if (moreBtn) moreBtn.classList.add('hidden');
setStatus('모두 불러왔습니다');
if (io) io.disconnect();
}
} catch (e) {
setStatus('불러오기 실패 — [더보기]로 다시 시도해 주세요.');
}
loading = false;
}
if (moreBtn) moreBtn.addEventListener('click', loadNext);
// 사용자가 실제로 스크롤한 뒤에만 자동 로드(내부 스크롤 리스트에서 로드 즉시 전체 로딩 방지).
// 스크롤 전에는 [더보기] 버튼으로 불러온다.
if (!window.__lazyUserScrolled) {
var mark = function () { window.__lazyUserScrolled = true; };
window.addEventListener('scroll', mark, { passive: true, capture: true, once: true });
window.addEventListener('wheel', mark, { passive: true, once: true });
window.addEventListener('touchmove', mark, { passive: true, once: true });
}
if ('IntersectionObserver' in window) {
io = new IntersectionObserver(function (entries) {
entries.forEach(function (en) { if (en.isIntersecting && window.__lazyUserScrolled) loadNext(); });
}, { root: null, rootMargin: '0px 0px 320px 0px', threshold: 0 });
io.observe(sentinel);
}
}
window.__lazyPagerInit = function () {
document.querySelectorAll('.lazy-pager:not([data-lazy-init])').forEach(initOne);
};
window.__lazyPagerInit();
})();
</script>
<?php endif; ?> <?php endif; ?>

View File

@@ -9,7 +9,7 @@ $brandHref = $brandHref ?? base_url('dashboard/gov-portal');
?> ?>
<a href="<?= esc($brandHref) ?>" class="gov-portal-brand"> <a href="<?= esc($brandHref) ?>" class="gov-portal-brand">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"> <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"/> <path fill="currentColor" fill-rule="evenodd" d="M6 7.5 H18 V19.5 A1.5 1.5 0 0 1 16.5 21 H7.5 A1.5 1.5 0 0 1 6 19.5 Z M6 7.5 L8.5 4.2 H15.5 L18 7.5 Z M10.2 5.2 h3.6 a0.5 0.5 0 0 1 0 1 h-3.6 a0.5 0.5 0 0 1 0 -1 z"/>
</svg> </svg>
<span style="display:inline-flex;flex-direction:column;line-height:1.02;"> <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> <strong style="font-size:1.02rem;font-weight:800;letter-spacing:.5px;">GBLS</strong>

View File

@@ -1,3 +1,6 @@
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>GBLS</title> <title>GBLS</title>
<link rel="icon" type="image/svg+xml" href="<?= base_url('favicon.svg') ?>"/>
<link rel="alternate icon" href="<?= base_url('favicon.ico') ?>"/>
<link rel="apple-touch-icon" href="<?= base_url('favicon.svg') ?>"/>

View File

@@ -6,6 +6,42 @@
var listEl = document.getElementById('portalSidebarList'); var listEl = document.getElementById('portalSidebarList');
var titleEl = document.getElementById('portalSidebarTitle'); var titleEl = document.getElementById('portalSidebarTitle');
// 모든 상위메뉴의 소메뉴 아이콘(Font Awesome solid) — 이름 키워드 기준(구체적인 것 먼저).
// 서버 렌더(_dashboard_gov_portal_sidebar.php)의 매핑과 동일하게 유지할 것.
function sidebarIcon(name) {
var n = String(name || '');
var rules = [
['바코드', 'fa-barcode'], ['스캐너', 'fa-barcode'], ['번호', 'fa-hashtag'],
['판매대장', 'fa-book'], ['대장', 'fa-book'],
['집계표', 'fa-table-list'], ['일계표', 'fa-table-list'],
['수불', 'fa-right-left'], ['입출고', 'fa-right-left'],
['무료', 'fa-gift'], ['반품', 'fa-rotate-left'], ['파기', 'fa-trash-can'],
['신규', 'fa-chart-column'], ['취소', 'fa-ban'],
['발주파일', 'fa-file-arrow-down'], ['발주', 'fa-cart-shopping'],
['입고', 'fa-dolly'], ['재고', 'fa-warehouse'], ['불출', 'fa-truck-ramp-box'],
['전화', 'fa-phone'], ['접수', 'fa-clipboard-list'],
['판매현황', 'fa-chart-column'], ['판매소', 'fa-store'], ['대행소', 'fa-handshake'],
['담당자', 'fa-user-tie'], ['대상자', 'fa-user-check'], ['업체', 'fa-building'],
['단가', 'fa-won-sign'], ['코드', 'fa-list-ol'], ['포장', 'fa-box'],
['계획', 'fa-clipboard-check'], ['실사', 'fa-clipboard-check'],
['분석', 'fa-chart-line'], ['추이', 'fa-chart-line'], ['통계', 'fa-chart-line'],
['홈텍스', 'fa-file-invoice-dollar'], ['판매', 'fa-cash-register'],
['현황', 'fa-chart-column'], ['조회', 'fa-magnifying-glass'],
['대시보드', 'fa-gauge-high'], ['회원', 'fa-users'], ['승인', 'fa-user-check'],
['로그', 'fa-clock-rotate-left'], ['이력', 'fa-clock-rotate-left'],
['메뉴', 'fa-bars'], ['지자체', 'fa-city'], ['문의', 'fa-comment-dots'],
['비밀번호', 'fa-key'], ['설정', 'fa-gear'], ['관리', 'fa-folder-open']
];
for (var i = 0; i < rules.length; i++) {
if (n.indexOf(rules[i][0]) !== -1) return rules[i][1];
}
if (n.toLowerCase().indexOf('password') !== -1) return 'fa-key';
return 'fa-angle-right';
}
function iconHtml(parentName, childName) {
return '<i class="fa-solid ' + sidebarIcon(childName) + '" style="width:1.15em;margin-right:.45em;opacity:.85;"></i>';
}
if (listEl && navData.length) { if (listEl && navData.length) {
function renderSidebar(idx, overrideHref) { function renderSidebar(idx, overrideHref) {
var parent = navData[idx]; var parent = navData[idx];
@@ -29,10 +65,11 @@
var li = document.createElement('li'); var li = document.createElement('li');
var chHref = (child.href || '').toLowerCase().replace(/^\//, ''); var chHref = (child.href || '').toLowerCase().replace(/^\//, '');
var on = activeHref ? (chHref === activeHref) : (hasOverride ? false : ci === 0); var on = activeHref ? (chHref === activeHref) : (hasOverride ? false : ci === 0);
var ic = iconHtml(parent.name, child.name);
if (child.href) { if (child.href) {
li.innerHTML = '<a href="' + child.url + '" class="' + (on ? 'active' : '') + '">' + child.name + '</a>'; li.innerHTML = '<a href="' + child.url + '" class="' + (on ? 'active' : '') + '">' + ic + child.name + '</a>';
} else { } else {
li.innerHTML = '<span class="menu-sub" style="opacity:.65;">' + child.name + '</span>'; li.innerHTML = '<span class="menu-sub" style="opacity:.65;">' + ic + child.name + '</span>';
} }
listEl.appendChild(li); listEl.appendChild(li);
}); });

View File

@@ -6,6 +6,72 @@ declare(strict_types=1);
$activeParent = $govNavItems[$govActiveParentIdx] ?? $govNavItems[0] ?? null; $activeParent = $govNavItems[$govActiveParentIdx] ?? $govNavItems[0] ?? null;
$sidebarTitle = $activeParent['name'] ?? 'MY MENU'; $sidebarTitle = $activeParent['name'] ?? 'MY MENU';
$activeChildHref = strtolower(ltrim((string) ($govActiveChildHref ?? ''), '/')); $activeChildHref = strtolower(ltrim((string) ($govActiveChildHref ?? ''), '/'));
// 모든 상위메뉴의 소메뉴에 어울리는 아이콘(Font Awesome solid) 매핑 — 이름 키워드 기준(구체적인 것 먼저).
$showSidebarIcons = true;
$sidebarIconFor = static function (string $name): string {
$rules = [
'바코드' => 'fa-barcode',
'스캐너' => 'fa-barcode',
'번호' => 'fa-hashtag',
'판매대장' => 'fa-book',
'대장' => 'fa-book',
'집계표' => 'fa-table-list',
'일계표' => 'fa-table-list',
'수불' => 'fa-right-left',
'입출고' => 'fa-right-left',
'무료' => 'fa-gift',
'반품' => 'fa-rotate-left',
'파기' => 'fa-trash-can',
'신규' => 'fa-chart-column',
'취소' => 'fa-ban',
'발주파일' => 'fa-file-arrow-down',
'발주' => 'fa-cart-shopping',
'입고' => 'fa-dolly',
'재고' => 'fa-warehouse',
'불출' => 'fa-truck-ramp-box',
'전화' => 'fa-phone',
'접수' => 'fa-clipboard-list',
'판매현황' => 'fa-chart-column',
'판매소' => 'fa-store',
'대행소' => 'fa-handshake',
'담당자' => 'fa-user-tie',
'대상자' => 'fa-user-check',
'업체' => 'fa-building',
'단가' => 'fa-won-sign',
'코드' => 'fa-list-ol',
'포장' => 'fa-box',
'계획' => 'fa-clipboard-check',
'실사' => 'fa-clipboard-check',
'분석' => 'fa-chart-line',
'추이' => 'fa-chart-line',
'통계' => 'fa-chart-line',
'홈텍스' => 'fa-file-invoice-dollar',
'판매' => 'fa-cash-register',
'현황' => 'fa-chart-column',
'조회' => 'fa-magnifying-glass',
'대시보드' => 'fa-gauge-high',
'회원' => 'fa-users',
'승인' => 'fa-user-check',
'로그' => 'fa-clock-rotate-left',
'이력' => 'fa-clock-rotate-left',
'메뉴' => 'fa-bars',
'지자체' => 'fa-city',
'문의' => 'fa-comment-dots',
'비밀번호' => 'fa-key',
'설정' => 'fa-gear',
'관리' => 'fa-folder-open',
];
foreach ($rules as $kw => $icon) {
if (mb_stripos($name, $kw) !== false) {
return $icon;
}
}
if (stripos($name, 'password') !== false) {
return 'fa-key';
}
return 'fa-angle-right';
};
?> ?>
<aside class="sidebar"> <aside class="sidebar">
<div class="my-menu-hd" id="portalSidebarTitle"><?= esc($sidebarTitle) ?></div> <div class="my-menu-hd" id="portalSidebarTitle"><?= esc($sidebarTitle) ?></div>
@@ -22,11 +88,11 @@ $activeChildHref = strtolower(ltrim((string) ($govActiveChildHref ?? ''), '/'));
<li> <li>
<?php if ($child['href'] !== ''): ?> <?php if ($child['href'] !== ''): ?>
<a href="<?= esc($child['url']) ?>" class="<?= $isChildActive ? 'active' : '' ?>"> <a href="<?= esc($child['url']) ?>" class="<?= $isChildActive ? 'active' : '' ?>">
<?= esc($child['name']) ?> <?php if ($showSidebarIcons): ?><i class="fa-solid <?= esc($sidebarIconFor((string) $child['name']), 'attr') ?>" style="width:1.15em;margin-right:.45em;opacity:.85;"></i><?php endif; ?><?= esc($child['name']) ?>
</a> </a>
<?php else: ?> <?php else: ?>
<span class="menu-sub" style="opacity:.65;cursor:default;"> <span class="menu-sub" style="opacity:.65;cursor:default;">
<?= esc($child['name']) ?> <?php if ($showSidebarIcons): ?><i class="fa-solid <?= esc($sidebarIconFor((string) $child['name']), 'attr') ?>" style="width:1.15em;margin-right:.45em;opacity:.85;"></i><?php endif; ?><?= esc($child['name']) ?>
</span> </span>
<?php endif; ?> <?php endif; ?>
</li> </li>

View File

@@ -28,7 +28,7 @@ tailwind.config = {
<header class="bg-navy text-white h-12 flex items-center justify-between px-4 shrink-0 shadow"> <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 tracking-tight hover:opacity-90" title="GBLS (Garbage Bag Logistics System)"> <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"> <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"/> <path fill="currentColor" fill-rule="evenodd" d="M6 7.5 H18 V19.5 A1.5 1.5 0 0 1 16.5 21 H7.5 A1.5 1.5 0 0 1 6 19.5 Z M6 7.5 L8.5 4.2 H15.5 L18 7.5 Z M10.2 5.2 h3.6 a0.5 0.5 0 0 1 0 1 h-3.6 a0.5 0.5 0 0 1 0 -1 z"/>
</svg> </svg>
<span class="leading-none flex flex-col"> <span class="leading-none flex flex-col">
<strong class="text-base font-extrabold tracking-wide">GBLS</strong> <strong class="text-base font-extrabold tracking-wide">GBLS</strong>
@@ -46,7 +46,7 @@ tailwind.config = {
<div class="bg-gradient-to-br from-navy to-[#007bff] text-white px-8 py-8 text-center"> <div class="bg-gradient-to-br from-navy to-[#007bff] text-white px-8 py-8 text-center">
<div class="inline-flex h-14 w-14 items-center justify-center rounded-full bg-white/15 mb-3"> <div class="inline-flex h-14 w-14 items-center justify-center rounded-full bg-white/15 mb-3">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="h-7 w-7 text-white" aria-hidden="true" focusable="false"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="h-7 w-7 text-white" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M9 3a1 1 0 00-1 1v1H5.75a.75.75 0 000 1.5h12.5a.75.75 0 000-1.5H16V4a1 1 0 00-1-1H9zm9 4H6v11a2 2 0 002 2h8a2 2 0 002-2V7zM10 9a.75.75 0 01.75.75v6a.75.75 0 01-1.5 0v-6A.75.75 0 0110 9zm4 0a.75.75 0 01.75.75v6a.75.75 0 01-1.5 0v-6A.75.75 0 0114 9z"/> <path fill="currentColor" fill-rule="evenodd" d="M6 7.5 H18 V19.5 A1.5 1.5 0 0 1 16.5 21 H7.5 A1.5 1.5 0 0 1 6 19.5 Z M6 7.5 L8.5 4.2 H15.5 L18 7.5 Z M10.2 5.2 h3.6 a0.5 0.5 0 0 1 0 1 h-3.6 a0.5 0.5 0 0 1 0 -1 z"/>
</svg> </svg>
</div> </div>
<h1 class="text-xl font-bold">종량제 쓰레기봉투 물류시스템</h1> <h1 class="text-xl font-bold">종량제 쓰레기봉투 물류시스템</h1>

20
public/favicon.svg Normal file
View File

@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="GBLS">
<!-- 브랜드 배경(네이비) -->
<rect x="0" y="0" width="64" height="64" rx="14" fill="#243a5e"/>
<!-- 쓰레기통(흰색) : 종량제 쓰레기봉투 물류 -->
<g fill="none" stroke="#ffffff" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round">
<!-- 뚜껑 -->
<path d="M16 22 H48"/>
<!-- 손잡이 -->
<path d="M27 22 V18.5 a2 2 0 0 1 2-2 h6 a2 2 0 0 1 2 2 V22"/>
<!-- 통 몸체 -->
<path d="M19 22 L21.5 47 a3 3 0 0 0 3 2.8 h15 a3 3 0 0 0 3-2.8 L45 22 Z"/>
</g>
<!-- 세로 줄무늬 -->
<g stroke="#ffffff" stroke-width="2.6" stroke-linecap="round" opacity="0.9">
<path d="M27 28 V44"/>
<path d="M37 28 V44"/>
</g>
<!-- 에코 잎사귀(그린 포인트) -->
<path d="M32 27 C33 22 37 20 41 20 C41 25 38 28 32 27 Z" fill="#34d399"/>
</svg>

After

Width:  |  Height:  |  Size: 931 B

View File

@@ -0,0 +1,23 @@
-- 지정판매소 PII 블라인드 인덱스 컬럼(정확일치 검색용) 추가 — HMAC-SHA256 값 저장(64 hex)
-- 실행: mysql -u jongryangje -p jongryangje_dev < writable/database/designated_shop_add_pii_bidx.sql
-- 멱등. 컬럼 추가 후, 암호화/백필은 spark 명령(pii:protect-designated-shops)으로 수행.
SET @db = DATABASE();
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=@db AND TABLE_NAME='designated_shop' AND COLUMN_NAME='ds_tel_bidx')>0,
'SELECT ''ds_tel_bidx exists''',
'ALTER TABLE `designated_shop` ADD COLUMN `ds_tel_bidx` CHAR(64) NOT NULL DEFAULT '''' COMMENT ''전화 블라인드인덱스(HMAC)'' , ADD INDEX `idx_ds_tel_bidx` (`ds_tel_bidx`)');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=@db AND TABLE_NAME='designated_shop' AND COLUMN_NAME='ds_rep_name_bidx')>0,
'SELECT ''ds_rep_name_bidx exists''',
'ALTER TABLE `designated_shop` ADD COLUMN `ds_rep_name_bidx` CHAR(64) NOT NULL DEFAULT '''' COMMENT ''대표자명 블라인드인덱스(HMAC)'' , ADD INDEX `idx_ds_rep_name_bidx` (`ds_rep_name_bidx`)');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
-- 참고: 암호문(ENC:...)은 평문보다 길어짐. 아래 PII 컬럼이 짧으면 길이 확대 필요할 수 있음(확인 후 조정).
-- ALTER TABLE `designated_shop`
-- MODIFY `ds_rep_name` VARCHAR(255) NULL, MODIFY `ds_tel` VARCHAR(255) NULL,
-- MODIFY `ds_rep_phone` VARCHAR(255) NULL, MODIFY `ds_email` VARCHAR(255) NULL,
-- MODIFY `ds_va_account` VARCHAR(255) NULL;

View File

@@ -0,0 +1,15 @@
-- 지자체별 허용 IP 대역(CIDR/단일IP, 쉼표·공백 구분) — 지정판매소 PII 자동 원문 열람 조건에 사용
-- 실행: mysql -u jongryangje -p jongryangje_dev < writable/database/local_government_add_allow_ips.sql
-- 멱등(이미 있으면 건너뜀).
--
-- 예시 값(운영 반영 시 각 지자체 실제 사무실 공인 IP로 UPDATE):
-- UPDATE local_government SET lg_allow_ips = '203.0.113.10, 203.0.113.0/24' WHERE lg_idx = 1;
SET @db = DATABASE();
SET @sql = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'local_government' AND COLUMN_NAME = 'lg_allow_ips') > 0,
'SELECT ''lg_allow_ips already exists'' AS msg',
'ALTER TABLE `local_government` ADD COLUMN `lg_allow_ips` VARCHAR(500) NOT NULL DEFAULT '''' COMMENT ''허용 IP 대역(CIDR/단일IP, 쉼표구분) — PII 자동 원문 열람'''
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;

View File

@@ -0,0 +1,15 @@
-- 계정별 · 메뉴별 글자크기(zoom %) 저장
-- 실행: mysql --default-character-set=utf8mb4 -u USER -p DBNAME < writable/database/member_menu_font_scale.sql
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `member_menu_font_scale` (
`mmf_idx` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`mmf_mb_idx` INT UNSIGNED NOT NULL,
`mmf_menu_key` VARCHAR(191) NOT NULL,
`mmf_scale` SMALLINT UNSIGNED NOT NULL DEFAULT 100,
`mmf_updated` DATETIME NOT NULL,
PRIMARY KEY (`mmf_idx`),
UNIQUE KEY `uk_mb_menu` (`mmf_mb_idx`, `mmf_menu_key`),
KEY `idx_mb` (`mmf_mb_idx`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,21 @@
-- 판매 현황 카테고리에 '맞춤 집계표' 소메뉴 추가 (사이트 메뉴)
-- 실행: mysql -u jongryangje -p jongryangje_dev < writable/database/menu_add_custom_report.sql
-- 멱등: 이미 있으면 건너뜀.
SET @mt_site := (SELECT mt_idx FROM menu_type WHERE mt_code = 'site' LIMIT 1);
SET @parent_sales := (
SELECT mm_idx FROM menu
WHERE mt_idx = @mt_site AND lg_idx = 1 AND mm_pidx = 0 AND mm_name = '판매 현황'
LIMIT 1
);
SET @next_num := (
SELECT IFNULL(MAX(mm_num), -1) + 1 FROM menu WHERE mt_idx = @mt_site AND lg_idx = 1 AND mm_pidx = @parent_sales
);
INSERT INTO menu (mt_idx, lg_idx, mm_name, mm_link, mm_pidx, mm_dep, mm_num, mm_cnode, mm_level, mm_is_view)
SELECT @mt_site, 1, '맞춤 집계표', 'bag/reports/custom', @parent_sales, 1, @next_num, 0, '', 'Y'
WHERE @parent_sales IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM menu m
WHERE m.mt_idx = @mt_site AND m.lg_idx = 1 AND m.mm_pidx = @parent_sales AND m.mm_name = '맞춤 집계표'
);

View File

@@ -0,0 +1,5 @@
-- 사용자 의견 #35: 메뉴 '전화 접수 관리' → '주문접수관리' 개명
-- 실행: mysql -u jongryangje -p jongryangje_dev < writable/database/menu_rename_phone_to_order.sql
-- 멱등(이미 바뀌었으면 0건).
UPDATE `menu` SET `mm_name` = '주문접수관리' WHERE `mm_name` = '전화 접수 관리';

View File

@@ -177,7 +177,7 @@ INSERT INTO menu (mt_idx, lg_idx, mm_name, mm_link, mm_pidx, mm_dep, mm_num, mm_
SELECT @mt_site, 1, t.mm_name, SELECT @mt_site, 1, t.mm_name,
CASE t.mm_name CASE t.mm_name
WHEN '전화 접수' THEN 'bag/shop-orders' WHEN '전화 접수' THEN 'bag/shop-orders'
WHEN '전화 접수 관리' THEN 'bag/shop-orders' WHEN '주문접수관리' THEN 'bag/shop-orders'
WHEN '지정 판매소 판매' THEN 'bag/sale/create' WHEN '지정 판매소 판매' THEN 'bag/sale/create'
WHEN '지정 판매소 반품' THEN 'bag/bag-sales' WHEN '지정 판매소 반품' THEN 'bag/bag-sales'
WHEN '지정 판매소 판매 취소' THEN 'bag/bag-sales' WHEN '지정 판매소 판매 취소' THEN 'bag/bag-sales'
@@ -187,7 +187,7 @@ SELECT @mt_site, 1, t.mm_name,
@parent_sales, 1, t.mm_num, 0, '', 'Y' @parent_sales, 1, t.mm_num, 0, '', 'Y'
FROM ( FROM (
SELECT 0 AS mm_num, '전화 접수' AS mm_name UNION ALL SELECT 0 AS mm_num, '전화 접수' AS mm_name UNION ALL
SELECT 1, '전화 접수 관리' UNION ALL SELECT 1, '주문접수관리' UNION ALL
SELECT 2, '지정 판매소 판매' UNION ALL SELECT 2, '지정 판매소 판매' UNION ALL
SELECT 3, '지정 판매소 반품' UNION ALL SELECT 3, '지정 판매소 반품' UNION ALL
SELECT 4, '지정 판매소 판매 취소' UNION ALL SELECT 4, '지정 판매소 판매 취소' UNION ALL

View File

@@ -0,0 +1,11 @@
-- 메뉴명 변경: '일괄입고' → '일괄입고(음식물,폐기물)'
-- 링크(mm_link='bag/receiving/batch') 기준, 기본 이름을 쓰는 항목만 변경.
-- 실행: mysql --default-character-set=utf8mb4 -u USER -p DBNAME < writable/database/rename_batch_receiving_menu.sql
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
SET CHARACTER_SET_CLIENT = utf8mb4;
UPDATE `menu`
SET `mm_name` = '일괄입고(음식물,폐기물)'
WHERE `mm_link` = 'bag/receiving/batch'
AND `mm_name` = '일괄입고';

View File

@@ -0,0 +1,18 @@
-- 맞춤 집계표: 계정별 리포트 프리셋(제목·품목구분·컬럼구성) 저장
-- 실행: mysql -u jongryangje -p jongryangje_dev < writable/database/report_preset_table.sql
-- 멱등(IF NOT EXISTS).
CREATE TABLE IF NOT EXISTS `report_preset` (
`rp_idx` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`rp_lg_idx` INT UNSIGNED NOT NULL COMMENT '지자체',
`rp_mb_idx` INT UNSIGNED NOT NULL COMMENT '소유 계정(mb_idx) — 계정별 저장',
`rp_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '프리셋 이름',
`rp_title` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '리포트(표) 제목',
`rp_category` VARCHAR(20) NOT NULL DEFAULT 'all' COMMENT 'all|envelope|food|waste',
`rp_mode` VARCHAR(10) NOT NULL DEFAULT 'detail' COMMENT 'detail|summary',
`rp_columns` TEXT NULL COMMENT '선택 컬럼 키 JSON 배열',
`rp_regdate` DATETIME NULL,
`rp_updated` DATETIME NULL,
PRIMARY KEY (`rp_idx`),
KEY `idx_rp_owner` (`rp_lg_idx`, `rp_mb_idx`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;