8 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
27 changed files with 1591 additions and 107 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');
@@ -64,6 +65,7 @@ $routes->group('bag', ['filter' => 'loginAuth'], static function ($routes): void
$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/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');
@@ -80,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');
@@ -159,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');

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

@@ -118,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)) {
@@ -3556,6 +3566,38 @@ class SalesReport extends BaseController
} }
} }
// 엑셀 다운로드 — 현재 컬럼·필터·제목 그대로
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', [ return $this->renderWorkPage('맞춤 집계표', 'admin/sales_report/custom_report', [
'columnCatalog' => self::CUSTOM_REPORT_COLUMNS, 'columnCatalog' => self::CUSTOM_REPORT_COLUMNS,
'selected' => $selected, 'selected' => $selected,

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

@@ -274,6 +274,70 @@ SQL);
return $this->response->setJSON(['ok' => true, 'csrf' => csrf_hash()]); 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()]);
}
// ────────────────────────────────────────────── // ──────────────────────────────────────────────
// 기본정보관리 (단가·포장 단위 진입 허브) // 기본정보관리 (단가·포장 단위 진입 허브)
// ────────────────────────────────────────────── // ──────────────────────────────────────────────
@@ -1312,6 +1376,142 @@ SQL);
); );
} }
/**
* 실사 재고 바코드 리스트 — 현재 창고에 있어야 하는(재고로 남아있는) 봉투 번호(바코드) 목록.
* 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}>,
@@ -7480,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'] ?? '')),
]; ];
} }
@@ -7751,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 전환 +
@@ -7776,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

@@ -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

@@ -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

@@ -32,6 +32,7 @@ $nf = static fn ($n): string => number_format((int) $n);
<section class="p-3 bg-white border-b border-gray-200 no-print"> <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"> <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="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 class="flex flex-wrap items-end gap-3 text-sm">
<div> <div>
<label class="block text-xs font-bold text-gray-600 mb-1">표 제목</label> <label class="block text-xs font-bold text-gray-600 mb-1">표 제목</label>
@@ -60,7 +61,8 @@ $nf = static fn ($n): string => number_format((int) $n);
<option value="summary" <?= $mode === 'summary' ? 'selected' : '' ?>>품목별 집계</option> <option value="summary" <?= $mode === 'summary' ? 'selected' : '' ?>>품목별 집계</option>
</select> </select>
</div> </div>
<button type="submit" 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='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>
<div> <div>

View File

@@ -44,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

@@ -191,18 +191,33 @@ 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') ?? '') ?>'; // 탭 저장 소유자(로그인 사용자) 식별
var WS_SAVE_URL = '<?= site_url('bag/pref/workspace-tabs') ?>';
var WS_CSRF_NAME = '<?= csrf_token() ?>';
var wsCsrfHash = '<?= csrf_hash() ?>';
var wsSaveTimer = null;
function wsStateObj() {
return {
owner: WS_OWNER,
tabs: order.map(function (id) { return { url: tabs[id].url, title: tabs[id].title }; }),
layout: layout,
focused: focused,
vRatio: vRatio,
hRatio: hRatio,
slots: slots.map(function (id) { return (id && tabs[id]) ? tabs[id].url : null; })
};
}
function persist() { function persist() {
try { var obj = wsStateObj();
sessionStorage.setItem(STORE_KEY, JSON.stringify({ var json = JSON.stringify(obj);
owner: WS_OWNER, try { sessionStorage.setItem(STORE_KEY, json); } catch (e) {}
tabs: order.map(function (id) { return { url: tabs[id].url, title: tabs[id].title }; }), // 계정(DB)에 저장 → 다음 로그인/다른 기기에서도 자동 복원 (연속 변경은 디바운스)
layout: layout, clearTimeout(wsSaveTimer);
focused: focused, wsSaveTimer = setTimeout(function () {
vRatio: vRatio, var b = new URLSearchParams();
hRatio: hRatio, b.set(WS_CSRF_NAME, wsCsrfHash); b.set('state', json);
slots: slots.map(function (id) { return (id && tabs[id]) ? tabs[id].url : null; }) 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 () {});
} catch (e) {} }, 500);
} }
// 분할 칸 헤더·빈칸 안내 요소 4개 미리 생성 // 분할 칸 헤더·빈칸 안내 요소 4개 미리 생성
@@ -560,11 +575,19 @@ if ($effectiveLgIdx) {
}); });
}); });
// 첫 화면: 세션 저장 복원(레이아웃·분할 배치 포함), 없으면 대시보드 1분할 // 첫 화면: ① 계정(DB) 저장 자동복원 → ② 세션 저장 → ③ 없으면 대시보드 1분할
var SERVER_STATE = <?= $savedWorkspaceJson ?? 'null' ?>;
(function restore() { (function restore() {
var saved = null; var saved = null;
try { saved = JSON.parse(sessionStorage.getItem(STORE_KEY) || 'null'); } catch (e) {} // ① 계정 DB에 저장된 상태 우선(다음 로그인·다른 기기에서도 복원)
if (saved && saved.owner !== WS_OWNER) { try { sessionStorage.removeItem(STORE_KEY); } catch (e) {} saved = null; } 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) {}
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="text-gray-600">포장량: <span id="packing-total" class="font-bold text-blue-700">0</span> 개</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>
<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> </div>
<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> <div class="border border-gray-300 rounded-lg overflow-hidden">
<th class="w-16 text-center sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">포장</th> <div class="px-2 py-1 bg-amber-50 border-b border-amber-200 text-xs font-semibold text-amber-800">앞으로 찍을 목록 (남은 수량)</div>
</tr> <div id="remaining-list" class="p-2 text-xs text-gray-400 max-h-[9rem] overflow-auto min-h-[3.5rem]">주문을 선택해 주세요.</div>
</thead> </div>
<tbody id="packing-body">
<tr><td colspan="5" class="text-center py-6 text-gray-400">주문을 선택해 주세요.</td></tr>
</tbody>
</table>
</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

@@ -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

@@ -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

@@ -6,17 +6,31 @@
var listEl = document.getElementById('portalSidebarList'); var listEl = document.getElementById('portalSidebarList');
var titleEl = document.getElementById('portalSidebarTitle'); var titleEl = document.getElementById('portalSidebarTitle');
// 기본정보관리 소메뉴 아이콘(Font Awesome solid) — 이름 키워드 기준(구체적인 것 먼저) // 모든 상위메뉴의 소메뉴 아이콘(Font Awesome solid) — 이름 키워드 기준(구체적인 것 먼저).
// 서버 렌더(_dashboard_gov_portal_sidebar.php)의 매핑과 동일하게 유지할 것.
function sidebarIcon(name) { function sidebarIcon(name) {
var n = String(name || ''); var n = String(name || '');
var rules = [ var rules = [
['바코드', 'fa-barcode'], ['조회', 'fa-magnifying-glass'], ['바코드', 'fa-barcode'], ['스캐너', 'fa-barcode'], ['번호', 'fa-hashtag'],
['신규', 'fa-chart-column'], ['현황', 'fa-chart-column'], ['판매대장', 'fa-book'], ['대장', 'fa-book'],
['판매소', 'fa-store'], ['대행소', 'fa-handshake'], ['집계표', 'fa-table-list'], ['일계표', 'fa-table-list'],
['담당자', 'fa-user-tie'], ['업체', 'fa-building'], ['수불', 'fa-right-left'], ['입출고', 'fa-right-left'],
['무료', 'fa-gift'], ['포장', 'fa-box'], ['무료', 'fa-gift'], ['반품', 'fa-rotate-left'], ['파기', 'fa-trash-can'],
['단가', 'fa-won-sign'], ['코드', 'fa-list-ol'], ['신규', 'fa-chart-column'], ['취소', 'fa-ban'],
['비밀번호', 'fa-key'], ['설정', 'fa-gear'] ['발주파일', '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++) { for (var i = 0; i < rules.length; i++) {
if (n.indexOf(rules[i][0]) !== -1) return rules[i][1]; if (n.indexOf(rules[i][0]) !== -1) return rules[i][1];
@@ -25,7 +39,6 @@
return 'fa-angle-right'; return 'fa-angle-right';
} }
function iconHtml(parentName, childName) { function iconHtml(parentName, childName) {
if (String(parentName || '').replace(/\s/g, '') !== '기본정보관리') return '';
return '<i class="fa-solid ' + sidebarIcon(childName) + '" style="width:1.15em;margin-right:.45em;opacity:.85;"></i>'; return '<i class="fa-solid ' + sidebarIcon(childName) + '" style="width:1.15em;margin-right:.45em;opacity:.85;"></i>';
} }

View File

@@ -7,24 +7,60 @@ $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) 매핑 — 이름 키워드 기준(구체적인 것 먼저) // 모든 상위메뉴의 소메뉴에 어울리는 아이콘(Font Awesome solid) 매핑 — 이름 키워드 기준(구체적인 것 먼저).
$showSidebarIcons = (str_replace(' ', '', (string) $sidebarTitle) === '기본정보관리'); $showSidebarIcons = true;
$sidebarIconFor = static function (string $name): string { $sidebarIconFor = static function (string $name): string {
$rules = [ $rules = [
'바코드' => 'fa-barcode', // 지정판매소 바코드 출력 '바코드' => 'fa-barcode',
'조회' => 'fa-magnifying-glass', // 지정 판매소 조회 '스캐너' => 'fa-barcode',
'신규' => 'fa-chart-column', // 지정 판매소 신규/취소 현황 '번호' => 'fa-hashtag',
'현황' => 'fa-chart-column', '판매대장' => 'fa-book',
'판매소' => 'fa-store', // 지정 판매소 관리 '대장' => 'fa-book',
'대행소' => 'fa-handshake', '집계표' => 'fa-table-list',
'담당자' => 'fa-user-tie', '일계표' => 'fa-table-list',
'업체' => 'fa-building', '수불' => 'fa-right-left',
'무료' => 'fa-gift', '입출고' => 'fa-right-left',
'포장' => 'fa-box', '무료' => 'fa-gift',
'단가' => 'fa-won-sign', '반품' => 'fa-rotate-left',
'코드' => 'fa-list-ol', '파기' => '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-key',
'설정' => 'fa-gear', '설정' => 'fa-gear',
'관리' => 'fa-folder-open',
]; ];
foreach ($rules as $kw => $icon) { foreach ($rules as $kw => $icon) {
if (mb_stripos($name, $kw) !== false) { if (mb_stripos($name, $kw) !== false) {

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>

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,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