Compare commits
24 Commits
249053412c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
215d87f991 | ||
|
|
dc5c38d242 | ||
|
|
6c421153e4 | ||
|
|
5e8e81f404 | ||
|
|
c5df784f91 | ||
|
|
a3f3e9cd64 | ||
|
|
f624c13b5b | ||
|
|
b814892352 | ||
|
|
794d34635b | ||
|
|
e9f1cf2b49 | ||
|
|
3a611972c1 | ||
|
|
9365848f9d | ||
|
|
65582c3439 | ||
|
|
03c2bbc05c | ||
|
|
62f7bda290 | ||
|
|
7e958a7ac9 | ||
|
|
bfcdd055b2 | ||
|
|
8d06c8eab4 | ||
|
|
8339f69303 | ||
|
|
b9cc810f97 | ||
|
|
9ee5d74f04 | ||
|
|
26384dbe2a | ||
|
|
952ca3e3e7 | ||
|
|
a61e7887ea |
86
app/Commands/ProtectDesignatedShops.php
Normal file
86
app/Commands/ProtectDesignatedShops.php
Normal 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
32
app/Config/Pii.php
Normal 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 = '';
|
||||
}
|
||||
@@ -38,6 +38,7 @@ $routes->get('bag/issue', 'Bag::issueLegacy');
|
||||
$routes->get('bag/issue/cancel', 'Bag::issue');
|
||||
$routes->get('bag/inventory', 'Bag::inventory');
|
||||
$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-work', 'Bag::inspectionWork');
|
||||
$routes->post('bag/inventory/inspection-run', 'Bag::inspectionRun');
|
||||
@@ -63,6 +64,8 @@ $routes->group('bag', ['filter' => 'loginAuth'], static function ($routes): void
|
||||
$routes->get('manual/search', 'Bag::manualSearch'); // (:segment) 보다 먼저
|
||||
$routes->get('manual/(:segment)', 'Bag::manualPage/$1');
|
||||
$routes->post('activity/print-log', 'Bag::printLog'); // 인쇄 활동 기록(beacon)
|
||||
$routes->post('pref/font-scale', 'Bag::saveFontScale'); // 계정별·메뉴별 글자크기 저장
|
||||
$routes->post('pref/workspace-tabs', 'Bag::saveWorkspaceTabs'); // 계정별 워크스페이스 탭 상태 저장(자동 복원)
|
||||
});
|
||||
|
||||
$routes->get('bag/number-lookup', 'Bag::numberLookup');
|
||||
@@ -74,10 +77,12 @@ $routes->post('bag/issue/store', 'Bag::issueStore');
|
||||
$routes->post('bag/issue/cancel/(:num)', 'Bag::issueCancel/$1');
|
||||
$routes->post('bag/issue/cancel-save', 'Bag::issueCancelSave');
|
||||
$routes->get('bag/order/create', 'Bag::orderCreate');
|
||||
$routes->get('bag/order/detail-json/(:num)', 'Bag::orderDetailJson/$1');
|
||||
$routes->get('bag/order/phone', 'Bag::phoneOrderCreate');
|
||||
$routes->get('bag/order/phone/manage', 'Bag::phoneOrderManage');
|
||||
$routes->post('bag/order/phone/manage/update', 'Bag::phoneOrderUpdate');
|
||||
$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->get('bag/order/phone/receipt/(:num)', 'Bag::phoneOrderReceipt/$1');
|
||||
$routes->get('bag/order/change', 'Bag::orderChange');
|
||||
@@ -93,6 +98,7 @@ $routes->get('bag/receiving/create', 'Bag::receivingCreate');
|
||||
$routes->post('bag/receiving/store', 'Bag::receivingStore');
|
||||
$routes->get('bag/receiving/scanner', 'Bag::receivingScanner');
|
||||
$routes->post('bag/receiving/scanner/store', 'Bag::receivingScannerStore');
|
||||
$routes->post('bag/receiving/scan-box', 'Bag::receivingScanBox');
|
||||
$routes->get('bag/receiving/batch', 'Bag::receivingBatch');
|
||||
$routes->post('bag/receiving/batch/store', 'Bag::receivingBatchStore');
|
||||
$routes->get('bag/receiving/status', 'Bag::receivingStatus');
|
||||
@@ -155,6 +161,8 @@ $routes->group('bag', ['filter' => 'adminAuth'], static function ($routes): void
|
||||
$routes->get('designated-shops/browse', 'Admin\DesignatedShop::browse');
|
||||
$routes->get('designated-shops', 'Admin\DesignatedShop::index');
|
||||
$routes->get('designated-shops/create', 'Admin\DesignatedShop::create');
|
||||
$routes->post('designated-shops/resolve-address-codes', 'Admin\DesignatedShop::resolveAddressCodes');
|
||||
$routes->post('designated-shops/reveal-pii', 'Admin\DesignatedShop::revealPii');
|
||||
$routes->post('designated-shops/store', 'Admin\DesignatedShop::store');
|
||||
$routes->get('designated-shops/edit/(:num)', 'Admin\DesignatedShop::edit/$1');
|
||||
$routes->post('designated-shops/update/(:num)', 'Admin\DesignatedShop::update/$1');
|
||||
@@ -207,6 +215,9 @@ $routes->group('bag', ['filter' => 'adminAuth'], static function ($routes): void
|
||||
$routes->post('packaging-units/manage/delete/(:num)', 'Admin\PackagingUnit::delete/$1');
|
||||
$routes->get('packaging-units/manage/history/(:num)', 'Admin\PackagingUnit::history/$1');
|
||||
|
||||
$routes->get('reports/custom', 'Admin\SalesReport::customReport');
|
||||
$routes->post('reports/custom/preset/save', 'Admin\SalesReport::customReportPresetSave');
|
||||
$routes->post('reports/custom/preset/delete', 'Admin\SalesReport::customReportPresetDelete');
|
||||
$routes->get('reports/sales-ledger', 'Admin\SalesReport::salesLedger');
|
||||
$routes->get('reports/daily-summary', 'Admin\SalesReport::dailySummary');
|
||||
$routes->get('reports/period-sales', 'Admin\SalesReport::periodSales');
|
||||
@@ -251,6 +262,7 @@ $routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): vo
|
||||
$routes->get('feedback/file/(:num)', 'Admin\Feedback::file/$1');
|
||||
$routes->get('feedback/(:num)', 'Admin\Feedback::show/$1');
|
||||
$routes->post('feedback/(:num)/status', 'Admin\Feedback::updateStatus/$1');
|
||||
$routes->post('feedback/(:num)/content', 'Admin\Feedback::updateContent/$1');
|
||||
$routes->get('/', 'Admin\Dashboard::index');
|
||||
$routes->get('users', 'Admin\User::index');
|
||||
$routes->get('users/create', 'Admin\User::create');
|
||||
|
||||
@@ -539,6 +539,11 @@ class BagOrder extends BaseController
|
||||
'bo_regdate' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
|
||||
// 발주담당(manager.mg_idx) — 컬럼이 있을 때만 저장
|
||||
if (\Config\Database::connect()->fieldExists('bo_manager_idx', 'bag_order')) {
|
||||
$orderData['bo_manager_idx'] = $this->request->getPost('bo_manager_idx') ?: null;
|
||||
}
|
||||
|
||||
// 품목 입력 후 최종 payload 기준으로 해시를 계산하므로 우선 빈값으로 생성
|
||||
$orderData['bo_hash'] = '';
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ class DesignatedShop extends BaseController
|
||||
private DesignatedShopModel $shopModel;
|
||||
private LocalGovernmentModel $lgModel;
|
||||
private Roles $roles;
|
||||
private ?bool $dongColumnExists = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
@@ -173,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);
|
||||
if ($dsName !== null && $dsName !== '') {
|
||||
@@ -185,12 +186,47 @@ class DesignatedShop extends BaseController
|
||||
if ($dsState !== null && $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}
|
||||
*/
|
||||
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();
|
||||
$builder = $db->table('designated_shop');
|
||||
@@ -204,6 +240,7 @@ class DesignatedShop extends BaseController
|
||||
if ($dsState !== null && $dsState !== '') {
|
||||
$builder->where('ds_state', (int) $dsState);
|
||||
}
|
||||
$this->applyPiiSearchFilter($builder, $dsRep, $dsTel);
|
||||
$rows = $builder->select('ds_state, COUNT(*) AS cnt', false)
|
||||
->groupBy('ds_state')
|
||||
->get()
|
||||
@@ -223,11 +260,12 @@ class DesignatedShop extends BaseController
|
||||
/**
|
||||
* @param list<object> $list
|
||||
* @param array<int, string> $lgMap
|
||||
* @param array<int, array{code: string, name: string}> $dongMap ds_idx => [동코드, 동명]
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function buildDesignatedShopDetailPayload(array $list, array $lgMap): array
|
||||
private function buildDesignatedShopDetailPayload(array $list, array $lgMap, array $dongMap = []): array
|
||||
{
|
||||
helper('admin');
|
||||
helper(['admin', 'pii_encryption']);
|
||||
$lgIdx = admin_effective_lg_idx() ?? 0;
|
||||
$gugunMap = $lgIdx > 0 ? $this->gugunCodeNameMap($lgIdx) : [];
|
||||
$payload = [];
|
||||
@@ -244,7 +282,19 @@ class DesignatedShop extends BaseController
|
||||
$stateMap = [1 => '정상', 2 => '폐업', 3 => '직권해지'];
|
||||
$da = $row->ds_designated_at ?? null;
|
||||
$daOut = ($da !== null && $da !== '' && $da !== '0000-00-00') ? (string) $da : '';
|
||||
$payload[] = [
|
||||
|
||||
$idxKey = (int) ($row->ds_idx ?? 0);
|
||||
$dongCode = $dongMap[$idxKey]['code'] ?? (string) ($row->ds_dong_code ?? '');
|
||||
$dongName = $dongMap[$idxKey]['name'] ?? '';
|
||||
$gugunName = $gugunMap[(string) ($row->ds_gugun_code ?? '')] ?? (string) ($row->ds_gugun_code ?? '');
|
||||
// 구·군 표시값: 동코드가 있으면 "동코드 (동명)", 없으면 기존 구·군명
|
||||
if ($dongCode !== '') {
|
||||
$dongDisplay = $dongCode . ($dongName !== '' ? ' (' . $dongName . ')' : '');
|
||||
} else {
|
||||
$dongDisplay = $gugunName;
|
||||
}
|
||||
|
||||
$p = [
|
||||
'ds_idx' => (int) $row->ds_idx,
|
||||
'ds_shop_no' => $sn,
|
||||
'shop_no_display' => $shortNo,
|
||||
@@ -266,7 +316,10 @@ class DesignatedShop extends BaseController
|
||||
'ds_rep_phone' => (string) ($row->ds_rep_phone ?? ''),
|
||||
'ds_email' => (string) ($row->ds_email ?? ''),
|
||||
'ds_gugun_code' => (string) ($row->ds_gugun_code ?? ''),
|
||||
'gugun_name' => $gugunMap[(string) ($row->ds_gugun_code ?? '')] ?? (string) ($row->ds_gugun_code ?? ''),
|
||||
'gugun_name' => $gugunName,
|
||||
'ds_dong_code' => $dongCode,
|
||||
'dong_name' => $dongName,
|
||||
'dong_display' => $dongDisplay,
|
||||
'ds_zone_code' => $this->designatedShopScalar($row, 'ds_zone_code'),
|
||||
'ds_branch_no' => $this->designatedShopScalar($row, 'ds_branch_no'),
|
||||
'ds_designated_at' => $daOut,
|
||||
@@ -274,7 +327,18 @@ class DesignatedShop extends BaseController
|
||||
'ds_change_reason' => $this->designatedShopScalar($row, 'ds_change_reason'),
|
||||
'ds_regdate' => (string) ($row->ds_regdate ?? ''),
|
||||
'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;
|
||||
@@ -294,14 +358,17 @@ class DesignatedShop extends BaseController
|
||||
return null;
|
||||
}
|
||||
|
||||
// 다조건 검색 (P2-15)
|
||||
// 다조건 검색 (P2-15) + 대표자·전화 정확일치(블라인드 인덱스)
|
||||
$dsName = $this->request->getGet('ds_name');
|
||||
$dsGugunCode = $this->request->getGet('ds_gugun_code');
|
||||
$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')->paginate(20);
|
||||
$pager = $this->shopModel->pager;
|
||||
// 페이징 대신 전체 목록을 한 번에 로드 — 리스트 패널 내부 스크롤로 표시
|
||||
$list = $this->shopModel->orderBy('ds_idx', 'DESC')->findAll();
|
||||
$pager = null;
|
||||
|
||||
// 지자체 이름 매핑용
|
||||
$lgMap = [];
|
||||
@@ -309,9 +376,23 @@ class DesignatedShop extends BaseController
|
||||
$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);
|
||||
$detailRows = $this->buildDesignatedShopDetailPayload($list, $lgMap);
|
||||
|
||||
// 각 판매소의 동코드를 주소로 산출 → (컬럼이 있으면) 저장 → 상세 표시에 사용
|
||||
$dongMap = $this->resolveDongCodesForShops($lgIdx, $list);
|
||||
$this->persistDesignatedShopDongCodes($list, $dongMap);
|
||||
$detailRows = $this->buildDesignatedShopDetailPayload($list, $lgMap, $dongMap);
|
||||
|
||||
// 리스트(서버 렌더)에서 동코드 표시용: ds_idx => "동코드 (동명)"
|
||||
$dongDisplayMap = [];
|
||||
foreach ($dongMap as $idx => $d) {
|
||||
$code = (string) ($d['code'] ?? '');
|
||||
$name = (string) ($d['name'] ?? '');
|
||||
if ($code !== '') {
|
||||
$dongDisplayMap[$idx] = $code . ($name !== '' ? ' (' . $name . ')' : '');
|
||||
}
|
||||
}
|
||||
|
||||
// 구군코드 목록 (검색 필터용)
|
||||
$db = \Config\Database::connect();
|
||||
@@ -324,9 +405,12 @@ class DesignatedShop extends BaseController
|
||||
'dsName' => $dsName ?? '',
|
||||
'dsGugunCode' => $dsGugunCode ?? '',
|
||||
'dsState' => $dsState ?? '',
|
||||
'dsRep' => $dsRep ?? '',
|
||||
'dsTel' => $dsTel ?? '',
|
||||
'gugunCodes' => $gugunCodes,
|
||||
'stateCounts' => $stateCounts,
|
||||
'gugunNameMap' => $gugunNameMap,
|
||||
'dongDisplayMap' => $dongDisplayMap,
|
||||
'detailRowsJson' => json_encode($detailRows, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE),
|
||||
'kakaoJavascriptKey' => $this->kakaoJavascriptKey(),
|
||||
];
|
||||
@@ -475,22 +559,45 @@ class DesignatedShop extends BaseController
|
||||
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();
|
||||
|
||||
// 동코드(구·군 동코드) 산출
|
||||
$dongMap = $this->resolveDongCodesForShops($lgIdx, $list);
|
||||
|
||||
$rows = [];
|
||||
foreach ($list as $row) {
|
||||
$stateMap = [1 => '정상', 2 => '폐업', 3 => '직권해지'];
|
||||
$idxKey = (int) ($row->ds_idx ?? 0);
|
||||
$dCode = (string) ($dongMap[$idxKey]['code'] ?? '');
|
||||
$dName = (string) ($dongMap[$idxKey]['name'] ?? '');
|
||||
$dongDisp = $dCode !== '' ? ($dCode . ($dName !== '' ? ' (' . $dName . ')' : '')) : '';
|
||||
$da = $row->ds_designated_at ?? null;
|
||||
$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[] = [
|
||||
$row->ds_idx,
|
||||
$row->ds_shop_no,
|
||||
$row->ds_name,
|
||||
$row->ds_rep_name,
|
||||
$repName,
|
||||
$dongDisp,
|
||||
$daDisp,
|
||||
$row->ds_biz_no,
|
||||
$this->designatedShopScalar($row, 'ds_biz_type'),
|
||||
$this->designatedShopScalar($row, 'ds_biz_kind'),
|
||||
$this->designatedShopScalar($row, 'ds_va_bank'),
|
||||
$this->designatedShopScalar($row, 'ds_va_account') !== '' ? $this->designatedShopScalar($row, 'ds_va_account') : ($row->ds_va_number ?? ''),
|
||||
$row->ds_tel ?? '',
|
||||
$vaAccount,
|
||||
$telVal,
|
||||
$row->ds_addr ?? '',
|
||||
$this->designatedShopScalar($row, 'ds_zone_code'),
|
||||
$this->designatedShopScalar($row, 'ds_branch_no'),
|
||||
@@ -504,7 +611,7 @@ class DesignatedShop extends BaseController
|
||||
export_csv(
|
||||
'지정판매소_' . date('Ymd') . '.csv',
|
||||
[
|
||||
'번호', '판매소번호', '상호명', '대표자', '사업자번호', '업태', '업종',
|
||||
'번호', '판매소번호', '상호명', '대표자', '구·군(동코드)', '지정일', '사업자번호', '업태', '업종',
|
||||
'가상계좌은행', '계좌번호', '전화번호', '주소', '구역', '종사업장번호',
|
||||
'변경일자', '변경사유', '상태', '등록일',
|
||||
],
|
||||
@@ -648,6 +755,8 @@ class DesignatedShop extends BaseController
|
||||
'ds_branch_no' => (string) $this->request->getPost('ds_branch_no'),
|
||||
'ds_designated_at' => $this->request->getPost('ds_designated_at') ?: null,
|
||||
'ds_state' => 1,
|
||||
// 동코드(컬럼이 있을 때만 저장)
|
||||
...($this->designatedShopHasDongColumn() ? ['ds_dong_code' => (string) ($resolvedNo['dong_code'] ?? '')] : []),
|
||||
'ds_state_changed_at' => $this->normalizeOptionalDate($this->request->getPost('ds_state_changed_at')),
|
||||
'ds_change_reason' => (string) $this->request->getPost('ds_change_reason'),
|
||||
'ds_regdate' => date('Y-m-d H:i:s'),
|
||||
@@ -770,6 +879,23 @@ class DesignatedShop extends BaseController
|
||||
|
||||
$va = $this->resolveVirtualAccountFromRequest();
|
||||
|
||||
// 주소로 동코드 재산출(컬럼이 있을 때만 저장). 매칭 실패 시 동코드는 갱신하지 않음.
|
||||
$dongCodeData = [];
|
||||
if ($this->designatedShopHasDongColumn()) {
|
||||
$resolvedDong = $this->resolveDesignatedShopNumberFromAddress(
|
||||
$lgIdx,
|
||||
$addrSido,
|
||||
$addrSigungu,
|
||||
$dsAddr,
|
||||
$dsAddrJibun,
|
||||
$dsZip,
|
||||
$lg
|
||||
);
|
||||
if (($resolvedDong['ok'] ?? false) && ($resolvedDong['dong_code'] ?? '') !== '') {
|
||||
$dongCodeData = ['ds_dong_code' => (string) $resolvedDong['dong_code']];
|
||||
}
|
||||
}
|
||||
|
||||
$data = [
|
||||
'ds_name' => (string) $this->request->getPost('ds_name'),
|
||||
'ds_biz_no' => (string) $this->request->getPost('ds_biz_no'),
|
||||
@@ -786,6 +912,7 @@ class DesignatedShop extends BaseController
|
||||
'ds_tel' => (string) $this->request->getPost('ds_tel'),
|
||||
'ds_rep_phone' => (string) $this->request->getPost('ds_rep_phone'),
|
||||
'ds_email' => (string) $this->request->getPost('ds_email'),
|
||||
...$dongCodeData,
|
||||
'ds_zone_code' => (string) $this->request->getPost('ds_zone_code'),
|
||||
'ds_branch_no' => (string) $this->request->getPost('ds_branch_no'),
|
||||
'ds_designated_at' => $this->request->getPost('ds_designated_at') ?: null,
|
||||
@@ -1532,9 +1659,11 @@ class DesignatedShop extends BaseController
|
||||
usort($dCandidates, static fn (array $a, array $b): int => $b['len'] <=> $a['len']);
|
||||
|
||||
$dCode = null;
|
||||
$dName = '';
|
||||
foreach ($dCandidates as $cand) {
|
||||
if ($this->addressHaystackContainsRegionName($blob, $cand['nm'])) {
|
||||
$dCode = $cand['cd'];
|
||||
$dName = $cand['nm'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1557,9 +1686,248 @@ class DesignatedShop extends BaseController
|
||||
'ok' => true,
|
||||
'shop_no' => $prefix . sprintf('%03d', $maxSerial + 1),
|
||||
'gugun_code' => $cCode,
|
||||
'dong_code' => $dCode,
|
||||
'dong_name' => $dName,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* [AJAX] 주소 선택 시, 해당 주소의 동 기본코드(D)와 동명을 반환.
|
||||
* 등록 폼에서 주소 검색 직후 「구·군(동코드)」 표시에 사용한다.
|
||||
*/
|
||||
public function resolveAddressCodes()
|
||||
{
|
||||
if (! $this->isSuperAdmin() && ! $this->isLocalAdmin()) {
|
||||
return $this->response->setStatusCode(403)->setJSON(['ok' => false, 'error' => '권한이 없습니다.']);
|
||||
}
|
||||
|
||||
helper('admin');
|
||||
$lgIdx = admin_effective_lg_idx();
|
||||
if ($lgIdx === null || $lgIdx <= 0) {
|
||||
return $this->response->setJSON(['ok' => false, 'error' => '작업할 지자체가 선택되지 않았습니다.']);
|
||||
}
|
||||
$lg = $this->lgModel->find($lgIdx);
|
||||
if ($lg === null) {
|
||||
return $this->response->setJSON(['ok' => false, 'error' => '지자체 정보를 찾을 수 없습니다.']);
|
||||
}
|
||||
|
||||
$addrSido = (string) $this->request->getPost('addr_search_sido');
|
||||
$addrSigungu = (string) $this->request->getPost('addr_search_sigungu');
|
||||
$dsAddr = (string) $this->request->getPost('ds_addr');
|
||||
$dsAddrJibun = (string) $this->request->getPost('ds_addr_jibun');
|
||||
$dsZip = (string) $this->request->getPost('ds_zip');
|
||||
|
||||
$resolved = $this->resolveDesignatedShopNumberFromAddress(
|
||||
$lgIdx,
|
||||
$addrSido,
|
||||
$addrSigungu,
|
||||
$dsAddr,
|
||||
$dsAddrJibun,
|
||||
$dsZip,
|
||||
$lg
|
||||
);
|
||||
if (! $resolved['ok']) {
|
||||
return $this->response->setJSON(['ok' => false, 'error' => $resolved['error']]);
|
||||
}
|
||||
|
||||
return $this->response->setJSON([
|
||||
'ok' => true,
|
||||
'dong_code' => $resolved['dong_code'] ?? '',
|
||||
'dong_name' => $resolved['dong_name'] ?? '',
|
||||
'gugun_code' => $resolved['gugun_code'] ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* [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회 조회 후 캐시).
|
||||
*/
|
||||
private function designatedShopHasDongColumn(): bool
|
||||
{
|
||||
if ($this->dongColumnExists === null) {
|
||||
$this->dongColumnExists = \Config\Database::connect()->fieldExists('ds_dong_code', 'designated_shop');
|
||||
}
|
||||
|
||||
return $this->dongColumnExists;
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 지정판매소의 동 기본코드(D)를 주소로 일괄 산출한다.
|
||||
* code_detail(B·C·D) 행을 한 번만 로드해 각 판매소를 매칭한다.
|
||||
*
|
||||
* @param list<object> $shops
|
||||
* @return array<int, array{code: string, name: string}> ds_idx => [동코드, 동명]
|
||||
*/
|
||||
private function resolveDongCodesForShops(int $lgIdx, array $shops): array
|
||||
{
|
||||
$out = [];
|
||||
if ($shops === []) {
|
||||
return $out;
|
||||
}
|
||||
|
||||
$bCk = $this->codeKindIdxByCkCode('B');
|
||||
$cCk = $this->codeKindIdxByCkCode('C');
|
||||
$dCk = $this->codeKindIdxByCkCode('D');
|
||||
if ($bCk === null || $cCk === null || $dCk === null) {
|
||||
return $out;
|
||||
}
|
||||
|
||||
$detailModel = model(CodeDetailModel::class);
|
||||
$bRows = $detailModel->getByKind($bCk, true, $lgIdx);
|
||||
$cRows = $detailModel->getByKind($cCk, true, $lgIdx);
|
||||
$dRows = $detailModel->getByKind($dCk, true, $lgIdx);
|
||||
|
||||
foreach ($shops as $row) {
|
||||
$idx = (int) ($row->ds_idx ?? 0);
|
||||
$road = (string) ($row->ds_addr ?? '');
|
||||
$jibun = (string) ($row->ds_addr_jibun ?? '');
|
||||
$zip = (string) ($row->ds_zip ?? '');
|
||||
$blob = trim($road . ' ' . $jibun . ' ' . $zip);
|
||||
if ($blob === '') {
|
||||
$out[$idx] = ['code' => '', 'name' => ''];
|
||||
continue;
|
||||
}
|
||||
|
||||
// B: 시·도
|
||||
$bCode = null;
|
||||
foreach ($bRows as $r) {
|
||||
$nm = trim((string) $r->cd_name);
|
||||
$cd = trim((string) $r->cd_code);
|
||||
if ($nm === '' || $cd === '') {
|
||||
continue;
|
||||
}
|
||||
if ($this->koreanRegionTokenMatches($nm, '', $blob)) {
|
||||
$bCode = $cd;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($bCode === null || $bCode === '') {
|
||||
$out[$idx] = ['code' => '', 'name' => ''];
|
||||
continue;
|
||||
}
|
||||
|
||||
// C: 구·군
|
||||
$cCode = null;
|
||||
foreach ($cRows as $r) {
|
||||
$cd = trim((string) $r->cd_code);
|
||||
if ($cd === '' || ! str_starts_with($cd, $bCode)) {
|
||||
continue;
|
||||
}
|
||||
$nm = trim((string) $r->cd_name);
|
||||
if ($nm === '') {
|
||||
continue;
|
||||
}
|
||||
if ($this->koreanRegionTokenMatches($nm, '', $blob)) {
|
||||
$cCode = $cd;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($cCode === null || $cCode === '') {
|
||||
$out[$idx] = ['code' => '', 'name' => ''];
|
||||
continue;
|
||||
}
|
||||
|
||||
// D: 동 — 긴 이름 우선 매칭(예: '노원동1가'가 '노원동'보다 먼저)
|
||||
$cands = [];
|
||||
foreach ($dRows as $r) {
|
||||
$cd = trim((string) $r->cd_code);
|
||||
if ($cd === '' || ! str_starts_with($cd, $cCode)) {
|
||||
continue;
|
||||
}
|
||||
$nm = trim((string) $r->cd_name);
|
||||
if ($nm === '') {
|
||||
continue;
|
||||
}
|
||||
$cands[] = ['len' => mb_strlen($nm, 'UTF-8'), 'nm' => $nm, 'cd' => $cd];
|
||||
}
|
||||
usort($cands, static fn (array $a, array $b): int => $b['len'] <=> $a['len']);
|
||||
|
||||
$dCode = '';
|
||||
$dName = '';
|
||||
foreach ($cands as $cand) {
|
||||
if ($this->addressHaystackContainsRegionName($blob, $cand['nm'])) {
|
||||
$dCode = $cand['cd'];
|
||||
$dName = $cand['nm'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
$out[$idx] = ['code' => $dCode, 'name' => $dName];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 산출된 동코드를 ds_dong_code 컬럼에 저장(값이 다를 때만). 컬럼이 없으면 아무것도 하지 않음.
|
||||
*
|
||||
* @param list<object> $shops
|
||||
* @param array<int, array{code: string, name: string}> $dongMap
|
||||
*/
|
||||
private function persistDesignatedShopDongCodes(array $shops, array $dongMap): void
|
||||
{
|
||||
if ($dongMap === [] || ! $this->designatedShopHasDongColumn()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db = \Config\Database::connect();
|
||||
foreach ($shops as $row) {
|
||||
$idx = (int) ($row->ds_idx ?? 0);
|
||||
if ($idx <= 0 || ! isset($dongMap[$idx])) {
|
||||
continue;
|
||||
}
|
||||
$code = $dongMap[$idx]['code'];
|
||||
$current = (string) ($row->ds_dong_code ?? '');
|
||||
if ($code !== '' && $code !== $current) {
|
||||
$db->table('designated_shop')->where('ds_idx', $idx)->update(['ds_dong_code' => $code]);
|
||||
$row->ds_dong_code = $code; // 뷰 페이로드에도 반영
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 카카오맵 JavaScript SDK용 키 (.env kakao.javascriptKey) */
|
||||
private function kakaoJavascriptKey(): string
|
||||
{
|
||||
|
||||
@@ -142,6 +142,30 @@ class Feedback extends BaseController
|
||||
return redirect()->to(site_url('admin/feedback/' . $id))->with('success', '처리 상태를 저장했습니다.');
|
||||
}
|
||||
|
||||
/** 문의 글(본문) 수정 */
|
||||
public function updateContent(int $id): ResponseInterface
|
||||
{
|
||||
$model = model(FeedbackModel::class);
|
||||
$row = $model->find($id);
|
||||
$scope = $this->scopeLgIdx();
|
||||
if ($row === null || ($scope !== null && (int) $row->fb_lg_idx !== $scope)) {
|
||||
return redirect()->to(site_url('admin/feedback'))->with('error', '의견을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
$content = trim((string) $this->request->getPost('fb_content'));
|
||||
if ($content === '') {
|
||||
return redirect()->to(site_url('admin/feedback/' . $id))->with('error', '내용을 입력해 주세요.');
|
||||
}
|
||||
$model->update($id, ['fb_content' => mb_substr($content, 0, 2000)]);
|
||||
|
||||
$returnTo = (string) $this->request->getPost('return_to');
|
||||
if ($returnTo === 'all') {
|
||||
return redirect()->to(site_url('admin/feedback/all') . '#fb-' . $id)->with('success', '문의 내용을 수정했습니다.');
|
||||
}
|
||||
|
||||
return redirect()->to(site_url('admin/feedback/' . $id))->with('success', '문의 내용을 수정했습니다.');
|
||||
}
|
||||
|
||||
/** 스크린샷 이미지 서빙 (관리자만, writable 보호 유지) */
|
||||
public function image(int $id): ResponseInterface
|
||||
{
|
||||
|
||||
@@ -210,7 +210,41 @@ class Menu extends BaseController
|
||||
'mm_level' => $this->normalizeMmLevel((int) $row->mt_idx),
|
||||
'mm_is_view' => $this->request->getPost('mm_is_view') ? 'Y' : 'N',
|
||||
];
|
||||
|
||||
// 위치(상위↔하위) 변경 지원: mm_pidx 가 바뀌면 상위/하위로 이동
|
||||
$oldPidx = (int) $row->mm_pidx;
|
||||
$newPidx = (int) $this->request->getPost('mm_pidx');
|
||||
$newDep = $newPidx > 0 ? 1 : 0;
|
||||
$positionChanged = $newPidx !== $oldPidx;
|
||||
if ($positionChanged) {
|
||||
if ($newPidx === $id) {
|
||||
return $this->menusRedirect((int) $row->mt_idx)->with('error', '자기 자신을 상위 메뉴로 지정할 수 없습니다.');
|
||||
}
|
||||
// 하위 메뉴를 보유한 상위 메뉴는 하위로 변경 불가(2단 구조 유지)
|
||||
$childCnt = $this->menuModel->where('mt_idx', (int) $row->mt_idx)->where('lg_idx', $lgIdx)->where('mm_pidx', $id)->countAllResults();
|
||||
if ($newPidx > 0 && $childCnt > 0) {
|
||||
return $this->menusRedirect((int) $row->mt_idx)->with('error', '하위 메뉴가 있는 상위 메뉴는 하위로 변경할 수 없습니다. 먼저 하위 메뉴를 옮겨 주세요.');
|
||||
}
|
||||
if ($newPidx > 0) {
|
||||
$parent = $this->menuModel->find($newPidx);
|
||||
if (! $parent || (int) $parent->lg_idx !== $lgIdx || (int) $parent->mt_idx !== (int) $row->mt_idx || (int) $parent->mm_dep !== 0) {
|
||||
return $this->menusRedirect((int) $row->mt_idx)->with('error', '올바른 상위 메뉴를 선택해 주세요.');
|
||||
}
|
||||
}
|
||||
$data['mm_pidx'] = $newPidx;
|
||||
$data['mm_dep'] = $newDep;
|
||||
$data['mm_num'] = $this->menuModel->getNextNum((int) $row->mt_idx, $lgIdx, $newPidx, $newDep);
|
||||
}
|
||||
|
||||
$this->menuModel->update($id, $data);
|
||||
if ($positionChanged) {
|
||||
if ($oldPidx > 0) {
|
||||
$this->menuModel->updateCnode($oldPidx, -1);
|
||||
}
|
||||
if ($newPidx > 0) {
|
||||
$this->menuModel->updateCnode($newPidx, 1);
|
||||
}
|
||||
}
|
||||
$this->menuModel->pruneInventoryManagementMenus((int) $row->mt_idx, $lgIdx);
|
||||
$this->menuModel->syncTypeToAllLgs((int) $row->mt_idx, $lgIdx);
|
||||
return $this->menusRedirect((int) $row->mt_idx)->with('success', '메뉴가 수정되었습니다.');
|
||||
|
||||
@@ -12,9 +12,57 @@ use App\Models\PackagingUnitModel;
|
||||
use App\Models\DesignatedShopModel;
|
||||
use App\Models\LocalGovernmentModel;
|
||||
use App\Models\SalesAgencyModel;
|
||||
use App\Models\ReportPresetModel;
|
||||
|
||||
class SalesReport extends BaseController
|
||||
{
|
||||
/** 맞춤 집계표에서 선택 가능한 컬럼 카탈로그 (표시 순서 = 정의 순서) */
|
||||
private const CUSTOM_REPORT_COLUMNS = [
|
||||
'date' => ['label' => '일자', 'type' => 'str'],
|
||||
'shop_no' => ['label' => '지정번호', 'type' => 'str'],
|
||||
'shop_name' => ['label' => '판매소명', 'type' => 'str'],
|
||||
'rep_name' => ['label' => '대표자', 'type' => 'str'],
|
||||
'address' => ['label' => '소재지', 'type' => 'str'],
|
||||
'zone' => ['label' => '구역', 'type' => 'str'],
|
||||
'category' => ['label' => '품목구분', 'type' => 'str'],
|
||||
'product' => ['label' => '품목', 'type' => 'str'],
|
||||
'size' => ['label' => '용량', 'type' => 'str'],
|
||||
'qty' => ['label' => '판매량', 'type' => 'num'],
|
||||
'amount' => ['label' => '판매금액', 'type' => 'num'],
|
||||
'fee' => ['label' => '수수료', 'type' => 'num'],
|
||||
'total' => ['label' => '총액', 'type' => 'num'],
|
||||
];
|
||||
|
||||
private const CUSTOM_REPORT_DEFAULT_COLUMNS = ['date', 'shop_no', 'shop_name', 'product', 'qty', 'amount'];
|
||||
|
||||
/** 품목명 → 3분류(봉투/음식물/폐기물) */
|
||||
private function customReportCategory3(string $name): string
|
||||
{
|
||||
if (mb_strpos($name, '음식물') !== false) {
|
||||
return 'food';
|
||||
}
|
||||
if (mb_strpos($name, '폐기물') !== false || mb_strpos($name, '대형') !== false) {
|
||||
return 'waste';
|
||||
}
|
||||
return 'envelope';
|
||||
}
|
||||
|
||||
private function customReportCategoryLabel(string $cat): string
|
||||
{
|
||||
return ['all' => '전체', 'envelope' => '봉투', 'food' => '음식물', 'waste' => '폐기물'][$cat] ?? '전체';
|
||||
}
|
||||
|
||||
/** 품목명에서 용량 토큰 추출(예: 10L / 1000원) */
|
||||
private function customReportSize(string $name): string
|
||||
{
|
||||
if (preg_match('/(\d+)\s*[lL]/u', $name, $m) === 1) {
|
||||
return $m[1] . 'L';
|
||||
}
|
||||
if (preg_match('/([\d,]+)\s*원/u', $name, $m) === 1) {
|
||||
return str_replace(',', '', $m[1]) . '원';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
/**
|
||||
* P5-01 / PWB-090101-001: 지정 판매소 판매 대장 (일자별·기간별, 엑셀·인쇄)
|
||||
*/
|
||||
@@ -70,6 +118,16 @@ class SalesReport extends BaseController
|
||||
$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 = [];
|
||||
foreach ($detailRows as $row) {
|
||||
if ($this->ledgerRowMatchesCategories($row, $cats)) {
|
||||
@@ -141,6 +199,10 @@ class SalesReport extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
// 하단 통계: 현재 필터(카테고리·크기)까지 적용된 결과 기준으로
|
||||
// ① 용량별(리터별) ② 품목별(봉투/음식물/폐기물) 집계
|
||||
[$sizeStats, $catStats] = $this->buildLedgerBottomStats($filtered, $sizeOf);
|
||||
|
||||
if ($mode === 'daily') {
|
||||
$built = $this->buildDailyLedgerPresentation($filtered, $hasBsFee);
|
||||
} else {
|
||||
@@ -235,6 +297,8 @@ class SalesReport extends BaseController
|
||||
'cats' => $cats,
|
||||
'sizeGroups' => $sizeGroups,
|
||||
'size' => $size,
|
||||
'sizeStats' => $sizeStats,
|
||||
'catStats' => $catStats,
|
||||
'shops' => $shops,
|
||||
'agencies' => $agencies,
|
||||
'lgName' => $lgName,
|
||||
@@ -286,6 +350,68 @@ class SalesReport extends BaseController
|
||||
return $builder->get()->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* 판매대장 하단 통계: 용량별(리터별)·품목별(봉투/음식물/폐기물) 집계.
|
||||
*
|
||||
* @param list<object> $rows 필터가 적용된 상세 판매행
|
||||
* @param callable $sizeOf 품목명 → 크기 토큰(예: '10L', '1000원', '')
|
||||
* @return array{0: list<array{label:string,qty:int,amount:int}>, 1: list<array{label:string,qty:int,amount:int}>}
|
||||
*/
|
||||
private function buildLedgerBottomStats(array $rows, callable $sizeOf): array
|
||||
{
|
||||
$bySize = []; // 크기토큰 => [qty, amount]
|
||||
$byCat = []; // '봉투'|'음식물'|'폐기물' => [qty, amount]
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$name = (string) ($row->bs_bag_name ?? '');
|
||||
$qty = (int) ($row->line_qty ?? 0);
|
||||
$amt = (float) ($row->line_amount ?? 0);
|
||||
|
||||
// ① 용량별(리터별) — 크기 토큰이 없으면 '기타'
|
||||
$sz = (string) $sizeOf($name);
|
||||
if ($sz === '') {
|
||||
$sz = '기타';
|
||||
}
|
||||
$bySize[$sz]['qty'] = ($bySize[$sz]['qty'] ?? 0) + $qty;
|
||||
$bySize[$sz]['amount'] = ($bySize[$sz]['amount'] ?? 0) + $amt;
|
||||
|
||||
// ② 품목별 — 봉투/음식물/폐기물 3분류
|
||||
if (mb_strpos($name, '음식물') !== false) {
|
||||
$cat = '음식물';
|
||||
} elseif (mb_strpos($name, '폐기물') !== false || mb_strpos($name, '대형') !== false) {
|
||||
$cat = '폐기물';
|
||||
} else {
|
||||
$cat = '봉투';
|
||||
}
|
||||
$byCat[$cat]['qty'] = ($byCat[$cat]['qty'] ?? 0) + $qty;
|
||||
$byCat[$cat]['amount'] = ($byCat[$cat]['amount'] ?? 0) + $amt;
|
||||
}
|
||||
|
||||
// 용량별: 숫자(리터/금액) 오름차순, '기타'는 맨 뒤
|
||||
$sizeStats = [];
|
||||
foreach ($bySize as $label => $v) {
|
||||
$sizeStats[] = ['label' => (string) $label, 'qty' => (int) $v['qty'], 'amount' => (int) round($v['amount'])];
|
||||
}
|
||||
usort($sizeStats, static function (array $a, array $b): int {
|
||||
$ao = $a['label'] === '기타' ? 1 : 0;
|
||||
$bo = $b['label'] === '기타' ? 1 : 0;
|
||||
if ($ao !== $bo) {
|
||||
return $ao <=> $bo;
|
||||
}
|
||||
return (int) preg_replace('/\D/', '', $a['label']) <=> (int) preg_replace('/\D/', '', $b['label']);
|
||||
});
|
||||
|
||||
// 품목별: 봉투 → 음식물 → 폐기물 고정 순서
|
||||
$catStats = [];
|
||||
foreach (['봉투', '음식물', '폐기물'] as $c) {
|
||||
if (isset($byCat[$c])) {
|
||||
$catStats[] = ['label' => $c, 'qty' => (int) $byCat[$c]['qty'], 'amount' => (int) round($byCat[$c]['amount'])];
|
||||
}
|
||||
}
|
||||
|
||||
return [$sizeStats, $catStats];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $cats 빈 배열이면 전체
|
||||
*/
|
||||
@@ -3310,4 +3436,256 @@ class SalesReport extends BaseController
|
||||
'salesLabel' => $scopeLabel($salesScope),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 맞춤 집계표 — 기간·품목구분·컬럼을 골라 제목을 붙여 조회/인쇄, 계정별 프리셋 저장.
|
||||
*/
|
||||
public function customReport()
|
||||
{
|
||||
helper(['admin', 'export']);
|
||||
$lgIdx = admin_effective_lg_idx();
|
||||
if (! $lgIdx) {
|
||||
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
|
||||
}
|
||||
$mbIdx = (int) session()->get('mb_idx');
|
||||
|
||||
// report_preset 테이블이 있을 때만 프리셋 기능 활성(없으면 조회·인쇄는 그대로 동작)
|
||||
$presetsEnabled = \Config\Database::connect()->tableExists('report_preset');
|
||||
$presetModel = model(ReportPresetModel::class);
|
||||
$presets = [];
|
||||
if ($presetsEnabled) {
|
||||
$presets = $presetModel->where('rp_lg_idx', $lgIdx)->where('rp_mb_idx', $mbIdx)
|
||||
->orderBy('rp_name', 'ASC')->findAll();
|
||||
}
|
||||
|
||||
// 활성 설정 결정: 폼 적용(applied) > 프리셋 로드(preset) > 기본값
|
||||
$presetIdx = (int) ($this->request->getGet('preset') ?? 0);
|
||||
$loaded = null;
|
||||
if ($presetsEnabled && $presetIdx > 0) {
|
||||
$loaded = $presetModel->find($presetIdx);
|
||||
if ($loaded === null || (int) $loaded->rp_lg_idx !== (int) $lgIdx || (int) $loaded->rp_mb_idx !== $mbIdx) {
|
||||
$loaded = null;
|
||||
}
|
||||
}
|
||||
|
||||
$catalogKeys = array_keys(self::CUSTOM_REPORT_COLUMNS);
|
||||
if ((string) ($this->request->getGet('applied') ?? '') === '1') {
|
||||
$cols = (array) ($this->request->getGet('cols') ?? []);
|
||||
$title = (string) ($this->request->getGet('title') ?? '');
|
||||
$category = (string) ($this->request->getGet('category') ?? 'all');
|
||||
$mode = (string) ($this->request->getGet('mode') ?? 'detail');
|
||||
} elseif ($loaded !== null) {
|
||||
$cols = json_decode((string) ($loaded->rp_columns ?? '[]'), true) ?: [];
|
||||
$title = (string) $loaded->rp_title;
|
||||
$category = (string) $loaded->rp_category;
|
||||
$mode = (string) $loaded->rp_mode;
|
||||
} else {
|
||||
$cols = self::CUSTOM_REPORT_DEFAULT_COLUMNS;
|
||||
$title = '';
|
||||
$category = 'all';
|
||||
$mode = 'detail';
|
||||
}
|
||||
// 정규화
|
||||
$selected = array_values(array_filter($catalogKeys, static fn ($k) => in_array($k, $cols, true)));
|
||||
if ($selected === []) {
|
||||
$selected = self::CUSTOM_REPORT_DEFAULT_COLUMNS;
|
||||
}
|
||||
if (! in_array($category, ['all', 'envelope', 'food', 'waste'], true)) {
|
||||
$category = 'all';
|
||||
}
|
||||
$mode = $mode === 'summary' ? 'summary' : 'detail';
|
||||
|
||||
$isYmd = static fn (string $d): bool => preg_match('/^\d{4}-\d{2}-\d{2}$/', $d) === 1;
|
||||
$startDate = (string) ($this->request->getGet('start_date') ?? date('Y-m-01'));
|
||||
$endDate = (string) ($this->request->getGet('end_date') ?? date('Y-m-d'));
|
||||
if (! $isYmd($startDate)) {
|
||||
$startDate = date('Y-m-01');
|
||||
}
|
||||
if (! $isYmd($endDate)) {
|
||||
$endDate = date('Y-m-d');
|
||||
}
|
||||
if ($startDate > $endDate) {
|
||||
[$startDate, $endDate] = [$endDate, $startDate];
|
||||
}
|
||||
|
||||
$db = \Config\Database::connect();
|
||||
$hasBsFee = $db->fieldExists('bs_fee', 'bag_sale');
|
||||
$detail = $this->fetchSalesLedgerDetailRows($db, (int) $lgIdx, $startDate, $endDate, 0, 0, $hasBsFee);
|
||||
|
||||
// 카테고리 필터 + 행 매핑
|
||||
$rows = [];
|
||||
foreach ($detail as $r) {
|
||||
$name = (string) ($r->bs_bag_name ?? '');
|
||||
$cat3 = $this->customReportCategory3($name);
|
||||
if ($category !== 'all' && $cat3 !== $category) {
|
||||
continue;
|
||||
}
|
||||
$qty = (int) ($r->line_qty ?? 0);
|
||||
$amount = (float) ($r->line_amount ?? 0);
|
||||
$fee = (float) ($r->line_fee ?? 0);
|
||||
$rows[] = [
|
||||
'date' => substr((string) ($r->bs_sale_date ?? ''), 0, 10),
|
||||
'shop_no' => (string) ($r->ds_shop_no ?? ''),
|
||||
'shop_name' => (string) ($r->ds_name ?? ''),
|
||||
'rep_name' => (string) ($r->ds_rep_name ?? ''),
|
||||
'address' => (string) ($r->ds_addr ?? ''),
|
||||
'zone' => (string) ($r->ds_zone_code ?? ''),
|
||||
'category' => $this->customReportCategoryLabel($cat3),
|
||||
'product' => $name,
|
||||
'size' => $this->customReportSize($name),
|
||||
'qty' => $qty,
|
||||
'amount' => (int) round($amount),
|
||||
'fee' => (int) round($fee),
|
||||
'total' => (int) round($amount + $fee),
|
||||
];
|
||||
}
|
||||
|
||||
// 집계 모드: 품목(구분+품명+용량) 기준 합산
|
||||
if ($mode === 'summary') {
|
||||
$agg = [];
|
||||
foreach ($rows as $row) {
|
||||
$key = $row['category'] . '|' . $row['product'] . '|' . $row['size'];
|
||||
if (! isset($agg[$key])) {
|
||||
$agg[$key] = ['category' => $row['category'], 'product' => $row['product'], 'size' => $row['size'],
|
||||
'date' => '', 'shop_no' => '', 'shop_name' => '', 'rep_name' => '', 'address' => '', 'zone' => '',
|
||||
'qty' => 0, 'amount' => 0, 'fee' => 0, 'total' => 0];
|
||||
}
|
||||
$agg[$key]['qty'] += $row['qty'];
|
||||
$agg[$key]['amount'] += $row['amount'];
|
||||
$agg[$key]['fee'] += $row['fee'];
|
||||
$agg[$key]['total'] += $row['total'];
|
||||
}
|
||||
$rows = array_values($agg);
|
||||
}
|
||||
|
||||
// 숫자 컬럼 합계
|
||||
$totals = [];
|
||||
foreach ($selected as $key) {
|
||||
if ((self::CUSTOM_REPORT_COLUMNS[$key]['type'] ?? '') === 'num') {
|
||||
$totals[$key] = array_sum(array_map(static fn ($r) => (int) $r[$key], $rows));
|
||||
}
|
||||
}
|
||||
|
||||
// 엑셀 다운로드 — 현재 컬럼·필터·제목 그대로
|
||||
if ((string) ($this->request->getGet('export') ?? '') === '1') {
|
||||
$headers = array_map(static fn ($k) => self::CUSTOM_REPORT_COLUMNS[$k]['label'], $selected);
|
||||
$exportRows = [];
|
||||
foreach ($rows as $row) {
|
||||
$line = [];
|
||||
foreach ($selected as $key) {
|
||||
$isNum = (self::CUSTOM_REPORT_COLUMNS[$key]['type'] ?? '') === 'num';
|
||||
$line[] = $isNum ? number_format((int) ($row[$key] ?? 0)) : (string) ($row[$key] ?? '');
|
||||
}
|
||||
$exportRows[] = $line;
|
||||
}
|
||||
// 합계 행
|
||||
$totalLine = [];
|
||||
foreach ($selected as $i => $key) {
|
||||
$isNum = (self::CUSTOM_REPORT_COLUMNS[$key]['type'] ?? '') === 'num';
|
||||
if ($i === 0) {
|
||||
$totalLine[] = '합계';
|
||||
} else {
|
||||
$totalLine[] = $isNum ? number_format((int) ($totals[$key] ?? 0)) : '';
|
||||
}
|
||||
}
|
||||
$exportRows[] = $totalLine;
|
||||
|
||||
$fileTitle = $title !== '' ? $title : '맞춤집계표';
|
||||
$topRows = [
|
||||
[$fileTitle],
|
||||
['조회기간: ' . $startDate . ' ~ ' . $endDate . ' / 품목구분: ' . $this->customReportCategoryLabel($category)],
|
||||
];
|
||||
export_excel_2003_xml($fileTitle . '_' . $startDate . '_' . $endDate, mb_substr($fileTitle, 0, 31), $headers, $exportRows, $topRows);
|
||||
}
|
||||
|
||||
return $this->renderWorkPage('맞춤 집계표', 'admin/sales_report/custom_report', [
|
||||
'columnCatalog' => self::CUSTOM_REPORT_COLUMNS,
|
||||
'selected' => $selected,
|
||||
'title' => $title,
|
||||
'category' => $category,
|
||||
'mode' => $mode,
|
||||
'startDate' => $startDate,
|
||||
'endDate' => $endDate,
|
||||
'rows' => $rows,
|
||||
'totals' => $totals,
|
||||
'presets' => $presets,
|
||||
'activePresetIdx' => $loaded !== null ? (int) $loaded->rp_idx : 0,
|
||||
'presetsEnabled' => $presetsEnabled,
|
||||
]);
|
||||
}
|
||||
|
||||
/** 맞춤 집계표 프리셋 저장(신규/수정) — 계정 소유 */
|
||||
public function customReportPresetSave()
|
||||
{
|
||||
helper('admin');
|
||||
$lgIdx = admin_effective_lg_idx();
|
||||
if (! $lgIdx) {
|
||||
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
|
||||
}
|
||||
$mbIdx = (int) session()->get('mb_idx');
|
||||
|
||||
if (! \Config\Database::connect()->tableExists('report_preset')) {
|
||||
return redirect()->to(site_url('bag/reports/custom'))
|
||||
->with('error', '프리셋 저장 테이블이 아직 생성되지 않았습니다. (report_preset_table.sql 실행 필요)');
|
||||
}
|
||||
$name = trim((string) $this->request->getPost('rp_name'));
|
||||
if ($name === '') {
|
||||
return redirect()->to(site_url('bag/reports/custom'))->with('error', '프리셋 이름을 입력해 주세요.');
|
||||
}
|
||||
$cols = (array) ($this->request->getPost('cols') ?? []);
|
||||
$catalog = array_keys(self::CUSTOM_REPORT_COLUMNS);
|
||||
$selected = array_values(array_filter($catalog, static fn ($k) => in_array($k, $cols, true)));
|
||||
if ($selected === []) {
|
||||
$selected = self::CUSTOM_REPORT_DEFAULT_COLUMNS;
|
||||
}
|
||||
$category = (string) $this->request->getPost('rp_category');
|
||||
if (! in_array($category, ['all', 'envelope', 'food', 'waste'], true)) {
|
||||
$category = 'all';
|
||||
}
|
||||
$mode = (string) $this->request->getPost('rp_mode') === 'summary' ? 'summary' : 'detail';
|
||||
|
||||
$data = [
|
||||
'rp_lg_idx' => $lgIdx,
|
||||
'rp_mb_idx' => $mbIdx,
|
||||
'rp_name' => mb_substr($name, 0, 100),
|
||||
'rp_title' => mb_substr((string) $this->request->getPost('rp_title'), 0, 200),
|
||||
'rp_category' => $category,
|
||||
'rp_mode' => $mode,
|
||||
'rp_columns' => json_encode($selected, JSON_UNESCAPED_UNICODE),
|
||||
];
|
||||
|
||||
$presetModel = model(ReportPresetModel::class);
|
||||
$rpIdx = (int) ($this->request->getPost('rp_idx') ?? 0);
|
||||
if ($rpIdx > 0) {
|
||||
$existing = $presetModel->find($rpIdx);
|
||||
if ($existing !== null && (int) $existing->rp_lg_idx === (int) $lgIdx && (int) $existing->rp_mb_idx === $mbIdx) {
|
||||
$presetModel->update($rpIdx, $data);
|
||||
}
|
||||
} else {
|
||||
$presetModel->insert($data);
|
||||
$rpIdx = (int) $presetModel->getInsertID();
|
||||
}
|
||||
|
||||
$q = http_build_query(['preset' => $rpIdx, 'start_date' => $this->request->getPost('start_date'), 'end_date' => $this->request->getPost('end_date')]);
|
||||
|
||||
return redirect()->to(site_url('bag/reports/custom') . '?' . $q)->with('success', '프리셋을 저장했습니다.');
|
||||
}
|
||||
|
||||
/** 맞춤 집계표 프리셋 삭제 — 계정 소유 */
|
||||
public function customReportPresetDelete()
|
||||
{
|
||||
helper('admin');
|
||||
$lgIdx = admin_effective_lg_idx();
|
||||
$mbIdx = (int) session()->get('mb_idx');
|
||||
$rpIdx = (int) ($this->request->getPost('rp_idx') ?? 0);
|
||||
$presetModel = model(ReportPresetModel::class);
|
||||
$existing = $rpIdx > 0 ? $presetModel->find($rpIdx) : null;
|
||||
if ($existing !== null && (int) $existing->rp_lg_idx === (int) $lgIdx && (int) $existing->rp_mb_idx === $mbIdx) {
|
||||
$presetModel->delete($rpIdx);
|
||||
|
||||
return redirect()->to(site_url('bag/reports/custom'))->with('success', '프리셋을 삭제했습니다.');
|
||||
}
|
||||
|
||||
return redirect()->to(site_url('bag/reports/custom'))->with('error', '삭제할 프리셋을 찾을 수 없습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +233,7 @@ class Auth extends BaseController
|
||||
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()
|
||||
@@ -329,7 +329,7 @@ class Auth extends BaseController
|
||||
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()
|
||||
@@ -573,7 +573,7 @@ class Auth extends BaseController
|
||||
/**
|
||||
* @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();
|
||||
// 중복 로그인 방지(나중 로그인 우선): 새 세션 토큰 발급 → 기존 다른 세션은 다음 요청 때 무효화됨
|
||||
@@ -586,6 +586,8 @@ class Auth extends BaseController
|
||||
'mb_lg_idx' => $member->mb_lg_idx ?? null,
|
||||
'logged_in' => true,
|
||||
'session_token' => $sessionToken,
|
||||
// 지정판매소 PII 자동 원문 열람 조건에 사용(TOTP 실제 통과 시에만 true)
|
||||
'auth_2fa_verified' => $twofaVerified,
|
||||
];
|
||||
session()->set($sessionData);
|
||||
|
||||
|
||||
@@ -224,6 +224,120 @@ class Bag extends BaseController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정별·메뉴별 글자크기(zoom %) 저장. AJAX 전용.
|
||||
* menu_key(화면 경로) + scale(70~150)을 로그인 계정 기준으로 upsert.
|
||||
*/
|
||||
public function saveFontScale(): ResponseInterface
|
||||
{
|
||||
$mbIdx = (int) (session()->get('mb_idx') ?? 0);
|
||||
if ($mbIdx <= 0) {
|
||||
return $this->response->setJSON(['ok' => false, 'csrf' => csrf_hash()]);
|
||||
}
|
||||
$menuKey = trim((string) ($this->request->getPost('menu_key') ?? ''));
|
||||
$scale = (int) ($this->request->getPost('scale') ?? 0);
|
||||
if ($menuKey === '' || $scale < 70 || $scale > 150) {
|
||||
return $this->response->setJSON(['ok' => false, 'csrf' => csrf_hash()]);
|
||||
}
|
||||
$menuKey = mb_substr($menuKey, 0, 191);
|
||||
|
||||
$db = \Config\Database::connect();
|
||||
if (! $db->tableExists('member_menu_font_scale')) {
|
||||
$db->query(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `member_menu_font_scale` (
|
||||
`mmf_idx` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`mmf_mb_idx` INT UNSIGNED NOT NULL,
|
||||
`mmf_menu_key` VARCHAR(191) NOT NULL,
|
||||
`mmf_scale` SMALLINT UNSIGNED NOT NULL DEFAULT 100,
|
||||
`mmf_updated` DATETIME NOT NULL,
|
||||
PRIMARY KEY (`mmf_idx`),
|
||||
UNIQUE KEY `uk_mb_menu` (`mmf_mb_idx`,`mmf_menu_key`),
|
||||
KEY `idx_mb` (`mmf_mb_idx`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL);
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$exists = $db->table('member_menu_font_scale')
|
||||
->where('mmf_mb_idx', $mbIdx)->where('mmf_menu_key', $menuKey)->countAllResults();
|
||||
if ($exists > 0) {
|
||||
$db->table('member_menu_font_scale')
|
||||
->where('mmf_mb_idx', $mbIdx)->where('mmf_menu_key', $menuKey)
|
||||
->update(['mmf_scale' => $scale, 'mmf_updated' => $now]);
|
||||
} else {
|
||||
$db->table('member_menu_font_scale')->insert([
|
||||
'mmf_mb_idx' => $mbIdx, 'mmf_menu_key' => $menuKey,
|
||||
'mmf_scale' => $scale, 'mmf_updated' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->response->setJSON(['ok' => true, 'csrf' => csrf_hash()]);
|
||||
}
|
||||
|
||||
/** member_workspace_tabs 테이블 보장(없으면 생성) */
|
||||
private function ensureWorkspaceTabsTable(\CodeIgniter\Database\BaseConnection $db): void
|
||||
{
|
||||
if ($db->tableExists('member_workspace_tabs')) {
|
||||
return;
|
||||
}
|
||||
$db->query(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `member_workspace_tabs` (
|
||||
`mwt_mb_idx` INT UNSIGNED NOT NULL,
|
||||
`mwt_state` MEDIUMTEXT NULL,
|
||||
`mwt_updated` DATETIME NOT NULL,
|
||||
PRIMARY KEY (`mwt_mb_idx`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL);
|
||||
}
|
||||
|
||||
/** 계정별 워크스페이스 탭 상태 조회(자동 복원용). 없으면 'null'. */
|
||||
public function loadWorkspaceTabsState(int $mbIdx): string
|
||||
{
|
||||
if ($mbIdx <= 0) {
|
||||
return 'null';
|
||||
}
|
||||
$db = \Config\Database::connect();
|
||||
if (! $db->tableExists('member_workspace_tabs')) {
|
||||
return 'null';
|
||||
}
|
||||
$row = $db->table('member_workspace_tabs')->select('mwt_state')->where('mwt_mb_idx', $mbIdx)->get()->getRowArray();
|
||||
$s = (string) ($row['mwt_state'] ?? '');
|
||||
return $s !== '' ? $s : 'null';
|
||||
}
|
||||
|
||||
/** 계정별 워크스페이스 탭 상태 저장 (자동 복원) */
|
||||
public function saveWorkspaceTabs(): ResponseInterface
|
||||
{
|
||||
$mbIdx = (int) (session()->get('mb_idx') ?? 0);
|
||||
if ($mbIdx <= 0) {
|
||||
return $this->response->setJSON(['ok' => false, 'csrf' => csrf_hash()]);
|
||||
}
|
||||
$state = (string) ($this->request->getPost('state') ?? '');
|
||||
// 크기 제한 + JSON 유효성
|
||||
if (strlen($state) > 30000) {
|
||||
return $this->response->setJSON(['ok' => false, 'csrf' => csrf_hash()]);
|
||||
}
|
||||
if ($state !== '') {
|
||||
json_decode($state);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
return $this->response->setJSON(['ok' => false, 'csrf' => csrf_hash()]);
|
||||
}
|
||||
}
|
||||
|
||||
$db = \Config\Database::connect();
|
||||
$this->ensureWorkspaceTabsTable($db);
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$exists = $db->table('member_workspace_tabs')->where('mwt_mb_idx', $mbIdx)->countAllResults();
|
||||
if ($exists > 0) {
|
||||
$db->table('member_workspace_tabs')->where('mwt_mb_idx', $mbIdx)
|
||||
->update(['mwt_state' => $state, 'mwt_updated' => $now]);
|
||||
} else {
|
||||
$db->table('member_workspace_tabs')->insert(['mwt_mb_idx' => $mbIdx, 'mwt_state' => $state, 'mwt_updated' => $now]);
|
||||
}
|
||||
|
||||
return $this->response->setJSON(['ok' => true, 'csrf' => csrf_hash()]);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 기본정보관리 (단가·포장 단위 진입 허브)
|
||||
// ──────────────────────────────────────────────
|
||||
@@ -1262,6 +1376,142 @@ class Bag extends BaseController
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 실사 재고 바코드 리스트 — 현재 창고에 있어야 하는(재고로 남아있는) 봉투 번호(바코드) 목록.
|
||||
* bag_receiving_pack_code 중 brpc_state='in_stock' 인 팩/박스 코드를 품목·박스·팩·시트번호(시작~끝)·매수로 뽑아
|
||||
* 실사 전 대조용으로 조회/엑셀/인쇄한다. (기존 실사 스냅샷과 동일한 in_stock 기준)
|
||||
*/
|
||||
public function stockBarcodeList(): string|ResponseInterface|RedirectResponse
|
||||
{
|
||||
$lgIdx = $this->lgIdx();
|
||||
if (! $lgIdx) {
|
||||
return redirect()->to(site_url('bag/inventory'))->with('error', '지자체를 선택해 주세요.');
|
||||
}
|
||||
|
||||
$db = \Config\Database::connect();
|
||||
$bagCode = trim((string) ($this->request->getGet('bag_code') ?? ''));
|
||||
$keyword = trim((string) ($this->request->getGet('keyword') ?? ''));
|
||||
$pageSize = 200; // 레이지 로딩 청크 크기
|
||||
|
||||
// 공통 필터(재고 상태 + 품목/키워드)를 빌더에 적용
|
||||
$applyFilter = static function ($builder) use ($lgIdx, $bagCode, $keyword) {
|
||||
$builder->where('brpc_lg_idx', $lgIdx)->where('brpc_state', 'in_stock');
|
||||
if ($bagCode !== '') {
|
||||
$builder->where('brpc_bag_code', $bagCode);
|
||||
}
|
||||
if ($keyword !== '') {
|
||||
$builder->groupStart()
|
||||
->like('brpc_box_code', $keyword)
|
||||
->orLike('brpc_pack_code', $keyword)
|
||||
->orLike('brpc_sheet_start_code', $keyword)
|
||||
->orLike('brpc_sheet_end_code', $keyword)
|
||||
->orLike('brpc_lot_no', $keyword)
|
||||
->groupEnd();
|
||||
}
|
||||
return $builder;
|
||||
};
|
||||
|
||||
// 필터 적용된 행을 offset/limit(0=전체)로 조회
|
||||
$fetchRows = function (int $offset, int $limit) use ($db, $applyFilter): array {
|
||||
$builder = $applyFilter($db->table('bag_receiving_pack_code'))
|
||||
->select('brpc_bag_code, brpc_bag_name, brpc_lot_no, brpc_box_code, brpc_pack_code, brpc_sheet_start_code, brpc_sheet_end_code, brpc_sheet_qty')
|
||||
->orderBy('brpc_bag_code', 'ASC')
|
||||
->orderBy('brpc_box_code', 'ASC')
|
||||
->orderBy('brpc_pack_code', 'ASC');
|
||||
if ($limit > 0) {
|
||||
$builder->limit($limit, max(0, $offset));
|
||||
}
|
||||
return $builder->get()->getResultArray();
|
||||
};
|
||||
|
||||
// 합계 — 전체 기준 SQL 집계(전체 행을 메모리에 올리지 않음)
|
||||
$agg = $applyFilter($db->table('bag_receiving_pack_code'))
|
||||
->select('COUNT(*) AS total_packs, COALESCE(SUM(brpc_sheet_qty),0) AS total_sheets, COUNT(DISTINCT brpc_box_code) AS total_boxes', false)
|
||||
->get()
|
||||
->getRowArray();
|
||||
$totalPacks = (int) ($agg['total_packs'] ?? 0);
|
||||
$totalSheets = (int) ($agg['total_sheets'] ?? 0);
|
||||
$totalBoxes = (int) ($agg['total_boxes'] ?? 0);
|
||||
|
||||
// AJAX 레이지 로딩 — 다음 청크만 JSON 으로 반환
|
||||
if ((string) ($this->request->getGet('ajax') ?? '') === '1') {
|
||||
$offset = max(0, (int) ($this->request->getGet('offset') ?? 0));
|
||||
$limit = (int) ($this->request->getGet('limit') ?? $pageSize);
|
||||
$limit = $limit <= 0 ? $pageSize : min($limit, 5000);
|
||||
$chunk = $fetchRows($offset, $limit);
|
||||
$out = [];
|
||||
foreach ($chunk as $r) {
|
||||
$out[] = [
|
||||
'name' => ($r['brpc_bag_name'] ?? '') !== '' ? (string) $r['brpc_bag_name'] : (string) ($r['brpc_bag_code'] ?? ''),
|
||||
'lot' => (string) ($r['brpc_lot_no'] ?? ''),
|
||||
'box' => (string) ($r['brpc_box_code'] ?? ''),
|
||||
'pack' => (string) ($r['brpc_pack_code'] ?? ''),
|
||||
'start' => (string) ($r['brpc_sheet_start_code'] ?? ''),
|
||||
'end' => (string) ($r['brpc_sheet_end_code'] ?? ''),
|
||||
'qty' => (int) ($r['brpc_sheet_qty'] ?? 0),
|
||||
];
|
||||
}
|
||||
$next = $offset + count($chunk);
|
||||
|
||||
return $this->response->setJSON([
|
||||
'rows' => $out,
|
||||
'offset' => $next,
|
||||
'hasMore' => $next < $totalPacks,
|
||||
'total' => $totalPacks,
|
||||
]);
|
||||
}
|
||||
|
||||
// 엑셀 다운로드 — 현재 필터 전체
|
||||
if ((string) ($this->request->getGet('export') ?? '') === '1') {
|
||||
helper('export');
|
||||
$exportRows = [];
|
||||
foreach ($fetchRows(0, 0) as $r) {
|
||||
$exportRows[] = [
|
||||
($r['brpc_bag_name'] ?? '') !== '' ? (string) $r['brpc_bag_name'] : (string) ($r['brpc_bag_code'] ?? ''),
|
||||
(string) ($r['brpc_lot_no'] ?? ''),
|
||||
(string) ($r['brpc_box_code'] ?? ''),
|
||||
(string) ($r['brpc_pack_code'] ?? ''),
|
||||
(string) ($r['brpc_sheet_start_code'] ?? ''),
|
||||
(string) ($r['brpc_sheet_end_code'] ?? ''),
|
||||
number_format((int) ($r['brpc_sheet_qty'] ?? 0)),
|
||||
];
|
||||
}
|
||||
$exportRows[] = ['합계', '', '박스 ' . number_format($totalBoxes), '팩 ' . number_format($totalPacks), '', '', number_format($totalSheets)];
|
||||
export_xlsx(
|
||||
'실사재고바코드리스트_' . date('Ymd') . '.xlsx',
|
||||
'창고재고바코드',
|
||||
['품목', 'LOT', '박스코드', '팩코드', '시작번호', '끝번호', '매수'],
|
||||
$exportRows
|
||||
);
|
||||
}
|
||||
|
||||
// 품목 선택 옵션 — 현재 재고로 남아있는 품목만
|
||||
$itemOptions = $db->table('bag_receiving_pack_code')
|
||||
->select('brpc_bag_code, MAX(brpc_bag_name) AS bag_name, COUNT(*) AS pack_cnt, SUM(brpc_sheet_qty) AS sheet_qty', false)
|
||||
->where('brpc_lg_idx', $lgIdx)
|
||||
->where('brpc_state', 'in_stock')
|
||||
->where('brpc_bag_code !=', '')
|
||||
->groupBy('brpc_bag_code')
|
||||
->orderBy('brpc_bag_code', 'ASC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
// 초기 1페이지만 렌더 — 나머지는 스크롤 시 레이지 로딩
|
||||
$rows = $fetchRows(0, $pageSize);
|
||||
|
||||
return $this->render('실사 재고 바코드 리스트', 'bag/inventory_stock_barcode_list', [
|
||||
'bagCode' => $bagCode,
|
||||
'keyword' => $keyword,
|
||||
'itemOptions' => $itemOptions,
|
||||
'rows' => $rows,
|
||||
'totalBoxes' => $totalBoxes,
|
||||
'totalPacks' => $totalPacks,
|
||||
'totalSheets' => $totalSheets,
|
||||
'pageSize' => $pageSize,
|
||||
'loadedCount' => count($rows),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* rows: list<array{group:string,name:string,total_qty:int,gugun_qty:int,agency_qty:int}>,
|
||||
@@ -3873,6 +4123,66 @@ SQL);
|
||||
return redirect()->to(site_url('bag/issue/cancel'))->with('success', session()->getFlashdata('success') ?? '취소되었습니다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* [AJAX] 발주 1건의 내역(헤더+품목)을 JSON으로 반환 — 발주 이력 "보기" 모달·삭제 화면 상세용.
|
||||
*/
|
||||
public function orderDetailJson(int $id)
|
||||
{
|
||||
helper('admin');
|
||||
$lgIdx = $this->lgIdx();
|
||||
if (! $lgIdx) {
|
||||
return $this->response->setStatusCode(403)->setJSON(['ok' => false, 'error' => '지자체가 선택되지 않았습니다.']);
|
||||
}
|
||||
$order = model(BagOrderModel::class)->find($id);
|
||||
if ($order === null || (int) $order->bo_lg_idx !== (int) $lgIdx) {
|
||||
return $this->response->setStatusCode(404)->setJSON(['ok' => false, 'error' => '발주를 찾을 수 없습니다.']);
|
||||
}
|
||||
|
||||
$companyName = '';
|
||||
if ($order->bo_company_idx) {
|
||||
$c = model(CompanyModel::class)->find($order->bo_company_idx);
|
||||
$companyName = $c ? (string) $c->cp_name : '';
|
||||
}
|
||||
$agencyName = '';
|
||||
if ($order->bo_agency_idx) {
|
||||
$a = model(SalesAgencyModel::class)->find($order->bo_agency_idx);
|
||||
if ($a) {
|
||||
$agencyName = '[' . ($a->sa_kind ?? '') . '] ' . ($a->sa_code ?? '') . ' — ' . ($a->sa_name ?? '');
|
||||
}
|
||||
}
|
||||
$managerName = '';
|
||||
if (\Config\Database::connect()->fieldExists('bo_manager_idx', 'bag_order') && ($order->bo_manager_idx ?? null)) {
|
||||
$mg = model(ManagerModel::class)->find($order->bo_manager_idx);
|
||||
$managerName = $mg ? (string) $mg->mg_name : '';
|
||||
}
|
||||
|
||||
$items = [];
|
||||
foreach (model(BagOrderItemModel::class)->where('boi_bo_idx', $id)->orderBy('boi_idx', 'ASC')->findAll() as $it) {
|
||||
$items[] = [
|
||||
'bag_code' => (string) ($it->boi_bag_code ?? ''),
|
||||
'bag_name' => (string) ($it->boi_bag_name ?? ''),
|
||||
'unit_price' => (float) ($it->boi_unit_price ?? 0),
|
||||
'qty_box' => (int) ($it->boi_qty_box ?? 0),
|
||||
'qty_sheet' => (int) ($it->boi_qty_sheet ?? 0),
|
||||
'amount' => (float) ($it->boi_amount ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
return $this->response->setJSON([
|
||||
'ok' => true,
|
||||
'bo_idx' => (int) $order->bo_idx,
|
||||
'bo_lot_no' => (string) ($order->bo_lot_no ?? ''),
|
||||
'bo_version' => (int) ($order->bo_version ?? 1),
|
||||
'order_date' => (string) ($order->bo_order_date ?? ''),
|
||||
'fee_rate' => (float) ($order->bo_fee_rate ?? 0),
|
||||
'status' => (string) ($order->bo_status ?? ''),
|
||||
'company' => $companyName,
|
||||
'agency' => $agencyName,
|
||||
'manager' => $managerName,
|
||||
'items' => $items,
|
||||
]);
|
||||
}
|
||||
|
||||
// --- 발주 등록 ---
|
||||
public function orderCreate(): string
|
||||
{
|
||||
@@ -3885,6 +4195,7 @@ SQL);
|
||||
? model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', '협회')->where('cp_state', 1)->findAll()
|
||||
: [];
|
||||
$agencies = $lgIdx ? model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->orderForDisplay()->findAll() : [];
|
||||
$managers = $lgIdx ? model(ManagerModel::class)->where('mg_lg_idx', $lgIdx)->where('mg_state', 1)->orderBy('mg_name', 'ASC')->findAll() : [];
|
||||
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
|
||||
$bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
|
||||
$priceMapRows = $lgIdx ? model(BagPriceModel::class)->latestActiveMapByBagCode($lgIdx) : [];
|
||||
@@ -3941,6 +4252,7 @@ SQL);
|
||||
'companies',
|
||||
'associations',
|
||||
'agencies',
|
||||
'managers',
|
||||
'bagCodes',
|
||||
'recentOrders',
|
||||
'companyMap',
|
||||
@@ -4402,6 +4714,7 @@ SQL);
|
||||
? model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', '협회')->where('cp_state', 1)->findAll()
|
||||
: [];
|
||||
$agencies = $lgIdx ? model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->orderForDisplay()->findAll() : [];
|
||||
$managers = $lgIdx ? model(ManagerModel::class)->where('mg_lg_idx', $lgIdx)->where('mg_state', 1)->orderBy('mg_name', 'ASC')->findAll() : [];
|
||||
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
|
||||
$bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
|
||||
$priceMapRows = $lgIdx ? model(BagPriceModel::class)->latestActiveMapByBagCode($lgIdx) : [];
|
||||
@@ -4492,6 +4805,7 @@ SQL);
|
||||
'bo_association_idx' => (string) ($target->bo_association_idx ?? ''),
|
||||
'bo_company_idx' => (string) ($target->bo_company_idx ?? ''),
|
||||
'bo_agency_idx' => (string) ($target->bo_agency_idx ?? ''),
|
||||
'bo_manager_idx' => (string) ($target->bo_manager_idx ?? ''),
|
||||
'item_bag_code' => $itemCodes,
|
||||
'item_qty_box' => $itemQtyBoxes,
|
||||
'item_qty_sheet' => $itemQtySheets,
|
||||
@@ -4504,6 +4818,7 @@ SQL);
|
||||
'companies',
|
||||
'associations',
|
||||
'agencies',
|
||||
'managers',
|
||||
'bagCodes',
|
||||
'recentOrders',
|
||||
'companyMap',
|
||||
@@ -4589,16 +4904,22 @@ SQL);
|
||||
}
|
||||
$month = substr((string) ($order->bo_order_date ?? date('Y-m-d')), 0, 7);
|
||||
|
||||
// 삭제 후 돌아갈 화면: 발주 등록에서 삭제한 경우 등록 화면으로 복귀
|
||||
$returnTo = (string) ($this->request->getPost('return_to') ?? '');
|
||||
$redirectUrl = $returnTo === 'create'
|
||||
? site_url('bag/order/create')
|
||||
: site_url('bag/order/change?month=' . $month);
|
||||
|
||||
$admin = new \App\Controllers\Admin\BagOrder();
|
||||
$admin->initController($this->request, $this->response, service('logger'));
|
||||
$response = $admin->delete($id);
|
||||
if ($response instanceof RedirectResponse) {
|
||||
$msg = session()->getFlashdata('success') ?? '발주가 삭제 처리되었습니다.';
|
||||
|
||||
return redirect()->to(site_url('bag/order/change?month=' . $month))->with('success', $msg);
|
||||
return redirect()->to($redirectUrl)->with('success', $msg);
|
||||
}
|
||||
|
||||
return redirect()->to(site_url('bag/order/change?month=' . $month))->with('success', '처리되었습니다.');
|
||||
return redirect()->to($redirectUrl)->with('success', '처리되었습니다.');
|
||||
}
|
||||
|
||||
public function orderCancel(int $id)
|
||||
@@ -4633,12 +4954,20 @@ SQL);
|
||||
}
|
||||
|
||||
/**
|
||||
* 발주 입고(스캐너 대체 수동입력)
|
||||
* - 미입고가 남은 발주의 LOT·봉투(이름)로 조회 범위를 좁힌 뒤 입고 처리
|
||||
* - 인수자: 대행소(agency) 담당자, 기본값 동명이면 로그인 사용자명과 일치하는 담당자
|
||||
* - 인계자: 제작업체(company) 담당자
|
||||
* 발주 입고(스캐너) — 좌우 분할 워크스페이스로 렌더.
|
||||
*/
|
||||
public function receivingScanner(): string|RedirectResponse
|
||||
{
|
||||
return $this->renderReceivingWorkspace('scanner');
|
||||
}
|
||||
|
||||
/**
|
||||
* 발주입고(스캐너) / 일괄입고 공용 워크스페이스.
|
||||
* 좌측: 발주 현황(탭: 제작업체·발주기간 / 인수자·인계자, 정렬) · 우측: 선택 발주 상세 + 스캔/수량 입고 + 입고 세부내역.
|
||||
*
|
||||
* @param string $mode 'scanner'(종량제 봉투) | 'batch'(음식물·폐기물)
|
||||
*/
|
||||
private function renderReceivingWorkspace(string $mode): string|RedirectResponse
|
||||
{
|
||||
helper('admin');
|
||||
$lgIdx = $this->lgIdx();
|
||||
@@ -4646,75 +4975,206 @@ SQL);
|
||||
return redirect()->to(site_url('bag/purchase-inbound'))->with('error', '지자체를 선택해 주세요.');
|
||||
}
|
||||
|
||||
$companyIdx = (int) old('company_idx', (int) ($this->request->getGet('company_idx') ?? 0));
|
||||
$lotNo = '';
|
||||
$bagCode = '';
|
||||
$isBatch = ($mode === 'batch');
|
||||
$category = $isBatch ? 'bulk' : 'bag';
|
||||
$title = $isBatch ? '일괄입고(음식물,폐기물)' : '발주 입고(스캐너)';
|
||||
|
||||
$tab = (string) ($this->request->getGet('tab') ?? 'company');
|
||||
if (! in_array($tab, ['company', 'agency'], true)) {
|
||||
$tab = 'company';
|
||||
}
|
||||
|
||||
$companyIdx = (int) ($this->request->getGet('company_idx') ?? 0);
|
||||
$agencyIdx = (int) ($this->request->getGet('agency_idx') ?? 0);
|
||||
$startMonth = (string) ($this->request->getGet('start_month') ?? '');
|
||||
$endMonth = (string) ($this->request->getGet('end_month') ?? '');
|
||||
$startDate = preg_match('/^\d{4}-\d{2}$/', $startMonth) ? $startMonth . '-01' : '';
|
||||
$endDate = preg_match('/^\d{4}-\d{2}$/', $endMonth) ? date('Y-m-t', strtotime($endMonth . '-01 00:00:00')) : '';
|
||||
|
||||
$companies = model(CompanyModel::class)
|
||||
->where('cp_lg_idx', $lgIdx)
|
||||
->where('cp_type', '제작업체')
|
||||
->where('cp_state', 1)
|
||||
->orderBy('cp_name', 'ASC')
|
||||
->findAll();
|
||||
->where('cp_lg_idx', $lgIdx)->where('cp_type', '제작업체')->where('cp_state', 1)
|
||||
->orderBy('cp_name', 'ASC')->findAll();
|
||||
$agencies = model(SalesAgencyModel::class)
|
||||
->where('sa_lg_idx', $lgIdx)->orderForDisplay()->findAll();
|
||||
|
||||
$defaultCompanyIdx = ! empty($companies)
|
||||
? (int) ($companies[0]->cp_idx ?? 0)
|
||||
: 0;
|
||||
|
||||
if ($companyIdx > 0) {
|
||||
$validCompany = false;
|
||||
foreach ($companies as $company) {
|
||||
if ((int) ($company->cp_idx ?? 0) === $companyIdx) {
|
||||
$validCompany = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (! $validCompany) {
|
||||
$companyIdx = $defaultCompanyIdx;
|
||||
}
|
||||
} elseif ($defaultCompanyIdx > 0) {
|
||||
// 초기 진입 시 드롭다운 최상단 제작업체를 기본 선택한다.
|
||||
$companyIdx = $defaultCompanyIdx;
|
||||
}
|
||||
|
||||
$lotChoices = [];
|
||||
$bagFilterOptions = $this->receivingBagFilterOptions($lgIdx, $companyIdx, '');
|
||||
|
||||
$pick = $this->receivingManagerPickers($lgIdx);
|
||||
$recvSel = $this->receivingReceiverSelect($lgIdx);
|
||||
$receiverRef = (string) old('br_receiver_ref', $recvSel['defaultReceiverRef']);
|
||||
$receiverRef = $this->sanitizeReceiverRef($recvSel['receiverOptions'], $receiverRef);
|
||||
if ($receiverRef === '') {
|
||||
$receiverRef = $recvSel['defaultReceiverRef'];
|
||||
}
|
||||
$senderIdx = (int) old('br_sender_idx', $pick['defaultSenderIdx']);
|
||||
|
||||
$rows = $companyIdx > 0
|
||||
? $this->buildReceivingCandidateRows($lgIdx, $companyIdx, '', true, '')
|
||||
: [];
|
||||
// 조회 기준: company 탭 = 제작업체+발주기간 / agency 탭 = 인수자(대행소)+인계자(제작업체)
|
||||
$rows = $this->buildReceivingCandidateRows(
|
||||
$lgIdx,
|
||||
$companyIdx,
|
||||
'',
|
||||
true,
|
||||
'',
|
||||
$tab === 'agency' ? $agencyIdx : 0,
|
||||
$category,
|
||||
$tab === 'company' ? $startDate : '',
|
||||
$tab === 'company' ? $endDate : ''
|
||||
);
|
||||
$rowsByKey = [];
|
||||
foreach ($rows as $row) {
|
||||
$rowsByKey[(string) $row['row_key']] = $row;
|
||||
}
|
||||
|
||||
return $this->render(
|
||||
'발주 입고(스캐너)',
|
||||
'bag/receiving_scanner',
|
||||
[
|
||||
'companyIdx' => $companyIdx,
|
||||
$pick = $this->receivingManagerPickers($lgIdx);
|
||||
$recvSel = $this->receivingReceiverSelect($lgIdx);
|
||||
|
||||
// 발주기간 드롭다운 옵션(최근 5년치 월)
|
||||
$monthOptions = [];
|
||||
$baseYear = (int) date('Y');
|
||||
for ($y = $baseYear; $y >= $baseYear - 4; $y--) {
|
||||
for ($m = 12; $m >= 1; $m--) {
|
||||
$v = sprintf('%04d-%02d', $y, $m);
|
||||
$monthOptions[] = ['value' => $v, 'label' => $y . '년 ' . $m . '월'];
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render($title, 'bag/receiving_scanner', [
|
||||
'mode' => $mode,
|
||||
'isBatch' => $isBatch,
|
||||
'workspaceTitle' => $title,
|
||||
'tab' => $tab,
|
||||
'companies' => $companies,
|
||||
'lotNo' => '',
|
||||
'bagCode' => '',
|
||||
'bagFilterOptions' => $bagFilterOptions,
|
||||
'lotChoices' => $lotChoices,
|
||||
'agencies' => $agencies,
|
||||
'companyIdx' => $companyIdx,
|
||||
'agencyIdx' => $agencyIdx,
|
||||
'startMonth' => $startMonth,
|
||||
'endMonth' => $endMonth,
|
||||
'monthOptions' => $monthOptions,
|
||||
'receiverOptions' => $recvSel['receiverOptions'],
|
||||
'receiverRef' => $receiverRef,
|
||||
'receiverRef' => $recvSel['defaultReceiverRef'],
|
||||
'senders' => $pick['senders'],
|
||||
'senderIdx' => $senderIdx,
|
||||
'senderIdx' => $pick['defaultSenderIdx'],
|
||||
'rows' => $rows,
|
||||
'rowsByKey' => $rowsByKey,
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 실시간 입고 처리(스캔 1회 = 1박스, 또는 수량 직접입력). AJAX 전용.
|
||||
* 선택 발주행에 대해 입고 레코드 생성 → 재고 반영 → 박스코드(brpc) 생성 후 반환.
|
||||
*/
|
||||
public function receivingScanBox(): ResponseInterface
|
||||
{
|
||||
helper('admin');
|
||||
$lgIdx = $this->lgIdx();
|
||||
if (! $lgIdx) {
|
||||
return $this->response->setJSON(['ok' => false, 'message' => '지자체를 선택해 주세요.']);
|
||||
}
|
||||
|
||||
$rowKey = (string) ($this->request->getPost('row_key') ?? '');
|
||||
$receiverRef = (string) ($this->request->getPost('br_receiver_ref') ?? '');
|
||||
$senderIdx = (int) ($this->request->getPost('br_sender_idx') ?? 0);
|
||||
$receiveDate = (string) ($this->request->getPost('br_receive_date') ?? date('Y-m-d'));
|
||||
$barcode = trim((string) ($this->request->getPost('barcode') ?? ''));
|
||||
$mode = (string) ($this->request->getPost('mode') ?? 'scanner');
|
||||
$qtySheetIn = (int) ($this->request->getPost('qty_sheet') ?? 0);
|
||||
$category = ($mode === 'batch') ? 'bulk' : 'bag';
|
||||
|
||||
$refresh = ['csrf' => csrf_hash()];
|
||||
|
||||
if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $receiveDate)) {
|
||||
return $this->response->setJSON(array_merge($refresh, ['ok' => false, 'message' => '입고일 형식을 확인해 주세요.']));
|
||||
}
|
||||
$recvSel = $this->receivingReceiverSelect($lgIdx);
|
||||
$receiverRef = $this->sanitizeReceiverRef($recvSel['receiverOptions'], $receiverRef);
|
||||
if ($receiverRef === '') {
|
||||
$receiverRef = $recvSel['defaultReceiverRef'];
|
||||
}
|
||||
$receiverIdx = $this->parseReceiverRefToStoredIdx($lgIdx, $receiverRef);
|
||||
if ($receiverIdx <= 0) {
|
||||
return $this->response->setJSON(array_merge($refresh, ['ok' => false, 'message' => '인수자를 선택해 주세요.']));
|
||||
}
|
||||
|
||||
$rows = $this->buildReceivingCandidateRows($lgIdx, 0, '', true, '', 0, $category, '', '');
|
||||
$map = [];
|
||||
foreach ($rows as $r) {
|
||||
$map[(string) $r['row_key']] = $r;
|
||||
}
|
||||
if (! isset($map[$rowKey])) {
|
||||
return $this->response->setJSON(array_merge($refresh, ['ok' => false, 'message' => '선택한 발주를 찾을 수 없거나 이미 전량 입고되었습니다.']));
|
||||
}
|
||||
$base = $map[$rowKey];
|
||||
$pending = (int) $base['pending_qty_sheet'];
|
||||
if ($pending <= 0) {
|
||||
return $this->response->setJSON(array_merge($refresh, ['ok' => false, 'message' => '미입고 잔량이 없습니다.']));
|
||||
}
|
||||
|
||||
$totalPerBox = max(1, (int) $base['total_per_box']);
|
||||
$qty = $qtySheetIn > 0 ? $qtySheetIn : $totalPerBox; // 스캔 1회 = 1박스
|
||||
if ($qty > $pending) {
|
||||
$qty = $pending;
|
||||
}
|
||||
$qtyBox = intdiv($qty, $totalPerBox);
|
||||
$senderName = $this->resolveCompanySenderName($lgIdx, $senderIdx);
|
||||
$sender = $senderName !== '' ? $senderName : (string) ($base['company_rep_name'] ?? '');
|
||||
|
||||
$recvModel = model(BagReceivingModel::class);
|
||||
$invModel = model(BagInventoryModel::class);
|
||||
$db = \Config\Database::connect();
|
||||
$db->transStart();
|
||||
$recvModel->insert([
|
||||
'br_bo_idx' => (int) $base['bo_idx'],
|
||||
'br_lg_idx' => $lgIdx,
|
||||
'br_bag_code' => (string) $base['bag_code'],
|
||||
'br_bag_name' => (string) $base['bag_name'],
|
||||
'br_qty_box' => $qtyBox,
|
||||
'br_qty_sheet' => $qty,
|
||||
'br_receive_date' => $receiveDate,
|
||||
'br_receiver_idx' => $receiverIdx,
|
||||
'br_sender_name' => $sender,
|
||||
'br_type' => 'scanner',
|
||||
'br_regdate' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
$brIdx = (int) $recvModel->getInsertID();
|
||||
$invModel->adjustQty($lgIdx, (string) $base['bag_code'], (string) $base['bag_name'], $qty);
|
||||
$this->createReceivingPackCodes(
|
||||
$lgIdx,
|
||||
$brIdx,
|
||||
(int) $base['bo_idx'],
|
||||
(string) $base['bag_code'],
|
||||
(string) $base['bag_name'],
|
||||
$qty,
|
||||
max(1, (int) $base['pack_per_sheet']),
|
||||
$totalPerBox,
|
||||
(string) ($base['lot_no'] ?? '')
|
||||
);
|
||||
$db->transComplete();
|
||||
if (! $db->transStatus()) {
|
||||
return $this->response->setJSON(array_merge($refresh, ['ok' => false, 'message' => '입고 처리 중 오류가 발생했습니다.']));
|
||||
}
|
||||
|
||||
$boxRows = $db->table('bag_receiving_pack_code')
|
||||
->select('brpc_box_code, COUNT(*) AS pack_cnt, SUM(brpc_sheet_qty) AS sheet_sum')
|
||||
->where('brpc_br_idx', $brIdx)
|
||||
->groupBy('brpc_box_code')
|
||||
->orderBy('brpc_box_code', 'ASC')
|
||||
->get()->getResultArray();
|
||||
$boxes = array_map(static fn ($b): array => [
|
||||
'box_code' => (string) ($b['brpc_box_code'] ?? ''),
|
||||
'packs' => (int) ($b['pack_cnt'] ?? 0),
|
||||
'sheet' => (int) ($b['sheet_sum'] ?? 0),
|
||||
], $boxRows);
|
||||
|
||||
// 갱신된 미입고/입고량 재계산
|
||||
$after = $this->buildReceivingCandidateRows($lgIdx, 0, '', false, '', 0, $category, '', '');
|
||||
$updated = null;
|
||||
foreach ($after as $r) {
|
||||
if ((string) $r['row_key'] === $rowKey) {
|
||||
$updated = $r;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->response->setJSON(array_merge($refresh, [
|
||||
'ok' => true,
|
||||
'row_key' => $rowKey,
|
||||
'bag_name' => (string) $base['bag_name'],
|
||||
'qty_sheet' => $qty,
|
||||
'qty_box' => $qtyBox,
|
||||
'received_qty_sheet' => (int) ($updated['received_qty_sheet'] ?? ((int) $base['received_qty_sheet'] + $qty)),
|
||||
'pending_qty_sheet' => (int) ($updated['pending_qty_sheet'] ?? max(0, $pending - $qty)),
|
||||
'boxes' => $boxes,
|
||||
'scanned' => $barcode,
|
||||
]));
|
||||
}
|
||||
|
||||
public function receivingScannerStore(): RedirectResponse
|
||||
@@ -4835,51 +5295,12 @@ SQL);
|
||||
/**
|
||||
* 일괄 입고: LOT-봉투 행 기준 미입고량 전체 입고.
|
||||
*/
|
||||
/**
|
||||
* 일괄입고(음식물,폐기물) — 발주입고(스캐너)와 동일 워크스페이스, 음식물·폐기물만 표시.
|
||||
*/
|
||||
public function receivingBatch(): string|RedirectResponse
|
||||
{
|
||||
helper('admin');
|
||||
$lgIdx = $this->lgIdx();
|
||||
if (! $lgIdx) {
|
||||
return redirect()->to(site_url('bag/purchase-inbound'))->with('error', '지자체를 선택해 주세요.');
|
||||
}
|
||||
|
||||
$companyIdx = (int) ($this->request->getGet('company_idx') ?? 0);
|
||||
$bagCode = trim((string) ($this->request->getGet('bag_code') ?? ''));
|
||||
|
||||
$companies = model(CompanyModel::class)
|
||||
->where('cp_lg_idx', $lgIdx)
|
||||
->where('cp_type', '제작업체')
|
||||
->where('cp_state', 1)
|
||||
->orderBy('cp_name', 'ASC')
|
||||
->findAll();
|
||||
|
||||
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
|
||||
$bagCodeOptions = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
|
||||
$pick = $this->receivingManagerPickers($lgIdx);
|
||||
$recvSel = $this->receivingReceiverSelect($lgIdx);
|
||||
$receiverRef = (string) old('br_receiver_ref', $recvSel['defaultReceiverRef']);
|
||||
$receiverRef = $this->sanitizeReceiverRef($recvSel['receiverOptions'], $receiverRef);
|
||||
if ($receiverRef === '') {
|
||||
$receiverRef = $recvSel['defaultReceiverRef'];
|
||||
}
|
||||
// 조회 화면에서는 입고완료 행도 함께 보여 미입고량 0을 확인할 수 있게 한다.
|
||||
$rows = $this->buildReceivingCandidateRows($lgIdx, $companyIdx, $bagCode, false, '');
|
||||
|
||||
return $this->render(
|
||||
'일괄 입고',
|
||||
'bag/receiving_batch',
|
||||
[
|
||||
'companyIdx' => $companyIdx,
|
||||
'bagCode' => $bagCode,
|
||||
'companies' => $companies,
|
||||
'bagCodeOptions' => $bagCodeOptions,
|
||||
'receiverOptions' => $recvSel['receiverOptions'],
|
||||
'receiverRef' => $receiverRef,
|
||||
'senders' => $pick['senders'],
|
||||
'senderIdx' => (int) old('br_sender_idx', $pick['defaultSenderIdx']),
|
||||
'rows' => $rows,
|
||||
]
|
||||
);
|
||||
return $this->renderReceivingWorkspace('batch');
|
||||
}
|
||||
|
||||
public function receivingBatchStore(): RedirectResponse
|
||||
@@ -5195,7 +5616,28 @@ SQL);
|
||||
*
|
||||
* @param string $lotNo 빈 문자열이면 LOT 제한 없음. 지정 시 해당 LOT(최신 헤드) 발주만.
|
||||
*/
|
||||
private function buildReceivingCandidateRows(int $lgIdx, int $companyIdx = 0, string $bagCode = '', bool $onlyPending = true, string $lotNo = ''): array
|
||||
/**
|
||||
* 봉투 이름 기준 품목 분류 (판매대장 통계와 동일 규칙).
|
||||
* 반환: '음식물' | '폐기물' | '봉투'
|
||||
*/
|
||||
private function receivingBagCategory(string $bagName): string
|
||||
{
|
||||
if (mb_strpos($bagName, '음식물') !== false) {
|
||||
return '음식물';
|
||||
}
|
||||
if (mb_strpos($bagName, '폐기물') !== false || mb_strpos($bagName, '대형') !== false) {
|
||||
return '폐기물';
|
||||
}
|
||||
|
||||
return '봉투';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $category '' = 전체, 'bulk' = 음식물+폐기물(일괄입고용), 'bag' = 봉투(종량제)만
|
||||
* @param string $startDate 'YYYY-MM-DD' (발주일 하한, '' = 무시)
|
||||
* @param string $endDate 'YYYY-MM-DD' (발주일 상한, '' = 무시)
|
||||
*/
|
||||
private function buildReceivingCandidateRows(int $lgIdx, int $companyIdx = 0, string $bagCode = '', bool $onlyPending = true, string $lotNo = '', int $agencyIdx = 0, string $category = '', string $startDate = '', string $endDate = ''): array
|
||||
{
|
||||
$orderBuilder = model(BagOrderModel::class)
|
||||
->where('bo_lg_idx', $lgIdx)
|
||||
@@ -5209,6 +5651,15 @@ SQL);
|
||||
if ($companyIdx > 0) {
|
||||
$orderBuilder->where('bo_company_idx', $companyIdx);
|
||||
}
|
||||
if ($agencyIdx > 0) {
|
||||
$orderBuilder->where('bo_agency_idx', $agencyIdx);
|
||||
}
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate)) {
|
||||
$orderBuilder->where('bo_order_date >=', $startDate);
|
||||
}
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate)) {
|
||||
$orderBuilder->where('bo_order_date <=', $endDate);
|
||||
}
|
||||
$orders = $orderBuilder->findAll();
|
||||
if (empty($orders)) {
|
||||
return [];
|
||||
@@ -5277,6 +5728,16 @@ SQL);
|
||||
|
||||
$unit = $unitMap[$itemBagCode] ?? ['pack_per_sheet' => 1, 'total_per_box' => 1];
|
||||
$companyInfo = $companyMap[(int) ($order->bo_company_idx ?? 0)] ?? ['name' => '', 'rep' => ''];
|
||||
$bagName = (string) ($item->boi_bag_name ?? '');
|
||||
$bagCategory = $this->receivingBagCategory($bagName);
|
||||
|
||||
// 카테고리 필터 (일괄입고: 음식물+폐기물만 / bag: 봉투만)
|
||||
if ($category === 'bulk' && $bagCategory === '봉투') {
|
||||
continue;
|
||||
}
|
||||
if ($category === 'bag' && $bagCategory !== '봉투') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'row_key' => $boIdx . '|' . $itemBagCode,
|
||||
@@ -5288,7 +5749,8 @@ SQL);
|
||||
'company_rep_name' => (string) ($companyInfo['rep'] ?? ''),
|
||||
'agency_name' => (string) ($agencyMap[(int) ($order->bo_agency_idx ?? 0)] ?? ''),
|
||||
'bag_code' => $itemBagCode,
|
||||
'bag_name' => (string) ($item->boi_bag_name ?? ''),
|
||||
'bag_name' => $bagName,
|
||||
'bag_category' => $bagCategory,
|
||||
'order_qty_sheet' => $orderQtySheet,
|
||||
'received_qty_sheet' => $receivedQtySheet,
|
||||
'pending_qty_sheet' => $pendingQtySheet,
|
||||
@@ -7218,16 +7680,25 @@ SQL;
|
||||
$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)));
|
||||
if ($dsIdxs !== [] && $db->tableExists('designated_shop')) {
|
||||
helper('pii_encryption');
|
||||
$canViewPii = can_view_shop_pii((int) $lgIdx); // 이 화면은 현재 지자체 판매소만
|
||||
$shopRows = $db->table('designated_shop')
|
||||
->select('ds_idx, ds_shop_no, ds_name, ds_rep_name, ds_tel, ds_addr, ds_addr_detail')
|
||||
->whereIn('ds_idx', $dsIdxs)
|
||||
->get()->getResultArray();
|
||||
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']] = [
|
||||
'shop_no' => (string) ($s['ds_shop_no'] ?? ''),
|
||||
'name' => (string) ($s['ds_name'] ?? ''),
|
||||
'rep_name' => (string) ($s['ds_rep_name'] ?? ''),
|
||||
'tel' => (string) ($s['ds_tel'] ?? ''),
|
||||
'rep_name' => $rep,
|
||||
'tel' => $tel,
|
||||
'addr' => trim((string) ($s['ds_addr'] ?? '') . ' ' . (string) ($s['ds_addr_detail'] ?? '')),
|
||||
];
|
||||
}
|
||||
@@ -7489,6 +7960,37 @@ SQL;
|
||||
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 응답).
|
||||
* 선택된 주문의 판매소로 판매 기록(bag_sale) + 재고 차감 + 팩코드 sold 전환 +
|
||||
@@ -7514,6 +8016,11 @@ SQL;
|
||||
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);
|
||||
if (! ($scan['ok'] ?? false)) {
|
||||
return $this->response->setJSON(array_merge($scan, ['csrf' => csrf_hash()]));
|
||||
|
||||
@@ -44,7 +44,11 @@ class Home extends BaseController
|
||||
}
|
||||
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]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -229,6 +229,37 @@ if (! function_exists('current_nav_request_path')) {
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('member_font_scale_for')) {
|
||||
/**
|
||||
* 로그인 계정의 특정 메뉴(화면 경로)에 저장된 글자크기(zoom %)를 반환.
|
||||
* 저장값이 없거나 범위(70~150) 밖이면 100.
|
||||
*/
|
||||
function member_font_scale_for(string $menuKey): int
|
||||
{
|
||||
$mbIdx = (int) (session()->get('mb_idx') ?? 0);
|
||||
$menuKey = trim($menuKey);
|
||||
if ($mbIdx <= 0 || $menuKey === '') {
|
||||
return 100;
|
||||
}
|
||||
try {
|
||||
$db = \Config\Database::connect();
|
||||
if (! $db->tableExists('member_menu_font_scale')) {
|
||||
return 100;
|
||||
}
|
||||
$row = $db->table('member_menu_font_scale')
|
||||
->select('mmf_scale')
|
||||
->where('mmf_mb_idx', $mbIdx)
|
||||
->where('mmf_menu_key', $menuKey)
|
||||
->get()->getRow();
|
||||
} catch (\Throwable $e) {
|
||||
return 100;
|
||||
}
|
||||
$s = $row ? (int) $row->mmf_scale : 100;
|
||||
|
||||
return ($s >= 70 && $s <= 150) ? $s : 100;
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('normalize_menu_link_for_url')) {
|
||||
/**
|
||||
* menu.mm_link 를 base_url() 인자로 쓸 수 있는 상대 경로로 정규화합니다.
|
||||
|
||||
@@ -70,3 +70,246 @@ if (! function_exists('pii_decrypt')) {
|
||||
if (! defined('PII_ENCRYPTED_FIELDS')) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ class BagOrderModel extends Model
|
||||
'bo_uuid', 'bo_version', 'bo_lg_idx', 'bo_gugun_code', 'bo_dong_code',
|
||||
'bo_company_idx', 'bo_agency_idx', 'bo_fee_rate', 'bo_order_date',
|
||||
'bo_bag_types', 'bo_unit_prices', 'bo_qty_boxes',
|
||||
'bo_lot_no', 'bo_hash', 'bo_status', 'bo_orderer_idx',
|
||||
'bo_lot_no', 'bo_hash', 'bo_status', 'bo_orderer_idx', 'bo_manager_idx',
|
||||
'bo_regdate', 'bo_moddate',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ class DesignatedShopModel extends Model
|
||||
'ds_rep_phone',
|
||||
'ds_email',
|
||||
'ds_gugun_code',
|
||||
'ds_dong_code',
|
||||
'ds_zone_code',
|
||||
'ds_branch_no',
|
||||
'ds_designated_at',
|
||||
@@ -40,6 +41,118 @@ class DesignatedShopModel extends Model
|
||||
'ds_state_changed_at',
|
||||
'ds_change_reason',
|
||||
'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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -270,6 +270,13 @@ class MenuModel extends Model
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
// 깊이(dep) 오름차순으로 그룹핑 — 상위부터 삽입해 자식의 mm_pidx를 새 ID로 재매핑
|
||||
$byDep = [];
|
||||
foreach ($source as $row) {
|
||||
$byDep[(int) ($row->mm_dep ?? 0)][] = $row;
|
||||
}
|
||||
ksort($byDep);
|
||||
|
||||
foreach ($lgRows as $lgRow) {
|
||||
$destLg = (int) ($lgRow['lg_idx'] ?? 0);
|
||||
if ($destLg <= 0 || $destLg === $sourceLg) {
|
||||
@@ -281,28 +288,41 @@ class MenuModel extends Model
|
||||
->where('lg_idx', $destLg)
|
||||
->delete();
|
||||
|
||||
// 원격 DB 왕복 최소화: 레벨(dep)별로 batch insert 후, 삽입 순서(mm_idx ASC)로 old→new ID 매핑
|
||||
$idMap = [];
|
||||
foreach ($source as $row) {
|
||||
$oldId = (int) ($row->mm_idx ?? 0);
|
||||
foreach ($byDep as $dep => $levelRows) {
|
||||
$batch = [];
|
||||
foreach ($levelRows as $row) {
|
||||
$oldP = (int) ($row->mm_pidx ?? 0);
|
||||
$newPidx = 0;
|
||||
if ($oldP > 0 && isset($idMap[$oldP])) {
|
||||
$newPidx = (int) $idMap[$oldP];
|
||||
}
|
||||
|
||||
$this->insert([
|
||||
$newPidx = ($oldP > 0 && isset($idMap[$oldP])) ? (int) $idMap[$oldP] : 0;
|
||||
$batch[] = [
|
||||
'mt_idx' => $mtIdx,
|
||||
'lg_idx' => $destLg,
|
||||
'mm_name' => (string) ($row->mm_name ?? ''),
|
||||
'mm_link' => (string) ($row->mm_link ?? ''),
|
||||
'mm_pidx' => $newPidx,
|
||||
'mm_dep' => (int) ($row->mm_dep ?? 0),
|
||||
'mm_dep' => (int) $dep,
|
||||
'mm_num' => (int) ($row->mm_num ?? 0),
|
||||
'mm_cnode' => (int) ($row->mm_cnode ?? 0),
|
||||
'mm_level' => (string) ($row->mm_level ?? ''),
|
||||
'mm_is_view' => (string) ($row->mm_is_view ?? 'Y'),
|
||||
]);
|
||||
$idMap[$oldId] = (int) $this->getInsertID();
|
||||
];
|
||||
}
|
||||
if ($batch === []) {
|
||||
continue;
|
||||
}
|
||||
$this->insertBatch($batch);
|
||||
// 방금 삽입한 이 레벨의 행을 삽입 순서(mm_idx ASC)대로 조회해 원본과 1:1 매핑
|
||||
$inserted = $this->where('mt_idx', $mtIdx)
|
||||
->where('lg_idx', $destLg)
|
||||
->where('mm_dep', (int) $dep)
|
||||
->orderBy('mm_idx', 'ASC')
|
||||
->findAll();
|
||||
foreach ($levelRows as $i => $row) {
|
||||
if (isset($inserted[$i])) {
|
||||
$idMap[(int) ($row->mm_idx ?? 0)] = (int) $inserted[$i]->mm_idx;
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->db->transComplete();
|
||||
}
|
||||
|
||||
21
app/Models/ReportPresetModel.php
Normal file
21
app/Models/ReportPresetModel.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
class ReportPresetModel extends Model
|
||||
{
|
||||
protected $table = 'report_preset';
|
||||
protected $primaryKey = 'rp_idx';
|
||||
protected $returnType = 'object';
|
||||
protected $useTimestamps = true;
|
||||
protected $createdField = 'rp_regdate';
|
||||
protected $updatedField = 'rp_updated';
|
||||
protected $allowedFields = [
|
||||
'rp_lg_idx', 'rp_mb_idx', 'rp_name', 'rp_title',
|
||||
'rp_category', 'rp_mode', 'rp_columns',
|
||||
];
|
||||
}
|
||||
@@ -115,8 +115,9 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<label class="block text-sm font-bold text-gray-700 w-28">구코드</label>
|
||||
<div class="text-sm text-gray-600">해당 지자체(구·군) 코드로 등록 시 자동 설정</div>
|
||||
<label class="block text-sm font-bold text-gray-700 w-28">구·군(동코드)</label>
|
||||
<input type="text" id="ds_dong_display" class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60 bg-gray-100 text-gray-800 cursor-not-allowed" value="" readonly tabindex="-1" placeholder="주소 검색 시 자동 표시"/>
|
||||
<span id="ds_dong_hint" class="text-xs text-gray-500">주소를 검색하면 해당 동코드가 표시됩니다.</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@@ -165,3 +166,53 @@
|
||||
'detailFieldName' => 'ds_addr_detail',
|
||||
]) ?>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var form = document.getElementById('designated-shop-create-form');
|
||||
if (!form) return;
|
||||
var display = document.getElementById('ds_dong_display');
|
||||
var hint = document.getElementById('ds_dong_hint');
|
||||
var endpoint = new URL('<?= mgmt_url('designated-shops/resolve-address-codes') ?>', window.location.href).pathname;
|
||||
var csrfName = '<?= csrf_token() ?>';
|
||||
|
||||
function setHint(msg, isError) {
|
||||
if (!hint) return;
|
||||
hint.textContent = msg;
|
||||
hint.className = 'text-xs ' + (isError ? 'text-red-600' : 'text-gray-500');
|
||||
}
|
||||
|
||||
// 주소 검색 완료 시 서버에서 동코드 조회 → 표시
|
||||
form.addEventListener('kakao-address-selected', function () {
|
||||
if (display) display.value = '';
|
||||
setHint('동코드 조회 중…', false);
|
||||
|
||||
var body = new URLSearchParams();
|
||||
body.set('addr_search_sido', (form.querySelector('[name="addr_search_sido"]') || {}).value || '');
|
||||
body.set('addr_search_sigungu', (form.querySelector('[name="addr_search_sigungu"]') || {}).value || '');
|
||||
body.set('ds_addr', (form.querySelector('[name="ds_addr"]') || {}).value || '');
|
||||
body.set('ds_addr_jibun', (form.querySelector('[name="ds_addr_jibun"]') || {}).value || '');
|
||||
body.set('ds_zip', (form.querySelector('[name="ds_zip"]') || {}).value || '');
|
||||
var csrfEl = form.querySelector('[name="' + csrfName + '"]');
|
||||
if (csrfEl) body.set(csrfName, csrfEl.value);
|
||||
|
||||
fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
body: body
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
if (d && d.ok) {
|
||||
if (display) display.value = d.dong_code + (d.dong_name ? ' (' + d.dong_name + ')' : '');
|
||||
setHint('등록 시 판매소번호에 이 동코드가 반영됩니다.', false);
|
||||
} else {
|
||||
setHint((d && d.error) ? d.error : '동코드를 조회하지 못했습니다.', true);
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
setHint('동코드 조회 중 오류가 발생했습니다.', true);
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<?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();
|
||||
if ($currentPath === 'bag/designated-shops') {
|
||||
$readOnly = false;
|
||||
@@ -191,10 +195,8 @@ $listBasePath = $readOnly ? 'designated-shops/browse' : 'designated-shops';
|
||||
<div class="flex flex-wrap items-center justify-between gap-y-2">
|
||||
<span class="text-sm font-bold text-gray-700"><?= $readOnly ? '지정판매소 조회' : '지정판매소 관리' ?></span>
|
||||
<div class="flex items-center gap-2">
|
||||
<?php if ($readOnly): ?>
|
||||
<a href="<?= mgmt_url('designated-shops/export') ?>" class="no-print border border-btn-excel-border text-btn-excel-text px-3 py-1 rounded-sm text-sm hover:bg-green-50 transition">엑셀저장</a>
|
||||
<button type="button" onclick="window.print()" class="no-print border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
|
||||
<?php endif; ?>
|
||||
<?php if (! $readOnly): ?>
|
||||
<a href="<?= mgmt_url('designated-shops/create') ?>" class="bg-btn-search text-white px-4 py-1.5 rounded-sm flex items-center gap-1 text-sm shadow hover:opacity-90 transition border border-transparent">지정판매소 등록</a>
|
||||
<?php endif; ?>
|
||||
@@ -215,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>
|
||||
<?php endforeach; ?>
|
||||
</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>
|
||||
<select name="ds_state" class="border border-gray-300 rounded px-2 py-1 text-sm">
|
||||
<option value="">전체</option>
|
||||
@@ -245,6 +251,9 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
|
||||
<th class="ds-sort-th py-2.5 px-2 w-14 text-left cursor-pointer select-none" data-sort-key="no" data-sort-type="num">번호<span class="ds-sort-ind"></span></th>
|
||||
<th class="ds-sort-th py-2.5 px-2 cursor-pointer select-none" data-sort-key="rep" data-sort-type="str">대표자명<span class="ds-sort-ind"></span></th>
|
||||
<th class="ds-sort-th py-2.5 px-2 cursor-pointer select-none" data-sort-key="name" data-sort-type="str">상호명<span class="ds-sort-ind"></span></th>
|
||||
<th class="ds-sort-th py-2.5 px-2 cursor-pointer select-none" data-sort-key="dong" data-sort-type="str">구·군(동코드)<span class="ds-sort-ind"></span></th>
|
||||
<th class="ds-sort-th py-2.5 px-2 w-24 text-left cursor-pointer select-none" data-sort-key="designated" data-sort-type="str">지정일<span class="ds-sort-ind"></span></th>
|
||||
<th class="ds-sort-th py-2.5 px-2 cursor-pointer select-none" data-sort-key="zone" data-sort-type="str">구역<span class="ds-sort-ind"></span></th>
|
||||
<th class="ds-sort-th py-2.5 px-2 w-16 text-left cursor-pointer select-none" data-sort-key="state" data-sort-type="num">상태<span class="ds-sort-ind"></span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -275,16 +284,24 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
|
||||
if ($addrCombinedList === '') {
|
||||
$addrCombinedList = $addrMainList;
|
||||
}
|
||||
// 구·군(동코드) 표시: 동코드가 있으면 "동코드 (동명)", 없으면 구·군명
|
||||
$dongDisp = (string) (($dongDisplayMap[(int) ($row->ds_idx ?? 0)] ?? '') !== '' ? $dongDisplayMap[(int) ($row->ds_idx ?? 0)] : $ggLabel);
|
||||
?>
|
||||
<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-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-dong="<?= esc($dongDisp, 'attr') ?>"
|
||||
data-sort-designated="<?= esc($daDisp, 'attr') ?>"
|
||||
data-sort-zone="<?= esc($zone, 'attr') ?>"
|
||||
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-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 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-gray-600" title="<?= esc($zone) ?>"><?= esc($zone) ?></td>
|
||||
<td class="py-2.5 px-2 text-left">
|
||||
<?php if ($st === 1): ?>
|
||||
<span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium bg-emerald-50 text-emerald-700">정상</span>
|
||||
@@ -303,6 +320,24 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
|
||||
<div class="ds-panel-title shrink-0">지정판매소 정보</div>
|
||||
<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>
|
||||
|
||||
<!-- 개인정보 마스킹 안내 + 원문 보기(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 class="ds-detail-form" id="ds-detail-info-table" aria-label="지정판매소 상세">
|
||||
@@ -469,6 +504,14 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
|
||||
fieldsWrap.classList.remove('hidden');
|
||||
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) {
|
||||
var id = d.ds_idx;
|
||||
editLink.href = editBase + '/' + id;
|
||||
@@ -560,6 +603,38 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
|
||||
delBtn.disabled = true;
|
||||
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>
|
||||
|
||||
@@ -570,7 +645,7 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
|
||||
<tr>
|
||||
<th class="text-center">번호</th>
|
||||
<th>지자체</th>
|
||||
<th>구·군</th>
|
||||
<th>구·군(동코드)</th>
|
||||
<th class="text-center">지정일</th>
|
||||
<th>구역</th>
|
||||
<th>대표자명</th>
|
||||
@@ -613,11 +688,15 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
|
||||
<tr>
|
||||
<td class="text-center"><?= esc($shortNoP) ?></td>
|
||||
<td class="text-left"><?= esc($lgMap[$row->ds_lg_idx] ?? '') ?></td>
|
||||
<?php $gCodeP = (string) ($row->ds_gugun_code ?? ''); ?>
|
||||
<td class="text-left"><?= esc((string) (($gugunNameMap[$gCodeP] ?? '') !== '' ? $gugunNameMap[$gCodeP] : $gCodeP)) ?></td>
|
||||
<?php
|
||||
$gCodeP = (string) ($row->ds_gugun_code ?? '');
|
||||
$ggLabelP = (string) (($gugunNameMap[$gCodeP] ?? '') !== '' ? $gugunNameMap[$gCodeP] : $gCodeP);
|
||||
$dongDispP = (string) (($dongDisplayMap[(int) ($row->ds_idx ?? 0)] ?? '') !== '' ? $dongDisplayMap[(int) ($row->ds_idx ?? 0)] : $ggLabelP);
|
||||
?>
|
||||
<td class="text-left"><?= esc($dongDispP) ?></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_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($zipP) ?></td>
|
||||
<td class="text-left"><?= esc($addrCombinedP) ?></td>
|
||||
|
||||
@@ -14,7 +14,7 @@ $badge = static function (string $s): string {
|
||||
$map = [
|
||||
'new' => 'bg-blue-50 text-blue-700',
|
||||
'in_progress' => 'bg-amber-50 text-amber-700',
|
||||
'answered' => 'bg-teal-50 text-teal-700',
|
||||
'answered' => 'bg-gray-100 text-gray-500',
|
||||
'done' => 'bg-emerald-50 text-emerald-700',
|
||||
'hold' => 'bg-gray-100 text-gray-500',
|
||||
];
|
||||
@@ -22,6 +22,7 @@ $badge = static function (string $s): string {
|
||||
return '<span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium ' . $c . '">' . esc(FeedbackModel::statusLabel($s)) . '</span>';
|
||||
};
|
||||
?>
|
||||
<div class="fb-selectable">
|
||||
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel flex flex-wrap items-center justify-between gap-2">
|
||||
<span class="text-sm font-bold text-gray-700">사용자 의견 · 전체보기 (<?= count($list) ?>건)</span>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -63,9 +64,20 @@ $badge = static function (string $s): string {
|
||||
<span class="text-gray-400 text-[12px]">·</span>
|
||||
<span class="text-gray-600 text-[12px]"><?= esc((string) ($row->fb_mb_name ?? '')) ?></span>
|
||||
</div>
|
||||
<?php
|
||||
$fbUrlRaw = (string) ($row->fb_page_url ?? '');
|
||||
$fbUrlOpen = $fbUrlRaw;
|
||||
if ($fbUrlOpen !== '' && ! preg_match('#^https?://#i', $fbUrlOpen)) {
|
||||
$fbUrlOpen = (string) preg_replace('/([?&])embed=1(?=&|$)/', '$1', $fbUrlOpen);
|
||||
$fbUrlOpen = rtrim($fbUrlOpen, '?&');
|
||||
$fbUrlOpen = base_url(ltrim($fbUrlOpen, '/'));
|
||||
}
|
||||
?>
|
||||
<div class="text-[12px] text-gray-500">
|
||||
<?= esc((string) ($row->fb_page_title ?: '-')) ?>
|
||||
<span class="text-gray-400"> · <?= esc((string) ($row->fb_page_url ?: '')) ?></span>
|
||||
<?php if ($fbUrlRaw !== ''): ?>
|
||||
<span class="text-gray-400"> · </span><a href="<?= esc($fbUrlOpen, 'attr') ?>" target="_blank" rel="noopener" class="text-blue-600 hover:underline break-all"><?= esc($fbUrlRaw) ?></a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -104,3 +116,4 @@ $badge = static function (string $s): string {
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div><!-- /.fb-selectable -->
|
||||
|
||||
@@ -12,7 +12,7 @@ $badge = static function (string $s): string {
|
||||
$map = [
|
||||
'new' => 'bg-blue-50 text-blue-700',
|
||||
'in_progress' => 'bg-amber-50 text-amber-700',
|
||||
'answered' => 'bg-teal-50 text-teal-700',
|
||||
'answered' => 'bg-gray-100 text-gray-500',
|
||||
'done' => 'bg-emerald-50 text-emerald-700',
|
||||
'hold' => 'bg-gray-100 text-gray-500',
|
||||
];
|
||||
@@ -20,6 +20,7 @@ $badge = static function (string $s): string {
|
||||
return '<span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium ' . $c . '">' . esc(FeedbackModel::statusLabel($s)) . '</span>';
|
||||
};
|
||||
?>
|
||||
<div class="fb-selectable">
|
||||
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel flex flex-wrap items-center justify-between gap-2">
|
||||
<span class="text-sm font-bold text-gray-700">사용자 의견</span>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -53,7 +54,7 @@ $badge = static function (string $s): string {
|
||||
<tr><td colspan="7" class="text-center text-gray-400 py-6">접수된 의견이 없습니다.</td></tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($list as $row): ?>
|
||||
<tr class="cursor-pointer hover:bg-blue-50/60" onclick="window.location.href='<?= site_url('admin/feedback/' . (int) $row->fb_idx) ?>'">
|
||||
<tr class="cursor-pointer hover:bg-blue-50/60" onclick="if(!window.getSelection().toString()){window.location.href='<?= site_url('admin/feedback/' . (int) $row->fb_idx) ?>';}">
|
||||
<td class="text-left text-gray-500"><?= (int) $row->fb_idx ?></td>
|
||||
<td class="text-left text-gray-500 text-[12px]"><?= esc((string) ($row->fb_regdate ?? '')) ?></td>
|
||||
<td class="text-left"><?= $badge((string) ($row->fb_status ?? 'new')) ?></td>
|
||||
@@ -68,3 +69,4 @@ $badge = static function (string $s): string {
|
||||
</table>
|
||||
</div>
|
||||
<?php if (isset($pager)): ?><div class="mt-3"><?= $pager->links() ?></div><?php endif; ?>
|
||||
</div><!-- /.fb-selectable -->
|
||||
|
||||
@@ -3,6 +3,16 @@ declare(strict_types=1);
|
||||
/** @var object $row */
|
||||
use App\Models\FeedbackModel;
|
||||
?>
|
||||
<style>
|
||||
/* 의견 상세: 레이아웃 body의 select-none 상속을 무효화하여 본문 텍스트를 복사 가능하게 함 */
|
||||
.fb-selectable, .fb-selectable * {
|
||||
-webkit-user-select: text;
|
||||
-moz-user-select: text;
|
||||
-ms-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
</style>
|
||||
<div class="fb-selectable">
|
||||
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel flex items-center justify-between gap-2">
|
||||
<span class="text-sm font-bold text-gray-700">의견 상세 #<?= (int) $row->fb_idx ?></span>
|
||||
<a href="<?= site_url('admin/feedback') ?>" class="text-sm text-gray-600 hover:underline">목록으로</a>
|
||||
@@ -10,12 +20,45 @@ use App\Models\FeedbackModel;
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3 mt-2">
|
||||
<section class="border border-gray-300 rounded-lg overflow-hidden">
|
||||
<div class="px-3 py-2 border-b bg-gray-50 text-sm font-semibold text-gray-700">의견 내용</div>
|
||||
<div class="px-3 py-2 border-b bg-gray-50 text-sm font-semibold text-gray-700 flex items-center justify-between">
|
||||
<span>의견 내용</span>
|
||||
<button type="button" id="fb-content-edit-btn" class="text-xs font-medium text-blue-600 hover:underline">수정</button>
|
||||
</div>
|
||||
<div class="p-3 space-y-2 text-sm">
|
||||
<!-- 보기 모드 -->
|
||||
<div id="fb-content-view">
|
||||
<p class="whitespace-pre-wrap text-gray-800"><?= esc((string) $row->fb_content) ?></p>
|
||||
</div>
|
||||
<!-- 수정 모드 -->
|
||||
<form id="fb-content-edit" action="<?= site_url('admin/feedback/' . (int) $row->fb_idx . '/content') ?>" method="post" class="hidden">
|
||||
<?= csrf_field() ?>
|
||||
<textarea name="fb_content" rows="6" maxlength="2000" class="w-full border border-gray-300 rounded px-2 py-1.5 text-sm"><?= esc((string) $row->fb_content) ?></textarea>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm">수정 저장</button>
|
||||
<button type="button" id="fb-content-cancel" class="bg-gray-200 text-gray-700 px-4 py-1.5 rounded-sm text-sm hover:bg-gray-300">취소</button>
|
||||
</div>
|
||||
</form>
|
||||
<hr class="my-2"/>
|
||||
<?php
|
||||
// 화면 URL — embed 파라미터를 떼고 전체 화면 절대 URL로 변환(클릭 시 해당 화면으로 바로 이동).
|
||||
$fbUrlRaw = (string) ($row->fb_page_url ?? '');
|
||||
$fbUrlOpen = $fbUrlRaw;
|
||||
if ($fbUrlOpen !== '' && ! preg_match('#^https?://#i', $fbUrlOpen)) {
|
||||
$fbUrlOpen = (string) preg_replace('/([?&])embed=1(?=&|$)/', '$1', $fbUrlOpen);
|
||||
$fbUrlOpen = rtrim($fbUrlOpen, '?&');
|
||||
$fbUrlOpen = base_url(ltrim($fbUrlOpen, '/'));
|
||||
}
|
||||
?>
|
||||
<dl class="grid grid-cols-[5rem_1fr] gap-y-1 text-[12px] text-gray-600">
|
||||
<dt class="font-semibold">화면</dt><dd><?= esc((string) ($row->fb_page_title ?: '-')) ?><br><span class="text-gray-400"><?= esc((string) ($row->fb_page_url ?: '')) ?></span></dd>
|
||||
<dt class="font-semibold">화면</dt>
|
||||
<dd>
|
||||
<?= esc((string) ($row->fb_page_title ?: '-')) ?><br>
|
||||
<?php if ($fbUrlRaw !== ''): ?>
|
||||
<a href="<?= esc($fbUrlOpen, 'attr') ?>" target="_blank" rel="noopener" class="text-blue-600 hover:underline break-all"><?= esc($fbUrlRaw) ?></a>
|
||||
<?php else: ?>
|
||||
<span class="text-gray-400">-</span>
|
||||
<?php endif; ?>
|
||||
</dd>
|
||||
<dt class="font-semibold">작성자</dt><dd><?= esc((string) ($row->fb_mb_name ?? '')) ?> (idx <?= (int) $row->fb_mb_idx ?>, level <?= (int) $row->fb_mb_level ?>)</dd>
|
||||
<dt class="font-semibold">지자체</dt><dd><?= (int) ($row->fb_lg_idx ?? 0) ?></dd>
|
||||
<dt class="font-semibold">일시</dt><dd><?= esc((string) ($row->fb_regdate ?? '')) ?></dd>
|
||||
@@ -62,3 +105,22 @@ use App\Models\FeedbackModel;
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
</div><!-- /.fb-selectable -->
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var btn = document.getElementById('fb-content-edit-btn');
|
||||
var view = document.getElementById('fb-content-view');
|
||||
var edit = document.getElementById('fb-content-edit');
|
||||
var cancel = document.getElementById('fb-content-cancel');
|
||||
if (!btn || !view || !edit) return;
|
||||
var show = function (editing) {
|
||||
view.classList.toggle('hidden', editing);
|
||||
edit.classList.toggle('hidden', !editing);
|
||||
btn.classList.toggle('hidden', editing);
|
||||
if (editing) { var ta = edit.querySelector('textarea'); if (ta) ta.focus(); }
|
||||
};
|
||||
btn.addEventListener('click', function () { show(true); });
|
||||
if (cancel) cancel.addEventListener('click', function () { show(false); });
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -62,6 +62,9 @@ $navPartial = [
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title><?= esc($title ?? '관리자') ?> - GBLS</title>
|
||||
<link rel="icon" type="image/svg+xml" href="<?= base_url('favicon.svg') ?>"/>
|
||||
<link rel="alternate icon" href="<?= base_url('favicon.ico') ?>"/>
|
||||
<link rel="apple-touch-icon" href="<?= base_url('favicon.svg') ?>"/>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"/>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"/>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
@@ -91,6 +94,13 @@ tailwind.config = {
|
||||
.data-table tbody td { color: #374151; }
|
||||
.data-table tbody tr:last-child td { border-bottom: 0; }
|
||||
.data-table tbody tr:hover td { background-color: #f9fafb; }
|
||||
/* body의 select-none 상속을 무효화해 본문 텍스트를 드래그·복사할 수 있게 하는 opt-in 클래스 */
|
||||
.fb-selectable, .fb-selectable * {
|
||||
-webkit-user-select: text;
|
||||
-moz-user-select: text;
|
||||
-ms-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
@media print {
|
||||
.portal-header, .sidebar, .portal-footer, .no-print, nav.portal-top-nav { display: none !important; }
|
||||
body.gov-portal-shell { background: #fff; display: block; }
|
||||
@@ -167,27 +177,40 @@ tailwind.config = {
|
||||
window.addEventListener('pagehide', closeStuckOverlays);
|
||||
})();
|
||||
|
||||
// 글씨 크기 조절(A−/A+) — 본문 + 상단 대메뉴 + 좌측 사이드바에 zoom 적용. 사이트/워크스페이스와 배율 공유.
|
||||
// 글씨 크기 조절(A−/A+) — 계정별·메뉴별 저장(서버). 현재 화면 경로 기준으로 개별 적용.
|
||||
<?php $__fmk = function_exists('current_nav_request_path') ? current_nav_request_path() : ''; $__fsc = function_exists('member_font_scale_for') ? member_font_scale_for($__fmk) : 100; ?>
|
||||
(function () {
|
||||
var FONT_KEY = 'jrj_font_scale';
|
||||
// 상단 헤더는 제외 → A−/A+ 버튼이 커지거나 밀리지 않아 연속 클릭이 편하다.
|
||||
var MENU_KEY = <?= json_encode($__fmk) ?>;
|
||||
var scale = <?= (int) $__fsc ?>;
|
||||
var SAVE_URL = <?= json_encode(site_url('bag/pref/font-scale')) ?>;
|
||||
var CSRF_NAME = <?= json_encode(csrf_token()) ?>;
|
||||
var csrfHash = <?= json_encode(csrf_hash()) ?>;
|
||||
var scaleSelectors = ['.sidebar', '.work-main'];
|
||||
function curScale() { var s = parseInt(localStorage.getItem(FONT_KEY) || '100', 10); return (s >= 70 && s <= 150) ? s : 100; }
|
||||
function applyScale(s) {
|
||||
var saveTimer = null;
|
||||
function persist(s) {
|
||||
try { localStorage.setItem('jrj_font_scale::' + MENU_KEY, String(s)); } catch (e) {}
|
||||
if (!MENU_KEY) return;
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(function () {
|
||||
var b = new URLSearchParams(); b.set(CSRF_NAME, csrfHash); b.set('menu_key', MENU_KEY); b.set('scale', String(s));
|
||||
fetch(SAVE_URL, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: b.toString() })
|
||||
.then(function (r) { return r.json(); }).then(function (d) { if (d && d.csrf) csrfHash = d.csrf; }).catch(function () {});
|
||||
}, 400);
|
||||
}
|
||||
function applyScale(s, save) {
|
||||
s = Math.min(150, Math.max(70, s));
|
||||
try { localStorage.setItem(FONT_KEY, String(s)); } catch (e) {}
|
||||
scale = s;
|
||||
var z = s / 100;
|
||||
scaleSelectors.forEach(function (sel) { var el = document.querySelector(sel); if (el) el.style.zoom = z; });
|
||||
// 상단 대메뉴는 font-size 만 키운다(바 높이 48px 고정 → A−/A+ 버튼이 안 밀림).
|
||||
var topnav = document.querySelector('.portal-top-nav');
|
||||
if (topnav) topnav.style.fontSize = (0.875 * z).toFixed(4) + 'rem';
|
||||
var pct = document.getElementById('wsFontPct'); if (pct) pct.textContent = s + '%';
|
||||
if (save) persist(s);
|
||||
}
|
||||
applyScale(curScale());
|
||||
applyScale(scale, false);
|
||||
var plus = document.getElementById('wsFontPlus'), minus = document.getElementById('wsFontMinus');
|
||||
if (plus) plus.addEventListener('click', function () { applyScale(curScale() + 10); });
|
||||
if (minus) minus.addEventListener('click', function () { applyScale(curScale() - 10); });
|
||||
window.addEventListener('storage', function (e) { if (e.key === FONT_KEY) applyScale(curScale()); });
|
||||
if (plus) plus.addEventListener('click', function () { applyScale(scale + 10, true); });
|
||||
if (minus) minus.addEventListener('click', function () { applyScale(scale - 10, true); });
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
|
||||
@@ -145,7 +145,8 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
|
||||
data-link="<?= esc($row->mm_link) ?>"
|
||||
data-level="<?= esc($row->mm_level) ?>"
|
||||
data-view="<?= (string) $row->mm_is_view ?>"
|
||||
data-dep="<?= (int) $row->mm_dep ?>">
|
||||
data-dep="<?= (int) $row->mm_dep ?>"
|
||||
data-pidx="<?= (int) $row->mm_pidx ?>">
|
||||
수정
|
||||
</button>
|
||||
<?php if ($dep === 0): ?>
|
||||
@@ -190,6 +191,26 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
|
||||
<label class="block text-sm font-medium text-gray-700">링크</label>
|
||||
<input type="text" name="mm_link" id="mm_link" class="border border-gray-300 rounded px-2 py-1 w-full text-sm" placeholder="예: admin/users"/>
|
||||
</div>
|
||||
<div class="space-y-2 mb-3" id="menu-position-wrap">
|
||||
<label class="block text-sm font-medium text-gray-700">메뉴 위치</label>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1">
|
||||
<label class="inline-flex items-center gap-1 cursor-pointer">
|
||||
<input type="radio" name="pos_type" value="top" id="pos-top" checked/>
|
||||
<span class="text-sm">상위 메뉴로 등록</span>
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-1 cursor-pointer">
|
||||
<input type="radio" name="pos_type" value="child" id="pos-child"/>
|
||||
<span class="text-sm">하위 메뉴로 등록</span>
|
||||
</label>
|
||||
</div>
|
||||
<select id="mm-parent-select" class="border border-gray-300 rounded px-2 py-1 w-full text-sm mt-1 hidden">
|
||||
<option value="">— 상위 메뉴 선택 —</option>
|
||||
<?php foreach ($list as $prow): ?>
|
||||
<?php if ((int) $prow->mm_dep !== 0) { continue; } ?>
|
||||
<option value="<?= (int) $prow->mm_idx ?>" data-dep="<?= (int) $prow->mm_dep ?>"><?= esc($prow->mm_name) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2 mb-3">
|
||||
<label class="block text-sm font-medium text-gray-700">노출 대상</label>
|
||||
<?php if ($mtCode === 'admin'): ?>
|
||||
@@ -259,6 +280,50 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
|
||||
const editIdInput = document.getElementById('mm_idx_edit');
|
||||
const mmPidxInput = form.querySelector('[name="mm_pidx"]');
|
||||
const mmDepInput = form.querySelector('[name="mm_dep"]');
|
||||
const posWrap = document.getElementById('menu-position-wrap');
|
||||
const posTop = document.getElementById('pos-top');
|
||||
const posChild = document.getElementById('pos-child');
|
||||
const parentSelect = document.getElementById('mm-parent-select');
|
||||
|
||||
// 상위/하위 위치 선택 → 숨은 mm_pidx / mm_dep 동기화
|
||||
function applyParentFromSelect() {
|
||||
const opt = parentSelect.options[parentSelect.selectedIndex];
|
||||
if (opt && opt.value) {
|
||||
mmPidxInput.value = opt.value;
|
||||
mmDepInput.value = String((parseInt(opt.dataset.dep || '0', 10)) + 1);
|
||||
} else {
|
||||
mmPidxInput.value = '0';
|
||||
mmDepInput.value = '1';
|
||||
}
|
||||
}
|
||||
function setPosition(type, parentId) {
|
||||
if (type === 'child') {
|
||||
if (posChild) posChild.checked = true;
|
||||
parentSelect.classList.remove('hidden');
|
||||
if (parentId != null) parentSelect.value = String(parentId);
|
||||
applyParentFromSelect();
|
||||
} else {
|
||||
if (posTop) posTop.checked = true;
|
||||
parentSelect.classList.add('hidden');
|
||||
mmPidxInput.value = '0';
|
||||
mmDepInput.value = '0';
|
||||
}
|
||||
}
|
||||
if (posTop) posTop.addEventListener('change', function () { if (posTop.checked) setPosition('top'); });
|
||||
if (posChild) posChild.addEventListener('change', function () { if (posChild.checked) setPosition('child', parentSelect.value); });
|
||||
if (parentSelect) parentSelect.addEventListener('change', applyParentFromSelect);
|
||||
// 하위 선택인데 상위 미지정이면 등록 차단
|
||||
function enableAllParentOptions() {
|
||||
if (!parentSelect) return;
|
||||
Array.prototype.forEach.call(parentSelect.options, function (o) { o.disabled = false; });
|
||||
}
|
||||
form.addEventListener('submit', function (e) {
|
||||
if (posChild && posChild.checked && !parentSelect.value) {
|
||||
e.preventDefault();
|
||||
alert('하위 메뉴로 지정하려면 상위 메뉴를 선택해 주세요.');
|
||||
}
|
||||
});
|
||||
|
||||
const levelAll = document.getElementById('mm_level_all');
|
||||
const levelCbs = document.querySelectorAll('.mm-level-cb');
|
||||
const isAdminType = '<?= esc($mtCode) ?>' === 'admin';
|
||||
@@ -298,11 +363,23 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
|
||||
const level = (this.dataset.level || '').toString().trim();
|
||||
const view = this.dataset.view || 'Y';
|
||||
const dep = parseInt(this.dataset.dep || '0', 10);
|
||||
const pidx = parseInt(this.dataset.pidx || '0', 10);
|
||||
form.action = '<?= base_url('admin/menus/update/') ?>' + id;
|
||||
form.querySelector('[name="mm_name"]').value = name;
|
||||
form.querySelector('[name="mm_link"]').value = link;
|
||||
mmPidxInput.value = '0';
|
||||
mmDepInput.value = String(dep);
|
||||
// 위치(상위/하위) 선택 UI를 현재 위치로 표시 — 수정 시에도 상위↔하위 이동 가능
|
||||
if (posWrap) posWrap.style.display = '';
|
||||
// 자기 자신은 상위 후보에서 제외
|
||||
if (parentSelect) {
|
||||
Array.prototype.forEach.call(parentSelect.options, function (o) {
|
||||
o.disabled = (o.value === String(id));
|
||||
});
|
||||
}
|
||||
if (pidx > 0) {
|
||||
setPosition('child', pidx);
|
||||
} else {
|
||||
setPosition('top');
|
||||
}
|
||||
if (!isAdminType && levelAll) {
|
||||
levelAll.checked = (level === '');
|
||||
levelCbs.forEach(function(cb){
|
||||
@@ -340,6 +417,10 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
|
||||
formTitle.textContent = '하위 메뉴 등록';
|
||||
btnSubmit.textContent = '등록';
|
||||
btnCancel.style.display = 'inline-block';
|
||||
// 위치 선택 UI를 '하위'로 반영(선택한 상위 메뉴 표시)
|
||||
if (posWrap) posWrap.style.display = '';
|
||||
enableAllParentOptions();
|
||||
setPosition('child', parentId);
|
||||
// 노출 기본값 재설정
|
||||
form.querySelector('[name="mm_is_view"]').checked = true;
|
||||
if (!isAdminType && levelAll) {
|
||||
@@ -368,6 +449,10 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
|
||||
formTitle.textContent = '메뉴 등록';
|
||||
btnSubmit.textContent = '등록';
|
||||
btnCancel.style.display = 'none';
|
||||
// 위치 선택 UI 복원 → 기본 '상위'
|
||||
if (posWrap) posWrap.style.display = '';
|
||||
enableAllParentOptions();
|
||||
setPosition('top');
|
||||
});
|
||||
|
||||
// 메뉴 목록 행 드래그 정렬 (마우스 이벤트 기반)
|
||||
|
||||
190
app/Views/admin/sales_report/custom_report.php
Normal file
190
app/Views/admin/sales_report/custom_report.php
Normal file
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* @var array<string, array{label:string,type:string}> $columnCatalog
|
||||
* @var list<string> $selected
|
||||
* @var string $title
|
||||
* @var string $category
|
||||
* @var string $mode
|
||||
* @var string $startDate
|
||||
* @var string $endDate
|
||||
* @var list<array<string,mixed>> $rows
|
||||
* @var array<string,int> $totals
|
||||
* @var list<object> $presets
|
||||
* @var int $activePresetIdx
|
||||
*/
|
||||
$catLabels = ['all' => '전체', 'envelope' => '봉투', 'food' => '음식물', 'waste' => '폐기물'];
|
||||
$printTitle = $title !== '' ? $title : '맞춤 집계표';
|
||||
$nf = static fn ($n): string => number_format((int) $n);
|
||||
?>
|
||||
<?= view('components/print_header', [
|
||||
'printTitle' => $printTitle,
|
||||
'printDate' => date('Y-m-d'),
|
||||
'printExtraLines' => ['조회기간: ' . $startDate . ' ~ ' . $endDate . ' · 품목구분: ' . ($catLabels[$category] ?? '전체') . ' · (단위: 매 / 원)'],
|
||||
]) ?>
|
||||
|
||||
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel no-print flex flex-wrap items-center justify-between gap-2">
|
||||
<span class="text-sm font-bold text-gray-700">맞춤 집계표</span>
|
||||
<button type="button" onclick="window.print()" class="border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
|
||||
</section>
|
||||
|
||||
<!-- 조회/구성 폼 -->
|
||||
<section class="p-3 bg-white border-b border-gray-200 no-print">
|
||||
<form method="get" action="<?= mgmt_url('reports/custom') ?>" id="custom-report-form" class="space-y-3">
|
||||
<input type="hidden" name="applied" value="1"/>
|
||||
<input type="hidden" name="export" id="cr-export" value="0"/>
|
||||
<div class="flex flex-wrap items-end gap-3 text-sm">
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">표 제목</label>
|
||||
<input type="text" name="title" value="<?= esc($title, 'attr') ?>" maxlength="200" placeholder="예: 2026년 음식물 봉투 판매 집계" class="border border-gray-300 rounded px-2 py-1 w-72"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">시작일</label>
|
||||
<input type="date" name="start_date" value="<?= esc($startDate, 'attr') ?>" class="border border-gray-300 rounded px-2 py-1"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">종료일</label>
|
||||
<input type="date" name="end_date" value="<?= esc($endDate, 'attr') ?>" class="border border-gray-300 rounded px-2 py-1"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">품목구분(종류)</label>
|
||||
<select name="category" class="border border-gray-300 rounded px-2 py-1 w-32">
|
||||
<?php foreach ($catLabels as $ck => $cl): ?>
|
||||
<option value="<?= esc($ck, 'attr') ?>" <?= $category === $ck ? 'selected' : '' ?>><?= esc($cl) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">집계 방식</label>
|
||||
<select name="mode" class="border border-gray-300 rounded px-2 py-1 w-40">
|
||||
<option value="detail" <?= $mode === 'detail' ? 'selected' : '' ?>>상세(건별)</option>
|
||||
<option value="summary" <?= $mode === 'summary' ? 'selected' : '' ?>>품목별 집계</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" onclick="document.getElementById('cr-export').value='0'" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm shadow hover:opacity-90">조회</button>
|
||||
<button type="submit" onclick="document.getElementById('cr-export').value='1'" class="border border-btn-excel-border text-btn-excel-text px-4 py-1.5 rounded-sm text-sm hover:bg-green-50 transition">엑셀저장</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">표시할 컬럼 선택</label>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1">
|
||||
<?php foreach ($columnCatalog as $key => $meta): ?>
|
||||
<label class="inline-flex items-center gap-1 text-sm cursor-pointer">
|
||||
<input type="checkbox" name="cols[]" value="<?= esc($key, 'attr') ?>" <?= in_array($key, $selected, true) ? 'checked' : '' ?>/>
|
||||
<span><?= esc($meta['label']) ?></span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<p class="text-[11px] text-gray-500 mt-1">집계 방식이 「품목별 집계」이면 일자·판매소 등 건별 컬럼은 빈 값으로 표시됩니다.</p>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- 프리셋 (계정별 저장) -->
|
||||
<?php if (! empty($presetsEnabled)): ?>
|
||||
<section class="p-3 bg-gray-50 border-b border-gray-200 no-print">
|
||||
<div class="flex flex-wrap items-end gap-3 text-sm">
|
||||
<form method="get" action="<?= mgmt_url('reports/custom') ?>" class="flex items-end gap-2">
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">저장된 프리셋 불러오기</label>
|
||||
<select name="preset" class="border border-gray-300 rounded px-2 py-1 min-w-[14rem]" onchange="this.form.submit()">
|
||||
<option value="">— 프리셋 선택 —</option>
|
||||
<?php foreach ($presets as $p): ?>
|
||||
<option value="<?= (int) $p->rp_idx ?>" <?= $activePresetIdx === (int) $p->rp_idx ? 'selected' : '' ?>><?= esc((string) $p->rp_name) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<input type="hidden" name="start_date" value="<?= esc($startDate, 'attr') ?>"/>
|
||||
<input type="hidden" name="end_date" value="<?= esc($endDate, 'attr') ?>"/>
|
||||
</form>
|
||||
|
||||
<?php if ($activePresetIdx > 0): ?>
|
||||
<form method="post" action="<?= mgmt_url('reports/custom/preset/delete') ?>" onsubmit="return confirm('이 프리셋을 삭제할까요?');" class="flex items-end">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="rp_idx" value="<?= (int) $activePresetIdx ?>"/>
|
||||
<button type="submit" class="text-red-600 hover:underline text-xs pb-1">현재 프리셋 삭제</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- 현재 화면 구성 그대로 저장 (JS로 현재 폼 값 복사) -->
|
||||
<form method="post" action="<?= mgmt_url('reports/custom/preset/save') ?>" id="preset-save-form" class="flex items-end gap-2">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="rp_idx" value="<?= (int) $activePresetIdx ?>"/>
|
||||
<input type="hidden" name="rp_title" id="ps-title"/>
|
||||
<input type="hidden" name="rp_category" id="ps-category"/>
|
||||
<input type="hidden" name="rp_mode" id="ps-mode"/>
|
||||
<input type="hidden" name="start_date" value="<?= esc($startDate, 'attr') ?>"/>
|
||||
<input type="hidden" name="end_date" value="<?= esc($endDate, 'attr') ?>"/>
|
||||
<div id="ps-cols-holder"></div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">프리셋 이름</label>
|
||||
<input type="text" name="rp_name" maxlength="100" placeholder="예: 음식물 담당 월집계" class="border border-gray-300 rounded px-2 py-1 w-56" required/>
|
||||
</div>
|
||||
<button type="submit" class="bg-[#243a5e] text-white px-4 py-1.5 rounded-sm text-sm shadow hover:opacity-90">현재 구성 저장</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- 결과 표 -->
|
||||
<div class="p-3">
|
||||
<div class="mb-2 text-center no-print">
|
||||
<h1 class="text-lg font-bold m-0"><?= esc($printTitle) ?></h1>
|
||||
<p class="text-xs text-gray-500 mt-0.5">조회기간 <?= esc($startDate) ?> ~ <?= esc($endDate) ?> · 품목구분 <?= esc($catLabels[$category] ?? '전체') ?> · 건수 <?= $nf(count($rows)) ?></p>
|
||||
</div>
|
||||
<div class="overflow-auto">
|
||||
<table class="w-full data-table text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-12 text-center">No</th>
|
||||
<?php foreach ($selected as $key): ?>
|
||||
<th class="<?= ($columnCatalog[$key]['type'] ?? '') === 'num' ? 'text-right' : 'text-left' ?> px-2"><?= esc($columnCatalog[$key]['label']) ?></th>
|
||||
<?php endforeach; ?>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if ($rows === []): ?>
|
||||
<tr><td colspan="<?= count($selected) + 1 ?>" class="text-center text-gray-400 py-6">조회된 데이터가 없습니다.</td></tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($rows as $i => $row): ?>
|
||||
<tr>
|
||||
<td class="text-center text-gray-500"><?= $i + 1 ?></td>
|
||||
<?php foreach ($selected as $key): ?>
|
||||
<?php $isNum = ($columnCatalog[$key]['type'] ?? '') === 'num'; ?>
|
||||
<td class="<?= $isNum ? 'text-right' : 'text-left' ?> px-2"><?= $isNum ? $nf($row[$key] ?? 0) : esc((string) ($row[$key] ?? '')) ?></td>
|
||||
<?php endforeach; ?>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<tr class="bg-amber-50 font-bold">
|
||||
<td class="text-center">합계</td>
|
||||
<?php foreach ($selected as $key): ?>
|
||||
<?php $isNum = ($columnCatalog[$key]['type'] ?? '') === 'num'; ?>
|
||||
<td class="<?= $isNum ? 'text-right' : '' ?> px-2"><?= $isNum ? $nf($totals[$key] ?? 0) : '' ?></td>
|
||||
<?php endforeach; ?>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
// "현재 구성 저장" 시, 조회 폼의 현재 값(제목/종류/방식/컬럼)을 프리셋 저장 폼으로 복사
|
||||
var mainForm = document.getElementById('custom-report-form');
|
||||
var saveForm = document.getElementById('preset-save-form');
|
||||
if (!mainForm || !saveForm) return;
|
||||
saveForm.addEventListener('submit', function () {
|
||||
document.getElementById('ps-title').value = mainForm.querySelector('[name="title"]').value;
|
||||
document.getElementById('ps-category').value = mainForm.querySelector('[name="category"]').value;
|
||||
document.getElementById('ps-mode').value = mainForm.querySelector('[name="mode"]').value;
|
||||
var holder = document.getElementById('ps-cols-holder');
|
||||
holder.innerHTML = '';
|
||||
mainForm.querySelectorAll('input[name="cols[]"]:checked').forEach(function (cb) {
|
||||
var h = document.createElement('input');
|
||||
h.type = 'hidden'; h.name = 'cols[]'; h.value = cb.value;
|
||||
holder.appendChild(h);
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@@ -250,6 +250,80 @@ $excelUrl = mgmt_url('reports/sales-ledger?' . http_build_query($exportParams));
|
||||
</table>
|
||||
</div>
|
||||
<p class="text-sm text-gray-700 mt-2 mb-0 no-print">판매건수(상세 행): <?= number_format((int) ($saleLineCount ?? 0)) ?>건</p>
|
||||
|
||||
<?php
|
||||
$sizeStats = $sizeStats ?? [];
|
||||
$catStats = $catStats ?? [];
|
||||
$sizeTotQty = array_sum(array_map(static fn ($r) => (int) $r['qty'], $sizeStats));
|
||||
$sizeTotAmt = array_sum(array_map(static fn ($r) => (int) $r['amount'], $sizeStats));
|
||||
$catTotQty = array_sum(array_map(static fn ($r) => (int) $r['qty'], $catStats));
|
||||
$catTotAmt = array_sum(array_map(static fn ($r) => (int) $r['amount'], $catStats));
|
||||
?>
|
||||
<div class="mt-4 grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<!-- 용량별(리터별) 통계 -->
|
||||
<div>
|
||||
<div class="text-sm font-bold text-gray-700 mb-1">용량별(리터별) 통계</div>
|
||||
<table class="w-full data-table text-sm">
|
||||
<thead>
|
||||
<tr class="bg-gray-50">
|
||||
<th class="text-left px-2 py-1">용량</th>
|
||||
<th class="text-right px-2 py-1">판매량(매)</th>
|
||||
<th class="text-right px-2 py-1">판매금액(원)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if ($sizeStats === []): ?>
|
||||
<tr><td colspan="3" class="text-center text-gray-400 py-4">데이터 없음</td></tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($sizeStats as $r): ?>
|
||||
<tr>
|
||||
<td class="text-left px-2 py-1"><?= esc($r['label']) ?></td>
|
||||
<td class="text-right px-2 py-1"><?= number_format((int) $r['qty']) ?></td>
|
||||
<td class="text-right px-2 py-1"><?= number_format((int) $r['amount']) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<tr class="bg-amber-50 font-bold">
|
||||
<td class="text-left px-2 py-1">합계</td>
|
||||
<td class="text-right px-2 py-1"><?= number_format((int) $sizeTotQty) ?></td>
|
||||
<td class="text-right px-2 py-1"><?= number_format((int) $sizeTotAmt) ?></td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 품목별(봉투/음식물/폐기물) 통계 -->
|
||||
<div>
|
||||
<div class="text-sm font-bold text-gray-700 mb-1">품목별(봉투·음식물·폐기물) 통계</div>
|
||||
<table class="w-full data-table text-sm">
|
||||
<thead>
|
||||
<tr class="bg-gray-50">
|
||||
<th class="text-left px-2 py-1">품목</th>
|
||||
<th class="text-right px-2 py-1">판매량(매)</th>
|
||||
<th class="text-right px-2 py-1">판매금액(원)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if ($catStats === []): ?>
|
||||
<tr><td colspan="3" class="text-center text-gray-400 py-4">데이터 없음</td></tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($catStats as $r): ?>
|
||||
<tr>
|
||||
<td class="text-left px-2 py-1"><?= esc($r['label']) ?></td>
|
||||
<td class="text-right px-2 py-1"><?= number_format((int) $r['qty']) ?></td>
|
||||
<td class="text-right px-2 py-1"><?= number_format((int) $r['amount']) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<tr class="bg-amber-50 font-bold">
|
||||
<td class="text-left px-2 py-1">합계</td>
|
||||
<td class="text-right px-2 py-1"><?= number_format((int) $catTotQty) ?></td>
|
||||
<td class="text-right px-2 py-1"><?= number_format((int) $catTotAmt) ?></td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -15,6 +15,9 @@ $subtitle = $subtitle ?? '종량제 쓰레기봉투 물류시스템';
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title><?= esc($pageTitle ?? 'GBLS') ?></title>
|
||||
<link rel="icon" type="image/svg+xml" href="<?= base_url('favicon.svg') ?>"/>
|
||||
<link rel="alternate icon" href="<?= base_url('favicon.ico') ?>"/>
|
||||
<link rel="apple-touch-icon" href="<?= base_url('favicon.svg') ?>"/>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"/>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"/>
|
||||
@@ -41,7 +44,7 @@ if (window.top !== window.self) { try { window.top.location.href = <?= json_enco
|
||||
<header class="bg-navy text-white h-12 flex items-center justify-between px-4 shrink-0 shadow">
|
||||
<a href="<?= base_url() ?>" class="flex items-center gap-2 shrink-0 tracking-tight hover:opacity-90" title="GBLS (Garbage Bag Logistics System)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="h-6 w-6 text-white shrink-0" aria-hidden="true" focusable="false">
|
||||
<path fill="currentColor" d="M9 3a1 1 0 00-1 1v1H5.75a.75.75 0 000 1.5h12.5a.75.75 0 000-1.5H16V4a1 1 0 00-1-1H9zm9 4H6v11a2 2 0 002 2h8a2 2 0 002-2V7zM10 9a.75.75 0 01.75.75v6a.75.75 0 01-1.5 0v-6A.75.75 0 0110 9zm4 0a.75.75 0 01.75.75v6a.75.75 0 01-1.5 0v-6A.75.75 0 0114 9z"/>
|
||||
<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>
|
||||
<span class="leading-none flex flex-col">
|
||||
<strong class="text-base font-extrabold tracking-wide">GBLS</strong>
|
||||
|
||||
@@ -114,7 +114,7 @@
|
||||
<tr class="<?= $rowNormal ? '' : 'opacity-60' ?>">
|
||||
<td class="text-center align-middle">
|
||||
<?php if ($rowNormal): ?>
|
||||
<input type="radio" name="bo_idx" value="<?= (int) $history->bo_idx ?>" class="accent-red-600" <?= (int) $history->bo_idx === $selectedDeleteBoIdx ? 'checked' : '' ?> <?= $deleteRadioFirst ? 'required' : '' ?> />
|
||||
<input type="radio" name="bo_idx" value="<?= (int) $history->bo_idx ?>" class="accent-red-600 js-delete-pick" <?= (int) $history->bo_idx === $selectedDeleteBoIdx ? 'checked' : '' ?> <?= $deleteRadioFirst ? 'required' : '' ?> />
|
||||
<?php $deleteRadioFirst = false; ?>
|
||||
<?php else: ?>
|
||||
<span class="text-gray-400" title="삭제할 수 없는 상태">—</span>
|
||||
@@ -135,7 +135,8 @@
|
||||
</section>
|
||||
<section class="xl:col-span-7 border border-red-200 bg-red-50 p-4">
|
||||
<p class="text-sm font-bold text-red-800 mb-2">발주 삭제</p>
|
||||
<p class="text-sm text-gray-700 mb-4">목록에서 선택한 발주를 삭제 처리합니다. 계속하시겠습니까?</p>
|
||||
<p class="text-sm text-gray-700 mb-3">아래 <b>삭제 대상 발주 내역</b>을 확인한 뒤 「삭제 실행」을 누르세요.</p>
|
||||
<div id="delete-detail" class="bg-white border border-red-200 rounded p-3 text-sm mb-4 max-h-[320px] overflow-auto">삭제할 발주를 선택하세요.</div>
|
||||
<div class="flex flex-wrap gap-2 items-center">
|
||||
<button type="submit" class="bg-red-600 text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90" <?= $firstNormalBoIdx === null ? 'disabled' : '' ?>>삭제 실행</button>
|
||||
<a href="<?= base_url('bag/order/change?month=' . rawurlencode($orderReturnMonth !== '' ? $orderReturnMonth : substr($defaultOrderDate, 0, 7))) ?>" class="bg-gray-200 text-gray-800 px-6 py-1.5 rounded-sm text-sm">취소</a>
|
||||
@@ -143,28 +144,64 @@
|
||||
</section>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const detailBox = document.getElementById('delete-detail');
|
||||
if (!detailBox) return;
|
||||
const detailBase = new URL(<?= json_encode(base_url('bag/order/detail-json'), JSON_UNESCAPED_SLASHES) ?>, window.location.href).pathname;
|
||||
const nf = (n) => new Intl.NumberFormat('ko-KR').format(Number(n) || 0);
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c]));
|
||||
|
||||
const load = (id) => {
|
||||
if (!id) { detailBox.innerHTML = '삭제할 발주를 선택하세요.'; return; }
|
||||
detailBox.innerHTML = '불러오는 중…';
|
||||
fetch(detailBase + '/' + id, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
if (!d || !d.ok) { detailBox.innerHTML = '<span class="text-red-600">' + esc((d && d.error) || '불러오지 못했습니다.') + '</span>'; return; }
|
||||
const rows = (d.items || []).map((it, i) => `<tr>
|
||||
<td class="text-center">${i + 1}</td>
|
||||
<td class="text-left pl-2">${esc(it.bag_name || it.bag_code)}</td>
|
||||
<td class="text-right pr-2">${nf(it.qty_box)}</td>
|
||||
<td class="text-right pr-2">${nf(it.qty_sheet)}</td>
|
||||
<td class="text-right pr-2">${nf(it.amount)}</td></tr>`).join('');
|
||||
detailBox.innerHTML = `
|
||||
<dl class="grid grid-cols-[6rem_1fr] gap-y-0.5 mb-2">
|
||||
<dt class="font-semibold text-gray-600">발주번호</dt><dd class="font-mono">${esc(d.bo_lot_no)} <span class="text-gray-400">(v${d.bo_version})</span></dd>
|
||||
<dt class="font-semibold text-gray-600">발주일</dt><dd>${esc(d.order_date)}</dd>
|
||||
<dt class="font-semibold text-gray-600">제작업체</dt><dd>${esc(d.company || '-')}</dd>
|
||||
<dt class="font-semibold text-gray-600">입고처</dt><dd>${esc(d.agency || '-')}</dd>
|
||||
</dl>
|
||||
<table class="w-full data-table text-sm">
|
||||
<thead><tr><th class="w-8 text-center">번호</th><th class="text-left">품명</th><th class="text-right">박스수량</th><th class="text-right">낱장</th><th class="text-right">금액</th></tr></thead>
|
||||
<tbody>${rows || '<tr><td colspan="5" class="text-center text-gray-400 py-3">품목이 없습니다.</td></tr>'}</tbody>
|
||||
</table>`;
|
||||
})
|
||||
.catch(() => { detailBox.innerHTML = '<span class="text-red-600">오류가 발생했습니다.</span>'; });
|
||||
};
|
||||
|
||||
document.querySelectorAll('.js-delete-pick').forEach((r) => {
|
||||
r.addEventListener('change', () => { if (r.checked) load(r.value); });
|
||||
});
|
||||
const checked = document.querySelector('.js-delete-pick:checked');
|
||||
if (checked) load(checked.value);
|
||||
})();
|
||||
</script>
|
||||
<?php else: ?>
|
||||
|
||||
<form action="<?= base_url('bag/order/store') ?>" method="POST" class="mt-2 space-y-2" id="bag-order-store-form">
|
||||
<?= csrf_field() ?>
|
||||
<?php if ($editMode): ?>
|
||||
<input type="hidden" name="bo_source_idx" value="<?= esc((string) ($editDefaults['bo_source_idx'] ?? 0)) ?>" />
|
||||
<fieldset class="border border-gray-200 rounded px-3 py-2 mb-1 text-sm bg-white">
|
||||
<legend class="text-xs font-bold text-gray-600 px-1">변경 구분</legend>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1">
|
||||
<label class="inline-flex items-center gap-1 cursor-pointer">
|
||||
<input type="radio" name="bo_change_mode" value="price" <?= $changeMode === 'price' ? 'checked' : '' ?> />
|
||||
<span>발주·도매·판매 단가</span>
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-1 cursor-pointer">
|
||||
<input type="radio" name="bo_change_mode" value="meta" <?= $changeMode === 'meta' ? 'checked' : '' ?> />
|
||||
<span>업체·수수료·협회·발주</span>
|
||||
</label>
|
||||
<input type="hidden" name="bo_change_mode" value="meta" />
|
||||
<div class="border border-amber-300 rounded px-3 py-2 mb-1 text-sm bg-amber-50 flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span class="font-bold text-gray-700">원 발주번호(LOT)</span>
|
||||
<span class="font-mono text-gray-900"><?= esc($orderLotNo !== '' ? $orderLotNo : '-') ?></span>
|
||||
<span class="text-gray-500">발주 #<?= (int) ($editDefaults['bo_source_idx'] ?? 0) ?></span>
|
||||
<span class="text-gray-500">— 변경 저장 시 원 발주번호는 유지되고, 이전 내용은 이력(버전)으로 보존됩니다.</span>
|
||||
<a class="text-red-600 hover:underline ml-auto" href="<?= base_url('bag/order/revise/' . (int) ($editDefaults['bo_source_idx'] ?? 0) . '?change_mode=delete') ?>">발주 삭제</a>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
발주 삭제는 <a class="text-blue-600 hover:underline" href="<?= base_url('bag/order/revise/' . (int) ($editDefaults['bo_source_idx'] ?? 0) . '?change_mode=delete') ?>">발주 삭제 화면</a>으로 이동합니다.
|
||||
</p>
|
||||
</fieldset>
|
||||
<?php endif; ?>
|
||||
<?php if ($hubReturn && $orderReturnMonth !== ''): ?>
|
||||
<input type="hidden" name="order_return_hub" value="1" />
|
||||
@@ -188,48 +225,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 xl:grid-cols-12 gap-2">
|
||||
<section class="xl:col-span-5 border border-gray-300 rounded-lg overflow-hidden bg-white">
|
||||
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">발주 이력</div>
|
||||
<div class="overflow-auto max-h-[410px]">
|
||||
<table class="w-full data-table text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-28 text-center">발주일</th>
|
||||
<th class="text-left">제작업체</th>
|
||||
<th class="text-left">입고처</th>
|
||||
<th class="w-16 text-center">상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach (($recentOrders ?? []) as $history): ?>
|
||||
<tr>
|
||||
<td class="text-center">
|
||||
<a href="<?= base_url('bag/order/revise/' . (int) $history->bo_idx) ?>" class="text-blue-600 hover:underline">
|
||||
<?= esc((string) $history->bo_order_date) ?>
|
||||
</a>
|
||||
</td>
|
||||
<td class="text-left pl-2"><?= esc((string) ($companyMap[(int) $history->bo_company_idx] ?? '-')) ?></td>
|
||||
<td class="text-left pl-2"><?= esc((string) ($agencyMap[(int) $history->bo_agency_idx] ?? '-')) ?></td>
|
||||
<td class="text-center"><?= esc((string) ($statusMap[(string) $history->bo_status] ?? $history->bo_status)) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($recentOrders)): ?>
|
||||
<tr><td colspan="4" class="text-center text-gray-400 py-4">발주 이력이 없습니다.</td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="grid grid-cols-1 xl:grid-cols-12 gap-2 items-start">
|
||||
<section class="xl:col-span-7 border border-gray-300 rounded-lg overflow-hidden bg-white">
|
||||
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700"><?= $editMode ? '발주 변경 수정' : '발주 Form' ?></div>
|
||||
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700"><?= $editMode ? '발주 변경 수정' : '발주등록' ?></div>
|
||||
<div class="p-2 space-y-2">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<label for="bo_order_date" class="w-20 font-bold text-gray-700">발주일 <span class="text-red-500">*</span></label>
|
||||
<input id="bo_order_date" name="bo_order_date" type="date" value="<?= esc($defaultOrderDate) ?>" required class="border border-gray-300 rounded px-2 py-1 w-full" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<label for="bo_manager_idx" class="w-20 font-bold text-gray-700">발주담당</label>
|
||||
<select id="bo_manager_idx" name="bo_manager_idx" class="border border-gray-300 rounded px-2 py-1 w-full">
|
||||
<option value="">선택</option>
|
||||
<?php foreach (($managers ?? []) as $manager): ?>
|
||||
<option value="<?= esc((string) $manager->mg_idx) ?>" <?= (int) old('bo_manager_idx', $editDefaults['bo_manager_idx'] ?? 0) === (int) $manager->mg_idx ? 'selected' : '' ?>>
|
||||
<?= esc((string) $manager->mg_name) ?><?= ($manager->mg_affiliation ?? '') !== '' ? ' (' . esc((string) $manager->mg_affiliation) . ')' : '' ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<label for="bo_association_idx" class="w-20 font-bold text-gray-700">협회</label>
|
||||
<select id="bo_association_idx" name="bo_association_idx" class="border border-gray-300 rounded px-2 py-1 w-full">
|
||||
@@ -272,7 +287,7 @@
|
||||
<th class="w-12 text-center">번호</th>
|
||||
<th class="w-16 text-center">선택</th>
|
||||
<th class="text-left">품명</th>
|
||||
<th class="w-24 text-right">수량</th>
|
||||
<th class="w-24 text-right">박스수량</th>
|
||||
<th class="w-24 text-right">단가</th>
|
||||
<th class="w-24 text-right">낱장환산</th>
|
||||
<?php if ($editMode): ?>
|
||||
@@ -325,15 +340,16 @@
|
||||
|
||||
<div class="flex gap-2 pt-1">
|
||||
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition"><?= $editMode ? '변경 저장' : '발주' ?></button>
|
||||
<a href="<?= base_url('bag/purchase-inbound') ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm hover:bg-gray-300 transition">취소</a>
|
||||
<?php if ($editMode): ?>
|
||||
<a href="<?= base_url('bag/order/create') ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm hover:bg-gray-300 transition">취소</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="border border-gray-300 rounded-lg overflow-hidden bg-white">
|
||||
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">발주 등록 종류</div>
|
||||
<div class="overflow-auto">
|
||||
<section class="xl:col-span-5 border border-gray-300 rounded-lg overflow-hidden bg-white">
|
||||
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">발주품목 선택</div>
|
||||
<div class="overflow-auto max-h-[560px]">
|
||||
<table class="w-full data-table text-sm order-reference-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -367,6 +383,40 @@
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="border border-gray-300 rounded-lg overflow-hidden bg-white">
|
||||
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">발주 이력</div>
|
||||
<div class="overflow-auto max-h-[410px]">
|
||||
<table class="w-full data-table text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-28 text-center">발주일</th>
|
||||
<th class="text-left">제작업체</th>
|
||||
<th class="text-left">입고처</th>
|
||||
<th class="w-16 text-center">상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach (($recentOrders ?? []) as $history): ?>
|
||||
<tr>
|
||||
<td class="text-center">
|
||||
<button type="button" class="js-order-view text-blue-600 hover:underline font-medium" data-order-id="<?= (int) $history->bo_idx ?>" title="발주 내역 보기">
|
||||
<?= esc((string) $history->bo_order_date) ?>
|
||||
</button>
|
||||
</td>
|
||||
<td class="text-left pl-2"><?= esc((string) ($companyMap[(int) $history->bo_company_idx] ?? '-')) ?></td>
|
||||
<td class="text-left pl-2"><?= esc((string) ($agencyMap[(int) $history->bo_agency_idx] ?? '-')) ?></td>
|
||||
<td class="text-center"><?= esc((string) ($statusMap[(string) $history->bo_status] ?? $history->bo_status)) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($recentOrders)): ?>
|
||||
<tr><td colspan="4" class="text-center text-gray-400 py-4">발주 이력이 없습니다.</td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -384,6 +434,96 @@
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 발주 내역 보기 모달 -->
|
||||
<div id="order-view-modal" class="hidden fixed inset-0 z-50 items-center justify-center" style="background:rgba(0,0,0,.4)">
|
||||
<div class="bg-white rounded-lg shadow-xl w-[680px] max-w-[95vw] max-h-[85vh] overflow-auto">
|
||||
<div class="flex items-center justify-between border-b px-4 py-2">
|
||||
<span class="font-bold text-gray-800">발주 내역</span>
|
||||
<button type="button" id="order-view-close" class="text-gray-500 hover:text-gray-800 text-lg leading-none">✕</button>
|
||||
</div>
|
||||
<div class="p-4 text-sm" id="order-view-body">불러오는 중…</div>
|
||||
<div class="flex items-center justify-end gap-2 border-t px-4 py-2">
|
||||
<form id="order-view-delete-form" action="<?= base_url('bag/order/delete') ?>" method="post" class="mr-auto">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="bo_idx" id="order-view-delete-id" value="" />
|
||||
<input type="hidden" name="return_to" value="create" />
|
||||
<button type="submit" id="order-view-delete" class="hidden bg-red-600 text-white px-4 py-1.5 rounded-sm text-sm shadow hover:opacity-90">삭제</button>
|
||||
</form>
|
||||
<a id="order-view-edit" href="#" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm shadow hover:opacity-90">발주변경</a>
|
||||
<button type="button" id="order-view-close2" class="bg-gray-200 text-gray-700 px-4 py-1.5 rounded-sm text-sm hover:bg-gray-300">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const modal = document.getElementById('order-view-modal');
|
||||
if (!modal) return;
|
||||
const body = document.getElementById('order-view-body');
|
||||
const editLink = document.getElementById('order-view-edit');
|
||||
const deleteBtn = document.getElementById('order-view-delete');
|
||||
const deleteIdInput = document.getElementById('order-view-delete-id');
|
||||
const deleteForm = document.getElementById('order-view-delete-form');
|
||||
const reviseBase = <?= json_encode(base_url('bag/order/revise'), JSON_UNESCAPED_SLASHES) ?>;
|
||||
const detailBase = new URL(<?= json_encode(base_url('bag/order/detail-json'), JSON_UNESCAPED_SLASHES) ?>, window.location.href).pathname;
|
||||
const nf = (n) => new Intl.NumberFormat('ko-KR').format(Number(n) || 0);
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c]));
|
||||
const open = () => { modal.classList.remove('hidden'); modal.style.display = 'flex'; };
|
||||
const close = () => { modal.classList.add('hidden'); modal.style.display = 'none'; };
|
||||
document.getElementById('order-view-close')?.addEventListener('click', close);
|
||||
document.getElementById('order-view-close2')?.addEventListener('click', close);
|
||||
modal.addEventListener('click', (e) => { if (e.target === modal) close(); });
|
||||
if (deleteForm) {
|
||||
deleteForm.addEventListener('submit', (e) => {
|
||||
if (!confirm('이 발주를 삭제하시겠습니까?')) { e.preventDefault(); }
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.js-order-view').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const id = btn.dataset.orderId;
|
||||
if (!id) return;
|
||||
editLink.href = reviseBase + '/' + id;
|
||||
if (deleteIdInput) deleteIdInput.value = id;
|
||||
if (deleteBtn) deleteBtn.classList.add('hidden'); // 상태 확인 전까지 숨김
|
||||
if (editLink) editLink.classList.add('hidden'); // 상태 확인 전까지 숨김
|
||||
body.innerHTML = '불러오는 중…';
|
||||
open();
|
||||
fetch(detailBase + '/' + id, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
if (!d || !d.ok) { body.innerHTML = '<p class="text-red-600">' + esc((d && d.error) || '불러오지 못했습니다.') + '</p>'; return; }
|
||||
// 정상 상태 발주만 삭제·변경 가능 (삭제/취소된 발주는 두 버튼 모두 숨김)
|
||||
const isNormal = d.status === 'normal';
|
||||
if (deleteBtn) deleteBtn.classList.toggle('hidden', !isNormal);
|
||||
if (editLink) editLink.classList.toggle('hidden', !isNormal);
|
||||
const rows = (d.items || []).map((it, i) => `<tr>
|
||||
<td class="text-center">${i + 1}</td>
|
||||
<td class="text-left pl-2">${esc(it.bag_name || it.bag_code)}</td>
|
||||
<td class="text-right pr-2">${nf(it.qty_box)}</td>
|
||||
<td class="text-right pr-2">${nf(it.qty_sheet)}</td>
|
||||
<td class="text-right pr-2">${nf(it.unit_price)}</td>
|
||||
<td class="text-right pr-2">${nf(it.amount)}</td></tr>`).join('');
|
||||
body.innerHTML = `
|
||||
<dl class="grid grid-cols-[7rem_1fr] gap-y-1 mb-3">
|
||||
<dt class="font-semibold text-gray-600">발주번호(LOT)</dt><dd class="font-mono">${esc(d.bo_lot_no)} <span class="text-gray-400">(v${d.bo_version})</span></dd>
|
||||
<dt class="font-semibold text-gray-600">발주일</dt><dd>${esc(d.order_date)}</dd>
|
||||
<dt class="font-semibold text-gray-600">제작업체</dt><dd>${esc(d.company || '-')}</dd>
|
||||
<dt class="font-semibold text-gray-600">입고처</dt><dd>${esc(d.agency || '-')}</dd>
|
||||
<dt class="font-semibold text-gray-600">발주담당</dt><dd>${esc(d.manager || '-')}</dd>
|
||||
<dt class="font-semibold text-gray-600">조달수수료</dt><dd>${nf(d.fee_rate)}%</dd>
|
||||
</dl>
|
||||
<table class="w-full data-table text-sm">
|
||||
<thead><tr><th class="w-10 text-center">번호</th><th class="text-left">품명</th><th class="text-right">박스수량</th><th class="text-right">낱장</th><th class="text-right">단가</th><th class="text-right">금액</th></tr></thead>
|
||||
<tbody>${rows || '<tr><td colspan="6" class="text-center text-gray-400 py-3">품목이 없습니다.</td></tr>'}</tbody>
|
||||
</table>`;
|
||||
})
|
||||
.catch(() => { body.innerHTML = '<p class="text-red-600">오류가 발생했습니다.</p>'; });
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const bagMeta = <?= json_encode($bagMeta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
|
||||
@@ -548,7 +688,7 @@
|
||||
const renderSelectedRows = () => {
|
||||
const codes = Object.keys(bagMeta).filter((code) => selectedItems.has(code));
|
||||
if (codes.length === 0) {
|
||||
selectedBody.innerHTML = `<tr><td colspan="${colEmpty}" class="text-center text-gray-400 py-4">아래 "발주 등록 종류"에서 봉투를 선택해 주세요.</td></tr>`;
|
||||
selectedBody.innerHTML = `<tr><td colspan="${colEmpty}" class="text-center text-gray-400 py-4">우측 "발주품목 선택"에서 봉투를 선택해 주세요.</td></tr>`;
|
||||
setActiveRow(null);
|
||||
updateTotals();
|
||||
updateReferenceSelectionUi();
|
||||
@@ -648,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) => {
|
||||
row.addEventListener('click', (event) => {
|
||||
const button = event.target.closest('.js-toggle-bag');
|
||||
|
||||
271
app/Views/bag/inventory_stock_barcode_list.php
Normal file
271
app/Views/bag/inventory_stock_barcode_list.php
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
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>
|
||||
@@ -175,12 +175,13 @@ tailwind.config = {
|
||||
document.addEventListener('mouseup', function () { if (!dragging) return; dragging = false; ov.style.display = 'none'; document.body.style.userSelect = ''; });
|
||||
})();
|
||||
|
||||
// 글씨 크기(zoom) — 상단바에서 조절한 값을 적용. localStorage 공유 + storage 이벤트로 실시간 반영.
|
||||
function applyFontScale() {
|
||||
try { var s = parseInt(localStorage.getItem('jrj_font_scale') || '100', 10); if (!(s >= 70 && s <= 150)) s = 100; document.documentElement.style.zoom = (s / 100); } catch (e) {}
|
||||
}
|
||||
applyFontScale();
|
||||
window.addEventListener('storage', function (e) { if (e.key === 'jrj_font_scale') applyFontScale(); });
|
||||
// 글씨 크기(zoom) — 계정별·메뉴별 저장값(서버 주입)을 이 화면에 적용.
|
||||
<?php $__fmk = function_exists('current_nav_request_path') ? current_nav_request_path() : ''; $__fsc = function_exists('member_font_scale_for') ? member_font_scale_for($__fmk) : 100; ?>
|
||||
(function () {
|
||||
var s = <?= (int) $__fsc ?>;
|
||||
if (!(s >= 70 && s <= 150)) s = 100;
|
||||
try { document.documentElement.style.zoom = (s / 100); } catch (e) {}
|
||||
})();
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
var a = e.target.closest ? e.target.closest('a[href]') : null;
|
||||
|
||||
@@ -264,25 +264,39 @@ tailwind.config = {
|
||||
document.addEventListener('mousemove', function (e) { if (!dragging) return; var w = window.innerWidth - e.clientX; drawer.style.width = Math.min(window.innerWidth * 0.92, Math.max(300, w)) + 'px'; });
|
||||
document.addEventListener('mouseup', function () { if (!dragging) return; dragging = false; ov.style.display = 'none'; document.body.style.userSelect = ''; });
|
||||
|
||||
// 글씨 크기 조절 — 본문 + 좌측 사이드바에 zoom 적용.
|
||||
// 상단 헤더는 제외 → A−/A+ 버튼이 커지거나 밀리지 않아 연속 클릭이 편하다.
|
||||
var FONT_KEY = 'jrj_font_scale';
|
||||
// 글씨 크기 조절 — 계정별·메뉴별 저장(서버). 현재 화면 경로 기준으로 개별 적용.
|
||||
<?php $__fmk = function_exists('current_nav_request_path') ? current_nav_request_path() : ''; $__fsc = function_exists('member_font_scale_for') ? member_font_scale_for($__fmk) : 100; ?>
|
||||
var MENU_KEY = <?= json_encode($__fmk) ?>;
|
||||
var scale = <?= (int) $__fsc ?>;
|
||||
var SAVE_URL = <?= json_encode(site_url('bag/pref/font-scale')) ?>;
|
||||
var CSRF_NAME = <?= json_encode(csrf_token()) ?>;
|
||||
var csrfHash = <?= json_encode(csrf_hash()) ?>;
|
||||
var scaleSelectors = ['.sidebar', '.work-main'];
|
||||
function curScale() { var s = parseInt(localStorage.getItem(FONT_KEY) || '100', 10); return (s >= 70 && s <= 150) ? s : 100; }
|
||||
function applyScale(s) {
|
||||
var saveTimer = null;
|
||||
function persist(s) {
|
||||
try { localStorage.setItem('jrj_font_scale::' + MENU_KEY, String(s)); } catch (e) {}
|
||||
if (!MENU_KEY) return;
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(function () {
|
||||
var b = new URLSearchParams(); b.set(CSRF_NAME, csrfHash); b.set('menu_key', MENU_KEY); b.set('scale', String(s));
|
||||
fetch(SAVE_URL, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: b.toString() })
|
||||
.then(function (r) { return r.json(); }).then(function (d) { if (d && d.csrf) csrfHash = d.csrf; }).catch(function () {});
|
||||
}, 400);
|
||||
}
|
||||
function applyScale(s, save) {
|
||||
s = Math.min(150, Math.max(70, s));
|
||||
try { localStorage.setItem(FONT_KEY, String(s)); } catch (e) {}
|
||||
scale = s;
|
||||
var z = s / 100;
|
||||
scaleSelectors.forEach(function (sel) { var el = document.querySelector(sel); if (el) el.style.zoom = z; });
|
||||
// 상단 대메뉴는 font-size 만 키운다(바 높이 48px 고정 → A−/A+ 버튼이 안 밀림).
|
||||
var topnav = document.querySelector('.portal-top-nav');
|
||||
if (topnav) topnav.style.fontSize = (0.875 * z).toFixed(4) + 'rem';
|
||||
var pct = document.getElementById('wsFontPct'); if (pct) pct.textContent = s + '%';
|
||||
if (save) persist(s);
|
||||
}
|
||||
applyScale(curScale());
|
||||
applyScale(scale, false);
|
||||
var plus = document.getElementById('wsFontPlus'), minus = document.getElementById('wsFontMinus');
|
||||
if (plus) plus.addEventListener('click', function () { applyScale(curScale() + 10); });
|
||||
if (minus) minus.addEventListener('click', function () { applyScale(curScale() - 10); });
|
||||
if (plus) plus.addEventListener('click', function () { applyScale(scale + 10, true); });
|
||||
if (minus) minus.addEventListener('click', function () { applyScale(scale - 10, true); });
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
|
||||
@@ -191,9 +191,12 @@ if ($effectiveLgIdx) {
|
||||
|
||||
var STORE_KEY = 'jrj_ws_tabs';
|
||||
var WS_OWNER = '<?= (string) (session()->get('mb_idx') ?? '') ?>'; // 탭 저장 소유자(로그인 사용자) 식별
|
||||
function persist() {
|
||||
try {
|
||||
sessionStorage.setItem(STORE_KEY, JSON.stringify({
|
||||
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,
|
||||
@@ -201,8 +204,20 @@ if ($effectiveLgIdx) {
|
||||
vRatio: vRatio,
|
||||
hRatio: hRatio,
|
||||
slots: slots.map(function (id) { return (id && tabs[id]) ? tabs[id].url : null; })
|
||||
}));
|
||||
} catch (e) {}
|
||||
};
|
||||
}
|
||||
function persist() {
|
||||
var obj = wsStateObj();
|
||||
var json = JSON.stringify(obj);
|
||||
try { sessionStorage.setItem(STORE_KEY, json); } catch (e) {}
|
||||
// 계정(DB)에 저장 → 다음 로그인/다른 기기에서도 자동 복원 (연속 변경은 디바운스)
|
||||
clearTimeout(wsSaveTimer);
|
||||
wsSaveTimer = setTimeout(function () {
|
||||
var b = new URLSearchParams();
|
||||
b.set(WS_CSRF_NAME, wsCsrfHash); b.set('state', json);
|
||||
fetch(WS_SAVE_URL, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: b.toString() })
|
||||
.then(function (r) { return r.json(); }).then(function (d) { if (d && d.csrf) wsCsrfHash = d.csrf; }).catch(function () {});
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// 분할 칸 헤더·빈칸 안내 요소 4개 미리 생성
|
||||
@@ -331,7 +346,7 @@ if ($effectiveLgIdx) {
|
||||
persist();
|
||||
}
|
||||
|
||||
function focusSlot(i) { focused = i; render(); }
|
||||
function focusSlot(i) { focused = i; render(); try { updateFontPct(); } catch (e) {} }
|
||||
|
||||
// 포커스 칸에 탭 배치 (이미 다른 칸에 있으면 두 칸을 맞바꿈)
|
||||
function placeInFocused(id) {
|
||||
@@ -343,6 +358,7 @@ if ($effectiveLgIdx) {
|
||||
else { slots[focused] = id; }
|
||||
}
|
||||
render();
|
||||
try { updateFontPct(); } catch (e) {}
|
||||
}
|
||||
|
||||
function setLayout(mode) {
|
||||
@@ -423,6 +439,7 @@ if ($effectiveLgIdx) {
|
||||
d.addEventListener('keydown', handleShortcut);
|
||||
d.addEventListener('mousedown', function () { var j = slots.indexOf(id); if (j >= 0 && j !== focused) focusSlot(j); }, true);
|
||||
} catch (e) {}
|
||||
try { readTabScale(id); } catch (e) {}
|
||||
});
|
||||
panels.appendChild(frame);
|
||||
|
||||
@@ -479,29 +496,62 @@ if ($effectiveLgIdx) {
|
||||
}
|
||||
document.addEventListener('keydown', handleShortcut);
|
||||
|
||||
// 글씨 크기 조절 — 각 탭(iframe) 내용에 zoom 적용. localStorage 공유 + storage 이벤트로 새 탭/실시간 반영.
|
||||
var FONT_KEY = 'jrj_font_scale';
|
||||
function curFontScale() { var s = parseInt(localStorage.getItem(FONT_KEY) || '100', 10); return (s >= 70 && s <= 150) ? s : 100; }
|
||||
function setFontScale(s) {
|
||||
s = Math.min(150, Math.max(70, s));
|
||||
try { localStorage.setItem(FONT_KEY, String(s)); } catch (e) {}
|
||||
var z = s / 100;
|
||||
// 글씨 크기 조절 — 포커스된 탭(메뉴) 내용에만 적용하고, 계정·메뉴별로 서버에 저장한다.
|
||||
// (예전엔 모든 탭·셸에 일괄 적용돼 "모든 메뉴가 함께 바뀌는" 문제가 있었음)
|
||||
var FONT_MIN = 70, FONT_MAX = 150;
|
||||
var FONT_SAVE_URL = '<?= site_url('bag/pref/font-scale') ?>';
|
||||
var FONT_CSRF_NAME = '<?= csrf_token() ?>';
|
||||
var fontCsrfHash = '<?= csrf_hash() ?>';
|
||||
function menuKeyOf(id) {
|
||||
var u = (tabs[id] && tabs[id].url) ? tabs[id].url : id;
|
||||
try { return new URL(u, location.href).pathname.replace(/^\/+/, '').replace(/^index\.php\//, ''); } catch (e) { return String(u); }
|
||||
}
|
||||
function updateFontPct() {
|
||||
var fid = slots[focused];
|
||||
var s = (fid && tabs[fid] && tabs[fid].scale) ? tabs[fid].scale : 100;
|
||||
var pct = document.getElementById('wsFontPct'); if (pct) pct.textContent = s + '%';
|
||||
// 탭(iframe) 내용
|
||||
Object.keys(tabs).forEach(function (k) { try { tabs[k].frame.contentDocument.documentElement.style.zoom = z; } catch (e) {} });
|
||||
// 좌측 사이드바 + 탭 바 텍스트도 함께 확대.
|
||||
['.sidebar', '#wsTabBar'].forEach(function (sel) {
|
||||
var el = document.querySelector(sel); if (el) el.style.zoom = z;
|
||||
});
|
||||
// 상단 대메뉴는 font-size 만 키운다(바 높이 48px 고정 → A−/A+ 버튼이 안 밀림).
|
||||
var topnav = document.querySelector('.portal-top-nav');
|
||||
if (topnav) topnav.style.fontSize = (0.875 * z).toFixed(4) + 'rem';
|
||||
}
|
||||
// 새 탭 로드 시: embed 레이아웃이 적용한 저장 배율을 읽어 탭 상태로 보관.
|
||||
function readTabScale(id) {
|
||||
var s = 100;
|
||||
try { var z = parseFloat(tabs[id].frame.contentDocument.documentElement.style.zoom || '1'); if (z > 0) s = Math.round(z * 100); } catch (e) {}
|
||||
if (!(s >= FONT_MIN && s <= FONT_MAX)) s = 100;
|
||||
if (tabs[id]) tabs[id].scale = s;
|
||||
if (slots[focused] === id) updateFontPct();
|
||||
}
|
||||
var fontSaveTimer = null, fontSavePending = null;
|
||||
function flushFontSave() {
|
||||
if (!fontSavePending) return;
|
||||
var p = fontSavePending; fontSavePending = null;
|
||||
var b = new URLSearchParams();
|
||||
b.set(FONT_CSRF_NAME, fontCsrfHash); b.set('menu_key', p.key); b.set('scale', String(p.scale));
|
||||
fetch(FONT_SAVE_URL, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: b.toString() })
|
||||
.then(function (r) { return r.json(); }).then(function (d) { if (d && d.csrf) fontCsrfHash = d.csrf; }).catch(function () {});
|
||||
}
|
||||
function applyTabFont(id, s, save) {
|
||||
if (!tabs[id]) return;
|
||||
s = Math.min(FONT_MAX, Math.max(FONT_MIN, s));
|
||||
tabs[id].scale = s;
|
||||
try { tabs[id].frame.contentDocument.documentElement.style.zoom = (s / 100); } catch (e) {}
|
||||
if (slots[focused] === id) updateFontPct();
|
||||
if (save) {
|
||||
var key = menuKeyOf(id);
|
||||
try { localStorage.setItem('jrj_font_scale::' + key, String(s)); } catch (e) {}
|
||||
// 연속 클릭 시 CSRF 토큰 회전 경합 방지 → 디바운스로 마지막 값 1회만 저장
|
||||
fontSavePending = { key: key, scale: s };
|
||||
clearTimeout(fontSaveTimer);
|
||||
fontSaveTimer = setTimeout(flushFontSave, 400);
|
||||
}
|
||||
}
|
||||
(function () {
|
||||
setFontScale(curFontScale()); // 저장된 배율을 셸 메뉴에도 적용(초기 로드)
|
||||
var plus = document.getElementById('wsFontPlus'), minus = document.getElementById('wsFontMinus');
|
||||
if (plus) plus.addEventListener('click', function () { setFontScale(curFontScale() + 10); });
|
||||
if (minus) minus.addEventListener('click', function () { setFontScale(curFontScale() - 10); });
|
||||
function bump(delta) {
|
||||
var fid = slots[focused];
|
||||
if (!fid || !tabs[fid]) { showToast('먼저 메뉴 탭을 여세요.'); return; }
|
||||
applyTabFont(fid, (tabs[fid].scale || 100) + delta, true);
|
||||
}
|
||||
if (plus) plus.addEventListener('click', function () { bump(10); });
|
||||
if (minus) minus.addEventListener('click', function () { bump(-10); });
|
||||
})();
|
||||
|
||||
// 좌측 사이드바 소메뉴 클릭 → 포커스 칸에 열기
|
||||
@@ -525,11 +575,19 @@ if ($effectiveLgIdx) {
|
||||
});
|
||||
});
|
||||
|
||||
// 첫 화면: 세션 저장 복원(레이아웃·분할 배치 포함), 없으면 대시보드 1분할
|
||||
// 첫 화면: ① 계정(DB) 저장 자동복원 → ② 세션 저장 → ③ 없으면 대시보드 1분할
|
||||
var SERVER_STATE = <?= $savedWorkspaceJson ?? 'null' ?>;
|
||||
(function restore() {
|
||||
var saved = null;
|
||||
// ① 계정 DB에 저장된 상태 우선(다음 로그인·다른 기기에서도 복원)
|
||||
if (SERVER_STATE && SERVER_STATE.tabs && SERVER_STATE.tabs.length) {
|
||||
saved = SERVER_STATE;
|
||||
}
|
||||
// ② 없으면 이 브라우저 세션 저장 사용
|
||||
if (!saved) {
|
||||
try { saved = JSON.parse(sessionStorage.getItem(STORE_KEY) || 'null'); } catch (e) {}
|
||||
if (saved && saved.owner !== WS_OWNER) { try { sessionStorage.removeItem(STORE_KEY); } catch (e) {} saved = null; }
|
||||
}
|
||||
if (saved && saved.tabs && saved.tabs.length) {
|
||||
saved.tabs.forEach(function (t) { if (t && t.url) openTab(t.url, t.title, { noFocus: true }); });
|
||||
layout = (saved.layout === 'lr' || saved.layout === 'tb' || saved.layout === 'quad') ? saved.layout : 'single';
|
||||
|
||||
@@ -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-center">B</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($boxPrice) ?></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-center item-box-cell">0</td>
|
||||
<td class="text-center item-pack-cell">0</td>
|
||||
@@ -330,12 +330,18 @@ unset($g);
|
||||
}
|
||||
|
||||
function calcRow(row) {
|
||||
const qtyInput = row.querySelector('.item-qty-input');
|
||||
const qty = parseInt(qtyInput.value || '0', 10) || 0;
|
||||
const qtyInput = row.querySelector('.item-pack-input');
|
||||
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 boxSheets = parseInt(row.dataset.boxSheets || '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 pack = 0;
|
||||
let sheet = qty;
|
||||
@@ -359,14 +365,14 @@ unset($g);
|
||||
row.querySelector('.item-pack-cell').textContent = pack ? nf(pack) : '';
|
||||
row.querySelector('.item-sheet-cell').textContent = sheet ? nf(sheet) : '';
|
||||
|
||||
return { qty, amount, box, pack, sheet };
|
||||
return { packQty, amount, box, pack, sheet };
|
||||
}
|
||||
|
||||
function recalcAllRows() {
|
||||
let sumQty = 0, sumAmount = 0, sumBox = 0, sumPack = 0, sumSheet = 0;
|
||||
document.querySelectorAll('.order-row').forEach((row) => {
|
||||
const r = calcRow(row);
|
||||
sumQty += r.qty;
|
||||
sumQty += r.packQty;
|
||||
sumAmount += r.amount;
|
||||
sumBox += r.box;
|
||||
sumPack += r.pack;
|
||||
|
||||
@@ -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"/>
|
||||
</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 class="flex flex-wrap items-center justify-between gap-2 px-1">
|
||||
<span class="text-sm font-semibold text-gray-700">접수 품목 내역</span>
|
||||
<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="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>
|
||||
@@ -121,26 +123,25 @@
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="flex items-center justify-between px-1 mb-1 text-sm">
|
||||
<span class="font-semibold text-gray-700">포장 명세</span>
|
||||
<span class="font-semibold text-gray-700">스캔 현황</span>
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="text-gray-600">포장량: <span id="packing-total" class="font-bold text-blue-700">0</span> 개</span>
|
||||
<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 id="packing-scroll" class="border border-gray-300 rounded-lg overflow-auto max-h-[179px]">
|
||||
<table class="w-full data-table text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-10 text-center sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">No</th>
|
||||
<th class="w-40 text-left sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">봉투종류</th>
|
||||
<th class="text-left sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">봉투코드</th>
|
||||
<th class="w-16 text-right sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">수량</th>
|
||||
<th class="w-16 text-center sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">포장</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="packing-body">
|
||||
<tr><td colspan="5" class="text-center py-6 text-gray-400">주문을 선택해 주세요.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<!-- 최근 스캔 2건 -->
|
||||
<div class="border border-gray-300 rounded-lg overflow-hidden">
|
||||
<div class="px-2 py-1 bg-gray-50 border-b border-gray-200 text-xs font-semibold text-gray-600">최근 스캔</div>
|
||||
<div id="recent-scans" class="p-2 text-xs text-gray-400 min-h-[3.5rem]">스캔 내역이 없습니다.</div>
|
||||
</div>
|
||||
</div><!-- /포장 명세 -->
|
||||
<!-- 앞으로 찍을 목록(품목별 남은 수량) -->
|
||||
<div class="border border-gray-300 rounded-lg overflow-hidden">
|
||||
<div class="px-2 py-1 bg-amber-50 border-b border-amber-200 text-xs font-semibold text-amber-800">앞으로 찍을 목록 (남은 수량)</div>
|
||||
<div id="remaining-list" class="p-2 text-xs text-gray-400 max-h-[9rem] overflow-auto min-h-[3.5rem]">주문을 선택해 주세요.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /스캔 현황 -->
|
||||
</form>
|
||||
|
||||
<!-- 주문 취소 폼 — 버튼은 접수 품목 내역 타이틀 옆(form 속성으로 연결) -->
|
||||
@@ -150,6 +151,36 @@
|
||||
</section>
|
||||
</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">×</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 제거) -->
|
||||
<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">
|
||||
@@ -179,6 +210,19 @@
|
||||
(() => {
|
||||
const orders = <?= json_encode($orders ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
|
||||
const orderMap = new Map(orders.map((o) => [String(o.so_idx), o]));
|
||||
// 표시용 접수번호: 접수일(so_order_date)별로 매일 1번부터 순차 부여(그 날의 가장 오래된 건 = 1).
|
||||
// 정렬과 무관하게 고정.
|
||||
const dateKey = (o) => String(o && o.so_order_date ? o.so_order_date : '').slice(0, 10);
|
||||
const displayNoMap = new Map();
|
||||
(() => {
|
||||
const perDate = {};
|
||||
orders.slice().sort((a, b) => Number(a.so_idx || 0) - Number(b.so_idx || 0)).forEach((o) => {
|
||||
const k = dateKey(o);
|
||||
perDate[k] = (perDate[k] || 0) + 1;
|
||||
displayNoMap.set(String(o.so_idx), perDate[k]);
|
||||
});
|
||||
})();
|
||||
const displayNo = (o) => displayNoMap.get(String(o && o.so_idx)) || '';
|
||||
|
||||
// 봉투코드 스캔(판매/포장) — CSRF 토큰(쿠키 방식, 매 요청 갱신)
|
||||
// base_url() 절대경로를 현재 문서(동일 출처) 기준 경로로 변환 → 워크스페이스 iframe/다른 호스트 접근에도 안전
|
||||
@@ -196,6 +240,8 @@
|
||||
const btnSave = document.getElementById('btn-save');
|
||||
const btnCancel = document.getElementById('btn-cancel-order');
|
||||
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') ?>';
|
||||
btnReceipt?.addEventListener('click', () => {
|
||||
const id = inputSoIdx.value;
|
||||
@@ -210,9 +256,46 @@
|
||||
|
||||
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) {
|
||||
var t = function (id, v) { document.getElementById(id).textContent = (v === undefined || v === null || v === '') ? '-' : v; };
|
||||
t('detail-so-no', order ? order.so_idx : '');
|
||||
t('detail-so-no', order ? displayNo(order) : '');
|
||||
t('detail-shop-code', order ? order.ds_shop_no : '');
|
||||
t('detail-shop-name', order ? order.so_ds_name : '');
|
||||
t('detail-shop-rep', order ? order.ds_rep_name : '');
|
||||
@@ -221,6 +304,12 @@
|
||||
t('detail-shop-addr', order ? order.ds_addr : '');
|
||||
t('detail-order-date', order ? order.so_order_date : '');
|
||||
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 입력
|
||||
var dd = document.getElementById('detail-delivery-date-input');
|
||||
if (dd) {
|
||||
@@ -304,6 +393,7 @@
|
||||
btnSave.disabled = isCancelled;
|
||||
btnCancel.disabled = isCancelled;
|
||||
if (btnReceipt) btnReceipt.disabled = false;
|
||||
if (btnTogglePaid) { btnTogglePaid.disabled = isCancelled; paintPaidButton(order); }
|
||||
|
||||
renderPacking(order); // 포장 명세 탭 동기화
|
||||
|
||||
@@ -451,9 +541,6 @@
|
||||
// 현재 이 주문을 보고 있으면 상세·포장명세 즉시 갱신
|
||||
if (String(selectedId) === String(data.so_idx)) {
|
||||
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)) + '"]');
|
||||
if (cell) {
|
||||
cell.textContent = nf(Number(data.packed_qty || 0));
|
||||
@@ -567,32 +654,93 @@
|
||||
applySplit(currentPct());
|
||||
}
|
||||
|
||||
// ── 포장 명세 탭: 주문 품목을 박스/팩/낱장 단위 행으로 펼침 ──────────
|
||||
const packingBody = document.getElementById('packing-body');
|
||||
// ── 스캔 현황(최근 2건 + 앞으로 찍을 목록) + 포장 명세 팝업 ──────────
|
||||
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) => ({ '&': '&', '<': '<', '>': '>' }[c])); }
|
||||
// 포장 명세 = 실제 스캔(=판매/포장)된 봉투코드 내역. 최근 스캔이 위로 오도록 역순 표시.
|
||||
function renderPacking(order) {
|
||||
if (!packingBody) return;
|
||||
|
||||
// 스캔 코드 전체 → 표 행(HTML) + 건수/합계 (팝업용). 최근이 위로.
|
||||
function scanFullRows(order) {
|
||||
const scans = Array.isArray(order.scans) ? order.scans.slice().reverse() : [];
|
||||
let no = 0;
|
||||
let totalQty = 0;
|
||||
let no = 0, total = 0;
|
||||
const rows = scans.map((s) => {
|
||||
no++;
|
||||
const code = s.bag_code || '';
|
||||
const name = s.bag_name || '';
|
||||
const qty = parseInt(s.qty || 0, 10) || 0;
|
||||
totalQty += qty;
|
||||
const code = s.bag_code || '', name = s.bag_name || '', qty = parseInt(s.qty || 0, 10) || 0;
|
||||
total += qty;
|
||||
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 font-mono text-gray-600">' + packEsc(s.code || '') + '</td>'
|
||||
+ '<td class="text-right pr-2">' + nf(qty) + '</td>'
|
||||
+ '<td class="text-center">' + packEsc(s.unit || '') + '</td></tr>';
|
||||
});
|
||||
packingBody.innerHTML = rows.length ? rows.join('')
|
||||
: '<tr><td colspan="5" class="text-center py-6 text-gray-400">스캔된 포장 내역이 없습니다.</td></tr>';
|
||||
if (packingTotal) packingTotal.textContent = nf(totalQty);
|
||||
return { html: rows.join(''), count: no, total: total };
|
||||
}
|
||||
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;
|
||||
@@ -604,7 +752,7 @@
|
||||
const channel = (o.so_channel && o.so_channel !== 'phone') ? o.so_channel : '전화';
|
||||
const packed = Number(o.so_received) === 1;
|
||||
return `<tr class="order-list-row cursor-pointer hover:bg-blue-50 ${cancelled ? 'bg-gray-50 text-gray-400' : ''} ${String(o.so_idx) === String(selectedId) ? 'bg-blue-100' : ''}" data-order-id="${o.so_idx}">
|
||||
<td class="text-center">${o.so_idx}</td>
|
||||
<td class="text-center">${displayNo(o)}</td>
|
||||
<td class="text-center">${esc(o.so_order_date)}</td>
|
||||
<td class="text-center">${esc(dDate)}</td>
|
||||
<td class="text-center">${esc(channel)}</td>
|
||||
@@ -655,6 +803,7 @@
|
||||
listBody.querySelectorAll('.order-list-row').forEach((row) => row.classList.remove('bg-blue-100'));
|
||||
tr.classList.add('bg-blue-100');
|
||||
renderDetail(selectedId);
|
||||
openPackingModal(); // 행 클릭 시 해당 주문 봉투코드 팝업 즉시 표시
|
||||
});
|
||||
|
||||
detailBody?.addEventListener('input', (e) => {
|
||||
|
||||
@@ -1,148 +1,360 @@
|
||||
<?php
|
||||
$mode = (string) ($mode ?? 'scanner');
|
||||
$isBatch = (bool) ($isBatch ?? false);
|
||||
$tab = (string) ($tab ?? 'company');
|
||||
$companies = is_array($companies ?? null) ? $companies : [];
|
||||
$agencies = is_array($agencies ?? null) ? $agencies : [];
|
||||
$rows = is_array($rows ?? null) ? $rows : [];
|
||||
$monthOptions = is_array($monthOptions ?? null) ? $monthOptions : [];
|
||||
$receiverOptions = is_array($receiverOptions ?? null) ? $receiverOptions : [];
|
||||
$senders = is_array($senders ?? null) ? $senders : [];
|
||||
$companyIdx = (int) ($companyIdx ?? 0);
|
||||
$agencyIdx = (int) ($agencyIdx ?? 0);
|
||||
$baseUrl = $isBatch ? base_url('bag/receiving/batch') : base_url('bag/receiving/scanner');
|
||||
$otherUrl = $isBatch ? base_url('bag/receiving/scanner') : base_url('bag/receiving/batch');
|
||||
$otherLabel = $isBatch ? '발주 입고(스캐너)' : '일괄입고(음식물,폐기물)';
|
||||
$tabUrl = static function (string $t) use ($baseUrl): string {
|
||||
return $baseUrl . '?' . http_build_query(['tab' => $t]);
|
||||
};
|
||||
?>
|
||||
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel flex flex-wrap items-center justify-between gap-2">
|
||||
<span class="text-sm font-bold text-gray-700">발주 입고(스캐너 대체 수동입력)</span>
|
||||
<span class="text-sm font-bold text-gray-700"><?= esc((string) ($workspaceTitle ?? '발주 입고')) ?></span>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="<?= base_url('bag/receiving/batch') ?>" class="border border-gray-300 text-gray-700 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">일괄 입고</a>
|
||||
<a href="<?= esc($otherUrl) ?>" class="border border-gray-300 text-gray-700 px-3 py-1 rounded-sm text-sm hover:bg-gray-50"><?= esc($otherLabel) ?></a>
|
||||
<a href="<?= base_url('bag/receiving/status') ?>" class="border border-gray-300 text-gray-700 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">입고 현황</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php /* 플래시 메시지는 레이아웃(embed/portal)에서 한 번만 표시 — 중복 출력 방지 */ ?>
|
||||
<div class="grid grid-cols-1 xl:grid-cols-12 gap-3 mt-2">
|
||||
<!-- 좌: 발주 현황 -->
|
||||
<section class="xl:col-span-7 border border-gray-300 rounded-lg bg-white overflow-hidden flex flex-col">
|
||||
<div class="px-3 py-2 border-b border-gray-200 bg-gray-50 text-sm font-semibold text-gray-700">발주 현황</div>
|
||||
|
||||
<section class="p-2 bg-white border border-gray-300 rounded-lg mt-2">
|
||||
<form method="get" action="<?= base_url('bag/receiving/scanner') ?>" class="flex flex-wrap items-end gap-2">
|
||||
<div class="flex flex-col min-w-[14rem] max-w-[22rem]">
|
||||
<label class="text-xs text-gray-500 mb-1">제작업체</label>
|
||||
<select name="company_idx" class="border border-gray-300 rounded px-2 py-1.5 text-sm bg-white">
|
||||
<option value="0">제작업체 선택</option>
|
||||
<?php foreach (($companies ?? []) as $company): ?>
|
||||
<option value="<?= (int) ($company->cp_idx ?? 0) ?>" <?= (int) ($companyIdx ?? 0) === (int) ($company->cp_idx ?? 0) ? 'selected' : '' ?>>
|
||||
<?= esc((string) ($company->cp_name ?? '')) ?>
|
||||
</option>
|
||||
<!-- 조회 기준 탭 -->
|
||||
<div class="flex border-b border-gray-200 text-sm">
|
||||
<a href="<?= esc($tabUrl('company')) ?>" class="px-4 py-2 border-b-2 <?= $tab === 'company' ? 'border-blue-600 text-blue-700 font-semibold' : 'border-transparent text-gray-500 hover:text-gray-700' ?>">제작업체 · 발주기간</a>
|
||||
<a href="<?= esc($tabUrl('agency')) ?>" class="px-4 py-2 border-b-2 <?= $tab === 'agency' ? 'border-blue-600 text-blue-700 font-semibold' : 'border-transparent text-gray-500 hover:text-gray-700' ?>">인수자 · 인계자</a>
|
||||
</div>
|
||||
|
||||
<!-- 조회 필터 -->
|
||||
<form method="get" action="<?= esc($baseUrl) ?>" class="p-2 flex flex-wrap items-end gap-2 border-b border-gray-100 text-sm">
|
||||
<input type="hidden" name="tab" value="<?= esc($tab) ?>"/>
|
||||
<?php if ($tab === 'company'): ?>
|
||||
<div class="flex flex-col">
|
||||
<label class="text-xs text-gray-500 mb-0.5">제작업체</label>
|
||||
<select name="company_idx" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[10rem]">
|
||||
<option value="0">전체</option>
|
||||
<?php foreach ($companies as $c): ?>
|
||||
<option value="<?= (int) ($c->cp_idx ?? 0) ?>" <?= $companyIdx === (int) ($c->cp_idx ?? 0) ? 'selected' : '' ?>><?= esc((string) ($c->cp_name ?? '')) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<label class="text-xs text-gray-500 mb-0.5">발주기간(시작월)</label>
|
||||
<select name="start_month" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[8rem]">
|
||||
<option value="">전체</option>
|
||||
<?php foreach ($monthOptions as $opt): ?>
|
||||
<option value="<?= esc((string) $opt['value']) ?>" <?= (string) ($startMonth ?? '') === (string) $opt['value'] ? 'selected' : '' ?>><?= esc((string) $opt['label']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<label class="text-xs text-gray-500 mb-0.5">종료월</label>
|
||||
<select name="end_month" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[8rem]">
|
||||
<option value="">전체</option>
|
||||
<?php foreach ($monthOptions as $opt): ?>
|
||||
<option value="<?= esc((string) $opt['value']) ?>" <?= (string) ($endMonth ?? '') === (string) $opt['value'] ? 'selected' : '' ?>><?= esc((string) $opt['label']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="flex flex-col">
|
||||
<label class="text-xs text-gray-500 mb-0.5">인수자(대행소)</label>
|
||||
<select name="agency_idx" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[10rem]">
|
||||
<option value="0">전체</option>
|
||||
<?php foreach ($agencies as $a): ?>
|
||||
<option value="<?= (int) ($a->sa_idx ?? 0) ?>" <?= $agencyIdx === (int) ($a->sa_idx ?? 0) ? 'selected' : '' ?>><?= esc((string) ($a->sa_name ?? '')) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<label class="text-xs text-gray-500 mb-0.5">인계자(제작업체)</label>
|
||||
<select name="company_idx" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[10rem]">
|
||||
<option value="0">전체</option>
|
||||
<?php foreach ($companies as $c): ?>
|
||||
<option value="<?= (int) ($c->cp_idx ?? 0) ?>" <?= $companyIdx === (int) ($c->cp_idx ?? 0) ? 'selected' : '' ?>><?= esc((string) ($c->cp_name ?? '')) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm shrink-0">조회</button>
|
||||
</form>
|
||||
<?php if ((int) ($companyIdx ?? 0) <= 0): ?>
|
||||
<p class="text-xs text-gray-600 mt-2">제작업체를 선택하면 해당 업체 발주 중 미입고 내역을 조회합니다.</p>
|
||||
<?php elseif (empty($rows ?? [])): ?>
|
||||
<p class="text-xs text-amber-700 mt-2">미입고 잔량이 있는 발주가 없습니다. 발주 등록 후 다시 확인해 주세요.</p>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
|
||||
</form>
|
||||
|
||||
<form action="<?= base_url('bag/receiving/scanner/store') ?>" method="post" class="mt-2 space-y-2">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="company_idx" value="<?= (int) ($companyIdx ?? 0) ?>" />
|
||||
|
||||
<section class="p-2 bg-white border border-gray-300 rounded-lg">
|
||||
<div class="grid grid-cols-1 xl:grid-cols-12 gap-2 items-end">
|
||||
<div class="xl:col-span-3 flex items-center gap-2">
|
||||
<label class="text-sm text-gray-600 shrink-0 w-28">인수자 (대행소)</label>
|
||||
<select name="br_receiver_ref" class="border border-gray-300 rounded px-2 py-1 text-sm w-full" required>
|
||||
<?php foreach (($receiverOptions ?? []) as $opt): ?>
|
||||
<option value="<?= esc((string) ($opt['ref'] ?? '')) ?>" <?= (string) ($receiverRef ?? '') === (string) ($opt['ref'] ?? '') ? 'selected' : '' ?>>
|
||||
<?= esc((string) ($opt['label'] ?? '')) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="xl:col-span-3 flex items-center gap-2">
|
||||
<label class="text-sm text-gray-600 shrink-0 w-28">인계자 (제작업체)</label>
|
||||
<select name="br_sender_idx" class="border border-gray-300 rounded px-2 py-1 text-sm w-full">
|
||||
<?php foreach (($senders ?? []) as $sender): ?>
|
||||
<option value="<?= (int) ($sender->mg_idx ?? 0) ?>" <?= (int) ($senderIdx ?? 0) === (int) ($sender->mg_idx ?? 0) ? 'selected' : '' ?>>
|
||||
<?= esc((string) ($sender->mg_name ?? '')) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="xl:col-span-2 flex items-center gap-2">
|
||||
<label class="text-sm text-gray-600 w-16">입고일</label>
|
||||
<input type="date" name="br_receive_date" value="<?= esc((string) old('br_receive_date', date('Y-m-d'))) ?>" class="border border-gray-300 rounded px-2 py-1 text-sm w-full" required />
|
||||
</div>
|
||||
<div class="xl:col-span-2">
|
||||
<button type="submit" class="w-full border border-blue-600 text-blue-700 px-2 py-1 rounded-sm text-sm hover:bg-blue-50">입고 처리</button>
|
||||
</div>
|
||||
<div class="xl:col-span-2 text-xs text-gray-500">상단에서 제작업체를 조회한 뒤, 아래에서 입고량(매)을 입력해 저장합니다.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="border border-gray-300 rounded-lg p-4 overflow-auto bg-white">
|
||||
<table class="w-full data-table text-sm">
|
||||
<div class="overflow-auto max-h-[60vh]">
|
||||
<table class="w-full data-table text-sm" id="order-status-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-center">발주일자</th>
|
||||
<th>봉투종류</th>
|
||||
<th class="text-right">발주량(매)</th>
|
||||
<th class="text-right">미입고량(매)</th>
|
||||
<th class="text-right">입고량(매)</th>
|
||||
<th>제작업체</th>
|
||||
<th class="text-center">LOT NO</th>
|
||||
<th class="text-center">발주NO</th>
|
||||
<th class="text-center sort-th cursor-pointer select-none" data-sort="order_date" data-type="str" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">발주일자<span class="sort-ind"></span></th>
|
||||
<th class="text-left sort-th cursor-pointer select-none" data-sort="bag_name" data-type="str" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">봉투종류<span class="sort-ind"></span></th>
|
||||
<th class="text-right sort-th cursor-pointer select-none" data-sort="order_qty" data-type="num" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">발주량<span class="sort-ind"></span></th>
|
||||
<th class="text-right sort-th cursor-pointer select-none" data-sort="pending" data-type="num" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">미입고량<span class="sort-ind"></span></th>
|
||||
<th class="text-right sort-th cursor-pointer select-none" data-sort="received" data-type="num" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">입고량<span class="sort-ind"></span></th>
|
||||
<th class="text-left" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">제작업체</th>
|
||||
<th class="text-center" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">LOT</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach (($rows ?? []) as $row): ?>
|
||||
<?php $k = (string) ($row['row_key'] ?? ''); ?>
|
||||
<tr data-row-key="<?= esc($k) ?>" data-lot-no="<?= esc((string) ($row['lot_no'] ?? '')) ?>" data-bag-code="<?= esc((string) ($row['bag_code'] ?? '')) ?>" data-total-per-box="<?= (int) ($row['total_per_box'] ?? 1) ?>" data-pack-per-sheet="<?= (int) ($row['pack_per_sheet'] ?? 1) ?>" data-pending-original="<?= (int) ($row['pending_qty_sheet'] ?? 0) ?>">
|
||||
<tbody id="order-status-body">
|
||||
<?php foreach ($rows as $row): $k = (string) ($row['row_key'] ?? ''); ?>
|
||||
<tr class="os-row cursor-pointer hover:bg-blue-50"
|
||||
data-row-key="<?= esc($k, 'attr') ?>"
|
||||
data-bag-name="<?= esc((string) ($row['bag_name'] ?? ''), 'attr') ?>"
|
||||
data-company="<?= esc((string) ($row['company_name'] ?? ''), 'attr') ?>"
|
||||
data-lot="<?= esc((string) ($row['lot_no'] ?? ''), 'attr') ?>"
|
||||
data-order-qty="<?= (int) ($row['order_qty_sheet'] ?? 0) ?>"
|
||||
data-pending="<?= (int) ($row['pending_qty_sheet'] ?? 0) ?>"
|
||||
data-received="<?= (int) ($row['received_qty_sheet'] ?? 0) ?>"
|
||||
data-total-per-box="<?= (int) ($row['total_per_box'] ?? 1) ?>">
|
||||
<td class="text-center"><?= esc((string) ($row['order_date'] ?? '')) ?></td>
|
||||
<td class="text-left pl-2"><?= esc((string) ($row['bag_name'] ?? '')) ?></td>
|
||||
<td class="text-right"><?= number_format((int) ($row['order_qty_sheet'] ?? 0)) ?></td>
|
||||
<td class="text-right pending-cell"><?= number_format((int) ($row['pending_qty_sheet'] ?? 0)) ?></td>
|
||||
<td class="text-right">
|
||||
<input type="number" min="0" max="<?= (int) ($row['pending_qty_sheet'] ?? 0) ?>" name="receive_qty_sheet[<?= esc($k) ?>]" value="<?= esc((string) old('receive_qty_sheet.' . $k, '0')) ?>" class="w-24 border border-gray-300 rounded px-1 py-0.5 text-sm text-right receive-input" />
|
||||
</td>
|
||||
<td class="text-right pr-2"><?= number_format((int) ($row['order_qty_sheet'] ?? 0)) ?></td>
|
||||
<td class="text-right pr-2 cell-pending"><?= number_format((int) ($row['pending_qty_sheet'] ?? 0)) ?></td>
|
||||
<td class="text-right pr-2 cell-received"><?= number_format((int) ($row['received_qty_sheet'] ?? 0)) ?></td>
|
||||
<td class="text-left pl-2"><?= esc((string) ($row['company_name'] ?? '')) ?></td>
|
||||
<td class="text-center font-mono"><?= esc((string) ($row['lot_no'] ?? '')) ?></td>
|
||||
<td class="text-center"><?= esc((string) ($row['order_no'] ?? '')) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($rows ?? [])): ?>
|
||||
<tr><td colspan="8" class="text-center text-gray-400 py-4"><?php
|
||||
if ((int) ($companyIdx ?? 0) <= 0) {
|
||||
echo '제작업체를 선택하고 조회해 주세요.';
|
||||
} else {
|
||||
echo '해당 제작업체의 미입고 발주 내역이 없습니다.';
|
||||
}
|
||||
?></td></tr>
|
||||
<?php if (empty($rows)): ?>
|
||||
<tr><td colspan="7" class="text-center text-gray-400 py-6">조회 조건에 맞는 미입고 발주가 없습니다.</td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="<?= base_url('bag/purchase-inbound') ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm">취소</a>
|
||||
<!-- 우: 선택 발주 상세 + 스캔/수량 입고 + 입고 세부내역 -->
|
||||
<section class="xl:col-span-5 flex flex-col gap-3">
|
||||
<div class="border border-gray-300 rounded-lg bg-white overflow-hidden">
|
||||
<div class="px-3 py-2 border-b border-gray-200 bg-gray-50 text-sm font-semibold text-gray-700">선택 발주 · 입고 처리</div>
|
||||
<div class="p-3 space-y-3 text-sm">
|
||||
<div id="recv-empty" class="text-gray-400 py-6 text-center">좌측 발주 현황에서 발주를 선택하세요.</div>
|
||||
<div id="recv-detail" class="hidden space-y-3">
|
||||
<dl class="grid grid-cols-[5rem_1fr] gap-y-1 text-[13px] border border-gray-200 rounded p-2 bg-gray-50">
|
||||
<dt class="font-semibold text-gray-600">봉투종류</dt><dd id="sel-bag"></dd>
|
||||
<dt class="font-semibold text-gray-600">제작업체</dt><dd id="sel-company"></dd>
|
||||
<dt class="font-semibold text-gray-600">발주량</dt><dd><span id="sel-orderqty">0</span> 매</dd>
|
||||
<dt class="font-semibold text-gray-600">입고량</dt><dd><span id="sel-received" class="font-bold text-blue-700">0</span> 매</dd>
|
||||
<dt class="font-semibold text-gray-600">미입고량</dt><dd><span id="sel-pending" class="font-bold text-red-600">0</span> 매</dd>
|
||||
</dl>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<label class="flex flex-col text-xs text-gray-500">인수자(대행소)
|
||||
<select id="br_receiver_ref" class="mt-0.5 border border-gray-300 rounded px-2 py-1 text-sm">
|
||||
<?php foreach ($receiverOptions as $opt): ?>
|
||||
<option value="<?= esc((string) ($opt['ref'] ?? ''), 'attr') ?>" <?= (string) ($receiverRef ?? '') === (string) ($opt['ref'] ?? '') ? 'selected' : '' ?>><?= esc((string) ($opt['label'] ?? '')) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col text-xs text-gray-500">인계자(제작업체)
|
||||
<select id="br_sender_idx" class="mt-0.5 border border-gray-300 rounded px-2 py-1 text-sm">
|
||||
<?php foreach ($senders as $s): ?>
|
||||
<option value="<?= (int) ($s->mg_idx ?? 0) ?>" <?= (int) ($senderIdx ?? 0) === (int) ($s->mg_idx ?? 0) ? 'selected' : '' ?>><?= esc((string) ($s->mg_name ?? '')) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label class="flex flex-col text-xs text-gray-500">입고일
|
||||
<input type="date" id="br_receive_date" value="<?= esc(date('Y-m-d'), 'attr') ?>" class="mt-0.5 border border-gray-300 rounded px-2 py-1 text-sm w-40"/>
|
||||
</label>
|
||||
|
||||
<div class="border border-emerald-300 rounded-lg p-2 bg-emerald-50/40">
|
||||
<label class="block text-xs font-semibold text-emerald-800 mb-1"><i class="fa-solid fa-barcode mr-1"></i>박스 바코드 스캔 (스캔 1회 = 1박스 입고)</label>
|
||||
<input type="text" id="scan-input" autocomplete="off" placeholder="박스 바코드를 스캔 후 Enter" class="border border-gray-300 rounded px-2 py-1 text-sm w-full" disabled/>
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<span class="text-xs text-gray-500 shrink-0">또는 수량 직접입력(매)</span>
|
||||
<input type="number" id="qty-input" min="1" placeholder="매수" class="border border-gray-300 rounded px-2 py-1 text-sm w-28 text-right" disabled/>
|
||||
<button type="button" id="btn-receive" class="border border-blue-600 text-blue-700 px-3 py-1 rounded-sm text-sm hover:bg-blue-50 disabled:opacity-50" disabled>입고 처리</button>
|
||||
</div>
|
||||
<p id="scan-msg" class="text-xs text-gray-500 mt-1"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border border-gray-300 rounded-lg bg-white overflow-hidden">
|
||||
<div class="px-3 py-2 border-b border-gray-200 bg-gray-50 text-sm font-semibold text-gray-700 flex items-center justify-between">
|
||||
<span>입고 세부내역 (박스코드)</span>
|
||||
<span class="text-gray-500 text-xs">박스 <span id="detail-count" class="font-bold text-blue-700">0</span></span>
|
||||
</div>
|
||||
<div class="overflow-auto max-h-[36vh]">
|
||||
<table class="w-full data-table text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-10 text-center" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">No</th>
|
||||
<th class="text-left" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">봉투종류</th>
|
||||
<th class="text-left" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">박스코드</th>
|
||||
<th class="w-16 text-right" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">수량</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="detail-body">
|
||||
<tr><td colspan="4" class="text-center text-gray-400 py-6">스캔/입고 시 박스코드가 추가됩니다.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const parseIntSafe = (v) => {
|
||||
const n = Number(String(v ?? '').replace(/,/g, ''));
|
||||
return Number.isFinite(n) ? Math.max(0, Math.floor(n)) : 0;
|
||||
};
|
||||
const mode = <?= json_encode($mode) ?>;
|
||||
const scanApi = new URL('<?= base_url('bag/receiving/scan-box') ?>', window.location.href).pathname;
|
||||
const csrfName = '<?= csrf_token() ?>';
|
||||
let csrfHash = '<?= csrf_hash() ?>';
|
||||
const nf = (n) => new Intl.NumberFormat('ko-KR').format(Number(n) || 0);
|
||||
const esc = (s) => String(s == null ? '' : s).replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c]));
|
||||
|
||||
const refreshPending = (row) => {
|
||||
const pendingCell = row.querySelector('.pending-cell');
|
||||
const input = row.querySelector('.receive-input');
|
||||
if (!pendingCell || !input) return;
|
||||
const original = parseIntSafe(row.getAttribute('data-pending-original'));
|
||||
const current = parseIntSafe(input.value);
|
||||
const remain = Math.max(0, original - current);
|
||||
pendingCell.textContent = Number(remain || 0).toLocaleString('ko-KR');
|
||||
};
|
||||
const tbody = document.getElementById('order-status-body');
|
||||
const detailBody = document.getElementById('detail-body');
|
||||
const detailCountEl = document.getElementById('detail-count');
|
||||
const scanInput = document.getElementById('scan-input');
|
||||
const qtyInput = document.getElementById('qty-input');
|
||||
const btnReceive = document.getElementById('btn-receive');
|
||||
const scanMsg = document.getElementById('scan-msg');
|
||||
let selectedRow = null;
|
||||
let detailNo = 0;
|
||||
|
||||
document.querySelectorAll('.receive-input').forEach((input) => {
|
||||
input.addEventListener('input', (e) => {
|
||||
const row = e.target.closest('tr');
|
||||
if (!row) return;
|
||||
const original = parseIntSafe(row.getAttribute('data-pending-original'));
|
||||
const current = Math.min(parseIntSafe(input.value), original);
|
||||
input.value = String(current);
|
||||
refreshPending(row);
|
||||
// ── 정렬 ──
|
||||
let sortKey = '', sortDir = 'asc';
|
||||
const numOf = (tr, key) => {
|
||||
const map = { order_qty: 'data-order-qty', pending: 'data-pending', received: 'data-received' };
|
||||
if (map[key]) return Number(tr.getAttribute(map[key]) || 0);
|
||||
return 0;
|
||||
};
|
||||
const strOf = (tr, key) => {
|
||||
if (key === 'order_date') return tr.children[0].textContent.trim();
|
||||
if (key === 'bag_name') return tr.getAttribute('data-bag-name') || '';
|
||||
return '';
|
||||
};
|
||||
document.querySelectorAll('#order-status-table .sort-th').forEach((th) => {
|
||||
th.addEventListener('click', () => {
|
||||
const key = th.dataset.sort, type = th.dataset.type;
|
||||
if (sortKey === key) { sortDir = sortDir === 'asc' ? 'desc' : 'asc'; } else { sortKey = key; sortDir = 'asc'; }
|
||||
const rows = Array.prototype.slice.call(tbody.querySelectorAll('tr.os-row'));
|
||||
rows.sort((a, b) => {
|
||||
let va, vb;
|
||||
if (type === 'num') { va = numOf(a, key); vb = numOf(b, key); return sortDir === 'asc' ? va - vb : vb - va; }
|
||||
va = strOf(a, key); vb = strOf(b, key);
|
||||
return sortDir === 'asc' ? va.localeCompare(vb, 'ko') : vb.localeCompare(va, 'ko');
|
||||
});
|
||||
const row = input.closest('tr');
|
||||
if (row) refreshPending(row);
|
||||
rows.forEach((r) => tbody.appendChild(r));
|
||||
document.querySelectorAll('#order-status-table .sort-ind').forEach((s) => { s.textContent = ''; });
|
||||
const ind = th.querySelector('.sort-ind');
|
||||
if (ind) ind.textContent = sortDir === 'asc' ? ' ▲' : ' ▼';
|
||||
});
|
||||
});
|
||||
|
||||
// ── 행 선택 ──
|
||||
function selectRow(tr) {
|
||||
if (selectedRow) selectedRow.classList.remove('bg-blue-100');
|
||||
selectedRow = tr;
|
||||
tr.classList.add('bg-blue-100');
|
||||
document.getElementById('recv-empty').classList.add('hidden');
|
||||
document.getElementById('recv-detail').classList.remove('hidden');
|
||||
document.getElementById('sel-bag').textContent = tr.getAttribute('data-bag-name') || '';
|
||||
document.getElementById('sel-company').textContent = tr.getAttribute('data-company') || '-';
|
||||
document.getElementById('sel-orderqty').textContent = nf(tr.getAttribute('data-order-qty'));
|
||||
document.getElementById('sel-received').textContent = nf(tr.getAttribute('data-received'));
|
||||
document.getElementById('sel-pending').textContent = nf(tr.getAttribute('data-pending'));
|
||||
const pending = Number(tr.getAttribute('data-pending') || 0);
|
||||
const enabled = pending > 0;
|
||||
scanInput.disabled = !enabled;
|
||||
qtyInput.disabled = !enabled;
|
||||
btnReceive.disabled = !enabled;
|
||||
scanMsg.textContent = enabled ? '' : '이미 전량 입고된 발주입니다.';
|
||||
scanMsg.className = 'text-xs mt-1 ' + (enabled ? 'text-gray-500' : 'text-amber-700');
|
||||
if (enabled) scanInput.focus();
|
||||
}
|
||||
tbody.addEventListener('click', (e) => {
|
||||
const tr = e.target.closest('tr.os-row');
|
||||
if (tr) selectRow(tr);
|
||||
});
|
||||
|
||||
// ── 입고 처리(스캔/수량) AJAX ──
|
||||
async function doReceive(barcode, qtySheet) {
|
||||
if (!selectedRow) return;
|
||||
const body = new URLSearchParams();
|
||||
body.set(csrfName, csrfHash);
|
||||
body.set('mode', mode);
|
||||
body.set('row_key', selectedRow.getAttribute('data-row-key'));
|
||||
body.set('br_receiver_ref', document.getElementById('br_receiver_ref').value || '');
|
||||
body.set('br_sender_idx', document.getElementById('br_sender_idx').value || '0');
|
||||
body.set('br_receive_date', document.getElementById('br_receive_date').value || '');
|
||||
body.set('barcode', barcode || '');
|
||||
body.set('qty_sheet', String(qtySheet || 0));
|
||||
|
||||
let data;
|
||||
try {
|
||||
const res = await fetch(scanApi, {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
|
||||
body: body.toString(),
|
||||
});
|
||||
data = await res.json();
|
||||
} catch (err) {
|
||||
scanMsg.textContent = '통신 오류가 발생했습니다.'; scanMsg.className = 'text-xs mt-1 text-red-600';
|
||||
return;
|
||||
}
|
||||
if (data && data.csrf) csrfHash = data.csrf;
|
||||
if (!data || !data.ok) {
|
||||
scanMsg.textContent = (data && data.message) || '입고 처리 실패';
|
||||
scanMsg.className = 'text-xs mt-1 text-red-600 font-semibold';
|
||||
return;
|
||||
}
|
||||
|
||||
// 선택 행 · 상세 수치 갱신
|
||||
selectedRow.setAttribute('data-received', String(data.received_qty_sheet));
|
||||
selectedRow.setAttribute('data-pending', String(data.pending_qty_sheet));
|
||||
selectedRow.querySelector('.cell-received').textContent = nf(data.received_qty_sheet);
|
||||
selectedRow.querySelector('.cell-pending').textContent = nf(data.pending_qty_sheet);
|
||||
document.getElementById('sel-received').textContent = nf(data.received_qty_sheet);
|
||||
document.getElementById('sel-pending').textContent = nf(data.pending_qty_sheet);
|
||||
|
||||
// 입고 세부내역에 박스코드 추가(최신이 위로)
|
||||
(data.boxes || []).forEach((b) => {
|
||||
detailNo++;
|
||||
if (detailNo === 1) detailBody.innerHTML = '';
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = '<td class="text-center">' + detailNo + '</td>'
|
||||
+ '<td class="text-left pl-2">' + esc(data.bag_name) + '</td>'
|
||||
+ '<td class="text-left pl-2 font-mono text-gray-600">' + esc(b.box_code) + '</td>'
|
||||
+ '<td class="text-right pr-2">' + nf(b.sheet) + '</td>';
|
||||
detailBody.insertBefore(tr, detailBody.firstChild);
|
||||
});
|
||||
detailCountEl.textContent = nf(detailNo);
|
||||
|
||||
scanMsg.textContent = '입고: ' + data.bag_name + ' / ' + nf(data.qty_sheet) + '매' + (data.qty_box ? ' (' + data.qty_box + '박스)' : '') + ' · 잔량 ' + nf(data.pending_qty_sheet);
|
||||
scanMsg.className = 'text-xs mt-1 text-emerald-700';
|
||||
|
||||
// 전량 입고되면 입력 잠금
|
||||
if (Number(data.pending_qty_sheet) <= 0) {
|
||||
scanInput.disabled = true; qtyInput.disabled = true; btnReceive.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
scanInput.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
e.preventDefault();
|
||||
const code = (scanInput.value || '').trim();
|
||||
if (!code) return;
|
||||
doReceive(code, 0);
|
||||
scanInput.value = '';
|
||||
scanInput.focus();
|
||||
});
|
||||
btnReceive.addEventListener('click', () => {
|
||||
const q = Math.floor(Number(qtyInput.value || 0));
|
||||
if (!(q > 0)) { scanMsg.textContent = '입고 수량(매)을 입력하세요.'; scanMsg.className = 'text-xs mt-1 text-amber-700'; return; }
|
||||
doReceive('', q);
|
||||
qtyInput.value = '';
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -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)">
|
||||
<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>
|
||||
<span class="leading-none flex flex-col">
|
||||
<strong class="font-extrabold tracking-wide">GBLS</strong>
|
||||
|
||||
@@ -112,6 +112,11 @@ $roadBaseOnly = ! empty($roadBaseOnly);
|
||||
var detEl = field(detailFieldName);
|
||||
if (detEl) detEl.value = data.buildingName || '';
|
||||
}
|
||||
|
||||
// 주소가 채워진 뒤, 폼에서 후속 처리(예: 동코드 조회)를 할 수 있도록 이벤트 발행
|
||||
try {
|
||||
form.dispatchEvent(new CustomEvent('kakao-address-selected', { detail: data }));
|
||||
} catch (e) { /* 구형 브라우저 무시 */ }
|
||||
}
|
||||
}).open();
|
||||
});
|
||||
|
||||
@@ -1,38 +1,109 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Custom Tailwind CSS Pagination View for CodeIgniter 4
|
||||
* 공용 리스트 페이저 → Lazy Loading(무한 스크롤) 표준 컴포넌트.
|
||||
*
|
||||
* CI4 Pager 템플릿(Config\Pager: default_full/default_simple)으로 등록돼 있어,
|
||||
* 모든 리스트 화면의 `<?= $pager->links() ?>` 호출이 이 컴포넌트를 렌더한다.
|
||||
* 페이지 번호 대신 센티넬 + '더보기' 버튼을 두고, 화면 하단 도달 시(IntersectionObserver)
|
||||
* 다음 페이지 HTML을 받아 바로 위 리스트 표(tbody)에 행을 이어붙인다.
|
||||
*
|
||||
* @var \CodeIgniter\Pager\PagerRenderer $pager
|
||||
*/
|
||||
|
||||
$pager->setSurroundCount(2);
|
||||
$hasNext = $pager->hasNextPage();
|
||||
$nextUrl = $hasNext ? (string) $pager->getNextPage() : '';
|
||||
$curPage = (int) $pager->getCurrent();
|
||||
$pageCount = (int) $pager->getPageCount();
|
||||
?>
|
||||
<?php if ($pageCount > 1): ?>
|
||||
<div class="lazy-pager no-print mt-3 mb-2 flex flex-col items-center gap-1"
|
||||
data-next-url="<?= esc($nextUrl, 'attr') ?>"
|
||||
data-has-next="<?= $hasNext ? '1' : '0' ?>">
|
||||
<button type="button" class="lazy-more px-4 py-1.5 text-xs border border-gray-300 rounded-full hover:bg-gray-50 text-gray-600<?= $hasNext ? '' : ' hidden' ?>">더보기</button>
|
||||
<span class="lazy-status text-[11px] text-gray-400"></span>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
if (window.__lazyPagerInit) { window.__lazyPagerInit(); return; }
|
||||
|
||||
<?php if ($pager->hasPreviousPage() || $pager->hasNextPage()): ?>
|
||||
<nav aria-label="Page navigation" class="flex items-center justify-center gap-1 mt-3 mb-2 no-print">
|
||||
<?php if ($pager->hasPreviousPage()): ?>
|
||||
<a href="<?= $pager->getFirst() ?>" class="px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-100 text-gray-600" title="처음">«</a>
|
||||
<a href="<?= $pager->getPreviousPage() ?>" class="px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-100 text-gray-600" title="이전">‹</a>
|
||||
<?php else: ?>
|
||||
<span class="px-2 py-1 text-xs border border-gray-200 rounded text-gray-300">«</span>
|
||||
<span class="px-2 py-1 text-xs border border-gray-200 rounded text-gray-300">‹</span>
|
||||
<?php endif; ?>
|
||||
function lastTableBefore(el, root) {
|
||||
var tables = root.querySelectorAll('table');
|
||||
var found = null;
|
||||
for (var i = 0; i < tables.length; i++) {
|
||||
if (el.compareDocumentPosition(tables[i]) & Node.DOCUMENT_POSITION_PRECEDING) found = tables[i];
|
||||
}
|
||||
return found;
|
||||
}
|
||||
function bodyOf(table) { return table ? (table.tBodies[0] || table.querySelector('tbody')) : null; }
|
||||
|
||||
<?php foreach ($pager->links() as $link): ?>
|
||||
<?php if ($link['active']): ?>
|
||||
<span class="px-3 py-1 text-xs border border-btn-search rounded bg-btn-search text-white font-bold"><?= $link['title'] ?></span>
|
||||
<?php else: ?>
|
||||
<a href="<?= $link['uri'] ?>" class="px-3 py-1 text-xs border border-gray-300 rounded hover:bg-gray-100 text-gray-700"><?= $link['title'] ?></a>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
function initOne(sentinel) {
|
||||
if (sentinel.getAttribute('data-lazy-init')) return;
|
||||
sentinel.setAttribute('data-lazy-init', '1');
|
||||
var tbody = bodyOf(lastTableBefore(sentinel, document));
|
||||
if (!tbody) return;
|
||||
var moreBtn = sentinel.querySelector('.lazy-more');
|
||||
var statusEl = sentinel.querySelector('.lazy-status');
|
||||
var loading = false;
|
||||
var io = null;
|
||||
var setStatus = function (t) { if (statusEl) statusEl.textContent = t || ''; };
|
||||
|
||||
<?php if ($pager->hasNextPage()): ?>
|
||||
<a href="<?= $pager->getNextPage() ?>" class="px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-100 text-gray-600" title="다음">›</a>
|
||||
<a href="<?= $pager->getLast() ?>" class="px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-100 text-gray-600" title="마지막">»</a>
|
||||
<?php else: ?>
|
||||
<span class="px-2 py-1 text-xs border border-gray-200 rounded text-gray-300">›</span>
|
||||
<span class="px-2 py-1 text-xs border border-gray-200 rounded text-gray-300">»</span>
|
||||
<?php endif; ?>
|
||||
</nav>
|
||||
async function loadNext() {
|
||||
if (loading) return;
|
||||
if (sentinel.getAttribute('data-has-next') !== '1') return;
|
||||
var url = sentinel.getAttribute('data-next-url');
|
||||
if (!url) return;
|
||||
loading = true; setStatus('불러오는 중…');
|
||||
try {
|
||||
var res = await fetch(url, { credentials: 'same-origin', headers: { 'X-Requested-With': 'fetch' } });
|
||||
var html = await res.text();
|
||||
var doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
var fSentinel = doc.querySelector('.lazy-pager');
|
||||
var fBody = bodyOf(fSentinel ? lastTableBefore(fSentinel, doc) : null);
|
||||
var added = 0;
|
||||
if (fBody) {
|
||||
fBody.querySelectorAll('tr').forEach(function (tr) {
|
||||
if (tr.children.length === 1 && tr.children[0].hasAttribute('colspan')) return; // 빈 목록 placeholder 제외
|
||||
tbody.appendChild(document.importNode(tr, true));
|
||||
added++;
|
||||
});
|
||||
}
|
||||
if (fSentinel && fSentinel.getAttribute('data-has-next') === '1') {
|
||||
sentinel.setAttribute('data-next-url', fSentinel.getAttribute('data-next-url'));
|
||||
setStatus('');
|
||||
} else {
|
||||
sentinel.setAttribute('data-has-next', '0');
|
||||
if (moreBtn) moreBtn.classList.add('hidden');
|
||||
setStatus('모두 불러왔습니다');
|
||||
if (io) io.disconnect();
|
||||
}
|
||||
} catch (e) {
|
||||
setStatus('불러오기 실패 — [더보기]로 다시 시도해 주세요.');
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
|
||||
if (moreBtn) moreBtn.addEventListener('click', loadNext);
|
||||
// 사용자가 실제로 스크롤한 뒤에만 자동 로드(내부 스크롤 리스트에서 로드 즉시 전체 로딩 방지).
|
||||
// 스크롤 전에는 [더보기] 버튼으로 불러온다.
|
||||
if (!window.__lazyUserScrolled) {
|
||||
var mark = function () { window.__lazyUserScrolled = true; };
|
||||
window.addEventListener('scroll', mark, { passive: true, capture: true, once: true });
|
||||
window.addEventListener('wheel', mark, { passive: true, once: true });
|
||||
window.addEventListener('touchmove', mark, { passive: true, once: true });
|
||||
}
|
||||
if ('IntersectionObserver' in window) {
|
||||
io = new IntersectionObserver(function (entries) {
|
||||
entries.forEach(function (en) { if (en.isIntersecting && window.__lazyUserScrolled) loadNext(); });
|
||||
}, { root: null, rootMargin: '0px 0px 320px 0px', threshold: 0 });
|
||||
io.observe(sentinel);
|
||||
}
|
||||
}
|
||||
|
||||
window.__lazyPagerInit = function () {
|
||||
document.querySelectorAll('.lazy-pager:not([data-lazy-init])').forEach(initOne);
|
||||
};
|
||||
window.__lazyPagerInit();
|
||||
})();
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -9,7 +9,7 @@ $brandHref = $brandHref ?? base_url('dashboard/gov-portal');
|
||||
?>
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>GBLS</title>
|
||||
<link rel="icon" type="image/svg+xml" href="<?= base_url('favicon.svg') ?>"/>
|
||||
<link rel="alternate icon" href="<?= base_url('favicon.ico') ?>"/>
|
||||
<link rel="apple-touch-icon" href="<?= base_url('favicon.svg') ?>"/>
|
||||
|
||||
@@ -6,6 +6,42 @@
|
||||
var listEl = document.getElementById('portalSidebarList');
|
||||
var titleEl = document.getElementById('portalSidebarTitle');
|
||||
|
||||
// 모든 상위메뉴의 소메뉴 아이콘(Font Awesome solid) — 이름 키워드 기준(구체적인 것 먼저).
|
||||
// 서버 렌더(_dashboard_gov_portal_sidebar.php)의 매핑과 동일하게 유지할 것.
|
||||
function sidebarIcon(name) {
|
||||
var n = String(name || '');
|
||||
var rules = [
|
||||
['바코드', 'fa-barcode'], ['스캐너', 'fa-barcode'], ['번호', 'fa-hashtag'],
|
||||
['판매대장', 'fa-book'], ['대장', 'fa-book'],
|
||||
['집계표', 'fa-table-list'], ['일계표', 'fa-table-list'],
|
||||
['수불', 'fa-right-left'], ['입출고', 'fa-right-left'],
|
||||
['무료', 'fa-gift'], ['반품', 'fa-rotate-left'], ['파기', 'fa-trash-can'],
|
||||
['신규', 'fa-chart-column'], ['취소', 'fa-ban'],
|
||||
['발주파일', 'fa-file-arrow-down'], ['발주', 'fa-cart-shopping'],
|
||||
['입고', 'fa-dolly'], ['재고', 'fa-warehouse'], ['불출', 'fa-truck-ramp-box'],
|
||||
['전화', 'fa-phone'], ['접수', 'fa-clipboard-list'],
|
||||
['판매현황', 'fa-chart-column'], ['판매소', 'fa-store'], ['대행소', 'fa-handshake'],
|
||||
['담당자', 'fa-user-tie'], ['대상자', 'fa-user-check'], ['업체', 'fa-building'],
|
||||
['단가', 'fa-won-sign'], ['코드', 'fa-list-ol'], ['포장', 'fa-box'],
|
||||
['계획', 'fa-clipboard-check'], ['실사', 'fa-clipboard-check'],
|
||||
['분석', 'fa-chart-line'], ['추이', 'fa-chart-line'], ['통계', 'fa-chart-line'],
|
||||
['홈텍스', 'fa-file-invoice-dollar'], ['판매', 'fa-cash-register'],
|
||||
['현황', 'fa-chart-column'], ['조회', 'fa-magnifying-glass'],
|
||||
['대시보드', 'fa-gauge-high'], ['회원', 'fa-users'], ['승인', 'fa-user-check'],
|
||||
['로그', 'fa-clock-rotate-left'], ['이력', 'fa-clock-rotate-left'],
|
||||
['메뉴', 'fa-bars'], ['지자체', 'fa-city'], ['문의', 'fa-comment-dots'],
|
||||
['비밀번호', 'fa-key'], ['설정', 'fa-gear'], ['관리', 'fa-folder-open']
|
||||
];
|
||||
for (var i = 0; i < rules.length; i++) {
|
||||
if (n.indexOf(rules[i][0]) !== -1) return rules[i][1];
|
||||
}
|
||||
if (n.toLowerCase().indexOf('password') !== -1) return 'fa-key';
|
||||
return 'fa-angle-right';
|
||||
}
|
||||
function iconHtml(parentName, childName) {
|
||||
return '<i class="fa-solid ' + sidebarIcon(childName) + '" style="width:1.15em;margin-right:.45em;opacity:.85;"></i>';
|
||||
}
|
||||
|
||||
if (listEl && navData.length) {
|
||||
function renderSidebar(idx, overrideHref) {
|
||||
var parent = navData[idx];
|
||||
@@ -29,10 +65,11 @@
|
||||
var li = document.createElement('li');
|
||||
var chHref = (child.href || '').toLowerCase().replace(/^\//, '');
|
||||
var on = activeHref ? (chHref === activeHref) : (hasOverride ? false : ci === 0);
|
||||
var ic = iconHtml(parent.name, child.name);
|
||||
if (child.href) {
|
||||
li.innerHTML = '<a href="' + child.url + '" class="' + (on ? 'active' : '') + '">' + child.name + '</a>';
|
||||
li.innerHTML = '<a href="' + child.url + '" class="' + (on ? 'active' : '') + '">' + ic + child.name + '</a>';
|
||||
} else {
|
||||
li.innerHTML = '<span class="menu-sub" style="opacity:.65;">' + child.name + '</span>';
|
||||
li.innerHTML = '<span class="menu-sub" style="opacity:.65;">' + ic + child.name + '</span>';
|
||||
}
|
||||
listEl.appendChild(li);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,72 @@ declare(strict_types=1);
|
||||
$activeParent = $govNavItems[$govActiveParentIdx] ?? $govNavItems[0] ?? null;
|
||||
$sidebarTitle = $activeParent['name'] ?? 'MY MENU';
|
||||
$activeChildHref = strtolower(ltrim((string) ($govActiveChildHref ?? ''), '/'));
|
||||
|
||||
// 모든 상위메뉴의 소메뉴에 어울리는 아이콘(Font Awesome solid) 매핑 — 이름 키워드 기준(구체적인 것 먼저).
|
||||
$showSidebarIcons = true;
|
||||
$sidebarIconFor = static function (string $name): string {
|
||||
$rules = [
|
||||
'바코드' => 'fa-barcode',
|
||||
'스캐너' => 'fa-barcode',
|
||||
'번호' => 'fa-hashtag',
|
||||
'판매대장' => 'fa-book',
|
||||
'대장' => 'fa-book',
|
||||
'집계표' => 'fa-table-list',
|
||||
'일계표' => 'fa-table-list',
|
||||
'수불' => 'fa-right-left',
|
||||
'입출고' => 'fa-right-left',
|
||||
'무료' => 'fa-gift',
|
||||
'반품' => 'fa-rotate-left',
|
||||
'파기' => 'fa-trash-can',
|
||||
'신규' => 'fa-chart-column',
|
||||
'취소' => 'fa-ban',
|
||||
'발주파일' => 'fa-file-arrow-down',
|
||||
'발주' => 'fa-cart-shopping',
|
||||
'입고' => 'fa-dolly',
|
||||
'재고' => 'fa-warehouse',
|
||||
'불출' => 'fa-truck-ramp-box',
|
||||
'전화' => 'fa-phone',
|
||||
'접수' => 'fa-clipboard-list',
|
||||
'판매현황' => 'fa-chart-column',
|
||||
'판매소' => 'fa-store',
|
||||
'대행소' => 'fa-handshake',
|
||||
'담당자' => 'fa-user-tie',
|
||||
'대상자' => 'fa-user-check',
|
||||
'업체' => 'fa-building',
|
||||
'단가' => 'fa-won-sign',
|
||||
'코드' => 'fa-list-ol',
|
||||
'포장' => 'fa-box',
|
||||
'계획' => 'fa-clipboard-check',
|
||||
'실사' => 'fa-clipboard-check',
|
||||
'분석' => 'fa-chart-line',
|
||||
'추이' => 'fa-chart-line',
|
||||
'통계' => 'fa-chart-line',
|
||||
'홈텍스' => 'fa-file-invoice-dollar',
|
||||
'판매' => 'fa-cash-register',
|
||||
'현황' => 'fa-chart-column',
|
||||
'조회' => 'fa-magnifying-glass',
|
||||
'대시보드' => 'fa-gauge-high',
|
||||
'회원' => 'fa-users',
|
||||
'승인' => 'fa-user-check',
|
||||
'로그' => 'fa-clock-rotate-left',
|
||||
'이력' => 'fa-clock-rotate-left',
|
||||
'메뉴' => 'fa-bars',
|
||||
'지자체' => 'fa-city',
|
||||
'문의' => 'fa-comment-dots',
|
||||
'비밀번호' => 'fa-key',
|
||||
'설정' => 'fa-gear',
|
||||
'관리' => 'fa-folder-open',
|
||||
];
|
||||
foreach ($rules as $kw => $icon) {
|
||||
if (mb_stripos($name, $kw) !== false) {
|
||||
return $icon;
|
||||
}
|
||||
}
|
||||
if (stripos($name, 'password') !== false) {
|
||||
return 'fa-key';
|
||||
}
|
||||
return 'fa-angle-right';
|
||||
};
|
||||
?>
|
||||
<aside class="sidebar">
|
||||
<div class="my-menu-hd" id="portalSidebarTitle"><?= esc($sidebarTitle) ?></div>
|
||||
@@ -22,11 +88,11 @@ $activeChildHref = strtolower(ltrim((string) ($govActiveChildHref ?? ''), '/'));
|
||||
<li>
|
||||
<?php if ($child['href'] !== ''): ?>
|
||||
<a href="<?= esc($child['url']) ?>" class="<?= $isChildActive ? 'active' : '' ?>">
|
||||
<?= esc($child['name']) ?>
|
||||
<?php if ($showSidebarIcons): ?><i class="fa-solid <?= esc($sidebarIconFor((string) $child['name']), 'attr') ?>" style="width:1.15em;margin-right:.45em;opacity:.85;"></i><?php endif; ?><?= esc($child['name']) ?>
|
||||
</a>
|
||||
<?php else: ?>
|
||||
<span class="menu-sub" style="opacity:.65;cursor:default;">
|
||||
<?= esc($child['name']) ?>
|
||||
<?php if ($showSidebarIcons): ?><i class="fa-solid <?= esc($sidebarIconFor((string) $child['name']), 'attr') ?>" style="width:1.15em;margin-right:.45em;opacity:.85;"></i><?php endif; ?><?= esc($child['name']) ?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</li>
|
||||
|
||||
@@ -28,7 +28,7 @@ tailwind.config = {
|
||||
<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)">
|
||||
<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>
|
||||
<span class="leading-none flex flex-col">
|
||||
<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="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">
|
||||
<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>
|
||||
</div>
|
||||
<h1 class="text-xl font-bold">종량제 쓰레기봉투 물류시스템</h1>
|
||||
|
||||
20
public/favicon.svg
Normal file
20
public/favicon.svg
Normal file
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="GBLS">
|
||||
<!-- 브랜드 배경(네이비) -->
|
||||
<rect x="0" y="0" width="64" height="64" rx="14" fill="#243a5e"/>
|
||||
<!-- 쓰레기통(흰색) : 종량제 쓰레기봉투 물류 -->
|
||||
<g fill="none" stroke="#ffffff" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- 뚜껑 -->
|
||||
<path d="M16 22 H48"/>
|
||||
<!-- 손잡이 -->
|
||||
<path d="M27 22 V18.5 a2 2 0 0 1 2-2 h6 a2 2 0 0 1 2 2 V22"/>
|
||||
<!-- 통 몸체 -->
|
||||
<path d="M19 22 L21.5 47 a3 3 0 0 0 3 2.8 h15 a3 3 0 0 0 3-2.8 L45 22 Z"/>
|
||||
</g>
|
||||
<!-- 세로 줄무늬 -->
|
||||
<g stroke="#ffffff" stroke-width="2.6" stroke-linecap="round" opacity="0.9">
|
||||
<path d="M27 28 V44"/>
|
||||
<path d="M37 28 V44"/>
|
||||
</g>
|
||||
<!-- 에코 잎사귀(그린 포인트) -->
|
||||
<path d="M32 27 C33 22 37 20 41 20 C41 25 38 28 32 27 Z" fill="#34d399"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 931 B |
16
writable/database/bag_order_add_manager.sql
Normal file
16
writable/database/bag_order_add_manager.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- 발주에 '발주담당'(담당자 manager.mg_idx) 저장 컬럼 추가
|
||||
-- bo_orderer_idx(발주 작성자 mb_idx) 다음에 bo_manager_idx(발주담당 mg_idx)를 둔다.
|
||||
-- 실행: mysql -u jongryangje -p jongryangje_dev < writable/database/bag_order_add_manager.sql
|
||||
-- 멱등 실행(이미 있으면 건너뜀).
|
||||
|
||||
SET @db = DATABASE();
|
||||
|
||||
SET @sql = IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'bag_order' AND COLUMN_NAME = 'bo_manager_idx') > 0,
|
||||
'SELECT ''bo_manager_idx already exists'' AS msg',
|
||||
'ALTER TABLE `bag_order` ADD COLUMN `bo_manager_idx` INT UNSIGNED NULL COMMENT ''발주담당 manager.mg_idx'' AFTER `bo_orderer_idx`'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
16
writable/database/designated_shop_add_dong_code.sql
Normal file
16
writable/database/designated_shop_add_dong_code.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- 지정판매소에 동코드(code_detail 종류 D) 저장 컬럼 추가
|
||||
-- ds_gugun_code(구코드) 다음에 ds_dong_code(동코드)를 둔다.
|
||||
-- 실행: mysql -u jongryangje -p jongryangje_dev < writable/database/designated_shop_add_dong_code.sql
|
||||
-- 멱등 실행(이미 있으면 건너뜀).
|
||||
|
||||
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_dong_code') > 0,
|
||||
'SELECT ''ds_dong_code already exists'' AS msg',
|
||||
'ALTER TABLE `designated_shop` ADD COLUMN `ds_dong_code` VARCHAR(20) NOT NULL DEFAULT '''' COMMENT ''동코드 code_detail(D)'' AFTER `ds_gugun_code`'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
23
writable/database/designated_shop_add_pii_bidx.sql
Normal file
23
writable/database/designated_shop_add_pii_bidx.sql
Normal 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;
|
||||
15
writable/database/local_government_add_allow_ips.sql
Normal file
15
writable/database/local_government_add_allow_ips.sql
Normal 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;
|
||||
15
writable/database/member_menu_font_scale.sql
Normal file
15
writable/database/member_menu_font_scale.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- 계정별 · 메뉴별 글자크기(zoom %) 저장
|
||||
-- 실행: mysql --default-character-set=utf8mb4 -u USER -p DBNAME < writable/database/member_menu_font_scale.sql
|
||||
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `member_menu_font_scale` (
|
||||
`mmf_idx` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`mmf_mb_idx` INT UNSIGNED NOT NULL,
|
||||
`mmf_menu_key` VARCHAR(191) NOT NULL,
|
||||
`mmf_scale` SMALLINT UNSIGNED NOT NULL DEFAULT 100,
|
||||
`mmf_updated` DATETIME NOT NULL,
|
||||
PRIMARY KEY (`mmf_idx`),
|
||||
UNIQUE KEY `uk_mb_menu` (`mmf_mb_idx`, `mmf_menu_key`),
|
||||
KEY `idx_mb` (`mmf_mb_idx`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
21
writable/database/menu_add_custom_report.sql
Normal file
21
writable/database/menu_add_custom_report.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
-- 판매 현황 카테고리에 '맞춤 집계표' 소메뉴 추가 (사이트 메뉴)
|
||||
-- 실행: mysql -u jongryangje -p jongryangje_dev < writable/database/menu_add_custom_report.sql
|
||||
-- 멱등: 이미 있으면 건너뜀.
|
||||
|
||||
SET @mt_site := (SELECT mt_idx FROM menu_type WHERE mt_code = 'site' LIMIT 1);
|
||||
SET @parent_sales := (
|
||||
SELECT mm_idx FROM menu
|
||||
WHERE mt_idx = @mt_site AND lg_idx = 1 AND mm_pidx = 0 AND mm_name = '판매 현황'
|
||||
LIMIT 1
|
||||
);
|
||||
SET @next_num := (
|
||||
SELECT IFNULL(MAX(mm_num), -1) + 1 FROM menu WHERE mt_idx = @mt_site AND lg_idx = 1 AND mm_pidx = @parent_sales
|
||||
);
|
||||
|
||||
INSERT INTO menu (mt_idx, lg_idx, mm_name, mm_link, mm_pidx, mm_dep, mm_num, mm_cnode, mm_level, mm_is_view)
|
||||
SELECT @mt_site, 1, '맞춤 집계표', 'bag/reports/custom', @parent_sales, 1, @next_num, 0, '', 'Y'
|
||||
WHERE @parent_sales IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM menu m
|
||||
WHERE m.mt_idx = @mt_site AND m.lg_idx = 1 AND m.mm_pidx = @parent_sales AND m.mm_name = '맞춤 집계표'
|
||||
);
|
||||
5
writable/database/menu_rename_phone_to_order.sql
Normal file
5
writable/database/menu_rename_phone_to_order.sql
Normal 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` = '전화 접수 관리';
|
||||
@@ -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,
|
||||
CASE t.mm_name
|
||||
WHEN '전화 접수' THEN 'bag/shop-orders'
|
||||
WHEN '전화 접수 관리' THEN 'bag/shop-orders'
|
||||
WHEN '주문접수관리' THEN 'bag/shop-orders'
|
||||
WHEN '지정 판매소 판매' THEN 'bag/sale/create'
|
||||
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'
|
||||
FROM (
|
||||
SELECT 0 AS mm_num, '전화 접수' AS mm_name UNION ALL
|
||||
SELECT 1, '전화 접수 관리' UNION ALL
|
||||
SELECT 1, '주문접수관리' UNION ALL
|
||||
SELECT 2, '지정 판매소 판매' UNION ALL
|
||||
SELECT 3, '지정 판매소 반품' UNION ALL
|
||||
SELECT 4, '지정 판매소 판매 취소' UNION ALL
|
||||
|
||||
11
writable/database/rename_batch_receiving_menu.sql
Normal file
11
writable/database/rename_batch_receiving_menu.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- 메뉴명 변경: '일괄입고' → '일괄입고(음식물,폐기물)'
|
||||
-- 링크(mm_link='bag/receiving/batch') 기준, 기본 이름을 쓰는 항목만 변경.
|
||||
-- 실행: mysql --default-character-set=utf8mb4 -u USER -p DBNAME < writable/database/rename_batch_receiving_menu.sql
|
||||
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
SET CHARACTER_SET_CLIENT = utf8mb4;
|
||||
|
||||
UPDATE `menu`
|
||||
SET `mm_name` = '일괄입고(음식물,폐기물)'
|
||||
WHERE `mm_link` = 'bag/receiving/batch'
|
||||
AND `mm_name` = '일괄입고';
|
||||
18
writable/database/report_preset_table.sql
Normal file
18
writable/database/report_preset_table.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- 맞춤 집계표: 계정별 리포트 프리셋(제목·품목구분·컬럼구성) 저장
|
||||
-- 실행: mysql -u jongryangje -p jongryangje_dev < writable/database/report_preset_table.sql
|
||||
-- 멱등(IF NOT EXISTS).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `report_preset` (
|
||||
`rp_idx` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`rp_lg_idx` INT UNSIGNED NOT NULL COMMENT '지자체',
|
||||
`rp_mb_idx` INT UNSIGNED NOT NULL COMMENT '소유 계정(mb_idx) — 계정별 저장',
|
||||
`rp_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '프리셋 이름',
|
||||
`rp_title` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '리포트(표) 제목',
|
||||
`rp_category` VARCHAR(20) NOT NULL DEFAULT 'all' COMMENT 'all|envelope|food|waste',
|
||||
`rp_mode` VARCHAR(10) NOT NULL DEFAULT 'detail' COMMENT 'detail|summary',
|
||||
`rp_columns` TEXT NULL COMMENT '선택 컬럼 키 JSON 배열',
|
||||
`rp_regdate` DATETIME NULL,
|
||||
`rp_updated` DATETIME NULL,
|
||||
PRIMARY KEY (`rp_idx`),
|
||||
KEY `idx_rp_owner` (`rp_lg_idx`, `rp_mb_idx`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
Reference in New Issue
Block a user