Compare commits
88 Commits
71534fb198
...
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 | ||
|
|
249053412c | ||
|
|
de15cc9e47 | ||
|
|
a5d995588b | ||
|
|
ace0ce6296 | ||
|
|
b3518f6777 | ||
|
|
9b3f5e4701 | ||
|
|
142ed1110d | ||
|
|
9b28d78a90 | ||
|
|
f0d59b1694 | ||
|
|
4835b2daaf | ||
|
|
378dc4de38 | ||
|
|
9d8e237eb0 | ||
|
|
e198838998 | ||
|
|
130fb5ffdb | ||
|
|
faaadf8543 | ||
|
|
386b547e0e | ||
|
|
9bf1b34faf | ||
|
|
5a8e61227e | ||
|
|
f0182f75c2 | ||
|
|
5ef50344af | ||
|
|
f76892a6ce | ||
|
|
aca40797aa | ||
|
|
762a32e8f2 | ||
|
|
259a3159f0 | ||
|
|
9933a18e03 | ||
|
|
1e21f07fd6 | ||
|
|
2415fc56bd | ||
|
|
05d9d8aff3 | ||
|
|
329fbf1592 | ||
|
|
0a39668478 | ||
|
|
bf9fbd57a4 | ||
|
|
8330790741 | ||
|
|
7cbe8877e3 | ||
|
|
34a9ecec24 | ||
|
|
b32656af91 | ||
|
|
36b9ada903 | ||
|
|
634b29148c | ||
|
|
264f190121 | ||
|
|
7294c56c50 | ||
|
|
41b6ccc339 | ||
|
|
003cf28183 | ||
|
|
8d49247803 | ||
|
|
a277e083da | ||
|
|
933d174fa4 | ||
|
|
4fef5556ca | ||
|
|
9f16942366 | ||
|
|
bdeef2c69d | ||
|
|
d366661b4b | ||
|
|
5b903c9aa6 | ||
|
|
acce968f9c | ||
|
|
8e767ff234 | ||
|
|
76260572b1 | ||
|
|
3b3f83461a | ||
|
|
8ba9c650e8 | ||
|
|
a7a2754fb7 | ||
|
|
278e75284e | ||
|
|
ec1f8f6b03 | ||
|
|
14b628b7ac | ||
|
|
59b33ccb42 | ||
|
|
4e681d08cc | ||
|
|
c3e8ebfe6d | ||
|
|
ec34ae348c | ||
|
|
9d7d1e8a94 | ||
|
|
24ac3f169e |
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -133,7 +133,7 @@ class App extends BaseConfig
|
|||||||
* @see https://www.php.net/manual/en/timezones.php for list of timezones
|
* @see https://www.php.net/manual/en/timezones.php for list of timezones
|
||||||
* supported by PHP.
|
* supported by PHP.
|
||||||
*/
|
*/
|
||||||
public string $appTimezone = 'UTC';
|
public string $appTimezone = 'Asia/Seoul';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* --------------------------------------------------------------------------
|
* --------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class Filters extends BaseFilters
|
|||||||
'adminAuth' => \App\Filters\AdminAuthFilter::class,
|
'adminAuth' => \App\Filters\AdminAuthFilter::class,
|
||||||
'loginAuth' => \App\Filters\LoginAuthFilter::class,
|
'loginAuth' => \App\Filters\LoginAuthFilter::class,
|
||||||
'embedRedirect' => \App\Filters\EmbedRedirectFilter::class,
|
'embedRedirect' => \App\Filters\EmbedRedirectFilter::class,
|
||||||
|
'sessionGuard' => \App\Filters\SessionGuardFilter::class,
|
||||||
'csrf' => CSRF::class,
|
'csrf' => CSRF::class,
|
||||||
'toolbar' => DebugToolbar::class,
|
'toolbar' => DebugToolbar::class,
|
||||||
'honeypot' => Honeypot::class,
|
'honeypot' => Honeypot::class,
|
||||||
@@ -78,6 +79,7 @@ class Filters extends BaseFilters
|
|||||||
// 'honeypot',
|
// 'honeypot',
|
||||||
// 'csrf',
|
// 'csrf',
|
||||||
// 'invalidchars',
|
// 'invalidchars',
|
||||||
|
'sessionGuard', // 중복 로그인 방지(나중 로그인 우선)
|
||||||
],
|
],
|
||||||
'after' => [
|
'after' => [
|
||||||
// 'honeypot',
|
// 'honeypot',
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ class Manual extends BaseConfig
|
|||||||
*/
|
*/
|
||||||
public array $screenHelp = [
|
public array $screenHelp = [
|
||||||
'bag/order/phone' => 'sales#전화 주문 접수',
|
'bag/order/phone' => 'sales#전화 주문 접수',
|
||||||
|
'bag/order/lot-seed' => 'order#발주파일 생성',
|
||||||
'bag/order' => 'order#발주 등록',
|
'bag/order' => 'order#발주 등록',
|
||||||
|
'bag/password-change' => 'account#비밀번호 변경',
|
||||||
'bag/bag-orders' => 'order#발주 현황',
|
'bag/bag-orders' => 'order#발주 현황',
|
||||||
'bag/receiving' => 'order#입고 처리',
|
'bag/receiving' => 'order#입고 처리',
|
||||||
'bag/bag-receivings' => 'order#입고 현황',
|
'bag/bag-receivings' => 'order#입고 현황',
|
||||||
@@ -58,9 +60,21 @@ class Manual extends BaseConfig
|
|||||||
'bag/bag-issues' => 'sales#무료용 불출 처리',
|
'bag/bag-issues' => 'sales#무료용 불출 처리',
|
||||||
'bag/bag-sales' => 'sales#판매/반품 현황',
|
'bag/bag-sales' => 'sales#판매/반품 현황',
|
||||||
'bag/shop-orders' => 'sales#전화 주문 접수',
|
'bag/shop-orders' => 'sales#전화 주문 접수',
|
||||||
'bag/flow' => 'reports',
|
'bag/flow' => 'reports#기간별 봉투 수불 현황',
|
||||||
|
'bag/reports/misc-flow' => 'reports#기타 입출고',
|
||||||
|
'bag/reports/returns' => 'reports#반품/파기 현황',
|
||||||
|
'bag/reports/lot-flow' => 'reports#LOT 수불 조회',
|
||||||
|
'bag/reports/supply-demand' => 'reports#쓰레기봉투 수급 계획',
|
||||||
|
'bag/reports/shop-sales' => 'reports#지정 판매소별 판매현황',
|
||||||
|
'bag/reports/daily-summary' => 'reports#일계표',
|
||||||
|
'bag/reports/period-sales' => 'reports#그 밖의 현황',
|
||||||
|
'bag/reports/yearly-sales' => 'reports#그 밖의 현황',
|
||||||
|
'bag/reports/sales-ledger' => 'reports#그 밖의 현황',
|
||||||
|
'bag/reports/hometax-export' => 'reports#그 밖의 현황',
|
||||||
'bag/reports' => 'reports#일계표',
|
'bag/reports' => 'reports#일계표',
|
||||||
'bag/analytics' => 'reports',
|
'bag/analytics' => 'reports#그 밖의 현황',
|
||||||
|
'bag/designated-shops/status' => 'basic#지정 판매소 신규/취소 현황',
|
||||||
|
'bag/designated-shops/district-new-cancel' => 'basic#지정 판매소 신규/취소 현황',
|
||||||
'bag/designated-shops' => 'basic#지정판매소 관리',
|
'bag/designated-shops' => 'basic#지정판매소 관리',
|
||||||
'bag/bag-prices' => 'basic#단가 관리',
|
'bag/bag-prices' => 'basic#단가 관리',
|
||||||
'bag/prices' => 'basic#단가 관리',
|
'bag/prices' => 'basic#단가 관리',
|
||||||
|
|||||||
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/issue/cancel', 'Bag::issue');
|
||||||
$routes->get('bag/inventory', 'Bag::inventory');
|
$routes->get('bag/inventory', 'Bag::inventory');
|
||||||
$routes->get('bag/inventory/export', 'Bag::inventoryExport');
|
$routes->get('bag/inventory/export', 'Bag::inventoryExport');
|
||||||
|
$routes->get('bag/inventory/stock-barcode-list', 'Bag::stockBarcodeList'); // 실사 재고 바코드 리스트(창고에 있어야 하는 번호)
|
||||||
$routes->get('bag/inventory/inspection-select', 'Bag::inspectionSelect');
|
$routes->get('bag/inventory/inspection-select', 'Bag::inspectionSelect');
|
||||||
$routes->get('bag/inventory/inspection-work', 'Bag::inspectionWork');
|
$routes->get('bag/inventory/inspection-work', 'Bag::inspectionWork');
|
||||||
$routes->post('bag/inventory/inspection-run', 'Bag::inspectionRun');
|
$routes->post('bag/inventory/inspection-run', 'Bag::inspectionRun');
|
||||||
@@ -62,6 +63,9 @@ $routes->group('bag', ['filter' => 'loginAuth'], static function ($routes): void
|
|||||||
$routes->get('manual', 'Bag::manual');
|
$routes->get('manual', 'Bag::manual');
|
||||||
$routes->get('manual/search', 'Bag::manualSearch'); // (:segment) 보다 먼저
|
$routes->get('manual/search', 'Bag::manualSearch'); // (:segment) 보다 먼저
|
||||||
$routes->get('manual/(:segment)', 'Bag::manualPage/$1');
|
$routes->get('manual/(:segment)', 'Bag::manualPage/$1');
|
||||||
|
$routes->post('activity/print-log', 'Bag::printLog'); // 인쇄 활동 기록(beacon)
|
||||||
|
$routes->post('pref/font-scale', 'Bag::saveFontScale'); // 계정별·메뉴별 글자크기 저장
|
||||||
|
$routes->post('pref/workspace-tabs', 'Bag::saveWorkspaceTabs'); // 계정별 워크스페이스 탭 상태 저장(자동 복원)
|
||||||
});
|
});
|
||||||
|
|
||||||
$routes->get('bag/number-lookup', 'Bag::numberLookup');
|
$routes->get('bag/number-lookup', 'Bag::numberLookup');
|
||||||
@@ -73,13 +77,18 @@ $routes->post('bag/issue/store', 'Bag::issueStore');
|
|||||||
$routes->post('bag/issue/cancel/(:num)', 'Bag::issueCancel/$1');
|
$routes->post('bag/issue/cancel/(:num)', 'Bag::issueCancel/$1');
|
||||||
$routes->post('bag/issue/cancel-save', 'Bag::issueCancelSave');
|
$routes->post('bag/issue/cancel-save', 'Bag::issueCancelSave');
|
||||||
$routes->get('bag/order/create', 'Bag::orderCreate');
|
$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', 'Bag::phoneOrderCreate');
|
||||||
$routes->get('bag/order/phone/manage', 'Bag::phoneOrderManage');
|
$routes->get('bag/order/phone/manage', 'Bag::phoneOrderManage');
|
||||||
$routes->post('bag/order/phone/manage/update', 'Bag::phoneOrderUpdate');
|
$routes->post('bag/order/phone/manage/update', 'Bag::phoneOrderUpdate');
|
||||||
|
$routes->post('bag/order/phone/manage/scan', 'Bag::phoneOrderManageScan');
|
||||||
|
$routes->post('bag/order/phone/manage/paid', 'Bag::phoneOrderMarkPaid');
|
||||||
$routes->post('bag/order/phone/manage/cancel/(:num)', 'Bag::phoneOrderCancel/$1');
|
$routes->post('bag/order/phone/manage/cancel/(:num)', 'Bag::phoneOrderCancel/$1');
|
||||||
|
$routes->get('bag/order/phone/receipt/(:num)', 'Bag::phoneOrderReceipt/$1');
|
||||||
$routes->get('bag/order/change', 'Bag::orderChange');
|
$routes->get('bag/order/change', 'Bag::orderChange');
|
||||||
$routes->get('bag/order/revise/(:num)', 'Bag::orderRevise/$1');
|
$routes->get('bag/order/revise/(:num)', 'Bag::orderRevise/$1');
|
||||||
$routes->get('bag/order/lot-seed', 'Bag::orderLotSeed');
|
$routes->get('bag/order/lot-seed', 'Bag::orderLotSeed');
|
||||||
|
$routes->get('bag/order/lot-seed/preview', 'Bag::orderLotSeedPreview');
|
||||||
$routes->post('bag/order/lot-seed/generate', 'Bag::orderLotSeedGenerate');
|
$routes->post('bag/order/lot-seed/generate', 'Bag::orderLotSeedGenerate');
|
||||||
$routes->post('bag/order/store', 'Bag::orderStore');
|
$routes->post('bag/order/store', 'Bag::orderStore');
|
||||||
$routes->post('bag/order/cancel/(:num)', 'Bag::orderCancel/$1');
|
$routes->post('bag/order/cancel/(:num)', 'Bag::orderCancel/$1');
|
||||||
@@ -89,6 +98,7 @@ $routes->get('bag/receiving/create', 'Bag::receivingCreate');
|
|||||||
$routes->post('bag/receiving/store', 'Bag::receivingStore');
|
$routes->post('bag/receiving/store', 'Bag::receivingStore');
|
||||||
$routes->get('bag/receiving/scanner', 'Bag::receivingScanner');
|
$routes->get('bag/receiving/scanner', 'Bag::receivingScanner');
|
||||||
$routes->post('bag/receiving/scanner/store', 'Bag::receivingScannerStore');
|
$routes->post('bag/receiving/scanner/store', 'Bag::receivingScannerStore');
|
||||||
|
$routes->post('bag/receiving/scan-box', 'Bag::receivingScanBox');
|
||||||
$routes->get('bag/receiving/batch', 'Bag::receivingBatch');
|
$routes->get('bag/receiving/batch', 'Bag::receivingBatch');
|
||||||
$routes->post('bag/receiving/batch/store', 'Bag::receivingBatchStore');
|
$routes->post('bag/receiving/batch/store', 'Bag::receivingBatchStore');
|
||||||
$routes->get('bag/receiving/status', 'Bag::receivingStatus');
|
$routes->get('bag/receiving/status', 'Bag::receivingStatus');
|
||||||
@@ -151,6 +161,8 @@ $routes->group('bag', ['filter' => 'adminAuth'], static function ($routes): void
|
|||||||
$routes->get('designated-shops/browse', 'Admin\DesignatedShop::browse');
|
$routes->get('designated-shops/browse', 'Admin\DesignatedShop::browse');
|
||||||
$routes->get('designated-shops', 'Admin\DesignatedShop::index');
|
$routes->get('designated-shops', 'Admin\DesignatedShop::index');
|
||||||
$routes->get('designated-shops/create', 'Admin\DesignatedShop::create');
|
$routes->get('designated-shops/create', 'Admin\DesignatedShop::create');
|
||||||
|
$routes->post('designated-shops/resolve-address-codes', 'Admin\DesignatedShop::resolveAddressCodes');
|
||||||
|
$routes->post('designated-shops/reveal-pii', 'Admin\DesignatedShop::revealPii');
|
||||||
$routes->post('designated-shops/store', 'Admin\DesignatedShop::store');
|
$routes->post('designated-shops/store', 'Admin\DesignatedShop::store');
|
||||||
$routes->get('designated-shops/edit/(:num)', 'Admin\DesignatedShop::edit/$1');
|
$routes->get('designated-shops/edit/(:num)', 'Admin\DesignatedShop::edit/$1');
|
||||||
$routes->post('designated-shops/update/(:num)', 'Admin\DesignatedShop::update/$1');
|
$routes->post('designated-shops/update/(:num)', 'Admin\DesignatedShop::update/$1');
|
||||||
@@ -203,6 +215,9 @@ $routes->group('bag', ['filter' => 'adminAuth'], static function ($routes): void
|
|||||||
$routes->post('packaging-units/manage/delete/(:num)', 'Admin\PackagingUnit::delete/$1');
|
$routes->post('packaging-units/manage/delete/(:num)', 'Admin\PackagingUnit::delete/$1');
|
||||||
$routes->get('packaging-units/manage/history/(:num)', 'Admin\PackagingUnit::history/$1');
|
$routes->get('packaging-units/manage/history/(:num)', 'Admin\PackagingUnit::history/$1');
|
||||||
|
|
||||||
|
$routes->get('reports/custom', 'Admin\SalesReport::customReport');
|
||||||
|
$routes->post('reports/custom/preset/save', 'Admin\SalesReport::customReportPresetSave');
|
||||||
|
$routes->post('reports/custom/preset/delete', 'Admin\SalesReport::customReportPresetDelete');
|
||||||
$routes->get('reports/sales-ledger', 'Admin\SalesReport::salesLedger');
|
$routes->get('reports/sales-ledger', 'Admin\SalesReport::salesLedger');
|
||||||
$routes->get('reports/daily-summary', 'Admin\SalesReport::dailySummary');
|
$routes->get('reports/daily-summary', 'Admin\SalesReport::dailySummary');
|
||||||
$routes->get('reports/period-sales', 'Admin\SalesReport::periodSales');
|
$routes->get('reports/period-sales', 'Admin\SalesReport::periodSales');
|
||||||
@@ -221,6 +236,9 @@ $routes->group('bag', ['filter' => 'adminAuth'], static function ($routes): void
|
|||||||
$routes->post('password-change', 'Admin\PasswordChange::update');
|
$routes->post('password-change', 'Admin\PasswordChange::update');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 사용자 의견 접수 (로그인 사용자)
|
||||||
|
$routes->post('feedback', 'Feedback::store', ['filter' => 'loginAuth']);
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
$routes->get('login', 'Auth::showLoginForm');
|
$routes->get('login', 'Auth::showLoginForm');
|
||||||
$routes->post('login', 'Auth::login');
|
$routes->post('login', 'Auth::login');
|
||||||
@@ -236,6 +254,15 @@ $routes->post('register', 'Auth::register');
|
|||||||
$routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): void {
|
$routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): void {
|
||||||
$routes->get('select-local-government', 'Admin\SelectLocalGovernment::index');
|
$routes->get('select-local-government', 'Admin\SelectLocalGovernment::index');
|
||||||
$routes->post('select-local-government', 'Admin\SelectLocalGovernment::store');
|
$routes->post('select-local-government', 'Admin\SelectLocalGovernment::store');
|
||||||
|
|
||||||
|
// 사용자 의견 검토
|
||||||
|
$routes->get('feedback', 'Admin\Feedback::index');
|
||||||
|
$routes->get('feedback/all', 'Admin\Feedback::all');
|
||||||
|
$routes->get('feedback/image/(:num)', 'Admin\Feedback::image/$1');
|
||||||
|
$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('/', 'Admin\Dashboard::index');
|
||||||
$routes->get('users', 'Admin\User::index');
|
$routes->get('users', 'Admin\User::index');
|
||||||
$routes->get('users/create', 'Admin\User::create');
|
$routes->get('users/create', 'Admin\User::create');
|
||||||
@@ -245,6 +272,8 @@ $routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): vo
|
|||||||
$routes->post('users/unlock-login/(:num)', 'Admin\User::unlockLogin/$1');
|
$routes->post('users/unlock-login/(:num)', 'Admin\User::unlockLogin/$1');
|
||||||
$routes->post('users/delete/(:num)', 'Admin\User::delete/$1');
|
$routes->post('users/delete/(:num)', 'Admin\User::delete/$1');
|
||||||
$routes->get('access/login-history', 'Admin\Access::loginHistory');
|
$routes->get('access/login-history', 'Admin\Access::loginHistory');
|
||||||
|
$routes->get('access/activity-logs', 'Admin\ActivityLog::index');
|
||||||
|
$routes->get('access/activity-logs/(:num)', 'Admin\ActivityLog::show/$1');
|
||||||
$routes->get('access/approvals', 'Admin\Access::approvals');
|
$routes->get('access/approvals', 'Admin\Access::approvals');
|
||||||
$routes->post('access/approve/(:num)', 'Admin\Access::approve/$1');
|
$routes->post('access/approve/(:num)', 'Admin\Access::approve/$1');
|
||||||
$routes->post('access/reject/(:num)', 'Admin\Access::reject/$1');
|
$routes->post('access/reject/(:num)', 'Admin\Access::reject/$1');
|
||||||
|
|||||||
298
app/Controllers/Admin/ActivityLog.php
Normal file
298
app/Controllers/Admin/ActivityLog.php
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Controllers\BaseController;
|
||||||
|
use App\Models\ActivityLogModel;
|
||||||
|
use Config\Roles;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 작업(감사) 로그 조회 — 생성/수정/삭제 이력.
|
||||||
|
* 관리자 전용(adminAuth). 슈퍼/본부는 전체, 지자체관리자는 소속 지자체 사용자만.
|
||||||
|
*/
|
||||||
|
class ActivityLog extends BaseController
|
||||||
|
{
|
||||||
|
private ActivityLogModel $logModel;
|
||||||
|
|
||||||
|
/** 테이블명 → 한글 라벨 */
|
||||||
|
private const TABLE_LABELS = [
|
||||||
|
'member' => '회원',
|
||||||
|
'member_approval_request' => '회원 승인요청',
|
||||||
|
'company' => '업체',
|
||||||
|
'designated_shop' => '지정판매소',
|
||||||
|
'sales_agency' => '판매 대행소',
|
||||||
|
'free_recipient' => '무료 수령처',
|
||||||
|
'manager' => '담당자',
|
||||||
|
'local_government' => '지자체',
|
||||||
|
'code_kind' => '기본코드 종류',
|
||||||
|
'code_detail' => '기본코드',
|
||||||
|
'packaging_unit' => '포장 단위',
|
||||||
|
'packaging_unit_history' => '포장 단위 이력',
|
||||||
|
'bag_price' => '단가',
|
||||||
|
'bag_price_history' => '단가 이력',
|
||||||
|
'bag_order' => '발주',
|
||||||
|
'bag_order_item' => '발주 품목',
|
||||||
|
'bag_receiving' => '입고',
|
||||||
|
'bag_inventory' => '재고',
|
||||||
|
'bag_sale' => '판매',
|
||||||
|
'bag_issue' => '무료용 불출',
|
||||||
|
'bag_issue_item_code' => '불출 품목코드',
|
||||||
|
'shop_order' => '전화 주문',
|
||||||
|
'shop_order_item' => '전화 주문 품목',
|
||||||
|
'menu' => '메뉴',
|
||||||
|
'menu_type' => '메뉴 유형',
|
||||||
|
'feedback' => '사용자 의견',
|
||||||
|
'feedback_file' => '의견 첨부',
|
||||||
|
'blockchain_ledger' => '원장',
|
||||||
|
'export' => '엑셀 다운로드',
|
||||||
|
'print' => '인쇄',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** action → 한글 라벨 */
|
||||||
|
private const ACTION_LABELS = [
|
||||||
|
'create' => '등록',
|
||||||
|
'update' => '수정',
|
||||||
|
'delete' => '삭제',
|
||||||
|
'export' => '엑셀 다운로드',
|
||||||
|
'print' => '인쇄',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 컬럼 → 한글 라벨 (없으면 접미사 추정 → 원문) */
|
||||||
|
private const FIELD_LABELS = [
|
||||||
|
'mb_id' => '아이디', 'mb_name' => '이름', 'mb_level' => '권한등급', 'mb_group' => '그룹',
|
||||||
|
'cp_type' => '구분', 'cp_biz_no' => '사업자번호', 'cp_rep_name' => '대표자',
|
||||||
|
'ds_shop_no' => '지정번호', 'ds_rep_name' => '대표자', 'ds_biz_no' => '사업자번호',
|
||||||
|
'ck_code' => '코드', 'cd_code' => '코드', 'cd_value' => '값', 'cd_sort' => '정렬순서',
|
||||||
|
'sa_kind' => '종류', 'lg_sido' => '시도', 'lg_gugun' => '구·군',
|
||||||
|
'mm_link' => '메뉴 링크', 'mm_is_view' => '노출여부', 'mm_level' => '노출등급',
|
||||||
|
'bs_bag_name' => '품목', 'bs_qty' => '수량', 'bs_amount' => '금액', 'bs_sale_date' => '판매일',
|
||||||
|
'bo_status' => '상태', 'bi2_status' => '상태',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 로그인 시 갱신되는 회원 필드(이것만 바뀌면 '로그인'으로 표시) */
|
||||||
|
private const LOGIN_FIELDS = ['mb_session_token', 'mb_latestdate', 'mb_login_fail_count', 'mb_locked_until'];
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->logModel = model(ActivityLogModel::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 컬럼명 → 한글 라벨 (정확 매칭 → 접미사 추정 → 원문) */
|
||||||
|
private function fieldLabel(string $col): string
|
||||||
|
{
|
||||||
|
if (isset(self::FIELD_LABELS[$col])) {
|
||||||
|
return self::FIELD_LABELS[$col];
|
||||||
|
}
|
||||||
|
$suffix = [
|
||||||
|
'_name' => '이름', '_tel' => '전화번호', '_phone' => '전화번호', '_addr' => '주소',
|
||||||
|
'_email' => '이메일', '_state' => '상태', '_regdate' => '등록일', '_date' => '일자',
|
||||||
|
'_qty' => '수량', '_amount' => '금액', '_price' => '단가', '_code' => '코드',
|
||||||
|
];
|
||||||
|
foreach ($suffix as $sfx => $lbl) {
|
||||||
|
if (str_ends_with($col, $sfx)) {
|
||||||
|
return $lbl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $col;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 레코드 표시 이름 추출 (이름 계열 필드 우선) */
|
||||||
|
private function recordName(array $data): string
|
||||||
|
{
|
||||||
|
foreach (['mb_name', 'cp_name', 'ds_name', 'lg_name', 'ck_name', 'cd_name', 'sa_name', 'fr_name', 'mn_name', 'mm_name'] as $k) {
|
||||||
|
if (! empty($data[$k])) {
|
||||||
|
return (string) $data[$k];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($data as $k => $v) {
|
||||||
|
if (str_ends_with((string) $k, '_name') && (string) $v !== '') {
|
||||||
|
return (string) $v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fmtVal(mixed $v): string
|
||||||
|
{
|
||||||
|
if (is_array($v)) {
|
||||||
|
$v = json_encode($v, JSON_UNESCAPED_UNICODE);
|
||||||
|
}
|
||||||
|
$s = (string) ($v ?? '');
|
||||||
|
if ($s === '') {
|
||||||
|
return '(없음)';
|
||||||
|
}
|
||||||
|
|
||||||
|
return mb_strlen($s) > 10 ? mb_substr($s, 0, 10) . '…' : $s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 로그 1행에 사람이 읽기 쉬운 요약(summaryText)·변경목록(changeList)·로그인여부(isLogin)를 붙인다 */
|
||||||
|
private function attachSummary(object $row): void
|
||||||
|
{
|
||||||
|
$table = (string) ($row->al_table ?? '');
|
||||||
|
$action = (string) ($row->al_action ?? '');
|
||||||
|
$before = $row->al_data_before ? (json_decode((string) $row->al_data_before, true) ?: []) : [];
|
||||||
|
$after = $row->al_data_after ? (json_decode((string) $row->al_data_after, true) ?: []) : [];
|
||||||
|
$tlabel = self::TABLE_LABELS[$table] ?? $table;
|
||||||
|
|
||||||
|
$changes = [];
|
||||||
|
$isLogin = false;
|
||||||
|
if ($action === 'update') {
|
||||||
|
foreach ($after as $k => $v) {
|
||||||
|
$bv = $before[$k] ?? null;
|
||||||
|
if (! array_key_exists($k, $before) || (string) $bv !== (string) $v) {
|
||||||
|
$changes[$k] = [$bv, $v];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($table === 'member' && $changes !== [] && array_diff(array_keys($changes), self::LOGIN_FIELDS) === []) {
|
||||||
|
$isLogin = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = $this->recordName($action === 'delete' ? $before : $after);
|
||||||
|
if ($name === '') {
|
||||||
|
$name = $this->recordName($before);
|
||||||
|
}
|
||||||
|
$namePart = $name !== '' ? " '" . $name . "'" : ((int) ($row->al_record_id ?? 0) ? ' #' . (int) $row->al_record_id : '');
|
||||||
|
|
||||||
|
if ($isLogin) {
|
||||||
|
$text = '로그인';
|
||||||
|
} elseif ($action === 'export') {
|
||||||
|
$text = '엑셀 다운로드 — ' . (string) ($after['파일'] ?? '');
|
||||||
|
} elseif ($action === 'print') {
|
||||||
|
$text = '인쇄 — ' . (string) ($after['화면'] ?? $after['경로'] ?? '');
|
||||||
|
} elseif ($action === 'create') {
|
||||||
|
$text = $tlabel . $namePart . ' 등록';
|
||||||
|
} elseif ($action === 'delete') {
|
||||||
|
$text = $tlabel . $namePart . ' 삭제';
|
||||||
|
} else {
|
||||||
|
$text = $tlabel . $namePart . ' 수정';
|
||||||
|
}
|
||||||
|
|
||||||
|
$skip = array_merge(self::LOGIN_FIELDS, ['mb_passwd', 'mb_totp_secret', 'mb_session_token']);
|
||||||
|
$changeList = [];
|
||||||
|
foreach ($changes as $k => $pair) {
|
||||||
|
if (in_array($k, $skip, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$changeList[] = [
|
||||||
|
'label' => $this->fieldLabel($k),
|
||||||
|
'from' => $this->fmtVal($pair[0]),
|
||||||
|
'to' => $this->fmtVal($pair[1]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$row->summaryText = $text;
|
||||||
|
$row->isLogin = $isLogin;
|
||||||
|
$row->changeList = $changeList;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(): string
|
||||||
|
{
|
||||||
|
helper('admin');
|
||||||
|
|
||||||
|
$start = (string) ($this->request->getGet('start') ?? '');
|
||||||
|
$end = (string) ($this->request->getGet('end') ?? '');
|
||||||
|
$action = (string) ($this->request->getGet('action') ?? '');
|
||||||
|
$table = (string) ($this->request->getGet('table') ?? '');
|
||||||
|
$user = trim((string) ($this->request->getGet('user') ?? ''));
|
||||||
|
|
||||||
|
$builder = $this->logModel
|
||||||
|
->select('activity_log.*, member.mb_id, member.mb_name, member.mb_lg_idx')
|
||||||
|
->join('member', 'member.mb_idx = activity_log.al_mb_idx', 'left');
|
||||||
|
|
||||||
|
// 테넌트 분리: 슈퍼/본부는 전체, 그 외 관리자는 소속 지자체 사용자만
|
||||||
|
$level = (int) session()->get('mb_level');
|
||||||
|
if (! Roles::isSuperAdminEquivalent($level)) {
|
||||||
|
$lgIdx = admin_effective_lg_idx();
|
||||||
|
$builder->where('member.mb_lg_idx', $lgIdx);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($start !== '') {
|
||||||
|
$builder->where('activity_log.al_regdate >=', $start . ' 00:00:00');
|
||||||
|
}
|
||||||
|
if ($end !== '') {
|
||||||
|
$builder->where('activity_log.al_regdate <=', $end . ' 23:59:59');
|
||||||
|
}
|
||||||
|
if (isset(self::ACTION_LABELS[$action])) {
|
||||||
|
$builder->where('activity_log.al_action', $action);
|
||||||
|
}
|
||||||
|
if ($table !== '' && isset(self::TABLE_LABELS[$table])) {
|
||||||
|
$builder->where('activity_log.al_table', $table);
|
||||||
|
}
|
||||||
|
if ($user !== '') {
|
||||||
|
$builder->groupStart()
|
||||||
|
->like('member.mb_id', $user)
|
||||||
|
->orLike('member.mb_name', $user)
|
||||||
|
->groupEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
$list = $builder->orderBy('activity_log.al_idx', 'DESC')->paginate(20);
|
||||||
|
$pager = $this->logModel->pager;
|
||||||
|
|
||||||
|
foreach ($list as $row) {
|
||||||
|
$this->attachSummary($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('admin/layout', [
|
||||||
|
'title' => '활동 로그',
|
||||||
|
'content' => view('admin/access/activity_log', [
|
||||||
|
'list' => $list,
|
||||||
|
'pager' => $pager,
|
||||||
|
'start' => $start,
|
||||||
|
'end' => $end,
|
||||||
|
'action' => $action,
|
||||||
|
'table' => $table,
|
||||||
|
'user' => $user,
|
||||||
|
'tableLabels' => self::TABLE_LABELS,
|
||||||
|
'actionLabels' => self::ACTION_LABELS,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(int $id): string
|
||||||
|
{
|
||||||
|
helper('admin');
|
||||||
|
$row = $this->logModel
|
||||||
|
->select('activity_log.*, member.mb_id, member.mb_name, member.mb_lg_idx')
|
||||||
|
->join('member', 'member.mb_idx = activity_log.al_mb_idx', 'left')
|
||||||
|
->where('activity_log.al_idx', $id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($row === null) {
|
||||||
|
return view('admin/layout', [
|
||||||
|
'title' => '활동 로그',
|
||||||
|
'content' => '<div class="p-6 text-gray-500">로그를 찾을 수 없습니다.</div>',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 테넌트 분리 검증
|
||||||
|
$level = (int) session()->get('mb_level');
|
||||||
|
if (! Roles::isSuperAdminEquivalent($level)) {
|
||||||
|
$lgIdx = admin_effective_lg_idx();
|
||||||
|
if ((int) ($row->mb_lg_idx ?? 0) !== (int) $lgIdx) {
|
||||||
|
return view('admin/layout', [
|
||||||
|
'title' => '활동 로그',
|
||||||
|
'content' => '<div class="p-6 text-gray-500">접근 권한이 없습니다.</div>',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$before = $row->al_data_before ? (json_decode((string) $row->al_data_before, true) ?: []) : [];
|
||||||
|
$after = $row->al_data_after ? (json_decode((string) $row->al_data_after, true) ?: []) : [];
|
||||||
|
|
||||||
|
return view('admin/layout', [
|
||||||
|
'title' => '활동 로그 상세',
|
||||||
|
'content' => view('admin/access/activity_log_detail', [
|
||||||
|
'row' => $row,
|
||||||
|
'before' => $before,
|
||||||
|
'after' => $after,
|
||||||
|
'tableLabels' => self::TABLE_LABELS,
|
||||||
|
'actionLabels' => self::ACTION_LABELS,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -539,6 +539,11 @@ class BagOrder extends BaseController
|
|||||||
'bo_regdate' => date('Y-m-d H:i:s'),
|
'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 기준으로 해시를 계산하므로 우선 빈값으로 생성
|
// 품목 입력 후 최종 payload 기준으로 해시를 계산하므로 우선 빈값으로 생성
|
||||||
$orderData['bo_hash'] = '';
|
$orderData['bo_hash'] = '';
|
||||||
|
|
||||||
|
|||||||
@@ -56,14 +56,11 @@ class CodeDetail extends BaseController
|
|||||||
|
|
||||||
helper('admin');
|
helper('admin');
|
||||||
|
|
||||||
return view('admin/layout', [
|
return $this->renderWorkPage('세부코드 등록 — ' . $kind->ck_name, 'admin/code_detail/create', [
|
||||||
'title' => '세부코드 등록 — ' . $kind->ck_name,
|
'kind' => $kind,
|
||||||
'content' => view('admin/code_detail/create', [
|
'canPlatformScope' => $canPlatformScope,
|
||||||
'kind' => $kind,
|
'localGovernments' => $govs,
|
||||||
'canPlatformScope' => $canPlatformScope,
|
'effectiveLgIdx' => admin_effective_lg_idx(),
|
||||||
'localGovernments' => $govs,
|
|
||||||
'effectiveLgIdx' => admin_effective_lg_idx(),
|
|
||||||
]),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,12 +153,9 @@ class CodeDetail extends BaseController
|
|||||||
|
|
||||||
$kind = $this->kindModel->find($item->cd_ck_idx);
|
$kind = $this->kindModel->find($item->cd_ck_idx);
|
||||||
|
|
||||||
return view('admin/layout', [
|
return $this->renderWorkPage('세부코드 수정 — ' . ($kind->ck_name ?? ''), 'admin/code_detail/edit', [
|
||||||
'title' => '세부코드 수정 — ' . ($kind->ck_name ?? ''),
|
'item' => $item,
|
||||||
'content' => view('admin/code_detail/edit', [
|
'kind' => $kind,
|
||||||
'item' => $item,
|
|
||||||
'kind' => $kind,
|
|
||||||
]),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,10 +34,7 @@ class CodeKind extends BaseController
|
|||||||
return $r;
|
return $r;
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('admin/layout', [
|
return $this->renderWorkPage('기본코드 종류 등록', 'admin/code_kind/create');
|
||||||
'title' => '기본코드 종류 등록',
|
|
||||||
'content' => view('admin/code_kind/create'),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store()
|
public function store()
|
||||||
@@ -76,10 +73,7 @@ class CodeKind extends BaseController
|
|||||||
return redirect()->to(site_url('bag/code-kinds'))->with('error', '코드 종류를 찾을 수 없습니다.');
|
return redirect()->to(site_url('bag/code-kinds'))->with('error', '코드 종류를 찾을 수 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('admin/layout', [
|
return $this->renderWorkPage('기본코드 종류 수정', 'admin/code_kind/edit', ['item' => $item]);
|
||||||
'title' => '기본코드 종류 수정',
|
|
||||||
'content' => view('admin/code_kind/edit', ['item' => $item]),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(int $id)
|
public function update(int $id)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class DesignatedShop extends BaseController
|
|||||||
private DesignatedShopModel $shopModel;
|
private DesignatedShopModel $shopModel;
|
||||||
private LocalGovernmentModel $lgModel;
|
private LocalGovernmentModel $lgModel;
|
||||||
private Roles $roles;
|
private Roles $roles;
|
||||||
|
private ?bool $dongColumnExists = null;
|
||||||
|
|
||||||
public function __construct()
|
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);
|
$model->where('ds_lg_idx', $lgIdx);
|
||||||
if ($dsName !== null && $dsName !== '') {
|
if ($dsName !== null && $dsName !== '') {
|
||||||
@@ -185,12 +186,47 @@ class DesignatedShop extends BaseController
|
|||||||
if ($dsState !== null && $dsState !== '') {
|
if ($dsState !== null && $dsState !== '') {
|
||||||
$model->where('ds_state', (int) $dsState);
|
$model->where('ds_state', (int) $dsState);
|
||||||
}
|
}
|
||||||
|
$this->applyPiiSearchFilter($model, $dsRep, $dsTel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대표자명·전화 검색(정확일치). 블라인드 인덱스 컬럼+키가 있으면 HMAC 정확일치,
|
||||||
|
* 없으면(플래그 OFF/미마이그레이션) 평문 정확일치로 폴백.
|
||||||
|
*
|
||||||
|
* @param DesignatedShopModel|\CodeIgniter\Database\BaseBuilder $q
|
||||||
|
*/
|
||||||
|
private function applyPiiSearchFilter($q, ?string $dsRep, ?string $dsTel): void
|
||||||
|
{
|
||||||
|
$dsRep = $dsRep !== null ? trim($dsRep) : '';
|
||||||
|
$dsTel = $dsTel !== null ? trim($dsTel) : '';
|
||||||
|
if ($dsRep === '' && $dsTel === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
helper('pii_encryption');
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
|
||||||
|
if ($dsRep !== '') {
|
||||||
|
$bidx = pii_blind_index($dsRep);
|
||||||
|
if ($bidx !== '' && $db->fieldExists('ds_rep_name_bidx', 'designated_shop')) {
|
||||||
|
$q->where('ds_rep_name_bidx', $bidx);
|
||||||
|
} else {
|
||||||
|
$q->where('ds_rep_name', $dsRep); // 평문 폴백(정확일치)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($dsTel !== '') {
|
||||||
|
$bidx = pii_blind_index($dsTel);
|
||||||
|
if ($bidx !== '' && $db->fieldExists('ds_tel_bidx', 'designated_shop')) {
|
||||||
|
$q->where('ds_tel_bidx', $bidx);
|
||||||
|
} else {
|
||||||
|
$q->where('ds_tel', $dsTel);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{1: int, 2: int, 3: int, total: int}
|
* @return array{1: int, 2: int, 3: int, total: int}
|
||||||
*/
|
*/
|
||||||
private function countDesignatedShopsByState(int $lgIdx, ?string $dsName, ?string $dsGugunCode, ?string $dsState): array
|
private function countDesignatedShopsByState(int $lgIdx, ?string $dsName, ?string $dsGugunCode, ?string $dsState, ?string $dsRep = null, ?string $dsTel = null): array
|
||||||
{
|
{
|
||||||
$db = \Config\Database::connect();
|
$db = \Config\Database::connect();
|
||||||
$builder = $db->table('designated_shop');
|
$builder = $db->table('designated_shop');
|
||||||
@@ -204,6 +240,7 @@ class DesignatedShop extends BaseController
|
|||||||
if ($dsState !== null && $dsState !== '') {
|
if ($dsState !== null && $dsState !== '') {
|
||||||
$builder->where('ds_state', (int) $dsState);
|
$builder->where('ds_state', (int) $dsState);
|
||||||
}
|
}
|
||||||
|
$this->applyPiiSearchFilter($builder, $dsRep, $dsTel);
|
||||||
$rows = $builder->select('ds_state, COUNT(*) AS cnt', false)
|
$rows = $builder->select('ds_state, COUNT(*) AS cnt', false)
|
||||||
->groupBy('ds_state')
|
->groupBy('ds_state')
|
||||||
->get()
|
->get()
|
||||||
@@ -223,11 +260,12 @@ class DesignatedShop extends BaseController
|
|||||||
/**
|
/**
|
||||||
* @param list<object> $list
|
* @param list<object> $list
|
||||||
* @param array<int, string> $lgMap
|
* @param array<int, string> $lgMap
|
||||||
|
* @param array<int, array{code: string, name: string}> $dongMap ds_idx => [동코드, 동명]
|
||||||
* @return list<array<string, mixed>>
|
* @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;
|
$lgIdx = admin_effective_lg_idx() ?? 0;
|
||||||
$gugunMap = $lgIdx > 0 ? $this->gugunCodeNameMap($lgIdx) : [];
|
$gugunMap = $lgIdx > 0 ? $this->gugunCodeNameMap($lgIdx) : [];
|
||||||
$payload = [];
|
$payload = [];
|
||||||
@@ -244,7 +282,19 @@ class DesignatedShop extends BaseController
|
|||||||
$stateMap = [1 => '정상', 2 => '폐업', 3 => '직권해지'];
|
$stateMap = [1 => '정상', 2 => '폐업', 3 => '직권해지'];
|
||||||
$da = $row->ds_designated_at ?? null;
|
$da = $row->ds_designated_at ?? null;
|
||||||
$daOut = ($da !== null && $da !== '' && $da !== '0000-00-00') ? (string) $da : '';
|
$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_idx' => (int) $row->ds_idx,
|
||||||
'ds_shop_no' => $sn,
|
'ds_shop_no' => $sn,
|
||||||
'shop_no_display' => $shortNo,
|
'shop_no_display' => $shortNo,
|
||||||
@@ -266,7 +316,10 @@ class DesignatedShop extends BaseController
|
|||||||
'ds_rep_phone' => (string) ($row->ds_rep_phone ?? ''),
|
'ds_rep_phone' => (string) ($row->ds_rep_phone ?? ''),
|
||||||
'ds_email' => (string) ($row->ds_email ?? ''),
|
'ds_email' => (string) ($row->ds_email ?? ''),
|
||||||
'ds_gugun_code' => (string) ($row->ds_gugun_code ?? ''),
|
'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_zone_code' => $this->designatedShopScalar($row, 'ds_zone_code'),
|
||||||
'ds_branch_no' => $this->designatedShopScalar($row, 'ds_branch_no'),
|
'ds_branch_no' => $this->designatedShopScalar($row, 'ds_branch_no'),
|
||||||
'ds_designated_at' => $daOut,
|
'ds_designated_at' => $daOut,
|
||||||
@@ -274,7 +327,18 @@ class DesignatedShop extends BaseController
|
|||||||
'ds_change_reason' => $this->designatedShopScalar($row, 'ds_change_reason'),
|
'ds_change_reason' => $this->designatedShopScalar($row, 'ds_change_reason'),
|
||||||
'ds_regdate' => (string) ($row->ds_regdate ?? ''),
|
'ds_regdate' => (string) ($row->ds_regdate ?? ''),
|
||||||
'lg_name' => $lgMap[(int) ($row->ds_lg_idx ?? 0)] ?? '',
|
'lg_name' => $lgMap[(int) ($row->ds_lg_idx ?? 0)] ?? '',
|
||||||
|
'pii_masked' => false,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 개인정보 마스킹(표시 계층 단일 지점) — 열람 권한 없으면 PII 필드 마스킹
|
||||||
|
if (! can_view_shop_pii((int) ($row->ds_lg_idx ?? 0))) {
|
||||||
|
foreach (['ds_rep_name', 'ds_tel', 'ds_rep_phone', 'ds_email', 'ds_va_account', 'ds_va_number'] as $f) {
|
||||||
|
$p[$f] = mask_shop_field($f, (string) $p[$f]);
|
||||||
|
}
|
||||||
|
$p['pii_masked'] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload[] = $p;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $payload;
|
return $payload;
|
||||||
@@ -294,14 +358,17 @@ class DesignatedShop extends BaseController
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 다조건 검색 (P2-15)
|
// 다조건 검색 (P2-15) + 대표자·전화 정확일치(블라인드 인덱스)
|
||||||
$dsName = $this->request->getGet('ds_name');
|
$dsName = $this->request->getGet('ds_name');
|
||||||
$dsGugunCode = $this->request->getGet('ds_gugun_code');
|
$dsGugunCode = $this->request->getGet('ds_gugun_code');
|
||||||
$dsState = $this->request->getGet('ds_state');
|
$dsState = $this->request->getGet('ds_state');
|
||||||
$this->applyDesignatedShopListFilters($this->shopModel, $lgIdx, $dsName, $dsGugunCode, $dsState);
|
$dsRep = $this->request->getGet('ds_rep');
|
||||||
|
$dsTel = $this->request->getGet('ds_tel');
|
||||||
|
$this->applyDesignatedShopListFilters($this->shopModel, $lgIdx, $dsName, $dsGugunCode, $dsState, $dsRep, $dsTel);
|
||||||
|
|
||||||
$list = $this->shopModel->orderBy('ds_idx', 'DESC')->paginate(20);
|
// 페이징 대신 전체 목록을 한 번에 로드 — 리스트 패널 내부 스크롤로 표시
|
||||||
$pager = $this->shopModel->pager;
|
$list = $this->shopModel->orderBy('ds_idx', 'DESC')->findAll();
|
||||||
|
$pager = null;
|
||||||
|
|
||||||
// 지자체 이름 매핑용
|
// 지자체 이름 매핑용
|
||||||
$lgMap = [];
|
$lgMap = [];
|
||||||
@@ -309,9 +376,23 @@ class DesignatedShop extends BaseController
|
|||||||
$lgMap[$lg->lg_idx] = $lg->lg_name;
|
$lgMap[$lg->lg_idx] = $lg->lg_name;
|
||||||
}
|
}
|
||||||
|
|
||||||
$stateCounts = $this->countDesignatedShopsByState($lgIdx, $dsName, $dsGugunCode, $dsState);
|
$stateCounts = $this->countDesignatedShopsByState($lgIdx, $dsName, $dsGugunCode, $dsState, $dsRep, $dsTel);
|
||||||
$gugunNameMap = $this->gugunCodeNameMap($lgIdx);
|
$gugunNameMap = $this->gugunCodeNameMap($lgIdx);
|
||||||
$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();
|
$db = \Config\Database::connect();
|
||||||
@@ -324,9 +405,12 @@ class DesignatedShop extends BaseController
|
|||||||
'dsName' => $dsName ?? '',
|
'dsName' => $dsName ?? '',
|
||||||
'dsGugunCode' => $dsGugunCode ?? '',
|
'dsGugunCode' => $dsGugunCode ?? '',
|
||||||
'dsState' => $dsState ?? '',
|
'dsState' => $dsState ?? '',
|
||||||
|
'dsRep' => $dsRep ?? '',
|
||||||
|
'dsTel' => $dsTel ?? '',
|
||||||
'gugunCodes' => $gugunCodes,
|
'gugunCodes' => $gugunCodes,
|
||||||
'stateCounts' => $stateCounts,
|
'stateCounts' => $stateCounts,
|
||||||
'gugunNameMap' => $gugunNameMap,
|
'gugunNameMap' => $gugunNameMap,
|
||||||
|
'dongDisplayMap' => $dongDisplayMap,
|
||||||
'detailRowsJson' => json_encode($detailRows, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE),
|
'detailRowsJson' => json_encode($detailRows, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE),
|
||||||
'kakaoJavascriptKey' => $this->kakaoJavascriptKey(),
|
'kakaoJavascriptKey' => $this->kakaoJavascriptKey(),
|
||||||
];
|
];
|
||||||
@@ -475,22 +559,45 @@ class DesignatedShop extends BaseController
|
|||||||
return redirect()->to(mgmt_url('designated-shops'))->with('error', '지자체를 선택해 주세요.');
|
return redirect()->to(mgmt_url('designated-shops'))->with('error', '지자체를 선택해 주세요.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
helper('pii_encryption');
|
||||||
$list = $this->shopModel->where('ds_lg_idx', $lgIdx)->orderBy('ds_idx', 'DESC')->findAll();
|
$list = $this->shopModel->where('ds_lg_idx', $lgIdx)->orderBy('ds_idx', 'DESC')->findAll();
|
||||||
|
|
||||||
|
// 동코드(구·군 동코드) 산출
|
||||||
|
$dongMap = $this->resolveDongCodesForShops($lgIdx, $list);
|
||||||
|
|
||||||
$rows = [];
|
$rows = [];
|
||||||
foreach ($list as $row) {
|
foreach ($list as $row) {
|
||||||
$stateMap = [1 => '정상', 2 => '폐업', 3 => '직권해지'];
|
$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[] = [
|
$rows[] = [
|
||||||
$row->ds_idx,
|
$row->ds_idx,
|
||||||
$row->ds_shop_no,
|
$row->ds_shop_no,
|
||||||
$row->ds_name,
|
$row->ds_name,
|
||||||
$row->ds_rep_name,
|
$repName,
|
||||||
|
$dongDisp,
|
||||||
|
$daDisp,
|
||||||
$row->ds_biz_no,
|
$row->ds_biz_no,
|
||||||
$this->designatedShopScalar($row, 'ds_biz_type'),
|
$this->designatedShopScalar($row, 'ds_biz_type'),
|
||||||
$this->designatedShopScalar($row, 'ds_biz_kind'),
|
$this->designatedShopScalar($row, 'ds_biz_kind'),
|
||||||
$this->designatedShopScalar($row, 'ds_va_bank'),
|
$this->designatedShopScalar($row, 'ds_va_bank'),
|
||||||
$this->designatedShopScalar($row, 'ds_va_account') !== '' ? $this->designatedShopScalar($row, 'ds_va_account') : ($row->ds_va_number ?? ''),
|
$vaAccount,
|
||||||
$row->ds_tel ?? '',
|
$telVal,
|
||||||
$row->ds_addr ?? '',
|
$row->ds_addr ?? '',
|
||||||
$this->designatedShopScalar($row, 'ds_zone_code'),
|
$this->designatedShopScalar($row, 'ds_zone_code'),
|
||||||
$this->designatedShopScalar($row, 'ds_branch_no'),
|
$this->designatedShopScalar($row, 'ds_branch_no'),
|
||||||
@@ -504,7 +611,7 @@ class DesignatedShop extends BaseController
|
|||||||
export_csv(
|
export_csv(
|
||||||
'지정판매소_' . date('Ymd') . '.csv',
|
'지정판매소_' . date('Ymd') . '.csv',
|
||||||
[
|
[
|
||||||
'번호', '판매소번호', '상호명', '대표자', '사업자번호', '업태', '업종',
|
'번호', '판매소번호', '상호명', '대표자', '구·군(동코드)', '지정일', '사업자번호', '업태', '업종',
|
||||||
'가상계좌은행', '계좌번호', '전화번호', '주소', '구역', '종사업장번호',
|
'가상계좌은행', '계좌번호', '전화번호', '주소', '구역', '종사업장번호',
|
||||||
'변경일자', '변경사유', '상태', '등록일',
|
'변경일자', '변경사유', '상태', '등록일',
|
||||||
],
|
],
|
||||||
@@ -648,6 +755,8 @@ class DesignatedShop extends BaseController
|
|||||||
'ds_branch_no' => (string) $this->request->getPost('ds_branch_no'),
|
'ds_branch_no' => (string) $this->request->getPost('ds_branch_no'),
|
||||||
'ds_designated_at' => $this->request->getPost('ds_designated_at') ?: null,
|
'ds_designated_at' => $this->request->getPost('ds_designated_at') ?: null,
|
||||||
'ds_state' => 1,
|
'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_state_changed_at' => $this->normalizeOptionalDate($this->request->getPost('ds_state_changed_at')),
|
||||||
'ds_change_reason' => (string) $this->request->getPost('ds_change_reason'),
|
'ds_change_reason' => (string) $this->request->getPost('ds_change_reason'),
|
||||||
'ds_regdate' => date('Y-m-d H:i:s'),
|
'ds_regdate' => date('Y-m-d H:i:s'),
|
||||||
@@ -770,6 +879,23 @@ class DesignatedShop extends BaseController
|
|||||||
|
|
||||||
$va = $this->resolveVirtualAccountFromRequest();
|
$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 = [
|
$data = [
|
||||||
'ds_name' => (string) $this->request->getPost('ds_name'),
|
'ds_name' => (string) $this->request->getPost('ds_name'),
|
||||||
'ds_biz_no' => (string) $this->request->getPost('ds_biz_no'),
|
'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_tel' => (string) $this->request->getPost('ds_tel'),
|
||||||
'ds_rep_phone' => (string) $this->request->getPost('ds_rep_phone'),
|
'ds_rep_phone' => (string) $this->request->getPost('ds_rep_phone'),
|
||||||
'ds_email' => (string) $this->request->getPost('ds_email'),
|
'ds_email' => (string) $this->request->getPost('ds_email'),
|
||||||
|
...$dongCodeData,
|
||||||
'ds_zone_code' => (string) $this->request->getPost('ds_zone_code'),
|
'ds_zone_code' => (string) $this->request->getPost('ds_zone_code'),
|
||||||
'ds_branch_no' => (string) $this->request->getPost('ds_branch_no'),
|
'ds_branch_no' => (string) $this->request->getPost('ds_branch_no'),
|
||||||
'ds_designated_at' => $this->request->getPost('ds_designated_at') ?: null,
|
'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']);
|
usort($dCandidates, static fn (array $a, array $b): int => $b['len'] <=> $a['len']);
|
||||||
|
|
||||||
$dCode = null;
|
$dCode = null;
|
||||||
|
$dName = '';
|
||||||
foreach ($dCandidates as $cand) {
|
foreach ($dCandidates as $cand) {
|
||||||
if ($this->addressHaystackContainsRegionName($blob, $cand['nm'])) {
|
if ($this->addressHaystackContainsRegionName($blob, $cand['nm'])) {
|
||||||
$dCode = $cand['cd'];
|
$dCode = $cand['cd'];
|
||||||
|
$dName = $cand['nm'];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1557,9 +1686,248 @@ class DesignatedShop extends BaseController
|
|||||||
'ok' => true,
|
'ok' => true,
|
||||||
'shop_no' => $prefix . sprintf('%03d', $maxSerial + 1),
|
'shop_no' => $prefix . sprintf('%03d', $maxSerial + 1),
|
||||||
'gugun_code' => $cCode,
|
'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) */
|
/** 카카오맵 JavaScript SDK용 키 (.env kakao.javascriptKey) */
|
||||||
private function kakaoJavascriptKey(): string
|
private function kakaoJavascriptKey(): string
|
||||||
{
|
{
|
||||||
|
|||||||
217
app/Controllers/Admin/Feedback.php
Normal file
217
app/Controllers/Admin/Feedback.php
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Controllers\BaseController;
|
||||||
|
use App\Models\FeedbackFileModel;
|
||||||
|
use App\Models\FeedbackModel;
|
||||||
|
use CodeIgniter\HTTP\ResponseInterface;
|
||||||
|
use Config\Roles;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 의견 검토(관리자). super·본부는 전체, 지자체관리자는 자기 지자체만.
|
||||||
|
*/
|
||||||
|
class Feedback extends BaseController
|
||||||
|
{
|
||||||
|
private function scopeLgIdx(): ?int
|
||||||
|
{
|
||||||
|
helper('admin');
|
||||||
|
$level = (int) session()->get('mb_level');
|
||||||
|
if (Roles::isSuperAdminEquivalent($level)) {
|
||||||
|
return null; // 전체
|
||||||
|
}
|
||||||
|
|
||||||
|
return admin_effective_lg_idx();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(): string
|
||||||
|
{
|
||||||
|
$model = model(FeedbackModel::class);
|
||||||
|
$builder = $model->orderBy('fb_idx', 'DESC');
|
||||||
|
|
||||||
|
$scope = $this->scopeLgIdx();
|
||||||
|
if ($scope !== null) {
|
||||||
|
$builder->where('fb_lg_idx', $scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = (string) ($this->request->getGet('status') ?? '');
|
||||||
|
if (in_array($status, FeedbackModel::STATUSES, true)) {
|
||||||
|
$builder->where('fb_status', $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
$list = $builder->paginate(20);
|
||||||
|
$pager = $model->pager;
|
||||||
|
|
||||||
|
return view('admin/layout', [
|
||||||
|
'title' => '사용자 의견',
|
||||||
|
'content' => view('admin/feedback/index', [
|
||||||
|
'list' => $list,
|
||||||
|
'pager' => $pager,
|
||||||
|
'status' => $status,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 의견을 한 페이지에서 스크롤로 훑어볼 수 있는 화면(상세 클릭 없이 내용 전체 표시).
|
||||||
|
*/
|
||||||
|
public function all(): string
|
||||||
|
{
|
||||||
|
$model = model(FeedbackModel::class);
|
||||||
|
$builder = $model->orderBy('fb_idx', 'DESC');
|
||||||
|
|
||||||
|
$scope = $this->scopeLgIdx();
|
||||||
|
if ($scope !== null) {
|
||||||
|
$builder->where('fb_lg_idx', $scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = (string) ($this->request->getGet('status') ?? '');
|
||||||
|
if (in_array($status, FeedbackModel::STATUSES, true)) {
|
||||||
|
$builder->where('fb_status', $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
$list = $builder->findAll();
|
||||||
|
|
||||||
|
$ids = array_map(static fn ($r): int => (int) $r->fb_idx, $list);
|
||||||
|
$filesByFb = [];
|
||||||
|
if ($ids !== []) {
|
||||||
|
$files = model(FeedbackFileModel::class)
|
||||||
|
->whereIn('ff_fb_idx', $ids)
|
||||||
|
->orderBy('ff_idx', 'ASC')
|
||||||
|
->findAll();
|
||||||
|
foreach ($files as $f) {
|
||||||
|
$filesByFb[(int) $f->ff_fb_idx][] = $f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('admin/layout', [
|
||||||
|
'title' => '사용자 의견 · 전체보기',
|
||||||
|
'content' => view('admin/feedback/all', [
|
||||||
|
'list' => $list,
|
||||||
|
'filesByFb' => $filesByFb,
|
||||||
|
'status' => $status,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(int $id): string|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', '의견을 찾을 수 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = model(FeedbackFileModel::class)
|
||||||
|
->where('ff_fb_idx', $id)
|
||||||
|
->orderBy('ff_idx', 'ASC')
|
||||||
|
->findAll();
|
||||||
|
|
||||||
|
return view('admin/layout', [
|
||||||
|
'title' => '의견 상세',
|
||||||
|
'content' => view('admin/feedback/show', ['row' => $row, 'files' => $files]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateStatus(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', '의견을 찾을 수 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = (string) $this->request->getPost('fb_status');
|
||||||
|
$memo = (string) $this->request->getPost('fb_admin_memo');
|
||||||
|
$update = ['fb_admin_memo' => mb_substr($memo, 0, 2000)];
|
||||||
|
if (in_array($status, FeedbackModel::STATUSES, true)) {
|
||||||
|
$update['fb_status'] = $status;
|
||||||
|
}
|
||||||
|
$model->update($id, $update);
|
||||||
|
|
||||||
|
// 전체보기 화면에서 저장한 경우 스크롤 위치(#fb-{id})를 유지한 채 그 화면으로 되돌아간다.
|
||||||
|
$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', '처리 상태를 저장했습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 문의 글(본문) 수정 */
|
||||||
|
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
|
||||||
|
{
|
||||||
|
$model = model(FeedbackModel::class);
|
||||||
|
$row = $model->find($id);
|
||||||
|
$scope = $this->scopeLgIdx();
|
||||||
|
if ($row === null || ($scope !== null && (int) $row->fb_lg_idx !== $scope) || empty($row->fb_screenshot)) {
|
||||||
|
return $this->response->setStatusCode(404)->setBody('not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = WRITEPATH . 'uploads/feedback/' . basename((string) $row->fb_screenshot);
|
||||||
|
if (! is_file($path)) {
|
||||||
|
return $this->response->setStatusCode(404)->setBody('not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$mime = str_ends_with($path, '.png') ? 'image/png' : 'image/jpeg';
|
||||||
|
|
||||||
|
return $this->response
|
||||||
|
->setHeader('Content-Type', $mime)
|
||||||
|
->setHeader('Cache-Control', 'private, max-age=300')
|
||||||
|
->setBody((string) file_get_contents($path));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 첨부 이미지(feedback_file) 서빙 — 관리자만, 테넌트 검증 */
|
||||||
|
public function file(int $ffIdx): ResponseInterface
|
||||||
|
{
|
||||||
|
$ff = model(FeedbackFileModel::class)->find($ffIdx);
|
||||||
|
if ($ff === null) {
|
||||||
|
return $this->response->setStatusCode(404)->setBody('not found');
|
||||||
|
}
|
||||||
|
$fb = model(FeedbackModel::class)->find((int) $ff->ff_fb_idx);
|
||||||
|
$scope = $this->scopeLgIdx();
|
||||||
|
if ($fb === null || ($scope !== null && (int) $fb->fb_lg_idx !== $scope)) {
|
||||||
|
return $this->response->setStatusCode(404)->setBody('not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = WRITEPATH . 'uploads/feedback/' . basename((string) $ff->ff_path);
|
||||||
|
if (! is_file($path)) {
|
||||||
|
return $this->response->setStatusCode(404)->setBody('not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$mime = (string) ($ff->ff_mime ?: (str_ends_with($path, '.png') ? 'image/png' : 'image/jpeg'));
|
||||||
|
|
||||||
|
return $this->response
|
||||||
|
->setHeader('Content-Type', $mime)
|
||||||
|
->setHeader('Cache-Control', 'private, max-age=300')
|
||||||
|
->setBody((string) file_get_contents($path));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ class Manager extends BaseController
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'company' => '제작업체',
|
'company' => '제작업체',
|
||||||
'district' => '구·군',
|
'district' => '지자체',
|
||||||
'agency' => '대행소',
|
'agency' => '대행소',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -77,6 +77,8 @@ class Manager extends BaseController
|
|||||||
$rules = [
|
$rules = [
|
||||||
'mg_name' => 'required|max_length[50]',
|
'mg_name' => 'required|max_length[50]',
|
||||||
'mg_category' => 'required|in_list[company,district,agency]',
|
'mg_category' => 'required|in_list[company,district,agency]',
|
||||||
|
'mg_affiliation' => 'permit_empty|max_length[100]',
|
||||||
|
'mg_company_name' => 'permit_empty|max_length[100]',
|
||||||
'mg_tel' => 'permit_empty|max_length[20]',
|
'mg_tel' => 'permit_empty|max_length[20]',
|
||||||
'mg_phone' => 'permit_empty|max_length[20]',
|
'mg_phone' => 'permit_empty|max_length[20]',
|
||||||
'mg_email' => 'permit_empty|valid_email|max_length[100]',
|
'mg_email' => 'permit_empty|valid_email|max_length[100]',
|
||||||
@@ -88,6 +90,8 @@ class Manager extends BaseController
|
|||||||
$this->model->insert([
|
$this->model->insert([
|
||||||
'mg_lg_idx' => admin_effective_lg_idx(),
|
'mg_lg_idx' => admin_effective_lg_idx(),
|
||||||
'mg_name' => $this->request->getPost('mg_name'),
|
'mg_name' => $this->request->getPost('mg_name'),
|
||||||
|
'mg_affiliation' => $this->request->getPost('mg_affiliation') ?? '',
|
||||||
|
'mg_company_name' => $this->request->getPost('mg_company_name') ?? '',
|
||||||
'mg_dept_code' => (string) ($this->request->getPost('mg_category') ?? ''),
|
'mg_dept_code' => (string) ($this->request->getPost('mg_category') ?? ''),
|
||||||
'mg_position_code' => $this->request->getPost('mg_position_code') ?? '',
|
'mg_position_code' => $this->request->getPost('mg_position_code') ?? '',
|
||||||
'mg_tel' => $this->request->getPost('mg_tel') ?? '',
|
'mg_tel' => $this->request->getPost('mg_tel') ?? '',
|
||||||
@@ -126,6 +130,8 @@ class Manager extends BaseController
|
|||||||
$rules = [
|
$rules = [
|
||||||
'mg_name' => 'required|max_length[50]',
|
'mg_name' => 'required|max_length[50]',
|
||||||
'mg_category' => 'required|in_list[company,district,agency]',
|
'mg_category' => 'required|in_list[company,district,agency]',
|
||||||
|
'mg_affiliation' => 'permit_empty|max_length[100]',
|
||||||
|
'mg_company_name' => 'permit_empty|max_length[100]',
|
||||||
'mg_state' => 'required|in_list[0,1]',
|
'mg_state' => 'required|in_list[0,1]',
|
||||||
];
|
];
|
||||||
if (! $this->validate($rules)) {
|
if (! $this->validate($rules)) {
|
||||||
@@ -134,6 +140,8 @@ class Manager extends BaseController
|
|||||||
|
|
||||||
$this->model->update($id, [
|
$this->model->update($id, [
|
||||||
'mg_name' => $this->request->getPost('mg_name'),
|
'mg_name' => $this->request->getPost('mg_name'),
|
||||||
|
'mg_affiliation' => $this->request->getPost('mg_affiliation') ?? '',
|
||||||
|
'mg_company_name' => $this->request->getPost('mg_company_name') ?? '',
|
||||||
'mg_dept_code' => (string) ($this->request->getPost('mg_category') ?? ''),
|
'mg_dept_code' => (string) ($this->request->getPost('mg_category') ?? ''),
|
||||||
'mg_position_code' => $this->request->getPost('mg_position_code') ?? '',
|
'mg_position_code' => $this->request->getPost('mg_position_code') ?? '',
|
||||||
'mg_tel' => $this->request->getPost('mg_tel') ?? '',
|
'mg_tel' => $this->request->getPost('mg_tel') ?? '',
|
||||||
|
|||||||
@@ -210,7 +210,41 @@ class Menu extends BaseController
|
|||||||
'mm_level' => $this->normalizeMmLevel((int) $row->mt_idx),
|
'mm_level' => $this->normalizeMmLevel((int) $row->mt_idx),
|
||||||
'mm_is_view' => $this->request->getPost('mm_is_view') ? 'Y' : 'N',
|
'mm_is_view' => $this->request->getPost('mm_is_view') ? 'Y' : 'N',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 위치(상위↔하위) 변경 지원: mm_pidx 가 바뀌면 상위/하위로 이동
|
||||||
|
$oldPidx = (int) $row->mm_pidx;
|
||||||
|
$newPidx = (int) $this->request->getPost('mm_pidx');
|
||||||
|
$newDep = $newPidx > 0 ? 1 : 0;
|
||||||
|
$positionChanged = $newPidx !== $oldPidx;
|
||||||
|
if ($positionChanged) {
|
||||||
|
if ($newPidx === $id) {
|
||||||
|
return $this->menusRedirect((int) $row->mt_idx)->with('error', '자기 자신을 상위 메뉴로 지정할 수 없습니다.');
|
||||||
|
}
|
||||||
|
// 하위 메뉴를 보유한 상위 메뉴는 하위로 변경 불가(2단 구조 유지)
|
||||||
|
$childCnt = $this->menuModel->where('mt_idx', (int) $row->mt_idx)->where('lg_idx', $lgIdx)->where('mm_pidx', $id)->countAllResults();
|
||||||
|
if ($newPidx > 0 && $childCnt > 0) {
|
||||||
|
return $this->menusRedirect((int) $row->mt_idx)->with('error', '하위 메뉴가 있는 상위 메뉴는 하위로 변경할 수 없습니다. 먼저 하위 메뉴를 옮겨 주세요.');
|
||||||
|
}
|
||||||
|
if ($newPidx > 0) {
|
||||||
|
$parent = $this->menuModel->find($newPidx);
|
||||||
|
if (! $parent || (int) $parent->lg_idx !== $lgIdx || (int) $parent->mt_idx !== (int) $row->mt_idx || (int) $parent->mm_dep !== 0) {
|
||||||
|
return $this->menusRedirect((int) $row->mt_idx)->with('error', '올바른 상위 메뉴를 선택해 주세요.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$data['mm_pidx'] = $newPidx;
|
||||||
|
$data['mm_dep'] = $newDep;
|
||||||
|
$data['mm_num'] = $this->menuModel->getNextNum((int) $row->mt_idx, $lgIdx, $newPidx, $newDep);
|
||||||
|
}
|
||||||
|
|
||||||
$this->menuModel->update($id, $data);
|
$this->menuModel->update($id, $data);
|
||||||
|
if ($positionChanged) {
|
||||||
|
if ($oldPidx > 0) {
|
||||||
|
$this->menuModel->updateCnode($oldPidx, -1);
|
||||||
|
}
|
||||||
|
if ($newPidx > 0) {
|
||||||
|
$this->menuModel->updateCnode($newPidx, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
$this->menuModel->pruneInventoryManagementMenus((int) $row->mt_idx, $lgIdx);
|
$this->menuModel->pruneInventoryManagementMenus((int) $row->mt_idx, $lgIdx);
|
||||||
$this->menuModel->syncTypeToAllLgs((int) $row->mt_idx, $lgIdx);
|
$this->menuModel->syncTypeToAllLgs((int) $row->mt_idx, $lgIdx);
|
||||||
return $this->menusRedirect((int) $row->mt_idx)->with('success', '메뉴가 수정되었습니다.');
|
return $this->menusRedirect((int) $row->mt_idx)->with('success', '메뉴가 수정되었습니다.');
|
||||||
@@ -256,8 +290,31 @@ class Menu extends BaseController
|
|||||||
return redirect()->to(base_url('admin/select-local-government'))
|
return redirect()->to(base_url('admin/select-local-government'))
|
||||||
->with('error', '지자체를 선택하세요.');
|
->with('error', '지자체를 선택하세요.');
|
||||||
}
|
}
|
||||||
$ids = $this->request->getPost('mm_idx');
|
|
||||||
$postMtIdx = (int) $this->request->getPost('mt_idx');
|
$postMtIdx = (int) $this->request->getPost('mt_idx');
|
||||||
|
|
||||||
|
// 순서 + 상위(부모) 변경 구조가 함께 전달되면 우선 처리 (하위 메뉴의 상위 이동 지원)
|
||||||
|
$treeJson = (string) $this->request->getPost('menu_tree');
|
||||||
|
$structure = $treeJson !== '' ? json_decode($treeJson, true) : null;
|
||||||
|
|
||||||
|
if (is_array($structure) && $structure !== []) {
|
||||||
|
$orderedIds = array_values(array_filter(array_map(
|
||||||
|
static fn ($it): int => (int) ($it['idx'] ?? 0),
|
||||||
|
$structure
|
||||||
|
), static fn (int $v): bool => $v > 0));
|
||||||
|
if (empty($orderedIds)) {
|
||||||
|
return $this->menusRedirect($postMtIdx)->with('error', '순서를 적용할 메뉴가 없습니다.');
|
||||||
|
}
|
||||||
|
$firstRow = $this->menuModel->find($orderedIds[0]);
|
||||||
|
$this->menuModel->setStructure($structure, $lgIdx);
|
||||||
|
if ($firstRow && (int) $firstRow->lg_idx === $lgIdx) {
|
||||||
|
$this->menuModel->pruneInventoryManagementMenus((int) $firstRow->mt_idx, $lgIdx);
|
||||||
|
$this->menuModel->syncTypeToAllLgs((int) $firstRow->mt_idx, $lgIdx);
|
||||||
|
}
|
||||||
|
$mtIdx = $firstRow ? (int) $firstRow->mt_idx : $postMtIdx;
|
||||||
|
return $this->menusRedirect($mtIdx)->with('success', '순서가 적용되었습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$ids = $this->request->getPost('mm_idx');
|
||||||
if (! is_array($ids) || empty($ids)) {
|
if (! is_array($ids) || empty($ids)) {
|
||||||
return $this->menusRedirect($postMtIdx)->with('error', '순서를 적용할 메뉴가 없습니다.');
|
return $this->menusRedirect($postMtIdx)->with('error', '순서를 적용할 메뉴가 없습니다.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,14 +19,15 @@ class PasswordChange extends BaseController
|
|||||||
helper('admin');
|
helper('admin');
|
||||||
$rules = [
|
$rules = [
|
||||||
'current_password' => 'required',
|
'current_password' => 'required',
|
||||||
'new_password' => 'required|min_length[4]|max_length[255]',
|
'new_password' => ['required', 'min_length[8]', 'max_length[255]', 'regex_match[/^(?=.*[A-Za-z])(?=.*\d)(?=.*[\W_]).+$/]'],
|
||||||
'new_password_confirm' => 'required|matches[new_password]',
|
'new_password_confirm' => 'required|matches[new_password]',
|
||||||
];
|
];
|
||||||
$messages = [
|
$messages = [
|
||||||
'current_password' => ['required' => '현재 비밀번호를 입력해 주세요.'],
|
'current_password' => ['required' => '현재 비밀번호를 입력해 주세요.'],
|
||||||
'new_password' => [
|
'new_password' => [
|
||||||
'required' => '새 비밀번호를 입력해 주세요.',
|
'required' => '새 비밀번호를 입력해 주세요.',
|
||||||
'min_length' => '비밀번호는 4자 이상이어야 합니다.',
|
'min_length' => '비밀번호는 8자 이상이어야 합니다.',
|
||||||
|
'regex_match' => '비밀번호는 영문·숫자·특수문자를 모두 포함해야 합니다.',
|
||||||
],
|
],
|
||||||
'new_password_confirm' => [
|
'new_password_confirm' => [
|
||||||
'required' => '비밀번호 확인을 입력해 주세요.',
|
'required' => '비밀번호 확인을 입력해 주세요.',
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ class SalesAgency extends BaseController
|
|||||||
$builder->where('sa_idx', (int) $saIdx);
|
$builder->where('sa_idx', (int) $saIdx);
|
||||||
}
|
}
|
||||||
|
|
||||||
$list = $builder->orderForDisplay()->paginate(20);
|
// 방금 등록한 대행소가 맨 위에 보이도록 최신 등록순(번호 내림차순)으로 정렬
|
||||||
|
$list = $builder->orderBy('sa_idx', 'DESC')->paginate(20);
|
||||||
$pager = $this->model->pager;
|
$pager = $this->model->pager;
|
||||||
|
|
||||||
$queryForPager = [
|
$queryForPager = [
|
||||||
|
|||||||
@@ -12,9 +12,57 @@ use App\Models\PackagingUnitModel;
|
|||||||
use App\Models\DesignatedShopModel;
|
use App\Models\DesignatedShopModel;
|
||||||
use App\Models\LocalGovernmentModel;
|
use App\Models\LocalGovernmentModel;
|
||||||
use App\Models\SalesAgencyModel;
|
use App\Models\SalesAgencyModel;
|
||||||
|
use App\Models\ReportPresetModel;
|
||||||
|
|
||||||
class SalesReport extends BaseController
|
class SalesReport extends BaseController
|
||||||
{
|
{
|
||||||
|
/** 맞춤 집계표에서 선택 가능한 컬럼 카탈로그 (표시 순서 = 정의 순서) */
|
||||||
|
private const CUSTOM_REPORT_COLUMNS = [
|
||||||
|
'date' => ['label' => '일자', 'type' => 'str'],
|
||||||
|
'shop_no' => ['label' => '지정번호', 'type' => 'str'],
|
||||||
|
'shop_name' => ['label' => '판매소명', 'type' => 'str'],
|
||||||
|
'rep_name' => ['label' => '대표자', 'type' => 'str'],
|
||||||
|
'address' => ['label' => '소재지', 'type' => 'str'],
|
||||||
|
'zone' => ['label' => '구역', 'type' => 'str'],
|
||||||
|
'category' => ['label' => '품목구분', 'type' => 'str'],
|
||||||
|
'product' => ['label' => '품목', 'type' => 'str'],
|
||||||
|
'size' => ['label' => '용량', 'type' => 'str'],
|
||||||
|
'qty' => ['label' => '판매량', 'type' => 'num'],
|
||||||
|
'amount' => ['label' => '판매금액', 'type' => 'num'],
|
||||||
|
'fee' => ['label' => '수수료', 'type' => 'num'],
|
||||||
|
'total' => ['label' => '총액', 'type' => 'num'],
|
||||||
|
];
|
||||||
|
|
||||||
|
private const CUSTOM_REPORT_DEFAULT_COLUMNS = ['date', 'shop_no', 'shop_name', 'product', 'qty', 'amount'];
|
||||||
|
|
||||||
|
/** 품목명 → 3분류(봉투/음식물/폐기물) */
|
||||||
|
private function customReportCategory3(string $name): string
|
||||||
|
{
|
||||||
|
if (mb_strpos($name, '음식물') !== false) {
|
||||||
|
return 'food';
|
||||||
|
}
|
||||||
|
if (mb_strpos($name, '폐기물') !== false || mb_strpos($name, '대형') !== false) {
|
||||||
|
return 'waste';
|
||||||
|
}
|
||||||
|
return 'envelope';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function customReportCategoryLabel(string $cat): string
|
||||||
|
{
|
||||||
|
return ['all' => '전체', 'envelope' => '봉투', 'food' => '음식물', 'waste' => '폐기물'][$cat] ?? '전체';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 품목명에서 용량 토큰 추출(예: 10L / 1000원) */
|
||||||
|
private function customReportSize(string $name): string
|
||||||
|
{
|
||||||
|
if (preg_match('/(\d+)\s*[lL]/u', $name, $m) === 1) {
|
||||||
|
return $m[1] . 'L';
|
||||||
|
}
|
||||||
|
if (preg_match('/([\d,]+)\s*원/u', $name, $m) === 1) {
|
||||||
|
return str_replace(',', '', $m[1]) . '원';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* P5-01 / PWB-090101-001: 지정 판매소 판매 대장 (일자별·기간별, 엑셀·인쇄)
|
* P5-01 / PWB-090101-001: 지정 판매소 판매 대장 (일자별·기간별, 엑셀·인쇄)
|
||||||
*/
|
*/
|
||||||
@@ -70,6 +118,16 @@ class SalesReport extends BaseController
|
|||||||
$hasBsFee
|
$hasBsFee
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 대표자명 PII: 암호화 대비 복호화 + 권한 없으면 마스킹 (표시 계층)
|
||||||
|
helper('pii_encryption');
|
||||||
|
$canViewPii = can_view_shop_pii((int) $lgIdx);
|
||||||
|
foreach ($detailRows as $row) {
|
||||||
|
if (isset($row->ds_rep_name) && $row->ds_rep_name !== '') {
|
||||||
|
$rep = pii_decrypt((string) $row->ds_rep_name);
|
||||||
|
$row->ds_rep_name = $canViewPii ? $rep : mask_shop_field('ds_rep_name', $rep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$filtered = [];
|
$filtered = [];
|
||||||
foreach ($detailRows as $row) {
|
foreach ($detailRows as $row) {
|
||||||
if ($this->ledgerRowMatchesCategories($row, $cats)) {
|
if ($this->ledgerRowMatchesCategories($row, $cats)) {
|
||||||
@@ -77,6 +135,74 @@ class SalesReport extends BaseController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 크기별(리터/금액) 조회 — 품목명에서 크기 토큰 추출(예: 10L, 1000원)
|
||||||
|
$sizeOf = static function (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 '';
|
||||||
|
};
|
||||||
|
// 봉투(일반용)/음식물/폐기물 등 카테고리별로 선택 가능한 크기 목록을 구성한다.
|
||||||
|
// (카테고리 필터까지 적용된 현재 결과 기준)
|
||||||
|
$sizeCatLabels = [
|
||||||
|
'general' => '일반용 봉투',
|
||||||
|
'reuse' => '재사용 봉투',
|
||||||
|
'public_use' => '공공용 봉투',
|
||||||
|
'food' => '음식물 봉투',
|
||||||
|
'waste' => '폐기물 봉투',
|
||||||
|
];
|
||||||
|
// 스티커·공동주택용(apt)·용기(container)는 판매대장 크기 필터에서 제외
|
||||||
|
// 순서: 봉투(일반용·재사용·공공용) → 음식물 → 폐기물 (피드백 #16)
|
||||||
|
$sizeCatOrder = ['general', 'reuse', 'public_use', 'food', 'waste'];
|
||||||
|
|
||||||
|
$sizeByCat = []; // catKey => [size => true]
|
||||||
|
foreach ($filtered as $row) {
|
||||||
|
$sz = $sizeOf((string) ($row->bs_bag_name ?? ''));
|
||||||
|
if ($sz === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$ck = $this->classifyLedgerProductCategory(
|
||||||
|
(string) ($row->bs_bag_name ?? ''),
|
||||||
|
(string) ($row->bs_bag_code ?? '')
|
||||||
|
);
|
||||||
|
$sizeByCat[$ck][$sz] = true;
|
||||||
|
}
|
||||||
|
$sizeGroups = [];
|
||||||
|
foreach ($sizeCatOrder as $ck) {
|
||||||
|
if (empty($sizeByCat[$ck])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$sizes = array_keys($sizeByCat[$ck]);
|
||||||
|
usort($sizes, static function (string $a, string $b): int {
|
||||||
|
return (int) preg_replace('/\D/', '', $a) <=> (int) preg_replace('/\D/', '', $b);
|
||||||
|
});
|
||||||
|
$sizeGroups[] = ['key' => $ck, 'label' => $sizeCatLabels[$ck] ?? $ck, 'sizes' => $sizes];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 선택값은 "카테고리|크기" 형식(예: general|10L). 구버전(크기만) 값도 호환.
|
||||||
|
$size = trim((string) ($this->request->getGet('size') ?? ''));
|
||||||
|
if ($size !== '') {
|
||||||
|
if (strpos($size, '|') !== false) {
|
||||||
|
[$selCat, $selSize] = array_pad(explode('|', $size, 2), 2, '');
|
||||||
|
$filtered = array_values(array_filter($filtered, function ($row) use ($sizeOf, $selCat, $selSize): bool {
|
||||||
|
$ck = $this->classifyLedgerProductCategory(
|
||||||
|
(string) ($row->bs_bag_name ?? ''),
|
||||||
|
(string) ($row->bs_bag_code ?? '')
|
||||||
|
);
|
||||||
|
return $ck === $selCat && $sizeOf((string) ($row->bs_bag_name ?? '')) === $selSize;
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
$filtered = array_values(array_filter($filtered, static fn ($row): bool => $sizeOf((string) ($row->bs_bag_name ?? '')) === $size));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 하단 통계: 현재 필터(카테고리·크기)까지 적용된 결과 기준으로
|
||||||
|
// ① 용량별(리터별) ② 품목별(봉투/음식물/폐기물) 집계
|
||||||
|
[$sizeStats, $catStats] = $this->buildLedgerBottomStats($filtered, $sizeOf);
|
||||||
|
|
||||||
if ($mode === 'daily') {
|
if ($mode === 'daily') {
|
||||||
$built = $this->buildDailyLedgerPresentation($filtered, $hasBsFee);
|
$built = $this->buildDailyLedgerPresentation($filtered, $hasBsFee);
|
||||||
} else {
|
} else {
|
||||||
@@ -169,6 +295,10 @@ class SalesReport extends BaseController
|
|||||||
'dsIdx' => $dsIdx,
|
'dsIdx' => $dsIdx,
|
||||||
'saIdx' => $saIdx,
|
'saIdx' => $saIdx,
|
||||||
'cats' => $cats,
|
'cats' => $cats,
|
||||||
|
'sizeGroups' => $sizeGroups,
|
||||||
|
'size' => $size,
|
||||||
|
'sizeStats' => $sizeStats,
|
||||||
|
'catStats' => $catStats,
|
||||||
'shops' => $shops,
|
'shops' => $shops,
|
||||||
'agencies' => $agencies,
|
'agencies' => $agencies,
|
||||||
'lgName' => $lgName,
|
'lgName' => $lgName,
|
||||||
@@ -220,6 +350,68 @@ class SalesReport extends BaseController
|
|||||||
return $builder->get()->getResult();
|
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 빈 배열이면 전체
|
* @param list<string> $cats 빈 배열이면 전체
|
||||||
*/
|
*/
|
||||||
@@ -1038,7 +1230,8 @@ class SalesReport extends BaseController
|
|||||||
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
|
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$startDate = (string) ($this->request->getGet('start_date') ?? date('Y-m-01'));
|
// 기본 조회 기간: 오늘로부터 일주일 전 ~ 오늘
|
||||||
|
$startDate = (string) ($this->request->getGet('start_date') ?? date('Y-m-d', strtotime('-7 days')));
|
||||||
$endDate = (string) ($this->request->getGet('end_date') ?? date('Y-m-d'));
|
$endDate = (string) ($this->request->getGet('end_date') ?? date('Y-m-d'));
|
||||||
if ($startDate > $endDate) {
|
if ($startDate > $endDate) {
|
||||||
[$startDate, $endDate] = [$endDate, $startDate];
|
[$startDate, $endDate] = [$endDate, $startDate];
|
||||||
@@ -1216,6 +1409,30 @@ class SalesReport extends BaseController
|
|||||||
|
|
||||||
if ($export) {
|
if ($export) {
|
||||||
$headers = array_merge(['품목', '구분'], array_map(static fn (array $c): string => (string) ($c['label'] ?? ''), $built['colSpec']));
|
$headers = array_merge(['품목', '구분'], array_map(static fn (array $c): string => (string) ($c['label'] ?? ''), $built['colSpec']));
|
||||||
|
$colCount = count($headers);
|
||||||
|
|
||||||
|
// 최상단 제목 블록: (지역/범위) ……… (단위 : 매/원) / (제목) ……… 출력일
|
||||||
|
$region = trim(
|
||||||
|
(string) ($lgRow->lg_sido ?? '') . ' ' . (string) ($lgRow->lg_gugun ?? '')
|
||||||
|
);
|
||||||
|
if ($region === '') {
|
||||||
|
$region = $lgName;
|
||||||
|
}
|
||||||
|
$makeTopRow = static function (string $left, string $right) use ($colCount): array {
|
||||||
|
$row = array_fill(0, max($colCount, 1), '');
|
||||||
|
$row[0] = $left;
|
||||||
|
if ($right !== '') {
|
||||||
|
$row[max($colCount - 1, 0)] = $right;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $row;
|
||||||
|
};
|
||||||
|
$topRows = [
|
||||||
|
$makeTopRow($region . ' / ' . $gugunLabel, '(단위 : 매/원)'),
|
||||||
|
$makeTopRow($year . '년 판매현황 · 대행소: ' . $agencyLabel, '출력일: ' . date('Y-m-d')),
|
||||||
|
array_fill(0, max($colCount, 1), ''), // 구분용 빈 줄
|
||||||
|
];
|
||||||
|
|
||||||
$exportRows = [];
|
$exportRows = [];
|
||||||
foreach ($built['itemBlocks'] as $block) {
|
foreach ($built['itemBlocks'] as $block) {
|
||||||
foreach ($block['lines'] as $li) {
|
foreach ($block['lines'] as $li) {
|
||||||
@@ -1238,7 +1455,8 @@ class SalesReport extends BaseController
|
|||||||
'년판매현황_' . $year . '_' . $exportStamp,
|
'년판매현황_' . $year . '_' . $exportStamp,
|
||||||
$year . '년판매',
|
$year . '년판매',
|
||||||
$headers,
|
$headers,
|
||||||
$exportRows
|
$exportRows,
|
||||||
|
$topRows
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2560,7 +2778,8 @@ class SalesReport extends BaseController
|
|||||||
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
|
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$queried = $this->request->getGet('search') === '1';
|
// 첫 진입(search 파라미터 없음)에도 기본 조건(이번 달·불출)으로 자동 조회해 바로 표시한다.
|
||||||
|
$queried = ($this->request->getGet('search') ?? '1') === '1';
|
||||||
$startDate = (string) ($this->request->getGet('start_date') ?? date('Y-m-01'));
|
$startDate = (string) ($this->request->getGet('start_date') ?? date('Y-m-01'));
|
||||||
$endDate = (string) ($this->request->getGet('end_date') ?? date('Y-m-d'));
|
$endDate = (string) ($this->request->getGet('end_date') ?? date('Y-m-d'));
|
||||||
if ($startDate > $endDate) {
|
if ($startDate > $endDate) {
|
||||||
@@ -3189,7 +3408,8 @@ class SalesReport extends BaseController
|
|||||||
$salesScope = 'all';
|
$salesScope = 'all';
|
||||||
}
|
}
|
||||||
|
|
||||||
$queried = $this->request->getGet('search') === '1';
|
// 첫 진입(search 파라미터 없음)에도 기본 조건(오늘·40일·ALL)으로 자동 조회해 바로 표시한다.
|
||||||
|
$queried = ($this->request->getGet('search') ?? '1') === '1';
|
||||||
$built = (new \App\Libraries\BagSupplyPlanBuilder())->build(
|
$built = (new \App\Libraries\BagSupplyPlanBuilder())->build(
|
||||||
$lgIdx,
|
$lgIdx,
|
||||||
$refDate,
|
$refDate,
|
||||||
@@ -3216,4 +3436,256 @@ class SalesReport extends BaseController
|
|||||||
'salesLabel' => $scopeLabel($salesScope),
|
'salesLabel' => $scopeLabel($salesScope),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 맞춤 집계표 — 기간·품목구분·컬럼을 골라 제목을 붙여 조회/인쇄, 계정별 프리셋 저장.
|
||||||
|
*/
|
||||||
|
public function customReport()
|
||||||
|
{
|
||||||
|
helper(['admin', 'export']);
|
||||||
|
$lgIdx = admin_effective_lg_idx();
|
||||||
|
if (! $lgIdx) {
|
||||||
|
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
|
||||||
|
}
|
||||||
|
$mbIdx = (int) session()->get('mb_idx');
|
||||||
|
|
||||||
|
// report_preset 테이블이 있을 때만 프리셋 기능 활성(없으면 조회·인쇄는 그대로 동작)
|
||||||
|
$presetsEnabled = \Config\Database::connect()->tableExists('report_preset');
|
||||||
|
$presetModel = model(ReportPresetModel::class);
|
||||||
|
$presets = [];
|
||||||
|
if ($presetsEnabled) {
|
||||||
|
$presets = $presetModel->where('rp_lg_idx', $lgIdx)->where('rp_mb_idx', $mbIdx)
|
||||||
|
->orderBy('rp_name', 'ASC')->findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 활성 설정 결정: 폼 적용(applied) > 프리셋 로드(preset) > 기본값
|
||||||
|
$presetIdx = (int) ($this->request->getGet('preset') ?? 0);
|
||||||
|
$loaded = null;
|
||||||
|
if ($presetsEnabled && $presetIdx > 0) {
|
||||||
|
$loaded = $presetModel->find($presetIdx);
|
||||||
|
if ($loaded === null || (int) $loaded->rp_lg_idx !== (int) $lgIdx || (int) $loaded->rp_mb_idx !== $mbIdx) {
|
||||||
|
$loaded = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$catalogKeys = array_keys(self::CUSTOM_REPORT_COLUMNS);
|
||||||
|
if ((string) ($this->request->getGet('applied') ?? '') === '1') {
|
||||||
|
$cols = (array) ($this->request->getGet('cols') ?? []);
|
||||||
|
$title = (string) ($this->request->getGet('title') ?? '');
|
||||||
|
$category = (string) ($this->request->getGet('category') ?? 'all');
|
||||||
|
$mode = (string) ($this->request->getGet('mode') ?? 'detail');
|
||||||
|
} elseif ($loaded !== null) {
|
||||||
|
$cols = json_decode((string) ($loaded->rp_columns ?? '[]'), true) ?: [];
|
||||||
|
$title = (string) $loaded->rp_title;
|
||||||
|
$category = (string) $loaded->rp_category;
|
||||||
|
$mode = (string) $loaded->rp_mode;
|
||||||
|
} else {
|
||||||
|
$cols = self::CUSTOM_REPORT_DEFAULT_COLUMNS;
|
||||||
|
$title = '';
|
||||||
|
$category = 'all';
|
||||||
|
$mode = 'detail';
|
||||||
|
}
|
||||||
|
// 정규화
|
||||||
|
$selected = array_values(array_filter($catalogKeys, static fn ($k) => in_array($k, $cols, true)));
|
||||||
|
if ($selected === []) {
|
||||||
|
$selected = self::CUSTOM_REPORT_DEFAULT_COLUMNS;
|
||||||
|
}
|
||||||
|
if (! in_array($category, ['all', 'envelope', 'food', 'waste'], true)) {
|
||||||
|
$category = 'all';
|
||||||
|
}
|
||||||
|
$mode = $mode === 'summary' ? 'summary' : 'detail';
|
||||||
|
|
||||||
|
$isYmd = static fn (string $d): bool => preg_match('/^\d{4}-\d{2}-\d{2}$/', $d) === 1;
|
||||||
|
$startDate = (string) ($this->request->getGet('start_date') ?? date('Y-m-01'));
|
||||||
|
$endDate = (string) ($this->request->getGet('end_date') ?? date('Y-m-d'));
|
||||||
|
if (! $isYmd($startDate)) {
|
||||||
|
$startDate = date('Y-m-01');
|
||||||
|
}
|
||||||
|
if (! $isYmd($endDate)) {
|
||||||
|
$endDate = date('Y-m-d');
|
||||||
|
}
|
||||||
|
if ($startDate > $endDate) {
|
||||||
|
[$startDate, $endDate] = [$endDate, $startDate];
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
$hasBsFee = $db->fieldExists('bs_fee', 'bag_sale');
|
||||||
|
$detail = $this->fetchSalesLedgerDetailRows($db, (int) $lgIdx, $startDate, $endDate, 0, 0, $hasBsFee);
|
||||||
|
|
||||||
|
// 카테고리 필터 + 행 매핑
|
||||||
|
$rows = [];
|
||||||
|
foreach ($detail as $r) {
|
||||||
|
$name = (string) ($r->bs_bag_name ?? '');
|
||||||
|
$cat3 = $this->customReportCategory3($name);
|
||||||
|
if ($category !== 'all' && $cat3 !== $category) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$qty = (int) ($r->line_qty ?? 0);
|
||||||
|
$amount = (float) ($r->line_amount ?? 0);
|
||||||
|
$fee = (float) ($r->line_fee ?? 0);
|
||||||
|
$rows[] = [
|
||||||
|
'date' => substr((string) ($r->bs_sale_date ?? ''), 0, 10),
|
||||||
|
'shop_no' => (string) ($r->ds_shop_no ?? ''),
|
||||||
|
'shop_name' => (string) ($r->ds_name ?? ''),
|
||||||
|
'rep_name' => (string) ($r->ds_rep_name ?? ''),
|
||||||
|
'address' => (string) ($r->ds_addr ?? ''),
|
||||||
|
'zone' => (string) ($r->ds_zone_code ?? ''),
|
||||||
|
'category' => $this->customReportCategoryLabel($cat3),
|
||||||
|
'product' => $name,
|
||||||
|
'size' => $this->customReportSize($name),
|
||||||
|
'qty' => $qty,
|
||||||
|
'amount' => (int) round($amount),
|
||||||
|
'fee' => (int) round($fee),
|
||||||
|
'total' => (int) round($amount + $fee),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 집계 모드: 품목(구분+품명+용량) 기준 합산
|
||||||
|
if ($mode === 'summary') {
|
||||||
|
$agg = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$key = $row['category'] . '|' . $row['product'] . '|' . $row['size'];
|
||||||
|
if (! isset($agg[$key])) {
|
||||||
|
$agg[$key] = ['category' => $row['category'], 'product' => $row['product'], 'size' => $row['size'],
|
||||||
|
'date' => '', 'shop_no' => '', 'shop_name' => '', 'rep_name' => '', 'address' => '', 'zone' => '',
|
||||||
|
'qty' => 0, 'amount' => 0, 'fee' => 0, 'total' => 0];
|
||||||
|
}
|
||||||
|
$agg[$key]['qty'] += $row['qty'];
|
||||||
|
$agg[$key]['amount'] += $row['amount'];
|
||||||
|
$agg[$key]['fee'] += $row['fee'];
|
||||||
|
$agg[$key]['total'] += $row['total'];
|
||||||
|
}
|
||||||
|
$rows = array_values($agg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 숫자 컬럼 합계
|
||||||
|
$totals = [];
|
||||||
|
foreach ($selected as $key) {
|
||||||
|
if ((self::CUSTOM_REPORT_COLUMNS[$key]['type'] ?? '') === 'num') {
|
||||||
|
$totals[$key] = array_sum(array_map(static fn ($r) => (int) $r[$key], $rows));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 엑셀 다운로드 — 현재 컬럼·필터·제목 그대로
|
||||||
|
if ((string) ($this->request->getGet('export') ?? '') === '1') {
|
||||||
|
$headers = array_map(static fn ($k) => self::CUSTOM_REPORT_COLUMNS[$k]['label'], $selected);
|
||||||
|
$exportRows = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$line = [];
|
||||||
|
foreach ($selected as $key) {
|
||||||
|
$isNum = (self::CUSTOM_REPORT_COLUMNS[$key]['type'] ?? '') === 'num';
|
||||||
|
$line[] = $isNum ? number_format((int) ($row[$key] ?? 0)) : (string) ($row[$key] ?? '');
|
||||||
|
}
|
||||||
|
$exportRows[] = $line;
|
||||||
|
}
|
||||||
|
// 합계 행
|
||||||
|
$totalLine = [];
|
||||||
|
foreach ($selected as $i => $key) {
|
||||||
|
$isNum = (self::CUSTOM_REPORT_COLUMNS[$key]['type'] ?? '') === 'num';
|
||||||
|
if ($i === 0) {
|
||||||
|
$totalLine[] = '합계';
|
||||||
|
} else {
|
||||||
|
$totalLine[] = $isNum ? number_format((int) ($totals[$key] ?? 0)) : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$exportRows[] = $totalLine;
|
||||||
|
|
||||||
|
$fileTitle = $title !== '' ? $title : '맞춤집계표';
|
||||||
|
$topRows = [
|
||||||
|
[$fileTitle],
|
||||||
|
['조회기간: ' . $startDate . ' ~ ' . $endDate . ' / 품목구분: ' . $this->customReportCategoryLabel($category)],
|
||||||
|
];
|
||||||
|
export_excel_2003_xml($fileTitle . '_' . $startDate . '_' . $endDate, mb_substr($fileTitle, 0, 31), $headers, $exportRows, $topRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->renderWorkPage('맞춤 집계표', 'admin/sales_report/custom_report', [
|
||||||
|
'columnCatalog' => self::CUSTOM_REPORT_COLUMNS,
|
||||||
|
'selected' => $selected,
|
||||||
|
'title' => $title,
|
||||||
|
'category' => $category,
|
||||||
|
'mode' => $mode,
|
||||||
|
'startDate' => $startDate,
|
||||||
|
'endDate' => $endDate,
|
||||||
|
'rows' => $rows,
|
||||||
|
'totals' => $totals,
|
||||||
|
'presets' => $presets,
|
||||||
|
'activePresetIdx' => $loaded !== null ? (int) $loaded->rp_idx : 0,
|
||||||
|
'presetsEnabled' => $presetsEnabled,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 맞춤 집계표 프리셋 저장(신규/수정) — 계정 소유 */
|
||||||
|
public function customReportPresetSave()
|
||||||
|
{
|
||||||
|
helper('admin');
|
||||||
|
$lgIdx = admin_effective_lg_idx();
|
||||||
|
if (! $lgIdx) {
|
||||||
|
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
|
||||||
|
}
|
||||||
|
$mbIdx = (int) session()->get('mb_idx');
|
||||||
|
|
||||||
|
if (! \Config\Database::connect()->tableExists('report_preset')) {
|
||||||
|
return redirect()->to(site_url('bag/reports/custom'))
|
||||||
|
->with('error', '프리셋 저장 테이블이 아직 생성되지 않았습니다. (report_preset_table.sql 실행 필요)');
|
||||||
|
}
|
||||||
|
$name = trim((string) $this->request->getPost('rp_name'));
|
||||||
|
if ($name === '') {
|
||||||
|
return redirect()->to(site_url('bag/reports/custom'))->with('error', '프리셋 이름을 입력해 주세요.');
|
||||||
|
}
|
||||||
|
$cols = (array) ($this->request->getPost('cols') ?? []);
|
||||||
|
$catalog = array_keys(self::CUSTOM_REPORT_COLUMNS);
|
||||||
|
$selected = array_values(array_filter($catalog, static fn ($k) => in_array($k, $cols, true)));
|
||||||
|
if ($selected === []) {
|
||||||
|
$selected = self::CUSTOM_REPORT_DEFAULT_COLUMNS;
|
||||||
|
}
|
||||||
|
$category = (string) $this->request->getPost('rp_category');
|
||||||
|
if (! in_array($category, ['all', 'envelope', 'food', 'waste'], true)) {
|
||||||
|
$category = 'all';
|
||||||
|
}
|
||||||
|
$mode = (string) $this->request->getPost('rp_mode') === 'summary' ? 'summary' : 'detail';
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'rp_lg_idx' => $lgIdx,
|
||||||
|
'rp_mb_idx' => $mbIdx,
|
||||||
|
'rp_name' => mb_substr($name, 0, 100),
|
||||||
|
'rp_title' => mb_substr((string) $this->request->getPost('rp_title'), 0, 200),
|
||||||
|
'rp_category' => $category,
|
||||||
|
'rp_mode' => $mode,
|
||||||
|
'rp_columns' => json_encode($selected, JSON_UNESCAPED_UNICODE),
|
||||||
|
];
|
||||||
|
|
||||||
|
$presetModel = model(ReportPresetModel::class);
|
||||||
|
$rpIdx = (int) ($this->request->getPost('rp_idx') ?? 0);
|
||||||
|
if ($rpIdx > 0) {
|
||||||
|
$existing = $presetModel->find($rpIdx);
|
||||||
|
if ($existing !== null && (int) $existing->rp_lg_idx === (int) $lgIdx && (int) $existing->rp_mb_idx === $mbIdx) {
|
||||||
|
$presetModel->update($rpIdx, $data);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$presetModel->insert($data);
|
||||||
|
$rpIdx = (int) $presetModel->getInsertID();
|
||||||
|
}
|
||||||
|
|
||||||
|
$q = http_build_query(['preset' => $rpIdx, 'start_date' => $this->request->getPost('start_date'), 'end_date' => $this->request->getPost('end_date')]);
|
||||||
|
|
||||||
|
return redirect()->to(site_url('bag/reports/custom') . '?' . $q)->with('success', '프리셋을 저장했습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 맞춤 집계표 프리셋 삭제 — 계정 소유 */
|
||||||
|
public function customReportPresetDelete()
|
||||||
|
{
|
||||||
|
helper('admin');
|
||||||
|
$lgIdx = admin_effective_lg_idx();
|
||||||
|
$mbIdx = (int) session()->get('mb_idx');
|
||||||
|
$rpIdx = (int) ($this->request->getPost('rp_idx') ?? 0);
|
||||||
|
$presetModel = model(ReportPresetModel::class);
|
||||||
|
$existing = $rpIdx > 0 ? $presetModel->find($rpIdx) : null;
|
||||||
|
if ($existing !== null && (int) $existing->rp_lg_idx === (int) $lgIdx && (int) $existing->rp_mb_idx === $mbIdx) {
|
||||||
|
$presetModel->delete($rpIdx);
|
||||||
|
|
||||||
|
return redirect()->to(site_url('bag/reports/custom'))->with('success', '프리셋을 삭제했습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->to(site_url('bag/reports/custom'))->with('error', '삭제할 프리셋을 찾을 수 없습니다.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,10 +22,13 @@ class SelectLocalGovernment extends BaseController
|
|||||||
->orderBy('lg_name', 'ASC')
|
->orderBy('lg_name', 'ASC')
|
||||||
->findAll();
|
->findAll();
|
||||||
|
|
||||||
|
$currentLgIdx = (int) (session()->get('admin_selected_lg_idx') ?? 0);
|
||||||
|
|
||||||
return view('admin/layout', [
|
return view('admin/layout', [
|
||||||
'title' => '지자체 선택',
|
'title' => '지자체 선택',
|
||||||
'content' => view('admin/select_local_government/index', [
|
'content' => view('admin/select_local_government/index', [
|
||||||
'list' => $list,
|
'list' => $list,
|
||||||
|
'currentLgIdx' => $currentLgIdx,
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,13 +83,13 @@ class User extends BaseController
|
|||||||
{
|
{
|
||||||
$rules = [
|
$rules = [
|
||||||
'mb_id' => 'required|min_length[2]|max_length[50]|is_unique[member.mb_id]',
|
'mb_id' => 'required|min_length[2]|max_length[50]|is_unique[member.mb_id]',
|
||||||
'mb_passwd' => 'required|min_length[4]|max_length[255]',
|
'mb_passwd' => ['required', 'min_length[8]', 'max_length[255]', 'regex_match[/^(?=.*[A-Za-z])(?=.*\d)(?=.*[\W_]).+$/]'],
|
||||||
'mb_name' => 'required|max_length[50]',
|
'mb_name' => 'required|max_length[50]',
|
||||||
'mb_email' => 'permit_empty|valid_email|max_length[100]',
|
'mb_email' => 'permit_empty|valid_email|max_length[100]',
|
||||||
'mb_phone' => 'permit_empty|max_length[20]',
|
'mb_phone' => 'permit_empty|max_length[20]',
|
||||||
'mb_level' => 'required',
|
'mb_level' => 'required',
|
||||||
];
|
];
|
||||||
if (! $this->validate($rules)) {
|
if (! $this->validate($rules, ['mb_passwd' => ['min_length' => '비밀번호는 8자 이상이어야 합니다.', 'regex_match' => '비밀번호는 영문·숫자·특수문자를 모두 포함해야 합니다.']])) {
|
||||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||||
}
|
}
|
||||||
$allowedLevels = array_keys($this->getAssignableLevels());
|
$allowedLevels = array_keys($this->getAssignableLevels());
|
||||||
@@ -154,9 +154,9 @@ class User extends BaseController
|
|||||||
];
|
];
|
||||||
$passwd = $this->request->getPost('mb_passwd');
|
$passwd = $this->request->getPost('mb_passwd');
|
||||||
if ($passwd !== '') {
|
if ($passwd !== '') {
|
||||||
$rules['mb_passwd'] = 'min_length[4]|max_length[255]';
|
$rules['mb_passwd'] = ['min_length[8]', 'max_length[255]', 'regex_match[/^(?=.*[A-Za-z])(?=.*\d)(?=.*[\W_]).+$/]'];
|
||||||
}
|
}
|
||||||
if (! $this->validate($rules)) {
|
if (! $this->validate($rules, ['mb_passwd' => ['min_length' => '비밀번호는 8자 이상이어야 합니다.', 'regex_match' => '비밀번호는 영문·숫자·특수문자를 모두 포함해야 합니다.']])) {
|
||||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||||
}
|
}
|
||||||
$allowedLevels = array_keys($this->getAssignableLevels());
|
$allowedLevels = array_keys($this->getAssignableLevels());
|
||||||
|
|||||||
@@ -19,15 +19,42 @@ class Auth extends BaseController
|
|||||||
public function showLoginForm()
|
public function showLoginForm()
|
||||||
{
|
{
|
||||||
if (session()->get('logged_in')) {
|
if (session()->get('logged_in')) {
|
||||||
return redirect()->to('/');
|
// 이미 로그인 상태면 역할별 홈으로(뒤로가기로 로그인 화면에 와도 작업 화면으로 복귀)
|
||||||
|
return redirect()->to($this->loggedInHomeUrl());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 로그인 후 뒤로가기 시 브라우저 캐시(bfcache)로 로그인 화면이 다시 보이는 것을 방지.
|
||||||
|
// 캐시 금지 → 뒤로가기 시 서버 재요청 → 로그인 상태면 위에서 작업 화면으로 리다이렉트.
|
||||||
|
$this->response->setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0');
|
||||||
|
$this->response->setHeader('Pragma', 'no-cache');
|
||||||
|
$this->response->setHeader('Expires', '0');
|
||||||
|
|
||||||
return view('auth/login', [
|
return view('auth/login', [
|
||||||
'pageTitle' => '로그인 - GBLS',
|
'pageTitle' => '로그인 - GBLS',
|
||||||
'cardMax' => 'max-w-md',
|
'cardMax' => 'max-w-md',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그인 상태 사용자의 역할별 기본 홈 URL.
|
||||||
|
* (로그인 성공 시 이동 지점과 일치 — 단, 슈퍼/본부는 지자체를 이미 선택했으면 작업 화면으로)
|
||||||
|
*/
|
||||||
|
private function loggedInHomeUrl(): string
|
||||||
|
{
|
||||||
|
$level = (int) session()->get('mb_level');
|
||||||
|
|
||||||
|
if ($level === \Config\Roles::LEVEL_LOCAL_ADMIN) {
|
||||||
|
return site_url('admin');
|
||||||
|
}
|
||||||
|
if (\Config\Roles::isSuperAdminEquivalent($level)) {
|
||||||
|
$hasLg = (int) (session()->get('admin_selected_lg_idx') ?? 0) > 0;
|
||||||
|
|
||||||
|
return site_url($hasLg ? 'admin' : 'admin/select-local-government');
|
||||||
|
}
|
||||||
|
|
||||||
|
return site_url('/');
|
||||||
|
}
|
||||||
|
|
||||||
public function login()
|
public function login()
|
||||||
{
|
{
|
||||||
$rules = [
|
$rules = [
|
||||||
@@ -206,7 +233,7 @@ class Auth extends BaseController
|
|||||||
return $this->handleTotpFailure($member, $this->buildLogData($member->mb_id, (int) $member->mb_idx));
|
return $this->handleTotpFailure($member, $this->buildLogData($member->mb_id, (int) $member->mb_idx));
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->completeLogin($member, $this->buildLogData($member->mb_id, (int) $member->mb_idx));
|
return $this->completeLogin($member, $this->buildLogData($member->mb_id, (int) $member->mb_idx), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function showTotpSetup()
|
public function showTotpSetup()
|
||||||
@@ -302,7 +329,7 @@ class Auth extends BaseController
|
|||||||
return redirect()->to(site_url('login'))->with('error', '회원 정보를 다시 확인할 수 없습니다.');
|
return redirect()->to(site_url('login'))->with('error', '회원 정보를 다시 확인할 수 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->completeLogin($fresh, $this->buildLogData($fresh->mb_id, (int) $fresh->mb_idx));
|
return $this->completeLogin($fresh, $this->buildLogData($fresh->mb_id, (int) $fresh->mb_idx), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function logout()
|
public function logout()
|
||||||
@@ -357,7 +384,7 @@ class Auth extends BaseController
|
|||||||
{
|
{
|
||||||
$rules = [
|
$rules = [
|
||||||
'mb_id' => 'required|min_length[4]|max_length[50]|is_unique[member.mb_id]',
|
'mb_id' => 'required|min_length[4]|max_length[50]|is_unique[member.mb_id]',
|
||||||
'mb_passwd' => 'required|min_length[4]|max_length[255]',
|
'mb_passwd' => ['required', 'min_length[8]', 'max_length[255]', 'regex_match[/^(?=.*[A-Za-z])(?=.*\d)(?=.*[\W_]).+$/]'],
|
||||||
'mb_passwd_confirm' => 'required|matches[mb_passwd]',
|
'mb_passwd_confirm' => 'required|matches[mb_passwd]',
|
||||||
'mb_name' => 'required|max_length[100]',
|
'mb_name' => 'required|max_length[100]',
|
||||||
'mb_email' => 'permit_empty|valid_email|max_length[100]',
|
'mb_email' => 'permit_empty|valid_email|max_length[100]',
|
||||||
@@ -374,9 +401,10 @@ class Auth extends BaseController
|
|||||||
'is_unique' => '이미 사용 중인 아이디입니다.',
|
'is_unique' => '이미 사용 중인 아이디입니다.',
|
||||||
],
|
],
|
||||||
'mb_passwd' => [
|
'mb_passwd' => [
|
||||||
'required' => '비밀번호를 입력해 주세요.',
|
'required' => '비밀번호를 입력해 주세요.',
|
||||||
'min_length' => '비밀번호는 4자 이상이어야 합니다.',
|
'min_length' => '비밀번호는 8자 이상이어야 합니다.',
|
||||||
'max_length' => '비밀번호는 255자 이하여야 합니다.',
|
'max_length' => '비밀번호는 255자 이하여야 합니다.',
|
||||||
|
'regex_match' => '비밀번호는 영문·숫자·특수문자를 모두 포함해야 합니다.',
|
||||||
],
|
],
|
||||||
'mb_passwd_confirm' => [
|
'mb_passwd_confirm' => [
|
||||||
'required' => '비밀번호 확인을 입력해 주세요.',
|
'required' => '비밀번호 확인을 입력해 주세요.',
|
||||||
@@ -545,9 +573,11 @@ class Auth extends BaseController
|
|||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $logData
|
* @param array<string, mixed> $logData
|
||||||
*/
|
*/
|
||||||
private function completeLogin(object $member, array $logData): RedirectResponse
|
private function completeLogin(object $member, array $logData, bool $twofaVerified = false): RedirectResponse
|
||||||
{
|
{
|
||||||
$this->clearPending2faSession();
|
$this->clearPending2faSession();
|
||||||
|
// 중복 로그인 방지(나중 로그인 우선): 새 세션 토큰 발급 → 기존 다른 세션은 다음 요청 때 무효화됨
|
||||||
|
$sessionToken = bin2hex(random_bytes(16));
|
||||||
$sessionData = [
|
$sessionData = [
|
||||||
'mb_idx' => $member->mb_idx,
|
'mb_idx' => $member->mb_idx,
|
||||||
'mb_id' => $member->mb_id,
|
'mb_id' => $member->mb_id,
|
||||||
@@ -555,6 +585,9 @@ class Auth extends BaseController
|
|||||||
'mb_level' => $member->mb_level,
|
'mb_level' => $member->mb_level,
|
||||||
'mb_lg_idx' => $member->mb_lg_idx ?? null,
|
'mb_lg_idx' => $member->mb_lg_idx ?? null,
|
||||||
'logged_in' => true,
|
'logged_in' => true,
|
||||||
|
'session_token' => $sessionToken,
|
||||||
|
// 지정판매소 PII 자동 원문 열람 조건에 사용(TOTP 실제 통과 시에만 true)
|
||||||
|
'auth_2fa_verified' => $twofaVerified,
|
||||||
];
|
];
|
||||||
session()->set($sessionData);
|
session()->set($sessionData);
|
||||||
|
|
||||||
@@ -562,18 +595,42 @@ class Auth extends BaseController
|
|||||||
'mb_latestdate' => date('Y-m-d H:i:s'),
|
'mb_latestdate' => date('Y-m-d H:i:s'),
|
||||||
'mb_login_fail_count' => 0,
|
'mb_login_fail_count' => 0,
|
||||||
'mb_locked_until' => null,
|
'mb_locked_until' => null,
|
||||||
|
'mb_session_token' => $sessionToken,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// 로그인 알림 — 직전 성공 로그인과 IP 비교(새 IP면 경고). 현재 로그인 기록 INSERT 이전에 조회.
|
||||||
|
$loginNotice = '';
|
||||||
|
try {
|
||||||
|
$curIp = (string) $this->request->getIPAddress();
|
||||||
|
$prev = model(MemberLogModel::class)
|
||||||
|
->where('mb_idx', (int) $member->mb_idx)
|
||||||
|
->where('mll_success', 1)
|
||||||
|
->orderBy('mll_idx', 'DESC')
|
||||||
|
->first();
|
||||||
|
if ($prev) {
|
||||||
|
$prevIp = (string) ($prev->mll_ip ?? '');
|
||||||
|
$prevAt = (string) ($prev->mll_regdate ?? '');
|
||||||
|
if ($prevIp !== '' && $prevIp !== $curIp) {
|
||||||
|
$loginNotice = ' ⚠ 새로운 환경(IP ' . $curIp . ')에서 로그인되었습니다. 본인이 아니면 비밀번호를 변경하세요. (이전 로그인: ' . $prevAt . ' · ' . $prevIp . ')';
|
||||||
|
} elseif ($prevAt !== '') {
|
||||||
|
$loginNotice = ' (이전 로그인: ' . $prevAt . ')';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// 알림 계산 실패는 로그인에 영향 주지 않음
|
||||||
|
}
|
||||||
|
|
||||||
$this->insertMemberLog($logData, true, '로그인 성공', (int) $member->mb_idx);
|
$this->insertMemberLog($logData, true, '로그인 성공', (int) $member->mb_idx);
|
||||||
|
|
||||||
|
$successMsg = '로그인되었습니다.' . $loginNotice;
|
||||||
if ((int) $member->mb_level === \Config\Roles::LEVEL_LOCAL_ADMIN) {
|
if ((int) $member->mb_level === \Config\Roles::LEVEL_LOCAL_ADMIN) {
|
||||||
return redirect()->to(site_url('admin'))->with('success', '로그인되었습니다.');
|
return redirect()->to(site_url('admin'))->with('success', $successMsg);
|
||||||
}
|
}
|
||||||
if (\Config\Roles::isSuperAdminEquivalent((int) $member->mb_level)) {
|
if (\Config\Roles::isSuperAdminEquivalent((int) $member->mb_level)) {
|
||||||
return redirect()->to(site_url('admin/select-local-government'))->with('success', '로그인되었습니다.');
|
return redirect()->to(site_url('admin/select-local-government'))->with('success', $successMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
return redirect()->to(site_url('/'))->with('success', '로그인되었습니다.');
|
return redirect()->to(site_url('/'))->with('success', $successMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function buildLogData(string $mbId, ?int $mbIdx): array
|
private function buildLogData(string $mbId, ?int $mbIdx): array
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -74,9 +74,17 @@ abstract class BaseController extends Controller
|
|||||||
while (str_starts_with($path, 'index.php/')) {
|
while (str_starts_with($path, 'index.php/')) {
|
||||||
$path = substr($path, strlen('index.php/'));
|
$path = substr($path, strlen('index.php/'));
|
||||||
}
|
}
|
||||||
|
// 워크스페이스 탭(iframe) 안이면 경로와 무관하게 임베드 레이아웃 → 셸 중첩 방지
|
||||||
|
// (예: bag/code-kinds 탭에서 admin/code-kinds/create 등록 화면을 열 때)
|
||||||
|
if ($this->isEmbeddedRequest()) {
|
||||||
|
return view('bag/layout/embed', [
|
||||||
|
'title' => $title,
|
||||||
|
'content' => $content,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
if ($path === 'bag' || str_starts_with($path, 'bag/')) {
|
if ($path === 'bag' || str_starts_with($path, 'bag/')) {
|
||||||
// /workspace 탭(iframe) 안에서는 임베드 레이아웃, 아니면 gov-portal 셸
|
return view('bag/layout/portal', [
|
||||||
return view($this->isEmbeddedRequest() ? 'bag/layout/embed' : 'bag/layout/portal', [
|
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'content' => $content,
|
'content' => $content,
|
||||||
]);
|
]);
|
||||||
|
|||||||
98
app/Controllers/Feedback.php
Normal file
98
app/Controllers/Feedback.php
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use App\Models\FeedbackFileModel;
|
||||||
|
use App\Models\FeedbackModel;
|
||||||
|
use CodeIgniter\HTTP\ResponseInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 의견 접수. 로그인 사용자가 화면에서 남긴 의견 + 스크린샷을 저장한다.
|
||||||
|
* (계정·지자체는 클라이언트 값을 믿지 않고 세션에서 채운다.)
|
||||||
|
*/
|
||||||
|
class Feedback extends BaseController
|
||||||
|
{
|
||||||
|
public function store(): ResponseInterface
|
||||||
|
{
|
||||||
|
if (! session()->get('logged_in')) {
|
||||||
|
return $this->response->setStatusCode(401)->setJSON(['ok' => false, 'message' => '로그인이 필요합니다.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = trim((string) $this->request->getPost('content'));
|
||||||
|
if ($content === '') {
|
||||||
|
return $this->response->setJSON(['ok' => false, 'message' => '의견 내용을 입력해 주세요.']);
|
||||||
|
}
|
||||||
|
if (mb_strlen($content) > 2000) {
|
||||||
|
$content = mb_substr($content, 0, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
helper('admin');
|
||||||
|
$lgIdx = function_exists('admin_effective_lg_idx') ? admin_effective_lg_idx() : null;
|
||||||
|
if (! $lgIdx) {
|
||||||
|
$raw = session()->get('mb_lg_idx');
|
||||||
|
$lgIdx = ($raw !== null && $raw !== '') ? (int) $raw : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$model = model(FeedbackModel::class);
|
||||||
|
$id = $model->insert([
|
||||||
|
'fb_lg_idx' => $lgIdx,
|
||||||
|
'fb_mb_idx' => (int) session()->get('mb_idx'),
|
||||||
|
'fb_mb_name' => (string) (session()->get('mb_name') ?? ''),
|
||||||
|
'fb_mb_level' => (int) session()->get('mb_level'),
|
||||||
|
'fb_content' => $content,
|
||||||
|
'fb_page_url' => mb_substr((string) $this->request->getPost('page_url'), 0, 255),
|
||||||
|
'fb_page_title' => mb_substr((string) $this->request->getPost('page_title'), 0, 255),
|
||||||
|
'fb_user_agent' => mb_substr((string) $this->request->getHeaderLine('User-Agent'), 0, 255),
|
||||||
|
'fb_status' => 'new',
|
||||||
|
], true);
|
||||||
|
|
||||||
|
if (! $id) {
|
||||||
|
return $this->response->setJSON(['ok' => false, 'message' => '저장 중 오류가 발생했습니다.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$dir = WRITEPATH . 'uploads/feedback/';
|
||||||
|
if (! is_dir($dir)) {
|
||||||
|
@mkdir($dir, 0775, true);
|
||||||
|
}
|
||||||
|
$fileModel = model(FeedbackFileModel::class);
|
||||||
|
|
||||||
|
// 1) 화면 캡처(dataURL) — 실패해도 의견 본문은 이미 저장됨
|
||||||
|
$shot = (string) $this->request->getPost('screenshot');
|
||||||
|
if ($shot !== '' && preg_match('#^data:image/(png|jpeg);base64,#', $shot, $m) === 1) {
|
||||||
|
$ext = $m[1] === 'png' ? 'png' : 'jpg';
|
||||||
|
$bin = base64_decode(substr($shot, strpos($shot, ',') + 1), true);
|
||||||
|
if ($bin !== false && strlen($bin) <= 5 * 1024 * 1024) {
|
||||||
|
$file = 'fb_' . (int) $id . '_cap_' . bin2hex(random_bytes(4)) . '.' . $ext;
|
||||||
|
if (@file_put_contents($dir . $file, $bin) !== false) {
|
||||||
|
$model->update($id, ['fb_screenshot' => $file]); // 목록 캡처여부 표시용
|
||||||
|
$fileModel->insert(['ff_fb_idx' => $id, 'ff_kind' => 'capture', 'ff_path' => $file, 'ff_mime' => 'image/' . ($ext === 'png' ? 'png' : 'jpeg')]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 사용자가 첨부한 이미지(여러 장)
|
||||||
|
$allowed = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/gif' => 'gif', 'image/webp' => 'webp'];
|
||||||
|
$count = 0;
|
||||||
|
foreach ($this->request->getFileMultiple('files') ?? [] as $f) {
|
||||||
|
if ($count >= 5 || $f === null || ! $f->isValid() || $f->hasMoved()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$mime = (string) $f->getMimeType();
|
||||||
|
if (! isset($allowed[$mime]) || $f->getSize() > 5 * 1024 * 1024) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$file = 'fb_' . (int) $id . '_up_' . bin2hex(random_bytes(4)) . '.' . $allowed[$mime];
|
||||||
|
try {
|
||||||
|
$f->move($dir, $file);
|
||||||
|
$fileModel->insert(['ff_fb_idx' => $id, 'ff_kind' => 'upload', 'ff_path' => $file, 'ff_mime' => $mime]);
|
||||||
|
$count++;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// 개별 파일 실패는 건너뜀
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->response->setJSON(['ok' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,7 +44,11 @@ class Home extends BaseController
|
|||||||
}
|
}
|
||||||
helper('admin');
|
helper('admin');
|
||||||
|
|
||||||
return view('bag/layout/workspace');
|
// 계정별 저장된 탭 상태(자동 복원). 없으면 'null'.
|
||||||
|
$savedWorkspaceJson = (new \App\Controllers\Bag())
|
||||||
|
->loadWorkspaceTabsState((int) (session()->get('mb_idx') ?? 0));
|
||||||
|
|
||||||
|
return view('bag/layout/workspace', ['savedWorkspaceJson' => $savedWorkspaceJson]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -42,6 +42,8 @@
|
|||||||
| 지자체 관리자 | 소속 지자체의 발주·입고·재고·불출·판매·리포트 전반 |
|
| 지자체 관리자 | 소속 지자체의 발주·입고·재고·불출·판매·리포트 전반 |
|
||||||
| 슈퍼 관리자 | 전체 + 기본코드 등 마스터 관리(지자체 선택 후 작업) |
|
| 슈퍼 관리자 | 전체 + 기본코드 등 마스터 관리(지자체 선택 후 작업) |
|
||||||
|
|
||||||
|
> **활동 로그(사용자 작업 기록)**: **지자체 관리자·슈퍼 관리자만** 접근할 수 있습니다. (일반 사용자·지정판매소는 볼 수 없습니다.)
|
||||||
|
|
||||||
## 5. 화면별 설명은 어디에?
|
## 5. 화면별 설명은 어디에?
|
||||||
|
|
||||||
좌측 목차에서 업무군을 고르면 그 안에 **화면(소메뉴)별 설명**이 있습니다.
|
좌측 목차에서 업무군을 고르면 그 안에 **화면(소메뉴)별 설명**이 있습니다.
|
||||||
|
|||||||
@@ -55,7 +55,15 @@
|
|||||||
|
|
||||||
화면 오른쪽 위 **[로그아웃]** 을 누르면 안전하게 종료됩니다. 공용 PC라면 사용 후 꼭 로그아웃하세요.
|
화면 오른쪽 위 **[로그아웃]** 을 누르면 안전하게 종료됩니다. 공용 PC라면 사용 후 꼭 로그아웃하세요.
|
||||||
|
|
||||||
## 4. 비밀번호·계정 문제
|
## 4. 비밀번호 변경 · *비밀번호 변경*
|
||||||
|
|
||||||
- **비밀번호를 바꾸거나 분실**한 경우, 계정·권한 변경은 **담당 관리자**가 처리합니다. 관리자에게 문의하세요.
|
로그인한 본인이 직접 비밀번호를 바꾸는 화면입니다.
|
||||||
|
|
||||||
|
**입력**: 현재 비밀번호 → 새 비밀번호 → 새 비밀번호 확인.
|
||||||
|
**순서**: 현재 비밀번호가 맞아야 변경되며, 새 비밀번호는 확인란과 동일해야 합니다.
|
||||||
|
> 주기적으로 변경하고, 다른 사이트와 같은 비밀번호는 피하세요.
|
||||||
|
|
||||||
|
## 5. 비밀번호 분실 · 계정·권한 문제
|
||||||
|
|
||||||
|
- **비밀번호를 분실**해 로그인 자체가 안 되는 경우에는 **담당 관리자**가 초기화해 줍니다. 관리자에게 문의하세요.
|
||||||
- 권한(역할)을 바꾸고 싶을 때도 관리자에게 요청하면 됩니다.
|
- 권한(역할)을 바꾸고 싶을 때도 관리자에게 요청하면 됩니다.
|
||||||
|
|||||||
@@ -64,3 +64,20 @@
|
|||||||
**필터**: 입고기간 · 제작업체 · 품명 · 입고구분(전체/완료/미완료).
|
**필터**: 입고기간 · 제작업체 · 품명 · 입고구분(전체/완료/미완료).
|
||||||
**표 컬럼**: 입고일자 · 품명 · 입고수량 · 발주일자 · 발주수량 · 발주번호 · 제작업체 · **입고여부(완료/미완료)** · 입고처 · 비고.
|
**표 컬럼**: 입고일자 · 품명 · 입고수량 · 발주일자 · 발주수량 · 발주번호 · 제작업체 · **입고여부(완료/미완료)** · 입고처 · 비고.
|
||||||
**버튼**: `엑셀저장` · `인쇄`.
|
**버튼**: `엑셀저장` · `인쇄`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 발주파일 생성 · *발주 입고 관리 › 발주파일 생성*
|
||||||
|
|
||||||
|
선택한 발주 건의 내용을 **봉투 생산기(바코드 인쇄) 전달용 파일**로 만드는 화면입니다. (예전 이름: *LOT-No 디스켓 불출*) 생성되는 파일은 발주 정보와 품목이 담긴 **암호화된 발주파일**(`.seed.json`)이며, 봉투 생산 장비가 이 파일을 읽어 그 발주의 바코드를 인쇄합니다.
|
||||||
|
|
||||||
|
**LOT-No란?** 발주를 등록할 때 그 건에 자동 부여되는 **6자리 배치(묶음) 번호**입니다. 이후 그 발주로 인쇄되는 모든 봉투 바코드의 앞자리(예: `ZLCH2M-000006-…`)가 되어, 어느 발주에서 나온 봉투인지 구분하는 기준이 됩니다.
|
||||||
|
|
||||||
|
**필터**: 시작월 · 종료월 · LOT-No · 제작업체.
|
||||||
|
**순서**
|
||||||
|
1. 기간·조건으로 발주 목록을 조회합니다.
|
||||||
|
2. 대상 발주 행의 **`내용 확인`** 을 누르면, 파일에 담길 **발주 정보와 품목 목록(봉투코드·수량·금액 등)** 을 팝업으로 미리 볼 수 있습니다.
|
||||||
|
3. 내용을 확인한 뒤 **`발주파일 생성(다운로드)`** 을 누르면 암호화된 발주파일이 내려받아집니다.
|
||||||
|
|
||||||
|
**표 컬럼**: 발주일자 · LOT-No · 품명 · 수량 · 제작업체 · (내용 확인 버튼).
|
||||||
|
> 정상·취소 발주가 함께 조회되며, 파일 생성은 발주 단위로 동작합니다. 생성 전 **`내용 확인`** 단계에서 반드시 품목·수량을 검토하세요.
|
||||||
|
|||||||
@@ -56,6 +56,47 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 기타 입출고 · *봉투 수불 관리 › 기타 입출고*
|
||||||
|
|
||||||
|
정상적인 **발주입고 · 판매 · 불출** 흐름에 속하지 않는 **그 밖의 재고 증감**을 수동으로 기록하는 화면입니다.
|
||||||
|
(예: 분실·파손, 실사 차이 보정, 행정상 조정, 타 부서 이관 등)
|
||||||
|
|
||||||
|
**구분**
|
||||||
|
- **입고(in)**: 재고가 **늘어나는** 조정 (예: 누락분 추가, 반입).
|
||||||
|
- **출고(out)**: 재고가 **줄어드는** 조정 (예: 파손·분실 차감).
|
||||||
|
|
||||||
|
**등록 입력값** *(모두 필수)*
|
||||||
|
- **구분**: 입고 / 출고
|
||||||
|
- **봉투**: 봉투코드(봉투구분으로 먼저 좁혀서 선택)
|
||||||
|
- **수량**: 1 이상
|
||||||
|
- **일자**: 처리 기준일
|
||||||
|
- **사유**: 왜 조정하는지(필수, 최대 200자) — 추후 추적·감사를 위해 구체적으로 적습니다.
|
||||||
|
|
||||||
|
**조회·표시**
|
||||||
|
- **수불년월 · 구분**으로 묶여 좌측 **목록(마스터)** → 우측 **상세 내역**으로 표시됩니다.
|
||||||
|
- 봉투구분으로 필터링할 수 있습니다.
|
||||||
|
|
||||||
|
**반영**: 등록한 입고/출고 수량은 **재고와 봉투 수불 집계에 그대로 반영**되므로, 정확한 사유와 수량으로 신중히 입력하세요. 잘못 등록한 행은 삭제로 정정합니다.
|
||||||
|
|
||||||
|
> 정상 업무(발주·판매·불출)는 각 전용 화면에서 처리하고, **여기서는 그 흐름으로 설명되지 않는 예외적 증감만** 기록합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 쓰레기봉투 수급 계획 · *봉투 수불 관리 › 쓰레기봉투 수급 계획*
|
||||||
|
|
||||||
|
봉투 품목별로 **현재고가 며칠 버티는지, 언제·얼마나 발주할지**를 보여 주는 수급·발주 계획표입니다. 처음 들어가면 기본 조건으로 자동 조회됩니다.
|
||||||
|
|
||||||
|
**필터**
|
||||||
|
- **기준일**: 계산 기준 날짜.
|
||||||
|
- **제작기일(리드타임, 일)**: 발주 후 입고까지 걸리는 기간(기본 40일). 재고 소진 전에 미리 발주하려는 여유.
|
||||||
|
- **재고/판매 범위**: 기존(수기·바코드 미등록) / 바코드(등록) / 전체.
|
||||||
|
|
||||||
|
**표 구성(3그룹)**: ① 최근 발주 내역 ② 현재고 및 예상 판매일수 ③ 추가발주 예정내역.
|
||||||
|
**핵심 계산**: **발주예정일 = 기준일 + 소진일수 − 보유일수**. 발주예정일이 지난(긴급) 품목은 **빨간색**으로 표시되고, 그 시점에 맞춘 **추가 발주 제안 장수**가 함께 나옵니다.
|
||||||
|
> 발주 타이밍을 놓치지 않도록 돕는 화면입니다. 빨간 품목부터 발주를 검토하세요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 그 밖의 현황
|
## 그 밖의 현황
|
||||||
- **기간별 판매현황 / 년 판매 현황**: 기간·연도 단위 판매 집계.
|
- **기간별 판매현황 / 년 판매 현황**: 기간·연도 단위 판매 집계.
|
||||||
- **지정 판매소 (일/기간) 판매대장**: 판매소별 거래 장부.
|
- **지정 판매소 (일/기간) 판매대장**: 판매소별 거래 장부.
|
||||||
|
|||||||
@@ -17,6 +17,44 @@
|
|||||||
**상세**: 사업자번호 · 우편번호 · 도로명/지번주소 · 이메일 · 결제 계좌 등.
|
**상세**: 사업자번호 · 우편번호 · 도로명/지번주소 · 이메일 · 결제 계좌 등.
|
||||||
> 목록에서 가게를 고르면 우측에 상세가 표시됩니다. (등록·수정은 관리자)
|
> 목록에서 가게를 고르면 우측에 상세가 표시됩니다. (등록·수정은 관리자)
|
||||||
|
|
||||||
|
**함께 제공되는 화면**
|
||||||
|
- **지정판매소 바코드 발행**: 판매소를 골라 봉투 바코드 라벨을 인쇄용으로 출력합니다.
|
||||||
|
- **지정 판매소 신규/취소 현황**: 한 해 동안 군·구별로 몇 곳이 새로 생기고 없어졌는지 **건수**를 집계하는 보고서입니다. (아래 별도 설명)
|
||||||
|
- **지정판매소 조회(브라우즈)**: 등록된 판매소를 검색·열람만 하는 보기 전용 화면입니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 지정 판매소 신규/취소 현황 · *기본정보관리 › 지정 판매소 신규/취소 현황*
|
||||||
|
|
||||||
|
선택한 **한 해 동안** 지정판매소가 **몇 곳 새로 생기고(지정)**, **몇 곳 없어졌는지(취소)**, 그래서 **연말에 몇 곳이 남았는지**를 **군·구별 건수**로 집계해 보여주는 현황판입니다. 개별 가게 명단이 아니라 **숫자 집계표**입니다.
|
||||||
|
|
||||||
|
**핵심 개념 — 4개의 기둥 컬럼**
|
||||||
|
- **종전(전년도말)**: **전년 12월 31일** 기준 정상 운영 중이던 판매소 수 → *작년 말 잔고*.
|
||||||
|
- **지정(당해년)**: 조회년도 중 **새로 지정**된(신규) 판매소 수.
|
||||||
|
- **취소(당해년)**: 조회년도 중 **취소**(폐업·해지)된 판매소 수.
|
||||||
|
- **현행(금년도말)**: **조회년도 12월 31일** 기준 정상 운영 중인 판매소 수 → *올해 말 잔고*.
|
||||||
|
|
||||||
|
> 관계식: `현행 ≈ 종전 + 지정 − 취소`.
|
||||||
|
> 예) 종전 120 · 지정 15 · 취소 8 → 현행 127. “올해 15곳 새로 지정하고 8곳이 취소되어 순증 7곳, 연말 기준 127곳이 운영 중”이라는 뜻입니다.
|
||||||
|
|
||||||
|
**그 밖의 계산 컬럼**
|
||||||
|
- **구코드**: 판매소에 저장된 구·군 코드 값.
|
||||||
|
- **증감(현행−종전)**: 작년 말 대비 올해 말 **순증감 곳 수**.
|
||||||
|
- **지정−취소**: 그 해 신규에서 취소를 뺀 값(당해 순증).
|
||||||
|
- **현행비중(%)**: 전체 현행 합계에서 그 군·구가 차지하는 비율.
|
||||||
|
- **전년대비 증감률(%)**: `((현행−종전) ÷ 종전) × 100` (종전이 0이면 표시 안 함).
|
||||||
|
- 맨 아래 **합계** 행: 전 군·구 총계.
|
||||||
|
|
||||||
|
**함께 표시되는 것**
|
||||||
|
- **동별 현행 요약**: 선택 군·구 안에서 동(구역)별 현행 수를 칩(badge)과 표로 보여줍니다.
|
||||||
|
- **연도별 요약(참고, 접이식)**: 활성/비활성 합계와 연도별 신규등록·취소 건수.
|
||||||
|
|
||||||
|
**사용법**: 상단에서 **조회년도**를 고르고 **[조회]**. 군·구는 로그인한 지자체 기준으로 자동 고정됩니다. 우측 상단 **엑셀저장 · 인쇄**로 내보낼 수 있습니다. (각 컬럼 제목 옆 **?** 배지에 마우스를 올리면 설명이 나옵니다.)
|
||||||
|
|
||||||
|
> **비슷한 이름과 혼동 주의**
|
||||||
|
> - **지정 판매소 신규/취소 현황**(이 화면): 군·구별 신규/취소 **건수 집계·통계**.
|
||||||
|
> - **지정판매소 관리 / 조회**: **개별 가게**를 등록·검색·열람하는 화면.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 단가 관리 · *기본정보관리 › 단가 관리*
|
## 단가 관리 · *기본정보관리 › 단가 관리*
|
||||||
|
|||||||
62
app/Filters/SessionGuardFilter.php
Normal file
62
app/Filters/SessionGuardFilter.php
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filters;
|
||||||
|
|
||||||
|
use App\Models\MemberModel;
|
||||||
|
use CodeIgniter\Filters\FilterInterface;
|
||||||
|
use CodeIgniter\HTTP\RequestInterface;
|
||||||
|
use CodeIgniter\HTTP\ResponseInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 중복 로그인 방지(나중 로그인 우선).
|
||||||
|
* 로그인 시 발급한 세션 토큰(session_token)을 회원 행(mb_session_token)과 매 요청마다 대조해,
|
||||||
|
* 다른 곳에서 로그인해 토큰이 바뀌면 이 세션을 무효화하고 로그인으로 보낸다.
|
||||||
|
* (DB를 공유하므로 로컬·운영 등 서로 다른 서버 간에도 동작)
|
||||||
|
*/
|
||||||
|
class SessionGuardFilter implements FilterInterface
|
||||||
|
{
|
||||||
|
public function before(RequestInterface $request, $arguments = null)
|
||||||
|
{
|
||||||
|
$session = session();
|
||||||
|
if (! $session->get('logged_in')) {
|
||||||
|
return null; // 비로그인 요청은 통과
|
||||||
|
}
|
||||||
|
|
||||||
|
$mbIdx = (int) ($session->get('mb_idx') ?? 0);
|
||||||
|
if ($mbIdx <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$member = model(MemberModel::class)->find($mbIdx);
|
||||||
|
if ($member === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dbToken = (string) ($member->mb_session_token ?? '');
|
||||||
|
$myToken = (string) ($session->get('session_token') ?? '');
|
||||||
|
|
||||||
|
// DB에 토큰이 있고 내 세션 토큰과 다르면 → 다른 곳에서 더 늦게 로그인한 것
|
||||||
|
if ($dbToken !== '' && $dbToken !== $myToken) {
|
||||||
|
$session->destroy();
|
||||||
|
|
||||||
|
// AJAX/JSON 요청은 401, 일반 요청은 로그인으로
|
||||||
|
if (strtolower((string) $request->getHeaderLine('X-Requested-With')) === 'xmlhttprequest'
|
||||||
|
|| str_contains(strtolower((string) $request->getHeaderLine('Accept')), 'application/json')) {
|
||||||
|
return service('response')->setStatusCode(401)->setJSON([
|
||||||
|
'ok' => false, 'message' => '다른 곳에서 로그인되어 로그아웃되었습니다.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->to(site_url('login'))->with('error', '다른 곳에서 로그인되어 자동 로그아웃되었습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
|
||||||
|
{
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -229,6 +229,37 @@ if (! function_exists('current_nav_request_path')) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! function_exists('member_font_scale_for')) {
|
||||||
|
/**
|
||||||
|
* 로그인 계정의 특정 메뉴(화면 경로)에 저장된 글자크기(zoom %)를 반환.
|
||||||
|
* 저장값이 없거나 범위(70~150) 밖이면 100.
|
||||||
|
*/
|
||||||
|
function member_font_scale_for(string $menuKey): int
|
||||||
|
{
|
||||||
|
$mbIdx = (int) (session()->get('mb_idx') ?? 0);
|
||||||
|
$menuKey = trim($menuKey);
|
||||||
|
if ($mbIdx <= 0 || $menuKey === '') {
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
if (! $db->tableExists('member_menu_font_scale')) {
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
$row = $db->table('member_menu_font_scale')
|
||||||
|
->select('mmf_scale')
|
||||||
|
->where('mmf_mb_idx', $mbIdx)
|
||||||
|
->where('mmf_menu_key', $menuKey)
|
||||||
|
->get()->getRow();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
$s = $row ? (int) $row->mmf_scale : 100;
|
||||||
|
|
||||||
|
return ($s >= 70 && $s <= 150) ? $s : 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (! function_exists('normalize_menu_link_for_url')) {
|
if (! function_exists('normalize_menu_link_for_url')) {
|
||||||
/**
|
/**
|
||||||
* menu.mm_link 를 base_url() 인자로 쓸 수 있는 상대 경로로 정규화합니다.
|
* menu.mm_link 를 base_url() 인자로 쓸 수 있는 상대 경로로 정규화합니다.
|
||||||
|
|||||||
@@ -8,6 +8,22 @@ declare(strict_types=1);
|
|||||||
* UTF-8 BOM 포함으로 한글 엑셀 호환성 보장
|
* UTF-8 BOM 포함으로 한글 엑셀 호환성 보장
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
if (! function_exists('audit_export_log')) {
|
||||||
|
/**
|
||||||
|
* 엑셀/CSV 다운로드를 활동 로그에 기록 (누가·무엇을 다운로드).
|
||||||
|
* 모든 export_* 함수 진입 시 호출 — 실패해도 다운로드에 영향 없음.
|
||||||
|
*/
|
||||||
|
function audit_export_log(string $filename): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
helper('audit');
|
||||||
|
audit_log('export', 'export', 0, null, ['파일' => $filename]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// 무시: 로깅 실패가 다운로드를 막지 않도록
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (! function_exists('export_csv')) {
|
if (! function_exists('export_csv')) {
|
||||||
/**
|
/**
|
||||||
* CSV 파일을 브라우저로 다운로드 전송
|
* CSV 파일을 브라우저로 다운로드 전송
|
||||||
@@ -18,6 +34,7 @@ if (! function_exists('export_csv')) {
|
|||||||
*/
|
*/
|
||||||
function export_csv(string $filename, array $headers, array $rows): void
|
function export_csv(string $filename, array $headers, array $rows): void
|
||||||
{
|
{
|
||||||
|
audit_export_log($filename);
|
||||||
// 파일명에 .csv 확장자 보장
|
// 파일명에 .csv 확장자 보장
|
||||||
if (! str_ends_with($filename, '.csv')) {
|
if (! str_ends_with($filename, '.csv')) {
|
||||||
$filename .= '.csv';
|
$filename .= '.csv';
|
||||||
@@ -76,9 +93,11 @@ if (! function_exists('export_excel_2003_xml')) {
|
|||||||
* @param string $sheetName 시트 이름(Excel 제한: 길이·일부 문자)
|
* @param string $sheetName 시트 이름(Excel 제한: 길이·일부 문자)
|
||||||
* @param string[] $headers 컬럼 헤더
|
* @param string[] $headers 컬럼 헤더
|
||||||
* @param array $rows 데이터 행(각 행은 배열, 값은 문자열로 출력)
|
* @param array $rows 데이터 행(각 행은 배열, 값은 문자열로 출력)
|
||||||
|
* @param array $topRows 헤더 위에 추가할 제목/부제 행(각 행은 배열). 비우면 없음.
|
||||||
*/
|
*/
|
||||||
function export_excel_2003_xml(string $filename, string $sheetName, array $headers, array $rows): void
|
function export_excel_2003_xml(string $filename, string $sheetName, array $headers, array $rows, array $topRows = []): void
|
||||||
{
|
{
|
||||||
|
audit_export_log($filename);
|
||||||
$filename = preg_replace('/\.[^.]+$/u', '', $filename) . '.xls';
|
$filename = preg_replace('/\.[^.]+$/u', '', $filename) . '.xls';
|
||||||
|
|
||||||
$safeSheet = str_replace(['/', '\\', '?', '*', '[', ']'], '', $sheetName);
|
$safeSheet = str_replace(['/', '\\', '?', '*', '[', ']'], '', $sheetName);
|
||||||
@@ -97,6 +116,15 @@ if (! function_exists('export_excel_2003_xml')) {
|
|||||||
$parts[] = '<Worksheet ss:Name="' . $esc($safeSheet) . '">';
|
$parts[] = '<Worksheet ss:Name="' . $esc($safeSheet) . '">';
|
||||||
$parts[] = '<Table>';
|
$parts[] = '<Table>';
|
||||||
|
|
||||||
|
// 상단 제목/부제 행(있을 때만)
|
||||||
|
foreach ($topRows as $topRow) {
|
||||||
|
$parts[] = '<Row>';
|
||||||
|
foreach (array_values((array) $topRow) as $cell) {
|
||||||
|
$parts[] = '<Cell><Data ss:Type="String">' . $esc($cell) . '</Data></Cell>';
|
||||||
|
}
|
||||||
|
$parts[] = '</Row>';
|
||||||
|
}
|
||||||
|
|
||||||
$parts[] = '<Row>';
|
$parts[] = '<Row>';
|
||||||
foreach ($headers as $h) {
|
foreach ($headers as $h) {
|
||||||
$parts[] = '<Cell><Data ss:Type="String">' . $esc($h) . '</Data></Cell>';
|
$parts[] = '<Cell><Data ss:Type="String">' . $esc($h) . '</Data></Cell>';
|
||||||
@@ -137,6 +165,7 @@ if (! function_exists('export_excel_2003_xml_workbook')) {
|
|||||||
*/
|
*/
|
||||||
function export_excel_2003_xml_workbook(string $filename, array $sheets): void
|
function export_excel_2003_xml_workbook(string $filename, array $sheets): void
|
||||||
{
|
{
|
||||||
|
audit_export_log($filename);
|
||||||
$filename = preg_replace('/\.[^.]+$/u', '', $filename) . '.xls';
|
$filename = preg_replace('/\.[^.]+$/u', '', $filename) . '.xls';
|
||||||
|
|
||||||
$esc = static function (mixed $v): string {
|
$esc = static function (mixed $v): string {
|
||||||
@@ -392,6 +421,7 @@ if (! function_exists('export_bag_flow_report_excel')) {
|
|||||||
array $metaLines,
|
array $metaLines,
|
||||||
array $reportRows
|
array $reportRows
|
||||||
): void {
|
): void {
|
||||||
|
audit_export_log($filename);
|
||||||
$baseName = preg_replace('/\.[^.]+$/u', '', $filename);
|
$baseName = preg_replace('/\.[^.]+$/u', '', $filename);
|
||||||
$baseName = preg_replace('/[^\p{L}\p{N}_\-]+/u', '_', $baseName) ?? 'bag_flow';
|
$baseName = preg_replace('/[^\p{L}\p{N}_\-]+/u', '_', $baseName) ?? 'bag_flow';
|
||||||
$baseName = trim($baseName, '_') !== '' ? trim($baseName, '_') : 'bag_flow';
|
$baseName = trim($baseName, '_') !== '' ? trim($baseName, '_') : 'bag_flow';
|
||||||
@@ -442,6 +472,7 @@ if (! function_exists('export_xlsx')) {
|
|||||||
*/
|
*/
|
||||||
function export_xlsx(string $filename, string $sheetName, array $headers, array $rows): void
|
function export_xlsx(string $filename, string $sheetName, array $headers, array $rows): void
|
||||||
{
|
{
|
||||||
|
audit_export_log($filename);
|
||||||
$filename = preg_replace('/\.[^.]+$/u', '', $filename) . '.xlsx';
|
$filename = preg_replace('/\.[^.]+$/u', '', $filename) . '.xlsx';
|
||||||
|
|
||||||
$safeSheet = str_replace(['/', '\\', '?', '*', '[', ']'], '', $sheetName);
|
$safeSheet = str_replace(['/', '\\', '?', '*', '[', ']'], '', $sheetName);
|
||||||
|
|||||||
@@ -70,3 +70,246 @@ if (! function_exists('pii_decrypt')) {
|
|||||||
if (! defined('PII_ENCRYPTED_FIELDS')) {
|
if (! defined('PII_ENCRYPTED_FIELDS')) {
|
||||||
define('PII_ENCRYPTED_FIELDS', ['mb_phone', 'mb_email']);
|
define('PII_ENCRYPTED_FIELDS', ['mb_phone', 'mb_email']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =========================================================================
|
||||||
|
* 개인정보 마스킹(비식별화) 헬퍼 — 표시 계층에서만 사용(원본 데이터는 그대로).
|
||||||
|
* ======================================================================= */
|
||||||
|
|
||||||
|
if (! function_exists('pii_mask_name')) {
|
||||||
|
/** 이름 마스킹: 홍길동→홍*동, 홍길→홍*, 홍→홍 */
|
||||||
|
function pii_mask_name(?string $v): string
|
||||||
|
{
|
||||||
|
$v = trim((string) $v);
|
||||||
|
if ($v === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$len = mb_strlen($v, 'UTF-8');
|
||||||
|
if ($len === 1) {
|
||||||
|
return $v;
|
||||||
|
}
|
||||||
|
if ($len === 2) {
|
||||||
|
return mb_substr($v, 0, 1, 'UTF-8') . '*';
|
||||||
|
}
|
||||||
|
return mb_substr($v, 0, 1, 'UTF-8') . str_repeat('*', $len - 2) . mb_substr($v, $len - 1, 1, 'UTF-8');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('pii_mask_phone')) {
|
||||||
|
/** 전화 마스킹: 앞 국번 + 마지막 4자리만 노출, 가운데 마스킹. 010-1234-5678→010-****-5678 */
|
||||||
|
function pii_mask_phone(?string $v): string
|
||||||
|
{
|
||||||
|
$v = trim((string) $v);
|
||||||
|
if ($v === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
// 하이픈 형식이면 가운데 그룹만 마스킹
|
||||||
|
if (preg_match('/^(\d{2,4})-(\d{3,4})-(\d{4})$/', $v, $m) === 1) {
|
||||||
|
return $m[1] . '-' . str_repeat('*', strlen($m[2])) . '-' . $m[3];
|
||||||
|
}
|
||||||
|
// 그 외: 숫자만 추출해 앞3·뒤4 노출
|
||||||
|
$digits = preg_replace('/\D/', '', $v);
|
||||||
|
$n = strlen($digits);
|
||||||
|
if ($n <= 4) {
|
||||||
|
return str_repeat('*', $n);
|
||||||
|
}
|
||||||
|
$head = substr($digits, 0, 3);
|
||||||
|
$tail = substr($digits, -4);
|
||||||
|
return $head . str_repeat('*', max(1, $n - 7)) . $tail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('pii_mask_email')) {
|
||||||
|
/** 이메일 마스킹: 앞부분 첫 글자만 노출. hong@a.com→h***@a.com */
|
||||||
|
function pii_mask_email(?string $v): string
|
||||||
|
{
|
||||||
|
$v = trim((string) $v);
|
||||||
|
if ($v === '' || strpos($v, '@') === false) {
|
||||||
|
return $v === '' ? '' : pii_mask_name($v);
|
||||||
|
}
|
||||||
|
[$local, $domain] = explode('@', $v, 2);
|
||||||
|
$llen = mb_strlen($local, 'UTF-8');
|
||||||
|
$maskedLocal = $llen <= 1 ? '*' : mb_substr($local, 0, 1, 'UTF-8') . str_repeat('*', $llen - 1);
|
||||||
|
return $maskedLocal . '@' . $domain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('pii_mask_account')) {
|
||||||
|
/** 계좌/번호 마스킹: 마지막 4자리만 노출 */
|
||||||
|
function pii_mask_account(?string $v): string
|
||||||
|
{
|
||||||
|
$v = trim((string) $v);
|
||||||
|
if ($v === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$len = mb_strlen($v, 'UTF-8');
|
||||||
|
if ($len <= 4) {
|
||||||
|
return str_repeat('*', $len);
|
||||||
|
}
|
||||||
|
return str_repeat('*', $len - 4) . mb_substr($v, $len - 4, 4, 'UTF-8');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('pii_blind_index')) {
|
||||||
|
/**
|
||||||
|
* 블라인드 인덱스(정확일치 검색용): HMAC-SHA256(정규화값, 별도 인덱스키).
|
||||||
|
* 키(pii.blindIndexKey)가 없으면 '' 반환(검색 인덱스 비활성).
|
||||||
|
*/
|
||||||
|
function pii_blind_index(?string $value): string
|
||||||
|
{
|
||||||
|
$value = trim((string) $value);
|
||||||
|
if ($value === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$key = (string) (config('Pii')->blindIndexKey ?? '');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$key = '';
|
||||||
|
}
|
||||||
|
if ($key === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
// 정규화: 공백 제거 + 소문자 (전화는 숫자만)
|
||||||
|
$norm = preg_replace('/\s+/u', '', $value);
|
||||||
|
if (preg_match('/^[\d\-()+ ]+$/', $value) === 1) {
|
||||||
|
$norm = preg_replace('/\D/', '', $value);
|
||||||
|
} else {
|
||||||
|
$norm = mb_strtolower($norm, 'UTF-8');
|
||||||
|
}
|
||||||
|
return hash_hmac('sha256', $norm, $key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================================================
|
||||||
|
* 지정판매소 PII 열람 게이트 — canViewShopPii (단일 판별 지점).
|
||||||
|
* ======================================================================= */
|
||||||
|
|
||||||
|
if (! function_exists('pii_shop_protection_enabled')) {
|
||||||
|
/** 지정판매소 PII 보호 마스터 플래그 (OFF면 현재 동작=항상 원문) */
|
||||||
|
function pii_shop_protection_enabled(): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return (bool) (config('Pii')->designatedShopProtection ?? false);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('pii_ip_in_cidr')) {
|
||||||
|
/** IP가 CIDR/단일IP 목록(쉼표·공백 구분)에 포함되는지 (IPv4) */
|
||||||
|
function pii_ip_in_cidr(string $ip, string $list): bool
|
||||||
|
{
|
||||||
|
$ip = trim($ip);
|
||||||
|
if ($ip === '' || $list === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$ipLong = ip2long($ip);
|
||||||
|
if ($ipLong === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
foreach (preg_split('/[\s,]+/', $list, -1, PREG_SPLIT_NO_EMPTY) as $entry) {
|
||||||
|
if (strpos($entry, '/') !== false) {
|
||||||
|
[$subnet, $bits] = explode('/', $entry, 2);
|
||||||
|
$subnetLong = ip2long(trim($subnet));
|
||||||
|
$bits = (int) $bits;
|
||||||
|
if ($subnetLong === false || $bits < 0 || $bits > 32) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$mask = $bits === 0 ? 0 : (-1 << (32 - $bits)) & 0xFFFFFFFF;
|
||||||
|
if (($ipLong & $mask) === ($subnetLong & $mask)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} elseif (ip2long(trim($entry)) === $ipLong) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('pii_request_ip_in_lg_range')) {
|
||||||
|
/** 현재 요청 IP가 해당 지자체 허용 IP 대역에 속하는지 (local_government.lg_allow_ips) */
|
||||||
|
function pii_request_ip_in_lg_range(int $lgIdx): bool
|
||||||
|
{
|
||||||
|
if ($lgIdx <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
if (! $db->fieldExists('lg_allow_ips', 'local_government')) {
|
||||||
|
return false; // 컬럼 없으면 대역 미설정 → 자동 원문 불가(마스킹+step-up)
|
||||||
|
}
|
||||||
|
$row = $db->table('local_government')->select('lg_allow_ips')->where('lg_idx', $lgIdx)->get()->getRowArray();
|
||||||
|
$list = (string) ($row['lg_allow_ips'] ?? '');
|
||||||
|
if ($list === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return pii_ip_in_cidr((string) service('request')->getIPAddress(), $list);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('pii_stepup_granted')) {
|
||||||
|
/** 이 세션에서 step-up 재인증으로 원문 열람이 승인됐는지 (P2에서 세팅) */
|
||||||
|
function pii_stepup_granted(int $dsLgIdx): bool
|
||||||
|
{
|
||||||
|
$g = session('pii_stepup_grant');
|
||||||
|
if (! is_array($g)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$until = (int) ($g['until'] ?? 0);
|
||||||
|
$lg = (int) ($g['lg_idx'] ?? -1);
|
||||||
|
return $until > time() && ($lg === 0 || $lg === $dsLgIdx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('can_view_shop_pii')) {
|
||||||
|
/**
|
||||||
|
* 지정판매소 개인정보 원문 열람 가능 여부(단일 판별 지점).
|
||||||
|
* 보호 OFF → 항상 true(현재 동작). 보호 ON → 조건 판별.
|
||||||
|
*/
|
||||||
|
function can_view_shop_pii(int $dsLgIdx): bool
|
||||||
|
{
|
||||||
|
if (! pii_shop_protection_enabled()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$level = (int) session('mb_level');
|
||||||
|
$adminLg = (int) session('admin_lg_idx');
|
||||||
|
$twofa = (bool) session('auth_2fa_verified');
|
||||||
|
|
||||||
|
// 정상(자동 원문): 지자체관리자 + 본인 지자체 + 2FA + 지자체 IP대역
|
||||||
|
if ($level === \Config\Roles::LEVEL_LOCAL_ADMIN
|
||||||
|
&& $adminLg === $dsLgIdx && $adminLg > 0
|
||||||
|
&& $twofa
|
||||||
|
&& pii_request_ip_in_lg_range($dsLgIdx)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 그 외(Super Admin·본부 포함, IP 밖 등): step-up 재인증으로 승인된 경우만
|
||||||
|
return pii_stepup_granted($dsLgIdx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! function_exists('mask_shop_field')) {
|
||||||
|
/** 필드명 기준 마스킹 라우팅 (원문 표시 불가일 때 사용) */
|
||||||
|
function mask_shop_field(string $field, ?string $value): string
|
||||||
|
{
|
||||||
|
$value = (string) $value;
|
||||||
|
switch ($field) {
|
||||||
|
case 'ds_rep_name':
|
||||||
|
return pii_mask_name($value);
|
||||||
|
case 'ds_tel':
|
||||||
|
case 'ds_rep_phone':
|
||||||
|
return pii_mask_phone($value);
|
||||||
|
case 'ds_email':
|
||||||
|
return pii_mask_email($value);
|
||||||
|
case 'ds_va_account':
|
||||||
|
case 'ds_va_number':
|
||||||
|
return pii_mask_account($value);
|
||||||
|
default:
|
||||||
|
return $value === '' ? '' : pii_mask_name($value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class BagInventoryModel extends Model
|
class BagInventoryModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'bag_inventory';
|
protected $table = 'bag_inventory';
|
||||||
protected $primaryKey = 'bi_idx';
|
protected $primaryKey = 'bi_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class BagIssueItemCodeModel extends Model
|
class BagIssueItemCodeModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'bag_issue_item_code';
|
protected $table = 'bag_issue_item_code';
|
||||||
protected $primaryKey = 'bic_idx';
|
protected $primaryKey = 'bic_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class BagIssueModel extends Model
|
class BagIssueModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'bag_issue';
|
protected $table = 'bag_issue';
|
||||||
protected $primaryKey = 'bi2_idx';
|
protected $primaryKey = 'bi2_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class BagOrderItemModel extends Model
|
class BagOrderItemModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'bag_order_item';
|
protected $table = 'bag_order_item';
|
||||||
protected $primaryKey = 'boi_idx';
|
protected $primaryKey = 'boi_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class BagOrderModel extends Model
|
class BagOrderModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'bag_order';
|
protected $table = 'bag_order';
|
||||||
protected $primaryKey = 'bo_idx';
|
protected $primaryKey = 'bo_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
@@ -30,7 +32,7 @@ class BagOrderModel extends Model
|
|||||||
'bo_uuid', 'bo_version', 'bo_lg_idx', 'bo_gugun_code', 'bo_dong_code',
|
'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_company_idx', 'bo_agency_idx', 'bo_fee_rate', 'bo_order_date',
|
||||||
'bo_bag_types', 'bo_unit_prices', 'bo_qty_boxes',
|
'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',
|
'bo_regdate', 'bo_moddate',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class BagPriceHistoryModel extends Model
|
class BagPriceHistoryModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'bag_price_history';
|
protected $table = 'bag_price_history';
|
||||||
protected $primaryKey = 'bph_idx';
|
protected $primaryKey = 'bph_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class BagPriceModel extends Model
|
class BagPriceModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'bag_price';
|
protected $table = 'bag_price';
|
||||||
protected $primaryKey = 'bp_idx';
|
protected $primaryKey = 'bp_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class BagReceivingModel extends Model
|
class BagReceivingModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'bag_receiving';
|
protected $table = 'bag_receiving';
|
||||||
protected $primaryKey = 'br_idx';
|
protected $primaryKey = 'br_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class BagSaleModel extends Model
|
class BagSaleModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'bag_sale';
|
protected $table = 'bag_sale';
|
||||||
protected $primaryKey = 'bs_idx';
|
protected $primaryKey = 'bs_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class BlockchainLedgerModel extends Model
|
class BlockchainLedgerModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'blockchain_ledger';
|
protected $table = 'blockchain_ledger';
|
||||||
protected $primaryKey = 'bl_idx';
|
protected $primaryKey = 'bl_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class CodeDetailModel extends Model
|
class CodeDetailModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'code_detail';
|
protected $table = 'code_detail';
|
||||||
protected $primaryKey = 'cd_idx';
|
protected $primaryKey = 'cd_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class CodeKindModel extends Model
|
class CodeKindModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'code_kind';
|
protected $table = 'code_kind';
|
||||||
protected $primaryKey = 'ck_idx';
|
protected $primaryKey = 'ck_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class CompanyModel extends Model
|
class CompanyModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'company';
|
protected $table = 'company';
|
||||||
protected $primaryKey = 'cp_idx';
|
protected $primaryKey = 'cp_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class DesignatedShopModel extends Model
|
class DesignatedShopModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'designated_shop';
|
protected $table = 'designated_shop';
|
||||||
protected $primaryKey = 'ds_idx';
|
protected $primaryKey = 'ds_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
@@ -31,6 +33,7 @@ class DesignatedShopModel extends Model
|
|||||||
'ds_rep_phone',
|
'ds_rep_phone',
|
||||||
'ds_email',
|
'ds_email',
|
||||||
'ds_gugun_code',
|
'ds_gugun_code',
|
||||||
|
'ds_dong_code',
|
||||||
'ds_zone_code',
|
'ds_zone_code',
|
||||||
'ds_branch_no',
|
'ds_branch_no',
|
||||||
'ds_designated_at',
|
'ds_designated_at',
|
||||||
@@ -38,6 +41,118 @@ class DesignatedShopModel extends Model
|
|||||||
'ds_state_changed_at',
|
'ds_state_changed_at',
|
||||||
'ds_change_reason',
|
'ds_change_reason',
|
||||||
'ds_regdate',
|
'ds_regdate',
|
||||||
|
// 블라인드 인덱스(정확일치 검색용). 컬럼이 있을 때만 실제로 기록됨.
|
||||||
|
'ds_tel_bidx',
|
||||||
|
'ds_rep_name_bidx',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// PII 암복호화·블라인드인덱스 콜백 (설계안 P3·P4)
|
||||||
|
protected $beforeInsert = ['piiBlindIndex', 'piiEncrypt'];
|
||||||
|
protected $beforeUpdate = ['piiBlindIndex', 'piiEncrypt'];
|
||||||
|
protected $afterFind = ['piiDecrypt'];
|
||||||
|
|
||||||
|
private ?bool $telBidxExists = null;
|
||||||
|
private ?bool $nameBidxExists = null;
|
||||||
|
|
||||||
|
/** 저장 계층 암호화 사용 여부 (Config\Pii.encryptAtRest) */
|
||||||
|
private function encryptAtRest(): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return (bool) (config('Pii')->encryptAtRest ?? false);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> 암호화·마스킹 대상 PII 필드 */
|
||||||
|
private function piiFields(): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$f = config('Pii')->designatedShopPiiFields ?? [];
|
||||||
|
return is_array($f) && $f !== [] ? $f : ['ds_rep_name', 'ds_tel', 'ds_rep_phone', 'ds_email', 'ds_va_account'];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return ['ds_rep_name', 'ds_tel', 'ds_rep_phone', 'ds_email', 'ds_va_account'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 저장 전 PII 필드 암호화 (encryptAtRest ON일 때만, 이미 ENC:면 건너뜀) */
|
||||||
|
protected function piiEncrypt(array $data): array
|
||||||
|
{
|
||||||
|
if (! $this->encryptAtRest() || empty($data['data']) || ! is_array($data['data'])) {
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
helper('pii_encryption');
|
||||||
|
foreach ($this->piiFields() as $f) {
|
||||||
|
if (array_key_exists($f, $data['data']) && $data['data'][$f] !== null && $data['data'][$f] !== '') {
|
||||||
|
$v = (string) $data['data'][$f];
|
||||||
|
if (strpos($v, 'ENC:') !== 0) {
|
||||||
|
$data['data'][$f] = pii_encrypt($v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 저장 전 블라인드 인덱스(전화·이름) 기록 — 컬럼과 인덱스키가 있을 때만 */
|
||||||
|
protected function piiBlindIndex(array $data): array
|
||||||
|
{
|
||||||
|
if (empty($data['data']) || ! is_array($data['data'])) {
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
helper('pii_encryption');
|
||||||
|
$db = $this->db;
|
||||||
|
if ($this->telBidxExists === null) {
|
||||||
|
$this->telBidxExists = $db->fieldExists('ds_tel_bidx', $this->table);
|
||||||
|
}
|
||||||
|
if ($this->nameBidxExists === null) {
|
||||||
|
$this->nameBidxExists = $db->fieldExists('ds_rep_name_bidx', $this->table);
|
||||||
|
}
|
||||||
|
// 원본값이 넘어온 경우에만 인덱스 갱신(암호화 콜백이 먼저 돌면 ENC:라 인덱스 불가 → piiBlindIndex를 piiEncrypt보다 먼저 두지 않음에 주의)
|
||||||
|
if ($this->telBidxExists && array_key_exists('ds_tel', $data['data'])) {
|
||||||
|
$raw = (string) $data['data']['ds_tel'];
|
||||||
|
$data['data']['ds_tel_bidx'] = strpos($raw, 'ENC:') === 0 ? '' : pii_blind_index($raw);
|
||||||
|
}
|
||||||
|
if ($this->nameBidxExists && array_key_exists('ds_rep_name', $data['data'])) {
|
||||||
|
$raw = (string) $data['data']['ds_rep_name'];
|
||||||
|
$data['data']['ds_rep_name_bidx'] = strpos($raw, 'ENC:') === 0 ? '' : pii_blind_index($raw);
|
||||||
|
}
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 조회 후 PII 필드 복호화 (ENC: 값만; 평문은 그대로) */
|
||||||
|
protected function piiDecrypt(array $data): array
|
||||||
|
{
|
||||||
|
if (empty($data['data'])) {
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
helper('pii_encryption');
|
||||||
|
$fields = $this->piiFields();
|
||||||
|
$decodeOne = static function ($row) use ($fields) {
|
||||||
|
if (is_object($row)) {
|
||||||
|
foreach ($fields as $f) {
|
||||||
|
if (isset($row->$f) && is_string($row->$f) && strpos($row->$f, 'ENC:') === 0) {
|
||||||
|
$row->$f = pii_decrypt($row->$f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} elseif (is_array($row)) {
|
||||||
|
foreach ($fields as $f) {
|
||||||
|
if (isset($row[$f]) && is_string($row[$f]) && strpos($row[$f], 'ENC:') === 0) {
|
||||||
|
$row[$f] = pii_decrypt($row[$f]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $row;
|
||||||
|
};
|
||||||
|
if (is_array($data['data'])) {
|
||||||
|
// findAll 등: 결과 배열
|
||||||
|
foreach ($data['data'] as $i => $row) {
|
||||||
|
$data['data'][$i] = $decodeOne($row);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// find/first: 단일
|
||||||
|
$data['data'] = $decodeOne($data['data']);
|
||||||
|
}
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
25
app/Models/FeedbackFileModel.php
Normal file
25
app/Models/FeedbackFileModel.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 의견 첨부 이미지(캡처/업로드).
|
||||||
|
*/
|
||||||
|
class FeedbackFileModel extends Model
|
||||||
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
|
protected $table = 'feedback_file';
|
||||||
|
protected $primaryKey = 'ff_idx';
|
||||||
|
protected $returnType = 'object';
|
||||||
|
protected $useTimestamps = true;
|
||||||
|
protected $createdField = 'ff_regdate';
|
||||||
|
protected $updatedField = '';
|
||||||
|
protected $dateFormat = 'datetime';
|
||||||
|
|
||||||
|
protected $allowedFields = ['ff_fb_idx', 'ff_kind', 'ff_path', 'ff_mime'];
|
||||||
|
}
|
||||||
43
app/Models/FeedbackModel.php
Normal file
43
app/Models/FeedbackModel.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 의견(피드백) 모델.
|
||||||
|
*/
|
||||||
|
class FeedbackModel extends Model
|
||||||
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
|
protected $table = 'feedback';
|
||||||
|
protected $primaryKey = 'fb_idx';
|
||||||
|
protected $returnType = 'object';
|
||||||
|
protected $useTimestamps = true;
|
||||||
|
protected $createdField = 'fb_regdate';
|
||||||
|
protected $updatedField = 'fb_updated';
|
||||||
|
protected $dateFormat = 'datetime';
|
||||||
|
|
||||||
|
protected $allowedFields = [
|
||||||
|
'fb_lg_idx', 'fb_mb_idx', 'fb_mb_name', 'fb_mb_level',
|
||||||
|
'fb_content', 'fb_page_url', 'fb_page_title', 'fb_screenshot',
|
||||||
|
'fb_user_agent', 'fb_status', 'fb_admin_memo',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 허용 상태 값 */
|
||||||
|
public const STATUSES = ['new', 'in_progress', 'answered', 'done', 'hold'];
|
||||||
|
|
||||||
|
public static function statusLabel(string $s): string
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'new' => '접수',
|
||||||
|
'in_progress' => '처리중',
|
||||||
|
'answered' => '답변 완료',
|
||||||
|
'done' => '완료',
|
||||||
|
'hold' => '보류',
|
||||||
|
][$s] ?? $s;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class FreeRecipientModel extends Model
|
class FreeRecipientModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'free_recipient';
|
protected $table = 'free_recipient';
|
||||||
protected $primaryKey = 'fr_idx';
|
protected $primaryKey = 'fr_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class LocalGovernmentModel extends Model
|
class LocalGovernmentModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'local_government';
|
protected $table = 'local_government';
|
||||||
protected $primaryKey = 'lg_idx';
|
protected $primaryKey = 'lg_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
@@ -6,12 +6,15 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class ManagerModel extends Model
|
class ManagerModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'manager';
|
protected $table = 'manager';
|
||||||
protected $primaryKey = 'mg_idx';
|
protected $primaryKey = 'mg_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
protected $useTimestamps = false;
|
protected $useTimestamps = false;
|
||||||
protected $allowedFields = [
|
protected $allowedFields = [
|
||||||
'mg_lg_idx', 'mg_name', 'mg_dept_code', 'mg_position_code',
|
'mg_lg_idx', 'mg_name', 'mg_affiliation', 'mg_company_name',
|
||||||
|
'mg_dept_code', 'mg_position_code',
|
||||||
'mg_tel', 'mg_phone', 'mg_email', 'mg_state', 'mg_regdate',
|
'mg_tel', 'mg_phone', 'mg_email', 'mg_state', 'mg_regdate',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class MemberApprovalRequestModel extends Model
|
class MemberApprovalRequestModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
public const STATUS_PENDING = 'pending';
|
public const STATUS_PENDING = 'pending';
|
||||||
public const STATUS_APPROVED = 'approved';
|
public const STATUS_APPROVED = 'approved';
|
||||||
public const STATUS_REJECTED = 'rejected';
|
public const STATUS_REJECTED = 'rejected';
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class MemberModel extends Model
|
class MemberModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
|
/** 로그인/세션 갱신만 있는 update는 활동 로그에 남기지 않음(로그인 이력은 별도 기록) */
|
||||||
|
protected function auditSkipUpdateOnlyFields(): array
|
||||||
|
{
|
||||||
|
return ['mb_latestdate', 'mb_login_fail_count', 'mb_locked_until', 'mb_session_token'];
|
||||||
|
}
|
||||||
|
|
||||||
protected $table = 'member';
|
protected $table = 'member';
|
||||||
protected $primaryKey = 'mb_idx';
|
protected $primaryKey = 'mb_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
@@ -28,6 +36,7 @@ class MemberModel extends Model
|
|||||||
'mb_leavedate',
|
'mb_leavedate',
|
||||||
'mb_login_fail_count',
|
'mb_login_fail_count',
|
||||||
'mb_locked_until',
|
'mb_locked_until',
|
||||||
|
'mb_session_token',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class MenuModel extends Model
|
class MenuModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'menu';
|
protected $table = 'menu';
|
||||||
protected $primaryKey = 'mm_idx';
|
protected $primaryKey = 'mm_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
@@ -80,6 +82,60 @@ class MenuModel extends Model
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 순서 + 상위(부모)·깊이 변경을 함께 반영한다.
|
||||||
|
* 하위 메뉴를 다른 상위 메뉴로 옮기는 드래그를 지원하기 위한 용도.
|
||||||
|
*
|
||||||
|
* @param array<int,array{idx:int,pidx:int,dep:int}> $items 화면 표시 순서대로 정렬된 구조 배열
|
||||||
|
*/
|
||||||
|
public function setStructure(array $items, int $lgIdx): void
|
||||||
|
{
|
||||||
|
$affectedTypes = [];
|
||||||
|
$i = 0;
|
||||||
|
foreach ($items as $it) {
|
||||||
|
$mmIdx = (int) ($it['idx'] ?? 0);
|
||||||
|
if ($mmIdx <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$row = $this->find($mmIdx);
|
||||||
|
if (! $row || (int) $row->lg_idx !== $lgIdx) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$this->update($mmIdx, [
|
||||||
|
'mm_num' => $i,
|
||||||
|
'mm_pidx' => max(0, (int) ($it['pidx'] ?? 0)),
|
||||||
|
'mm_dep' => max(0, (int) ($it['dep'] ?? 0)),
|
||||||
|
]);
|
||||||
|
$affectedTypes[(int) $row->mt_idx] = true;
|
||||||
|
$i++;
|
||||||
|
}
|
||||||
|
// 상위가 바뀌었을 수 있으므로 종류별로 mm_cnode(자식 수)를 재계산
|
||||||
|
foreach (array_keys($affectedTypes) as $mtIdx) {
|
||||||
|
$this->recountChildNodes((int) $mtIdx, $lgIdx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 메뉴 종류·지자체의 각 메뉴에 대해 mm_cnode(직속 자식 수)를 실제 값으로 재계산한다.
|
||||||
|
*/
|
||||||
|
private function recountChildNodes(int $mtIdx, int $lgIdx): void
|
||||||
|
{
|
||||||
|
$rows = $this->where('mt_idx', $mtIdx)->where('lg_idx', $lgIdx)->findAll();
|
||||||
|
$childCount = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$pid = (int) $r->mm_pidx;
|
||||||
|
if ($pid > 0) {
|
||||||
|
$childCount[$pid] = ($childCount[$pid] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$want = (int) ($childCount[(int) $r->mm_idx] ?? 0);
|
||||||
|
if ((int) $r->mm_cnode !== $want) {
|
||||||
|
$this->update((int) $r->mm_idx, ['mm_cnode' => $want]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 추가 시 같은 레벨에서 mm_num 결정 (동일 지자체·메뉴종류·부모·깊이 기준)
|
* 추가 시 같은 레벨에서 mm_num 결정 (동일 지자체·메뉴종류·부모·깊이 기준)
|
||||||
*/
|
*/
|
||||||
@@ -214,6 +270,13 @@ class MenuModel extends Model
|
|||||||
->get()
|
->get()
|
||||||
->getResultArray();
|
->getResultArray();
|
||||||
|
|
||||||
|
// 깊이(dep) 오름차순으로 그룹핑 — 상위부터 삽입해 자식의 mm_pidx를 새 ID로 재매핑
|
||||||
|
$byDep = [];
|
||||||
|
foreach ($source as $row) {
|
||||||
|
$byDep[(int) ($row->mm_dep ?? 0)][] = $row;
|
||||||
|
}
|
||||||
|
ksort($byDep);
|
||||||
|
|
||||||
foreach ($lgRows as $lgRow) {
|
foreach ($lgRows as $lgRow) {
|
||||||
$destLg = (int) ($lgRow['lg_idx'] ?? 0);
|
$destLg = (int) ($lgRow['lg_idx'] ?? 0);
|
||||||
if ($destLg <= 0 || $destLg === $sourceLg) {
|
if ($destLg <= 0 || $destLg === $sourceLg) {
|
||||||
@@ -225,28 +288,41 @@ class MenuModel extends Model
|
|||||||
->where('lg_idx', $destLg)
|
->where('lg_idx', $destLg)
|
||||||
->delete();
|
->delete();
|
||||||
|
|
||||||
|
// 원격 DB 왕복 최소화: 레벨(dep)별로 batch insert 후, 삽입 순서(mm_idx ASC)로 old→new ID 매핑
|
||||||
$idMap = [];
|
$idMap = [];
|
||||||
foreach ($source as $row) {
|
foreach ($byDep as $dep => $levelRows) {
|
||||||
$oldId = (int) ($row->mm_idx ?? 0);
|
$batch = [];
|
||||||
$oldP = (int) ($row->mm_pidx ?? 0);
|
foreach ($levelRows as $row) {
|
||||||
$newPidx = 0;
|
$oldP = (int) ($row->mm_pidx ?? 0);
|
||||||
if ($oldP > 0 && isset($idMap[$oldP])) {
|
$newPidx = ($oldP > 0 && isset($idMap[$oldP])) ? (int) $idMap[$oldP] : 0;
|
||||||
$newPidx = (int) $idMap[$oldP];
|
$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) $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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
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->insert([
|
|
||||||
'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_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();
|
|
||||||
}
|
}
|
||||||
$this->db->transComplete();
|
$this->db->transComplete();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class MenuTypeModel extends Model
|
class MenuTypeModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'menu_type';
|
protected $table = 'menu_type';
|
||||||
protected $primaryKey = 'mt_idx';
|
protected $primaryKey = 'mt_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class PackagingUnitHistoryModel extends Model
|
class PackagingUnitHistoryModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'packaging_unit_history';
|
protected $table = 'packaging_unit_history';
|
||||||
protected $primaryKey = 'puh_idx';
|
protected $primaryKey = 'puh_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class PackagingUnitModel extends Model
|
class PackagingUnitModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'packaging_unit';
|
protected $table = 'packaging_unit';
|
||||||
protected $primaryKey = 'pu_idx';
|
protected $primaryKey = 'pu_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
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',
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class SalesAgencyModel extends Model
|
class SalesAgencyModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'sales_agency';
|
protected $table = 'sales_agency';
|
||||||
protected $primaryKey = 'sa_idx';
|
protected $primaryKey = 'sa_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class ShopOrderItemModel extends Model
|
class ShopOrderItemModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'shop_order_item';
|
protected $table = 'shop_order_item';
|
||||||
protected $primaryKey = 'soi_idx';
|
protected $primaryKey = 'soi_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
protected $useTimestamps = false;
|
protected $useTimestamps = false;
|
||||||
protected $allowedFields = [
|
protected $allowedFields = [
|
||||||
'soi_so_idx', 'soi_bag_code', 'soi_bag_name', 'soi_unit_price',
|
'soi_so_idx', 'soi_bag_code', 'soi_bag_name', 'soi_unit_price',
|
||||||
'soi_qty', 'soi_amount', 'soi_box_count', 'soi_pack_count', 'soi_sheet_count',
|
'soi_qty', 'soi_packed_qty', 'soi_amount', 'soi_box_count', 'soi_pack_count', 'soi_sheet_count',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
|||||||
|
|
||||||
class ShopOrderModel extends Model
|
class ShopOrderModel extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Models\Traits\Auditable;
|
||||||
|
|
||||||
protected $table = 'shop_order';
|
protected $table = 'shop_order';
|
||||||
protected $primaryKey = 'so_idx';
|
protected $primaryKey = 'so_idx';
|
||||||
protected $returnType = 'object';
|
protected $returnType = 'object';
|
||||||
|
|||||||
171
app/Models/Traits/Auditable.php
Normal file
171
app/Models/Traits/Auditable.php
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models\Traits;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모델 CRUD(생성/수정/삭제) 자동 감사 로그 트레잇.
|
||||||
|
*
|
||||||
|
* 모델에 `use \App\Models\Traits\Auditable;` 만 추가하면
|
||||||
|
* insert/update/delete 시 activity_log 에 "누가·무엇을·언제" 자동 기록한다.
|
||||||
|
* (조회(select)는 기록하지 않음)
|
||||||
|
*
|
||||||
|
* - 변경 전(before)/후(after) 데이터를 JSON 으로 보관
|
||||||
|
* - 비밀번호·OTP 비밀키·이메일·전화 등 민감 필드는 마스킹
|
||||||
|
* - 기록 실패는 audit_log() 내부에서 흡수되어 본 로직에 영향 없음
|
||||||
|
*/
|
||||||
|
trait Auditable
|
||||||
|
{
|
||||||
|
/** 변경/삭제 전 원본 보관 (PK => row) */
|
||||||
|
protected array $auditBeforeRows = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모델 이벤트(콜백) 등록.
|
||||||
|
* BaseModel 이 이미 $afterInsert 등 속성을 선언하므로(속성 충돌 방지),
|
||||||
|
* 트레잇에서는 속성 대신 initialize()에서 런타임에 콜백을 덧붙인다.
|
||||||
|
*/
|
||||||
|
protected function initialize()
|
||||||
|
{
|
||||||
|
$this->allowCallbacks = true;
|
||||||
|
$this->afterInsert[] = 'auditAfterInsert';
|
||||||
|
$this->beforeUpdate[] = 'auditBeforeUpdate';
|
||||||
|
$this->afterUpdate[] = 'auditAfterUpdate';
|
||||||
|
$this->beforeDelete[] = 'auditBeforeDelete';
|
||||||
|
$this->afterDelete[] = 'auditAfterDelete';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 마스킹 대상 필드명 */
|
||||||
|
protected array $auditMaskFields = ['mb_passwd', 'mb_totp_secret', 'mb_session_token', 'mb_email', 'mb_phone'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이 필드들'만' 변경된 update는 활동 로그를 남기지 않는다.
|
||||||
|
* (예: 로그인/세션 갱신 같은 시스템 내부 변경 — 노이즈 제거)
|
||||||
|
* 모델에서 이 메서드를 override 해 사용. (프로퍼티로 두면 trait/class 비호환 오류)
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
protected function auditSkipUpdateOnlyFields(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function auditMask(?array $row): ?array
|
||||||
|
{
|
||||||
|
if ($row === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
foreach ($this->auditMaskFields as $f) {
|
||||||
|
if (array_key_exists($f, $row) && (string) $row[$f] !== '') {
|
||||||
|
$row[$f] = '***';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** find 콜백 재귀를 피하려고 쿼리빌더로 원본 행을 직접 조회 */
|
||||||
|
private function auditFetchRaw(int|string $id): ?array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$row = $this->db->table($this->table)
|
||||||
|
->where($this->primaryKey, $id)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
|
||||||
|
return $row ?: null;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function auditAfterInsert(array $eventData): array
|
||||||
|
{
|
||||||
|
helper('audit');
|
||||||
|
$after = is_array($eventData['data'] ?? null) ? $eventData['data'] : null;
|
||||||
|
audit_log('create', $this->table, (int) ($eventData['id'] ?? 0), null, $this->auditMask($after));
|
||||||
|
|
||||||
|
return $eventData;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function auditBeforeUpdate(array $eventData): array
|
||||||
|
{
|
||||||
|
$this->auditBeforeRows = [];
|
||||||
|
$ids = $eventData['id'] ?? null;
|
||||||
|
if ($ids !== null) {
|
||||||
|
foreach ((array) $ids as $one) {
|
||||||
|
$row = $this->auditFetchRaw($one);
|
||||||
|
if ($row !== null) {
|
||||||
|
$this->auditBeforeRows[(string) $one] = $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $eventData;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function auditAfterUpdate(array $eventData): array
|
||||||
|
{
|
||||||
|
helper('audit');
|
||||||
|
$ids = $eventData['id'] ?? null;
|
||||||
|
$changes = is_array($eventData['data'] ?? null) ? $eventData['data'] : [];
|
||||||
|
|
||||||
|
// 지정 필드만 변경된 경우(로그인/세션 갱신 등) → 기록 생략
|
||||||
|
$skipOnly = $this->auditSkipUpdateOnlyFields();
|
||||||
|
if ($skipOnly !== [] && $changes !== []) {
|
||||||
|
$changedKeys = array_values(array_diff(array_keys($changes), [$this->primaryKey]));
|
||||||
|
if ($changedKeys !== [] && array_diff($changedKeys, $skipOnly) === []) {
|
||||||
|
$this->auditBeforeRows = [];
|
||||||
|
|
||||||
|
return $eventData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ids !== null) {
|
||||||
|
foreach ((array) $ids as $one) {
|
||||||
|
$before = $this->auditBeforeRows[(string) $one] ?? null;
|
||||||
|
$after = $before !== null ? array_merge($before, $changes) : $changes;
|
||||||
|
audit_log('update', $this->table, (int) $one, $this->auditMask($before), $this->auditMask($after));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 조건(where) 기반 일괄 수정: 대상 PK 미상 → 변경값만 기록
|
||||||
|
audit_log('update', $this->table, 0, null, $this->auditMask($changes));
|
||||||
|
}
|
||||||
|
$this->auditBeforeRows = [];
|
||||||
|
|
||||||
|
return $eventData;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function auditBeforeDelete(array $eventData): array
|
||||||
|
{
|
||||||
|
$this->auditBeforeRows = [];
|
||||||
|
$ids = $eventData['id'] ?? null;
|
||||||
|
if ($ids !== null) {
|
||||||
|
foreach ((array) $ids as $one) {
|
||||||
|
$row = $this->auditFetchRaw($one);
|
||||||
|
if ($row !== null) {
|
||||||
|
$this->auditBeforeRows[(string) $one] = $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $eventData;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function auditAfterDelete(array $eventData): array
|
||||||
|
{
|
||||||
|
helper('audit');
|
||||||
|
$ids = $eventData['id'] ?? null;
|
||||||
|
if ($ids !== null) {
|
||||||
|
foreach ((array) $ids as $one) {
|
||||||
|
$before = $this->auditBeforeRows[(string) $one] ?? null;
|
||||||
|
audit_log('delete', $this->table, (int) $one, $this->auditMask($before), null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
audit_log('delete', $this->table, 0, null, null);
|
||||||
|
}
|
||||||
|
$this->auditBeforeRows = [];
|
||||||
|
|
||||||
|
return $eventData;
|
||||||
|
}
|
||||||
|
}
|
||||||
110
app/Views/admin/access/activity_log.php
Normal file
110
app/Views/admin/access/activity_log.php
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
/**
|
||||||
|
* @var list<object> $list
|
||||||
|
* @var string $start
|
||||||
|
* @var string $end
|
||||||
|
* @var string $action
|
||||||
|
* @var string $table
|
||||||
|
* @var string $user
|
||||||
|
* @var array<string,string> $tableLabels
|
||||||
|
* @var array<string,string> $actionLabels
|
||||||
|
*/
|
||||||
|
$actionBadge = static function (string $a): string {
|
||||||
|
$map = [
|
||||||
|
'create' => 'background:#dcfce7;color:#166534;',
|
||||||
|
'update' => 'background:#dbeafe;color:#1e40af;',
|
||||||
|
'delete' => 'background:#fee2e2;color:#991b1b;',
|
||||||
|
'export' => 'background:#cffafe;color:#155e75;',
|
||||||
|
'print' => 'background:#f3e8ff;color:#6b21a8;',
|
||||||
|
];
|
||||||
|
return $map[$a] ?? 'background:#f3f4f6;color:#374151;';
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
<?= view('components/print_header', ['printTitle' => '활동 로그']) ?>
|
||||||
|
|
||||||
|
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel no-print">
|
||||||
|
<form method="GET" action="<?= base_url('admin/access/activity-logs') ?>" class="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
<label class="font-bold text-gray-700">조회기간</label>
|
||||||
|
<input type="date" name="start" class="border border-gray-300 rounded px-2 py-1 text-sm" value="<?= esc($start) ?>"/>
|
||||||
|
<span>~</span>
|
||||||
|
<input type="date" name="end" class="border border-gray-300 rounded px-2 py-1 text-sm" value="<?= esc($end) ?>"/>
|
||||||
|
|
||||||
|
<select name="action" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[8rem]">
|
||||||
|
<option value="">작업 전체</option>
|
||||||
|
<?php foreach ($actionLabels as $k => $lbl): ?>
|
||||||
|
<option value="<?= esc($k, 'attr') ?>" <?= $action === $k ? 'selected' : '' ?>><?= esc($lbl) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select name="table" class="border border-gray-300 rounded px-2 py-1 text-sm max-w-[12rem]">
|
||||||
|
<option value="">대상 전체</option>
|
||||||
|
<?php foreach ($tableLabels as $k => $lbl): ?>
|
||||||
|
<option value="<?= esc($k, 'attr') ?>" <?= $table === $k ? 'selected' : '' ?>><?= esc($lbl) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<input type="text" name="user" placeholder="사용자(아이디/이름)" class="border border-gray-300 rounded px-2 py-1 text-sm" value="<?= esc($user) ?>"/>
|
||||||
|
|
||||||
|
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition border border-transparent">조회</button>
|
||||||
|
<a href="<?= base_url('admin/access/activity-logs') ?>" class="border border-gray-300 text-gray-600 px-3 py-1.5 rounded-sm text-sm hover:bg-gray-50">초기화</a>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="border border-gray-300 rounded-lg p-4 overflow-auto mt-2">
|
||||||
|
<table class="w-full data-table text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="text-left">일시</th>
|
||||||
|
<th class="text-left">사용자</th>
|
||||||
|
<th class="text-center">작업</th>
|
||||||
|
<th class="text-left">내용</th>
|
||||||
|
<th class="text-center no-print">상세</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($list as $row): ?>
|
||||||
|
<?php
|
||||||
|
$act = (string) ($row->al_action ?? '');
|
||||||
|
$isLogin = ! empty($row->isLogin);
|
||||||
|
$who = trim((string) ($row->mb_name ?? '') . (($row->mb_id ?? '') !== '' ? ' (' . $row->mb_id . ')' : ''));
|
||||||
|
$badgeText = $isLogin ? '로그인' : ($actionLabels[$act] ?? $act);
|
||||||
|
$badgeStyle = $isLogin ? 'background:#ede9fe;color:#5b21b6;' : $actionBadge($act);
|
||||||
|
$changeList = $row->changeList ?? [];
|
||||||
|
?>
|
||||||
|
<tr>
|
||||||
|
<td class="text-left pl-2 whitespace-nowrap"><?= esc((string) ($row->al_regdate ?? '')) ?></td>
|
||||||
|
<td class="text-left pl-2"><?= $who !== '' ? esc($who) : '<span class="text-gray-400">시스템</span>' ?></td>
|
||||||
|
<td class="text-center">
|
||||||
|
<span style="<?= $badgeStyle ?>display:inline-block;padding:1px 8px;border-radius:9999px;font-size:11px;font-weight:700;white-space:nowrap;">
|
||||||
|
<?= esc($badgeText) ?>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-left pl-2">
|
||||||
|
<span class="font-semibold text-gray-800"><?= esc((string) ($row->summaryText ?? '')) ?></span>
|
||||||
|
<?php if ($changeList !== []): ?>
|
||||||
|
<div class="text-xs text-gray-500 mt-0.5">
|
||||||
|
<?php foreach (array_slice($changeList, 0, 4) as $c): ?>
|
||||||
|
<span class="inline-block mr-2">
|
||||||
|
<span class="text-gray-600"><?= esc($c['label']) ?>:</span>
|
||||||
|
<span class="text-gray-400"><?= esc($c['from']) ?></span>
|
||||||
|
→
|
||||||
|
<span class="text-gray-700"><?= esc($c['to']) ?></span>
|
||||||
|
</span>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php if (count($changeList) > 4): ?><span class="text-gray-400">외 <?= count($changeList) - 4 ?>건</span><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td class="text-center no-print">
|
||||||
|
<a href="<?= base_url('admin/access/activity-logs/' . (int) $row->al_idx) ?>" class="text-blue-700 hover:underline">보기</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php if ($list === []): ?>
|
||||||
|
<tr><td colspan="5" class="text-center text-gray-400 py-6">조회된 로그가 없습니다.</td></tr>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<?php if (isset($pager)): ?><div class="mt-3 no-print"><?= $pager->links() ?></div><?php endif; ?>
|
||||||
68
app/Views/admin/access/activity_log_detail.php
Normal file
68
app/Views/admin/access/activity_log_detail.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
/**
|
||||||
|
* @var object $row
|
||||||
|
* @var array<string,mixed> $before
|
||||||
|
* @var array<string,mixed> $after
|
||||||
|
* @var array<string,string> $tableLabels
|
||||||
|
* @var array<string,string> $actionLabels
|
||||||
|
*/
|
||||||
|
$act = (string) ($row->al_action ?? '');
|
||||||
|
$tbl = (string) ($row->al_table ?? '');
|
||||||
|
$who = trim((string) ($row->mb_name ?? '') . (($row->mb_id ?? '') !== '' ? ' (' . $row->mb_id . ')' : ''));
|
||||||
|
|
||||||
|
// 변경된 키 강조용: before/after 값이 다른 키
|
||||||
|
$allKeys = array_values(array_unique(array_merge(array_keys($before), array_keys($after))));
|
||||||
|
$fmt = static function ($v): string {
|
||||||
|
if (is_array($v)) {
|
||||||
|
return json_encode($v, JSON_UNESCAPED_UNICODE);
|
||||||
|
}
|
||||||
|
return (string) ($v ?? '');
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
<section class="p-4">
|
||||||
|
<div class="flex items-center justify-between mb-3 no-print">
|
||||||
|
<a href="<?= base_url('admin/access/activity-logs') ?>" class="text-sm text-blue-700 hover:underline">← 목록으로</a>
|
||||||
|
<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">인쇄</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="w-full data-table text-sm mb-5" style="max-width:680px;">
|
||||||
|
<tbody>
|
||||||
|
<tr><th class="text-left bg-gray-50 w-32">일시</th><td class="text-left pl-2"><?= esc((string) ($row->al_regdate ?? '')) ?></td></tr>
|
||||||
|
<tr><th class="text-left bg-gray-50">사용자</th><td class="text-left pl-2"><?= $who !== '' ? esc($who) : '시스템' ?></td></tr>
|
||||||
|
<tr><th class="text-left bg-gray-50">작업</th><td class="text-left pl-2"><?= esc($actionLabels[$act] ?? $act) ?></td></tr>
|
||||||
|
<tr><th class="text-left bg-gray-50">대상</th><td class="text-left pl-2"><?= esc($tableLabels[$tbl] ?? $tbl) ?> (<?= esc($tbl) ?>) · 번호 <?= (int) ($row->al_record_id ?? 0) ?></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3 class="font-bold text-gray-700 mb-2">변경 내역 (전 → 후)</h3>
|
||||||
|
<?php if ($allKeys === []): ?>
|
||||||
|
<p class="text-gray-400 text-sm">상세 데이터가 없습니다.</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="border border-gray-300 rounded-lg overflow-auto">
|
||||||
|
<table class="w-full data-table text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="text-left w-40">항목</th>
|
||||||
|
<th class="text-left">변경 전</th>
|
||||||
|
<th class="text-left">변경 후</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($allKeys as $k): ?>
|
||||||
|
<?php
|
||||||
|
$b = array_key_exists($k, $before) ? $fmt($before[$k]) : null;
|
||||||
|
$a = array_key_exists($k, $after) ? $fmt($after[$k]) : null;
|
||||||
|
$changed = ($act === 'update') && ($b !== $a);
|
||||||
|
?>
|
||||||
|
<tr<?= $changed ? ' style="background:#fffbeb;"' : '' ?>>
|
||||||
|
<td class="text-left pl-2 font-semibold text-gray-700"><?= esc((string) $k) ?></td>
|
||||||
|
<td class="text-left pl-2 <?= $changed ? 'text-red-700' : 'text-gray-500' ?>"><?= $b === null ? '<span class="text-gray-300">—</span>' : esc($b) ?></td>
|
||||||
|
<td class="text-left pl-2 <?= $changed ? 'text-blue-700 font-semibold' : 'text-gray-700' ?>"><?= $a === null ? '<span class="text-gray-300">—</span>' : esc($a) ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</section>
|
||||||
@@ -115,8 +115,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<label class="block text-sm font-bold text-gray-700 w-28">구코드</label>
|
<label class="block text-sm font-bold text-gray-700 w-28">구·군(동코드)</label>
|
||||||
<div class="text-sm text-gray-600">해당 지자체(구·군) 코드로 등록 시 자동 설정</div>
|
<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>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
@@ -165,3 +166,53 @@
|
|||||||
'detailFieldName' => 'ds_addr_detail',
|
'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
|
<?php
|
||||||
helper('admin');
|
helper(['admin', 'pii_encryption']);
|
||||||
|
$repDisp = static function ($row): string {
|
||||||
|
$v = (string) ($row->ds_rep_name ?? '');
|
||||||
|
return can_view_shop_pii((int) ($row->ds_lg_idx ?? 0)) ? $v : mask_shop_field('ds_rep_name', $v);
|
||||||
|
};
|
||||||
$currentPath = current_nav_request_path();
|
$currentPath = current_nav_request_path();
|
||||||
if ($currentPath === 'bag/designated-shops') {
|
if ($currentPath === 'bag/designated-shops') {
|
||||||
$readOnly = false;
|
$readOnly = false;
|
||||||
@@ -11,27 +15,37 @@ if ($currentPath === 'bag/designated-shops') {
|
|||||||
?>
|
?>
|
||||||
<?= view('components/print_header', ['printTitle' => $readOnly ? '지정판매소 조회 목록' : '지정판매소 목록']) ?>
|
<?= view('components/print_header', ['printTitle' => $readOnly ? '지정판매소 조회 목록' : '지정판매소 목록']) ?>
|
||||||
<style>
|
<style>
|
||||||
/* 목록 위 → 지정판매소 정보 아래 (가로 2열 없음) */
|
/* 목록 왼쪽 → 지정판매소 정보 오른쪽 (좌우 2열) */
|
||||||
.ds-split {
|
.ds-split {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
.ds-list-panel {
|
.ds-list-panel {
|
||||||
flex: 0 1 auto;
|
flex: 1 1 55%;
|
||||||
width: 100%;
|
min-width: 0;
|
||||||
max-height: 42vh;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
border: 1px solid #ccc;
|
border: 1px solid #ccc;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
|
/* 기본은 내용 높이만 차지(리스트를 10행 정도로 제한하고 아래 빈 공간 없이). */
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
/* 지정판매소 리스트: 헤더 + 약 15행만 보이고 나머지는 스크롤 (행 39px×15 + 헤더 34) */
|
||||||
|
.ds-list-scroll { max-height: 619px; }
|
||||||
|
.ds-list-panel thead th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: #f8f9fa;
|
||||||
|
z-index: 2;
|
||||||
|
box-shadow: inset 0 -1px 0 #e5e7eb;
|
||||||
}
|
}
|
||||||
.ds-detail-panel {
|
.ds-detail-panel {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 45%;
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 12rem;
|
min-height: 12rem;
|
||||||
border: 1px solid #ccc;
|
border: 1px solid #ccc;
|
||||||
@@ -39,6 +53,12 @@ if ($currentPath === 'bag/designated-shops') {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
/* 좁은 화면에서는 다시 위·아래로 쌓아 가독성 확보 */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.ds-split { flex-direction: column; }
|
||||||
|
.ds-list-panel { flex: 0 1 auto; max-height: 42vh; align-self: stretch; }
|
||||||
|
.ds-detail-panel { flex: 1 1 auto; }
|
||||||
|
}
|
||||||
.ds-panel-title {
|
.ds-panel-title {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
@@ -175,10 +195,8 @@ $listBasePath = $readOnly ? 'designated-shops/browse' : 'designated-shops';
|
|||||||
<div class="flex flex-wrap items-center justify-between gap-y-2">
|
<div class="flex flex-wrap items-center justify-between gap-y-2">
|
||||||
<span class="text-sm font-bold text-gray-700"><?= $readOnly ? '지정판매소 조회' : '지정판매소 관리' ?></span>
|
<span class="text-sm font-bold text-gray-700"><?= $readOnly ? '지정판매소 조회' : '지정판매소 관리' ?></span>
|
||||||
<div class="flex items-center gap-2">
|
<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>
|
<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>
|
<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): ?>
|
<?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>
|
<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; ?>
|
<?php endif; ?>
|
||||||
@@ -199,6 +217,10 @@ $listBasePath = $readOnly ? 'designated-shops/browse' : 'designated-shops';
|
|||||||
<option value="<?= esc($gCode) ?>" <?= ($dsGugunCode ?? '') === $gCode ? 'selected' : '' ?>><?= esc((string) (($gugunNameMap[$gCode] ?? '') !== '' ? $gugunNameMap[$gCode] : $gCode)) ?></option>
|
<option value="<?= esc($gCode) ?>" <?= ($dsGugunCode ?? '') === $gCode ? 'selected' : '' ?>><?= esc((string) (($gugunNameMap[$gCode] ?? '') !== '' ? $gugunNameMap[$gCode] : $gCode)) ?></option>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
|
<label class="text-sm text-gray-600">대표자명</label>
|
||||||
|
<input type="text" name="ds_rep" value="<?= esc($dsRep ?? '') ?>" placeholder="정확히 일치" class="border border-gray-300 rounded px-2 py-1 text-sm w-28" title="개인정보 보호를 위해 정확일치 검색만 지원합니다"/>
|
||||||
|
<label class="text-sm text-gray-600">전화</label>
|
||||||
|
<input type="text" name="ds_tel" value="<?= esc($dsTel ?? '') ?>" placeholder="정확히 일치" class="border border-gray-300 rounded px-2 py-1 text-sm w-32" title="개인정보 보호를 위해 정확일치 검색만 지원합니다"/>
|
||||||
<label class="text-sm text-gray-600">상태</label>
|
<label class="text-sm text-gray-600">상태</label>
|
||||||
<select name="ds_state" class="border border-gray-300 rounded px-2 py-1 text-sm">
|
<select name="ds_state" class="border border-gray-300 rounded px-2 py-1 text-sm">
|
||||||
<option value="">전체</option>
|
<option value="">전체</option>
|
||||||
@@ -222,21 +244,17 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
|
|||||||
<div class="ds-split no-print mx-2 mb-2 mt-2 flex-1 min-h-0">
|
<div class="ds-split no-print mx-2 mb-2 mt-2 flex-1 min-h-0">
|
||||||
<div class="ds-list-panel">
|
<div class="ds-list-panel">
|
||||||
<div class="ds-panel-title shrink-0">지정판매소 리스트</div>
|
<div class="ds-panel-title shrink-0">지정판매소 리스트</div>
|
||||||
<div class="overflow-auto flex-1 min-h-0">
|
<div class="ds-list-scroll overflow-auto flex-1 min-h-0">
|
||||||
<table class="w-full text-[13px]">
|
<table class="w-full text-[13px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="text-left text-[11px] font-semibold text-gray-500 border-b border-gray-200">
|
<tr class="text-left text-[11px] font-semibold text-gray-500 border-b border-gray-200">
|
||||||
<th class="py-2.5 px-2 w-14 text-left">번호</th>
|
<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="py-2.5 px-2 w-24">구·군</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="py-2.5 px-2 w-24 text-left">지정일</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="py-2.5 px-2 w-24">구역</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="py-2.5 px-2 ds-col-tight">대표자명</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="py-2.5 px-2 ds-col-tight">상호명</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="py-2.5 px-2 ds-col-zip text-left">우편번호</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>
|
||||||
<th class="py-2.5 px-2 text-left">주소</th>
|
|
||||||
<th class="py-2.5 px-2 w-28">사업자번호</th>
|
|
||||||
<th class="py-2.5 px-2 w-28">전화</th>
|
|
||||||
<th class="py-2.5 px-2 w-16 text-left">상태</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="ds-list-body">
|
<tbody id="ds-list-body">
|
||||||
@@ -266,18 +284,24 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
|
|||||||
if ($addrCombinedList === '') {
|
if ($addrCombinedList === '') {
|
||||||
$addrCombinedList = $addrMainList;
|
$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">
|
<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($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-left font-mono text-gray-700"><?= esc($shortNo) ?></td>
|
||||||
<td class="py-2.5 px-2 text-gray-600"><?= esc($ggLabel) ?></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 text-left text-gray-500 text-[12px]"><?= esc($daDisp) ?></td>
|
|
||||||
<td class="py-2.5 px-2 text-gray-600"><?= esc($zone) ?></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 font-medium text-gray-900 ds-col-tight" title="<?= esc($row->ds_name ?? '') ?>"><?= esc($row->ds_name ?? '') ?></td>
|
<td class="py-2.5 px-2 font-medium text-gray-900 ds-col-tight" title="<?= esc($row->ds_name ?? '') ?>"><?= esc($row->ds_name ?? '') ?></td>
|
||||||
<td class="py-2.5 px-2 text-left font-mono text-gray-700 ds-col-zip" title="<?= esc($zipList) ?>"><?= esc($zipList) ?></td>
|
<td class="py-2.5 px-2 text-gray-600 font-mono" title="<?= esc($dongDisp) ?>"><?= esc($dongDisp) ?></td>
|
||||||
<td class="py-2.5 px-2 text-gray-600 ds-col-addr-list" title="<?= esc($addrCombinedList) ?>"><?= esc($addrCombinedList) ?></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 font-mono text-gray-700"><?= esc($row->ds_biz_no ?? '') ?></td>
|
<td class="py-2.5 px-2 text-gray-600" title="<?= esc($zone) ?>"><?= esc($zone) ?></td>
|
||||||
<td class="py-2.5 px-2 font-mono text-gray-700"><?= esc($row->ds_tel ?? '') ?></td>
|
|
||||||
<td class="py-2.5 px-2 text-left">
|
<td class="py-2.5 px-2 text-left">
|
||||||
<?php if ($st === 1): ?>
|
<?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>
|
<span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium bg-emerald-50 text-emerald-700">정상</span>
|
||||||
@@ -296,70 +320,108 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
|
|||||||
<div class="ds-panel-title shrink-0">지정판매소 정보</div>
|
<div class="ds-panel-title shrink-0">지정판매소 정보</div>
|
||||||
<div class="ds-detail-inner" id="ds-detail-box">
|
<div class="ds-detail-inner" id="ds-detail-box">
|
||||||
<p id="ds-detail-placeholder" class="text-sm text-gray-500 py-6 text-center">위 목록에서 행을 선택하세요.</p>
|
<p id="ds-detail-placeholder" class="text-sm text-gray-500 py-6 text-center">위 목록에서 행을 선택하세요.</p>
|
||||||
|
|
||||||
|
<!-- 개인정보 마스킹 안내 + 원문 보기(2단계 인증) -->
|
||||||
|
<div id="ds-pii-reveal" class="hidden mb-2 border border-amber-300 bg-amber-50 rounded p-2 text-[12px] no-print">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="text-amber-800">개인정보가 마스킹되어 있습니다.</span>
|
||||||
|
<button type="button" id="ds-pii-reveal-btn" class="border border-amber-500 text-amber-800 px-2 py-0.5 rounded hover:bg-amber-100">원문 보기</button>
|
||||||
|
</div>
|
||||||
|
<div id="ds-pii-reveal-form" class="hidden mt-2 space-y-1">
|
||||||
|
<input type="text" id="ds-pii-otp" inputmode="numeric" maxlength="6" placeholder="인증코드 6자리(OTP)" class="border border-gray-300 rounded px-2 py-1 w-full"/>
|
||||||
|
<input type="text" id="ds-pii-reason" maxlength="200" placeholder="열람 사유" class="border border-gray-300 rounded px-2 py-1 w-full"/>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button type="button" id="ds-pii-reveal-submit" class="bg-[#243a5e] text-white px-3 py-1 rounded text-[12px]">인증 후 열람</button>
|
||||||
|
<button type="button" id="ds-pii-reveal-cancel" class="bg-gray-200 text-gray-700 px-3 py-1 rounded text-[12px]">취소</button>
|
||||||
|
</div>
|
||||||
|
<p id="ds-pii-reveal-msg" class="text-red-600"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="ds-detail-fields" class="hidden">
|
<div id="ds-detail-fields" class="hidden">
|
||||||
<div class="ds-detail-info-wrap">
|
<!-- 제목 왼쪽 · 내용 오른쪽 (라벨/값) 폼 형태 -->
|
||||||
<table class="w-full data-table text-sm" id="ds-detail-info-table" aria-label="지정판매소 상세">
|
<div class="ds-detail-form" id="ds-detail-info-table" aria-label="지정판매소 상세">
|
||||||
<thead>
|
<div class="ds-row ds-row-wide">
|
||||||
<tr>
|
<div class="ds-field-label">판매소번호</div>
|
||||||
<th>판매소번호</th>
|
<div class="ds-field-value ds-value-shop-wide" data-ro="ds_shop_no">—</div>
|
||||||
<th class="ds-col-tight-head">상호명</th>
|
</div>
|
||||||
<th>우편번호</th>
|
<div class="ds-row ds-row-4-even">
|
||||||
<th>사업자번호</th>
|
<div class="ds-field-label">상호명</div>
|
||||||
<th>일반전화</th>
|
<div class="ds-field-value" data-ro="ds_name">—</div>
|
||||||
<th class="ds-col-tight-head">대표자명</th>
|
<div class="ds-field-label">우편번호</div>
|
||||||
<th>이메일</th>
|
<div class="ds-field-value" data-ro="ds_zip">—</div>
|
||||||
<th>업태</th>
|
</div>
|
||||||
<th>업종</th>
|
<div class="ds-row ds-row-4-even">
|
||||||
<th>지정일자</th>
|
<div class="ds-field-label">사업자번호</div>
|
||||||
<th>지자체</th>
|
<div class="ds-field-value" data-ro="ds_biz_no">—</div>
|
||||||
<th>도로명주소</th>
|
<div class="ds-field-label">일반전화</div>
|
||||||
<th>지번주소</th>
|
<div class="ds-field-value" data-ro="ds_tel">—</div>
|
||||||
<th>상세주소</th>
|
</div>
|
||||||
<th>개인전화</th>
|
<div class="ds-row ds-row-4-even">
|
||||||
<th>구·군</th>
|
<div class="ds-field-label">대표자명</div>
|
||||||
<th>구역</th>
|
<div class="ds-field-value" data-ro="ds_rep_name">—</div>
|
||||||
<th>가상계좌(은행)</th>
|
<div class="ds-field-label">이메일</div>
|
||||||
<th>계좌번호</th>
|
<div class="ds-field-value" data-ro="ds_email">—</div>
|
||||||
<th>종사업장번호</th>
|
</div>
|
||||||
<th>변경일자</th>
|
<div class="ds-row ds-row-wide">
|
||||||
<th>영업상태</th>
|
<div class="ds-field-label">도로명주소</div>
|
||||||
<th>등록일시</th>
|
<div class="ds-field-value ds-field-value-with-map">
|
||||||
<th>변경사유</th>
|
<span class="ds-addr-text" data-ro="ds_addr">—</span>
|
||||||
<th class="no-print w-14 text-center">지도</th>
|
<button type="button" class="no-print border border-btn-print-border text-gray-700 px-2 py-0.5 rounded-sm text-xs hover:bg-gray-50 shrink-0" id="ds-ro-map-btn">지도 보기</button>
|
||||||
</tr>
|
</div>
|
||||||
</thead>
|
</div>
|
||||||
<tbody>
|
<div class="ds-row ds-row-wide">
|
||||||
<tr>
|
<div class="ds-field-label">지번주소</div>
|
||||||
<td class="text-left" data-ro="ds_shop_no">—</td>
|
<div class="ds-field-value" data-ro="ds_addr_jibun">—</div>
|
||||||
<td class="text-left ds-col-tight-cell" data-ro="ds_name">—</td>
|
</div>
|
||||||
<td class="text-left" data-ro="ds_zip">—</td>
|
<div class="ds-row ds-row-wide">
|
||||||
<td class="text-left" data-ro="ds_biz_no">—</td>
|
<div class="ds-field-label">상세주소</div>
|
||||||
<td class="text-left" data-ro="ds_tel">—</td>
|
<div class="ds-field-value" data-ro="ds_addr_detail">—</div>
|
||||||
<td class="text-left ds-col-tight-cell" data-ro="ds_rep_name">—</td>
|
</div>
|
||||||
<td class="text-left" data-ro="ds_email">—</td>
|
<div class="ds-row ds-row-wide">
|
||||||
<td class="text-left" data-ro="ds_biz_type">—</td>
|
<div class="ds-field-label">개인전화</div>
|
||||||
<td class="text-left" data-ro="ds_biz_kind">—</td>
|
<div class="ds-field-value" data-ro="ds_rep_phone">—</div>
|
||||||
<td class="text-left" data-ro="ds_designated_at">—</td>
|
</div>
|
||||||
<td class="text-left" data-ro="lg_name">—</td>
|
<div class="ds-row ds-row-4-even">
|
||||||
<td class="text-left min-w-[10rem]"><span data-ro="ds_addr">—</span></td>
|
<div class="ds-field-label">지정일자</div>
|
||||||
<td class="text-left" data-ro="ds_addr_jibun">—</td>
|
<div class="ds-field-value" data-ro="ds_designated_at">—</div>
|
||||||
<td class="text-left" data-ro="ds_addr_detail">—</td>
|
<div class="ds-field-label">업태</div>
|
||||||
<td class="text-left" data-ro="ds_rep_phone">—</td>
|
<div class="ds-field-value" data-ro="ds_biz_type">—</div>
|
||||||
<td class="text-left" data-ro="gugun_name">—</td>
|
</div>
|
||||||
<td class="text-left" data-ro="ds_zone_code">—</td>
|
<div class="ds-row ds-row-4-even">
|
||||||
<td class="text-left" data-ro="ds_va_bank">—</td>
|
<div class="ds-field-label">업종</div>
|
||||||
<td class="text-left" data-ro="ds_va_account">—</td>
|
<div class="ds-field-value" data-ro="ds_biz_kind">—</div>
|
||||||
<td class="text-left" data-ro="ds_branch_no">—</td>
|
<div class="ds-field-label">구·군(동코드)</div>
|
||||||
<td class="text-left" data-ro="ds_state_changed_at">—</td>
|
<div class="ds-field-value" data-ro="dong_display">—</div>
|
||||||
<td class="text-left" data-ro="state_label">—</td>
|
</div>
|
||||||
<td class="text-left" data-ro="ds_regdate">—</td>
|
<div class="ds-row ds-row-4-even">
|
||||||
<td class="text-left min-w-[8rem]" data-ro="ds_change_reason">—</td>
|
<div class="ds-field-label">구역</div>
|
||||||
<td class="text-center no-print">
|
<div class="ds-field-value" data-ro="ds_zone_code">—</div>
|
||||||
<button type="button" class="border border-btn-print-border text-gray-700 px-2 py-0.5 rounded-sm text-xs hover:bg-gray-50" id="ds-ro-map-btn">지도</button>
|
<div class="ds-field-label">종사업장번호</div>
|
||||||
</td>
|
<div class="ds-field-value" data-ro="ds_branch_no">—</div>
|
||||||
</tr>
|
</div>
|
||||||
</tbody>
|
<div class="ds-row ds-row-4-even">
|
||||||
</table>
|
<div class="ds-field-label">가상계좌</div>
|
||||||
|
<div class="ds-field-value" data-ro="ds_va_bank">—</div>
|
||||||
|
<div class="ds-field-label">계좌번호</div>
|
||||||
|
<div class="ds-field-value" data-ro="ds_va_account">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="ds-row ds-row-4-even">
|
||||||
|
<div class="ds-field-label">영업상태</div>
|
||||||
|
<div class="ds-field-value" data-ro="state_label">—</div>
|
||||||
|
<div class="ds-field-label">변경일자</div>
|
||||||
|
<div class="ds-field-value" data-ro="ds_state_changed_at">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="ds-row ds-row-wide">
|
||||||
|
<div class="ds-field-label">변경사유</div>
|
||||||
|
<div class="ds-field-value" data-ro="ds_change_reason">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="ds-row ds-row-4-even">
|
||||||
|
<div class="ds-field-label">지자체</div>
|
||||||
|
<div class="ds-field-value" data-ro="lg_name">—</div>
|
||||||
|
<div class="ds-field-label">등록일시</div>
|
||||||
|
<div class="ds-field-value" data-ro="ds_regdate">—</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -442,6 +504,14 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
|
|||||||
fieldsWrap.classList.remove('hidden');
|
fieldsWrap.classList.remove('hidden');
|
||||||
fillDetailInfoTable(d);
|
fillDetailInfoTable(d);
|
||||||
|
|
||||||
|
// 마스킹된 행이면 "원문 보기" 안내 노출
|
||||||
|
var revealBox = document.getElementById('ds-pii-reveal');
|
||||||
|
if (revealBox) {
|
||||||
|
revealBox.classList.toggle('hidden', !d.pii_masked);
|
||||||
|
var rf = document.getElementById('ds-pii-reveal-form');
|
||||||
|
if (rf) rf.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
if (!readOnly && editLink && delForm && delBtn) {
|
if (!readOnly && editLink && delForm && delBtn) {
|
||||||
var id = d.ds_idx;
|
var id = d.ds_idx;
|
||||||
editLink.href = editBase + '/' + id;
|
editLink.href = editBase + '/' + id;
|
||||||
@@ -469,6 +539,44 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 컬럼 헤더 클릭 정렬 (DOM 행 재배치 — data-row-index 유지) ──
|
||||||
|
(function initSort() {
|
||||||
|
if (!body) return;
|
||||||
|
var ths = document.querySelectorAll('.ds-sort-th');
|
||||||
|
var curKey = null, curDir = 'asc';
|
||||||
|
function apply(key, type, dir) {
|
||||||
|
var trs = Array.prototype.slice.call(body.querySelectorAll('tr.ds-list-row'));
|
||||||
|
trs.sort(function (a, b) {
|
||||||
|
var va = a.getAttribute('data-sort-' + key) || '';
|
||||||
|
var vb = b.getAttribute('data-sort-' + key) || '';
|
||||||
|
var r;
|
||||||
|
if (type === 'num') {
|
||||||
|
r = (parseFloat(va) || 0) - (parseFloat(vb) || 0);
|
||||||
|
} else {
|
||||||
|
r = String(va).localeCompare(String(vb), 'ko');
|
||||||
|
}
|
||||||
|
return dir === 'asc' ? r : -r;
|
||||||
|
});
|
||||||
|
trs.forEach(function (tr) { body.appendChild(tr); });
|
||||||
|
}
|
||||||
|
ths.forEach(function (th) {
|
||||||
|
th.addEventListener('click', function () {
|
||||||
|
var key = th.getAttribute('data-sort-key');
|
||||||
|
var type = th.getAttribute('data-sort-type') || 'str';
|
||||||
|
if (curKey === key) {
|
||||||
|
curDir = curDir === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
curKey = key; curDir = 'asc';
|
||||||
|
}
|
||||||
|
apply(key, type, curDir);
|
||||||
|
ths.forEach(function (t) {
|
||||||
|
var ind = t.querySelector('.ds-sort-ind');
|
||||||
|
if (ind) ind.textContent = (t === th) ? (curDir === 'asc' ? ' ▲' : ' ▼') : '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
var mapBtnRo = document.getElementById('ds-ro-map-btn');
|
var mapBtnRo = document.getElementById('ds-ro-map-btn');
|
||||||
if (mapBtnRo) {
|
if (mapBtnRo) {
|
||||||
mapBtnRo.addEventListener('click', function (ev) {
|
mapBtnRo.addEventListener('click', function (ev) {
|
||||||
@@ -495,6 +603,38 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
|
|||||||
delBtn.disabled = true;
|
delBtn.disabled = true;
|
||||||
delBtn.classList.add('pointer-events-none', 'opacity-40');
|
delBtn.classList.add('pointer-events-none', 'opacity-40');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 개인정보 원문 보기(2단계 인증 step-up) ──
|
||||||
|
(function () {
|
||||||
|
var box = document.getElementById('ds-pii-reveal');
|
||||||
|
if (!box) return;
|
||||||
|
var btn = document.getElementById('ds-pii-reveal-btn');
|
||||||
|
var form = document.getElementById('ds-pii-reveal-form');
|
||||||
|
var otp = document.getElementById('ds-pii-otp');
|
||||||
|
var reason = document.getElementById('ds-pii-reason');
|
||||||
|
var submit = document.getElementById('ds-pii-reveal-submit');
|
||||||
|
var cancel = document.getElementById('ds-pii-reveal-cancel');
|
||||||
|
var msg = document.getElementById('ds-pii-reveal-msg');
|
||||||
|
var endpoint = new URL('<?= mgmt_url('designated-shops/reveal-pii') ?>', window.location.href).pathname;
|
||||||
|
var csrfName = '<?= csrf_token() ?>';
|
||||||
|
btn.addEventListener('click', function () { form.classList.remove('hidden'); otp.focus(); });
|
||||||
|
cancel.addEventListener('click', function () { form.classList.add('hidden'); msg.textContent = ''; });
|
||||||
|
submit.addEventListener('click', function () {
|
||||||
|
msg.textContent = '';
|
||||||
|
var body = new URLSearchParams();
|
||||||
|
body.set('totp_code', (otp.value || '').trim());
|
||||||
|
body.set('reason', (reason.value || '').trim());
|
||||||
|
var t = document.querySelector('meta[name="' + csrfName + '"]');
|
||||||
|
body.set(csrfName, '<?= csrf_hash() ?>');
|
||||||
|
fetch(endpoint, { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' }, body: body })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (d) {
|
||||||
|
if (d && d.ok) { location.reload(); }
|
||||||
|
else { msg.textContent = (d && d.error) || '열람 승인 실패'; }
|
||||||
|
})
|
||||||
|
.catch(function () { msg.textContent = '통신 오류'; });
|
||||||
|
});
|
||||||
|
})();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -505,7 +645,7 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
|
|||||||
<tr>
|
<tr>
|
||||||
<th class="text-center">번호</th>
|
<th class="text-center">번호</th>
|
||||||
<th>지자체</th>
|
<th>지자체</th>
|
||||||
<th>구·군</th>
|
<th>구·군(동코드)</th>
|
||||||
<th class="text-center">지정일</th>
|
<th class="text-center">지정일</th>
|
||||||
<th>구역</th>
|
<th>구역</th>
|
||||||
<th>대표자명</th>
|
<th>대표자명</th>
|
||||||
@@ -548,11 +688,15 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="text-center"><?= esc($shortNoP) ?></td>
|
<td class="text-center"><?= esc($shortNoP) ?></td>
|
||||||
<td class="text-left"><?= esc($lgMap[$row->ds_lg_idx] ?? '') ?></td>
|
<td class="text-left"><?= esc($lgMap[$row->ds_lg_idx] ?? '') ?></td>
|
||||||
<?php $gCodeP = (string) ($row->ds_gugun_code ?? ''); ?>
|
<?php
|
||||||
<td class="text-left"><?= esc((string) (($gugunNameMap[$gCodeP] ?? '') !== '' ? $gugunNameMap[$gCodeP] : $gCodeP)) ?></td>
|
$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-center"><?= esc($daDispP) ?></td>
|
||||||
<td class="text-left"><?= esc($row->ds_zone_code ?? '') ?></td>
|
<td class="text-left"><?= esc($row->ds_zone_code ?? '') ?></td>
|
||||||
<td class="text-left"><?= esc($row->ds_rep_name ?? '') ?></td>
|
<td class="text-left"><?= esc($repDisp($row)) ?></td>
|
||||||
<td class="text-left"><?= esc($row->ds_name ?? '') ?></td>
|
<td class="text-left"><?= esc($row->ds_name ?? '') ?></td>
|
||||||
<td class="text-left"><?= esc($zipP) ?></td>
|
<td class="text-left"><?= esc($zipP) ?></td>
|
||||||
<td class="text-left"><?= esc($addrCombinedP) ?></td>
|
<td class="text-left"><?= esc($addrCombinedP) ?></td>
|
||||||
|
|||||||
119
app/Views/admin/feedback/all.php
Normal file
119
app/Views/admin/feedback/all.php
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
/**
|
||||||
|
* @var list<object> $list
|
||||||
|
* @var array<int, list<object>> $filesByFb
|
||||||
|
* @var string $status
|
||||||
|
*/
|
||||||
|
use App\Models\FeedbackModel;
|
||||||
|
|
||||||
|
$list = $list ?? [];
|
||||||
|
$filesByFb = $filesByFb ?? [];
|
||||||
|
$status = (string) ($status ?? '');
|
||||||
|
$badge = static function (string $s): string {
|
||||||
|
$map = [
|
||||||
|
'new' => 'bg-blue-50 text-blue-700',
|
||||||
|
'in_progress' => 'bg-amber-50 text-amber-700',
|
||||||
|
'answered' => 'bg-gray-100 text-gray-500',
|
||||||
|
'done' => 'bg-emerald-50 text-emerald-700',
|
||||||
|
'hold' => 'bg-gray-100 text-gray-500',
|
||||||
|
];
|
||||||
|
$c = $map[$s] ?? 'bg-gray-100 text-gray-600';
|
||||||
|
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">
|
||||||
|
<form method="get" class="flex items-center gap-2 text-sm">
|
||||||
|
<select name="status" class="border border-gray-300 rounded px-3 py-1 text-sm w-40 min-w-[10rem]" onchange="this.form.submit()">
|
||||||
|
<option value="">전체 상태</option>
|
||||||
|
<?php foreach (FeedbackModel::STATUSES as $s): ?>
|
||||||
|
<option value="<?= esc($s, 'attr') ?>" <?= $status === $s ? 'selected' : '' ?>><?= esc(FeedbackModel::statusLabel($s)) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
<a href="<?= site_url('admin/feedback') ?>" class="text-sm text-gray-600 hover:underline whitespace-nowrap">목록형으로 보기</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<?php if (empty($list)): ?>
|
||||||
|
<div class="border border-gray-300 rounded-lg p-10 text-center text-gray-400 mt-2">접수된 의견이 없습니다.</div>
|
||||||
|
<?php else: ?>
|
||||||
|
|
||||||
|
<!-- 빠른 이동 — 클릭하면 아래 해당 의견으로 스크롤 -->
|
||||||
|
<div class="border border-gray-300 rounded-lg bg-white p-2 mt-2 sticky top-0 z-10 shadow-sm">
|
||||||
|
<div class="flex flex-wrap gap-1.5 max-h-24 overflow-auto">
|
||||||
|
<?php foreach ($list as $row): ?>
|
||||||
|
<a href="#fb-<?= (int) $row->fb_idx ?>" class="inline-flex items-center gap-1 border border-gray-200 rounded-full px-2 py-0.5 text-[11px] text-gray-600 hover:bg-blue-50 hover:border-blue-200 whitespace-nowrap">
|
||||||
|
#<?= (int) $row->fb_idx ?> <?= $badge((string) ($row->fb_status ?? 'new')) ?>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3 mt-3">
|
||||||
|
<?php foreach ($list as $row): $fid = (int) $row->fb_idx; $files = $filesByFb[$fid] ?? []; ?>
|
||||||
|
<section id="fb-<?= $fid ?>" class="border border-gray-300 rounded-lg overflow-hidden bg-white scroll-mt-20">
|
||||||
|
<div class="px-3 py-2 border-b bg-gray-50 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<span class="font-bold text-gray-800">#<?= $fid ?></span>
|
||||||
|
<?= $badge((string) ($row->fb_status ?? 'new')) ?>
|
||||||
|
<span class="text-gray-500 text-[12px]"><?= esc((string) ($row->fb_regdate ?? '')) ?></span>
|
||||||
|
<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 ?: '-')) ?>
|
||||||
|
<?php if ($fbUrlRaw !== ''): ?>
|
||||||
|
<span class="text-gray-400"> · </span><a href="<?= esc($fbUrlOpen, 'attr') ?>" target="_blank" rel="noopener" class="text-blue-600 hover:underline break-all"><?= esc($fbUrlRaw) ?></a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-3 grid grid-cols-1 lg:grid-cols-[1fr_20rem] gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="whitespace-pre-wrap text-sm text-gray-800"><?= esc((string) $row->fb_content) ?></p>
|
||||||
|
|
||||||
|
<?php if (! empty($files)): ?>
|
||||||
|
<div class="flex flex-col gap-3 mt-3">
|
||||||
|
<?php foreach ($files as $f): ?>
|
||||||
|
<a href="<?= site_url('admin/feedback/file/' . (int) $f->ff_idx) ?>" target="_blank" rel="noopener" title="새 창에서 원본 크기로 보기">
|
||||||
|
<img src="<?= site_url('admin/feedback/file/' . (int) $f->ff_idx) ?>" alt="첨부 이미지"
|
||||||
|
style="max-width:100%;width:640px;height:auto;border:1px solid #e5e7eb;border-radius:6px;"/>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action="<?= site_url('admin/feedback/' . $fid . '/status') ?>" method="post" class="space-y-2 border-t lg:border-t-0 lg:border-l border-gray-200 pt-3 lg:pt-0 lg:pl-3">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="return_to" value="all"/>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<select name="fb_status" class="border border-gray-300 rounded px-2 py-1 text-sm flex-1">
|
||||||
|
<?php foreach (FeedbackModel::STATUSES as $s): ?>
|
||||||
|
<option value="<?= esc($s, 'attr') ?>" <?= (string) $row->fb_status === $s ? 'selected' : '' ?>><?= esc(FeedbackModel::statusLabel($s)) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<a href="<?= site_url('admin/feedback/' . $fid) ?>" class="text-[12px] text-gray-500 hover:underline whitespace-nowrap">상세</a>
|
||||||
|
</div>
|
||||||
|
<textarea name="fb_admin_memo" rows="3" placeholder="처리 메모" class="w-full border border-gray-300 rounded px-2 py-1.5 text-[12px]" maxlength="2000"><?= esc((string) ($row->fb_admin_memo ?? '')) ?></textarea>
|
||||||
|
<button type="submit" class="bg-btn-search text-white px-3 py-1 rounded-sm text-[12px] w-full">저장</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div><!-- /.fb-selectable -->
|
||||||
72
app/Views/admin/feedback/index.php
Normal file
72
app/Views/admin/feedback/index.php
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
/**
|
||||||
|
* @var list<object> $list
|
||||||
|
* @var string $status
|
||||||
|
*/
|
||||||
|
use App\Models\FeedbackModel;
|
||||||
|
|
||||||
|
$list = $list ?? [];
|
||||||
|
$status = (string) ($status ?? '');
|
||||||
|
$badge = static function (string $s): string {
|
||||||
|
$map = [
|
||||||
|
'new' => 'bg-blue-50 text-blue-700',
|
||||||
|
'in_progress' => 'bg-amber-50 text-amber-700',
|
||||||
|
'answered' => 'bg-gray-100 text-gray-500',
|
||||||
|
'done' => 'bg-emerald-50 text-emerald-700',
|
||||||
|
'hold' => 'bg-gray-100 text-gray-500',
|
||||||
|
];
|
||||||
|
$c = $map[$s] ?? 'bg-gray-100 text-gray-600';
|
||||||
|
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">
|
||||||
|
<form method="get" class="flex items-center gap-2 text-sm">
|
||||||
|
<select name="status" class="border border-gray-300 rounded px-3 py-1 text-sm w-40 min-w-[10rem]" onchange="this.form.submit()">
|
||||||
|
<option value="">전체 상태</option>
|
||||||
|
<?php foreach (FeedbackModel::STATUSES as $s): ?>
|
||||||
|
<option value="<?= esc($s, 'attr') ?>" <?= $status === $s ? 'selected' : '' ?>><?= esc(FeedbackModel::statusLabel($s)) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
<a href="<?= site_url('admin/feedback/all') ?>" class="inline-flex items-center rounded-lg bg-[#243a5e] px-3 py-1.5 text-white text-xs font-semibold shadow-sm hover:opacity-90 whitespace-nowrap">전체보기(스크롤)</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="border border-gray-300 rounded-lg p-4 overflow-auto mt-2">
|
||||||
|
<table class="w-full data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="w-16 text-left">번호</th>
|
||||||
|
<th class="w-36 text-left">일시</th>
|
||||||
|
<th class="w-20 text-left">상태</th>
|
||||||
|
<th class="text-left">화면</th>
|
||||||
|
<th class="text-left">내용</th>
|
||||||
|
<th class="w-24 text-left">작성자</th>
|
||||||
|
<th class="w-16 text-center">캡처</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php if (empty($list)): ?>
|
||||||
|
<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="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>
|
||||||
|
<td class="text-left text-[12px] text-gray-600"><?= esc((string) ($row->fb_page_title ?: $row->fb_page_url)) ?></td>
|
||||||
|
<td class="text-left"><?= esc(mb_substr((string) $row->fb_content, 0, 50)) ?><?= mb_strlen((string) $row->fb_content) > 50 ? '…' : '' ?></td>
|
||||||
|
<td class="text-left text-[12px]"><?= esc((string) ($row->fb_mb_name ?? '')) ?></td>
|
||||||
|
<td class="text-center"><?= ! empty($row->fb_screenshot) ? '<i class="fa-regular fa-image text-gray-400"></i>' : '' ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<?php if (isset($pager)): ?><div class="mt-3"><?= $pager->links() ?></div><?php endif; ?>
|
||||||
|
</div><!-- /.fb-selectable -->
|
||||||
126
app/Views/admin/feedback/show.php
Normal file
126
app/Views/admin/feedback/show.php
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<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 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>
|
||||||
|
<?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>
|
||||||
|
<dt class="font-semibold">브라우저</dt><dd class="break-all text-gray-400"><?= esc((string) ($row->fb_user_agent ?? '')) ?></dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<form action="<?= site_url('admin/feedback/' . (int) $row->fb_idx . '/status') ?>" method="post" class="p-3 space-y-3">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-bold text-gray-700 mb-1">상태</label>
|
||||||
|
<select name="fb_status" class="border border-gray-300 rounded px-2 py-1.5 text-sm w-40">
|
||||||
|
<?php foreach (FeedbackModel::STATUSES as $s): ?>
|
||||||
|
<option value="<?= esc($s, 'attr') ?>" <?= (string) $row->fb_status === $s ? 'selected' : '' ?>><?= esc(FeedbackModel::statusLabel($s)) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-bold text-gray-700 mb-1">처리 메모</label>
|
||||||
|
<textarea name="fb_admin_memo" rows="4" class="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" maxlength="2000"><?= esc((string) ($row->fb_admin_memo ?? '')) ?></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm">저장</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php $files = $files ?? []; ?>
|
||||||
|
<?php if (! empty($files)): ?>
|
||||||
|
<section class="border border-gray-300 rounded-lg overflow-hidden mt-3">
|
||||||
|
<div class="px-3 py-2 border-b bg-gray-50 text-sm font-semibold text-gray-700">첨부 이미지 (<?= count($files) ?>)</div>
|
||||||
|
<div class="p-3 flex flex-wrap gap-3">
|
||||||
|
<?php foreach ($files as $f): ?>
|
||||||
|
<figure class="m-0">
|
||||||
|
<a href="<?= site_url('admin/feedback/file/' . (int) $f->ff_idx) ?>" target="_blank" rel="noopener">
|
||||||
|
<img src="<?= site_url('admin/feedback/file/' . (int) $f->ff_idx) ?>" alt="첨부 이미지"
|
||||||
|
style="max-width:280px;max-height:220px;border:1px solid #e5e7eb;border-radius:6px;"/>
|
||||||
|
</a>
|
||||||
|
<figcaption class="text-[11px] text-gray-400 mt-1"><?= $f->ff_kind === 'capture' ? '화면 캡처' : '사용자 첨부' ?></figcaption>
|
||||||
|
</figure>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</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>
|
||||||
@@ -33,7 +33,7 @@ if ($navItems === []) {
|
|||||||
$navItems = [
|
$navItems = [
|
||||||
['idx' => 0, 'name' => '대시보드', 'href' => 'admin', 'url' => base_url('admin'), 'children' => [], 'hasChildren' => false],
|
['idx' => 0, 'name' => '대시보드', 'href' => 'admin', 'url' => base_url('admin'), 'children' => [], 'hasChildren' => false],
|
||||||
['idx' => 0, 'name' => '회원·접근', 'href' => '', 'url' => '', 'hasChildren' => true, 'children' => [
|
['idx' => 0, 'name' => '회원·접근', 'href' => '', 'url' => '', 'hasChildren' => true, 'children' => [
|
||||||
$mk('회원 관리', 'admin/users'), $mk('로그인 이력', 'admin/access/login-history'), $mk('승인 대기', 'admin/access/approvals'),
|
$mk('회원 관리', 'admin/users'), $mk('로그인 이력', 'admin/access/login-history'), $mk('활동 로그', 'admin/access/activity-logs'), $mk('승인 대기', 'admin/access/approvals'),
|
||||||
]],
|
]],
|
||||||
['idx' => 0, 'name' => '시스템', 'href' => '', 'url' => '', 'hasChildren' => true, 'children' => [
|
['idx' => 0, 'name' => '시스템', 'href' => '', 'url' => '', 'hasChildren' => true, 'children' => [
|
||||||
$mk('역할', 'admin/roles'), $mk('메뉴', 'admin/menus'),
|
$mk('역할', 'admin/roles'), $mk('메뉴', 'admin/menus'),
|
||||||
@@ -62,6 +62,9 @@ $navPartial = [
|
|||||||
<meta charset="utf-8"/>
|
<meta charset="utf-8"/>
|
||||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||||
<title><?= esc($title ?? '관리자') ?> - GBLS</title>
|
<title><?= esc($title ?? '관리자') ?> - GBLS</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="<?= base_url('favicon.svg') ?>"/>
|
||||||
|
<link rel="alternate icon" href="<?= base_url('favicon.ico') ?>"/>
|
||||||
|
<link rel="apple-touch-icon" href="<?= base_url('favicon.svg') ?>"/>
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"/>
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"/>
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"/>
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"/>
|
||||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||||
@@ -91,6 +94,13 @@ tailwind.config = {
|
|||||||
.data-table tbody td { color: #374151; }
|
.data-table tbody td { color: #374151; }
|
||||||
.data-table tbody tr:last-child td { border-bottom: 0; }
|
.data-table tbody tr:last-child td { border-bottom: 0; }
|
||||||
.data-table tbody tr:hover td { background-color: #f9fafb; }
|
.data-table tbody tr:hover td { background-color: #f9fafb; }
|
||||||
|
/* body의 select-none 상속을 무효화해 본문 텍스트를 드래그·복사할 수 있게 하는 opt-in 클래스 */
|
||||||
|
.fb-selectable, .fb-selectable * {
|
||||||
|
-webkit-user-select: text;
|
||||||
|
-moz-user-select: text;
|
||||||
|
-ms-user-select: text;
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
@media print {
|
@media print {
|
||||||
.portal-header, .sidebar, .portal-footer, .no-print, nav.portal-top-nav { display: none !important; }
|
.portal-header, .sidebar, .portal-footer, .no-print, nav.portal-top-nav { display: none !important; }
|
||||||
body.gov-portal-shell { background: #fff; display: block; }
|
body.gov-portal-shell { background: #fff; display: block; }
|
||||||
@@ -167,24 +177,56 @@ tailwind.config = {
|
|||||||
window.addEventListener('pagehide', closeStuckOverlays);
|
window.addEventListener('pagehide', closeStuckOverlays);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// 글씨 크기 조절(A−/A+) — 본문 + 상단 대메뉴 + 좌측 사이드바에 zoom 적용. 사이트/워크스페이스와 배율 공유.
|
// 글씨 크기 조절(A−/A+) — 계정별·메뉴별 저장(서버). 현재 화면 경로 기준으로 개별 적용.
|
||||||
|
<?php $__fmk = function_exists('current_nav_request_path') ? current_nav_request_path() : ''; $__fsc = function_exists('member_font_scale_for') ? member_font_scale_for($__fmk) : 100; ?>
|
||||||
(function () {
|
(function () {
|
||||||
var FONT_KEY = 'jrj_font_scale';
|
var MENU_KEY = <?= json_encode($__fmk) ?>;
|
||||||
var scaleSelectors = ['.portal-header', '.sidebar', '.work-main'];
|
var scale = <?= (int) $__fsc ?>;
|
||||||
function curScale() { var s = parseInt(localStorage.getItem(FONT_KEY) || '100', 10); return (s >= 70 && s <= 150) ? s : 100; }
|
var SAVE_URL = <?= json_encode(site_url('bag/pref/font-scale')) ?>;
|
||||||
function applyScale(s) {
|
var CSRF_NAME = <?= json_encode(csrf_token()) ?>;
|
||||||
|
var csrfHash = <?= json_encode(csrf_hash()) ?>;
|
||||||
|
var scaleSelectors = ['.sidebar', '.work-main'];
|
||||||
|
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));
|
s = Math.min(150, Math.max(70, s));
|
||||||
try { localStorage.setItem(FONT_KEY, String(s)); } catch (e) {}
|
scale = s;
|
||||||
var z = s / 100;
|
var z = s / 100;
|
||||||
scaleSelectors.forEach(function (sel) { var el = document.querySelector(sel); if (el) el.style.zoom = z; });
|
scaleSelectors.forEach(function (sel) { var el = document.querySelector(sel); if (el) el.style.zoom = z; });
|
||||||
|
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 + '%';
|
var pct = document.getElementById('wsFontPct'); if (pct) pct.textContent = s + '%';
|
||||||
|
if (save) persist(s);
|
||||||
}
|
}
|
||||||
applyScale(curScale());
|
applyScale(scale, false);
|
||||||
var plus = document.getElementById('wsFontPlus'), minus = document.getElementById('wsFontMinus');
|
var plus = document.getElementById('wsFontPlus'), minus = document.getElementById('wsFontMinus');
|
||||||
if (plus) plus.addEventListener('click', function () { applyScale(curScale() + 10); });
|
if (plus) plus.addEventListener('click', function () { applyScale(scale + 10, true); });
|
||||||
if (minus) minus.addEventListener('click', function () { applyScale(curScale() - 10); });
|
if (minus) minus.addEventListener('click', function () { applyScale(scale - 10, true); });
|
||||||
window.addEventListener('storage', function (e) { if (e.key === FONT_KEY) applyScale(curScale()); });
|
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
<script>
|
||||||
|
// 네이티브 날짜 입력의 월 표기를 한글(예: 6월)로 — 요소 lang 지정(브라우저 영어 로케일 대응)
|
||||||
|
(function () {
|
||||||
|
function fixLang() {
|
||||||
|
document.querySelectorAll('input[type=date],input[type=month],input[type=datetime-local],input[type=week],input[type=time]')
|
||||||
|
.forEach(function (el) { el.setAttribute('lang', 'ko'); });
|
||||||
|
}
|
||||||
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fixLang); else fixLang();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<?= view('components/feedback_widget') ?>
|
||||||
|
<script>
|
||||||
|
/* 인쇄 시 활동 로그 기록(beacon) */
|
||||||
|
(function(){var last=0;window.addEventListener('beforeprint',function(){var now=Date.now();if(now-last<4000)return;last=now;try{var fd=new FormData();fd.append('title',document.title||'');fd.append('path',location.pathname||'');navigator.sendBeacon('<?= site_url('bag/activity/print-log') ?>',fd);}catch(e){}});})();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -10,6 +10,16 @@
|
|||||||
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="mg_name" type="text" value="<?= esc(old('mg_name')) ?>" required/>
|
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="mg_name" type="text" value="<?= esc(old('mg_name')) ?>" required/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<label class="block text-sm font-bold text-gray-700 w-28">소속</label>
|
||||||
|
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="mg_affiliation" type="text" value="<?= esc(old('mg_affiliation')) ?>"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<label class="block text-sm font-bold text-gray-700 w-28">상호</label>
|
||||||
|
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="mg_company_name" type="text" value="<?= esc(old('mg_company_name')) ?>"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<label class="block text-sm font-bold text-gray-700 w-28">담당자 구분 <span class="text-red-500">*</span></label>
|
<label class="block text-sm font-bold text-gray-700 w-28">담당자 구분 <span class="text-red-500">*</span></label>
|
||||||
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="mg_category" required>
|
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="mg_category" required>
|
||||||
|
|||||||
@@ -10,6 +10,16 @@
|
|||||||
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="mg_name" type="text" value="<?= esc(old('mg_name', $item->mg_name)) ?>" required/>
|
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="mg_name" type="text" value="<?= esc(old('mg_name', $item->mg_name)) ?>" required/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<label class="block text-sm font-bold text-gray-700 w-28">소속</label>
|
||||||
|
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="mg_affiliation" type="text" value="<?= esc(old('mg_affiliation', $item->mg_affiliation ?? '')) ?>"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<label class="block text-sm font-bold text-gray-700 w-28">상호</label>
|
||||||
|
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="mg_company_name" type="text" value="<?= esc(old('mg_company_name', $item->mg_company_name ?? '')) ?>"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<label class="block text-sm font-bold text-gray-700 w-28">담당자 구분 <span class="text-red-500">*</span></label>
|
<label class="block text-sm font-bold text-gray-700 w-28">담당자 구분 <span class="text-red-500">*</span></label>
|
||||||
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="mg_category" required>
|
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="mg_category" required>
|
||||||
|
|||||||
@@ -27,6 +27,8 @@
|
|||||||
<tr class="text-left text-[11px] font-semibold text-gray-500 border-b border-gray-200">
|
<tr class="text-left text-[11px] font-semibold text-gray-500 border-b border-gray-200">
|
||||||
<th class="py-2.5 px-2 w-16 text-left">번호</th>
|
<th class="py-2.5 px-2 w-16 text-left">번호</th>
|
||||||
<th class="py-2.5 px-2">담당자명</th>
|
<th class="py-2.5 px-2">담당자명</th>
|
||||||
|
<th class="py-2.5 px-2">소속</th>
|
||||||
|
<th class="py-2.5 px-2">상호</th>
|
||||||
<th class="py-2.5 px-2">카테고리</th>
|
<th class="py-2.5 px-2">카테고리</th>
|
||||||
<th class="py-2.5 px-2">전화</th>
|
<th class="py-2.5 px-2">전화</th>
|
||||||
<th class="py-2.5 px-2">휴대전화</th>
|
<th class="py-2.5 px-2">휴대전화</th>
|
||||||
@@ -40,6 +42,8 @@
|
|||||||
<tr class="border-b border-gray-200 last:border-0 hover:bg-gray-50">
|
<tr class="border-b border-gray-200 last:border-0 hover:bg-gray-50">
|
||||||
<td class="py-2.5 px-2 text-left font-mono text-gray-700"><?= esc($row->mg_idx) ?></td>
|
<td class="py-2.5 px-2 text-left font-mono text-gray-700"><?= esc($row->mg_idx) ?></td>
|
||||||
<td class="py-2.5 px-2 font-medium text-gray-900"><?= esc($row->mg_name) ?></td>
|
<td class="py-2.5 px-2 font-medium text-gray-900"><?= esc($row->mg_name) ?></td>
|
||||||
|
<td class="py-2.5 px-2 text-gray-600"><?= esc($row->mg_affiliation ?? '') ?></td>
|
||||||
|
<td class="py-2.5 px-2 text-gray-600"><?= esc($row->mg_company_name ?? '') ?></td>
|
||||||
<td class="py-2.5 px-2">
|
<td class="py-2.5 px-2">
|
||||||
<?php
|
<?php
|
||||||
$cat = (string) ($row->mg_dept_code ?? '');
|
$cat = (string) ($row->mg_dept_code ?? '');
|
||||||
@@ -67,7 +71,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<?php if (empty($list)): ?>
|
<?php if (empty($list)): ?>
|
||||||
<tr><td colspan="8" class="text-center text-gray-400 py-6">등록된 데이터가 없습니다.</td></tr>
|
<tr><td colspan="10" class="text-center text-gray-400 py-6">등록된 데이터가 없습니다.</td></tr>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
|
|||||||
<form id="menu-move-form" method="post" action="<?= base_url('admin/menus/move') ?>">
|
<form id="menu-move-form" method="post" action="<?= base_url('admin/menus/move') ?>">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="mt_idx" value="<?= $mtIdx ?>"/>
|
<input type="hidden" name="mt_idx" value="<?= $mtIdx ?>"/>
|
||||||
|
<input type="hidden" name="menu_tree" id="menu-tree-payload" value=""/>
|
||||||
<table class="data-table w-full">
|
<table class="data-table w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -92,7 +93,7 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
|
|||||||
?>
|
?>
|
||||||
<tr class="menu-row" data-mm-idx="<?= (int) $row->mm_idx ?>" data-mm-pidx="<?= (int) $row->mm_pidx ?>" data-mm-dep="<?= (int) $row->mm_dep ?>">
|
<tr class="menu-row" data-mm-idx="<?= (int) $row->mm_idx ?>" data-mm-pidx="<?= (int) $row->mm_pidx ?>" data-mm-dep="<?= (int) $row->mm_dep ?>">
|
||||||
<td class="text-center align-middle">
|
<td class="text-center align-middle">
|
||||||
<span class="menu-drag-handle cursor-move text-gray-400 select-none" title="드래그해서 순서를 변경하세요">↕</span>
|
<span class="menu-drag-handle cursor-move text-gray-400 select-none" title="드래그해서 순서 변경 · 하위 메뉴는 다른 상위 메뉴 아래로 이동 가능">↕</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<input type="hidden" name="mm_idx[]" value="<?= (int) $row->mm_idx ?>"/>
|
<input type="hidden" name="mm_idx[]" value="<?= (int) $row->mm_idx ?>"/>
|
||||||
@@ -144,7 +145,8 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
|
|||||||
data-link="<?= esc($row->mm_link) ?>"
|
data-link="<?= esc($row->mm_link) ?>"
|
||||||
data-level="<?= esc($row->mm_level) ?>"
|
data-level="<?= esc($row->mm_level) ?>"
|
||||||
data-view="<?= (string) $row->mm_is_view ?>"
|
data-view="<?= (string) $row->mm_is_view ?>"
|
||||||
data-dep="<?= (int) $row->mm_dep ?>">
|
data-dep="<?= (int) $row->mm_dep ?>"
|
||||||
|
data-pidx="<?= (int) $row->mm_pidx ?>">
|
||||||
수정
|
수정
|
||||||
</button>
|
</button>
|
||||||
<?php if ($dep === 0): ?>
|
<?php if ($dep === 0): ?>
|
||||||
@@ -189,6 +191,26 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
|
|||||||
<label class="block text-sm font-medium text-gray-700">링크</label>
|
<label class="block text-sm font-medium text-gray-700">링크</label>
|
||||||
<input type="text" name="mm_link" id="mm_link" class="border border-gray-300 rounded px-2 py-1 w-full text-sm" placeholder="예: admin/users"/>
|
<input type="text" name="mm_link" id="mm_link" class="border border-gray-300 rounded px-2 py-1 w-full text-sm" placeholder="예: admin/users"/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="space-y-2 mb-3" id="menu-position-wrap">
|
||||||
|
<label class="block text-sm font-medium text-gray-700">메뉴 위치</label>
|
||||||
|
<div class="flex flex-wrap gap-x-4 gap-y-1">
|
||||||
|
<label class="inline-flex items-center gap-1 cursor-pointer">
|
||||||
|
<input type="radio" name="pos_type" value="top" id="pos-top" checked/>
|
||||||
|
<span class="text-sm">상위 메뉴로 등록</span>
|
||||||
|
</label>
|
||||||
|
<label class="inline-flex items-center gap-1 cursor-pointer">
|
||||||
|
<input type="radio" name="pos_type" value="child" id="pos-child"/>
|
||||||
|
<span class="text-sm">하위 메뉴로 등록</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<select id="mm-parent-select" class="border border-gray-300 rounded px-2 py-1 w-full text-sm mt-1 hidden">
|
||||||
|
<option value="">— 상위 메뉴 선택 —</option>
|
||||||
|
<?php foreach ($list as $prow): ?>
|
||||||
|
<?php if ((int) $prow->mm_dep !== 0) { continue; } ?>
|
||||||
|
<option value="<?= (int) $prow->mm_idx ?>" data-dep="<?= (int) $prow->mm_dep ?>"><?= esc($prow->mm_name) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="space-y-2 mb-3">
|
<div class="space-y-2 mb-3">
|
||||||
<label class="block text-sm font-medium text-gray-700">노출 대상</label>
|
<label class="block text-sm font-medium text-gray-700">노출 대상</label>
|
||||||
<?php if ($mtCode === 'admin'): ?>
|
<?php if ($mtCode === 'admin'): ?>
|
||||||
@@ -258,6 +280,50 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
|
|||||||
const editIdInput = document.getElementById('mm_idx_edit');
|
const editIdInput = document.getElementById('mm_idx_edit');
|
||||||
const mmPidxInput = form.querySelector('[name="mm_pidx"]');
|
const mmPidxInput = form.querySelector('[name="mm_pidx"]');
|
||||||
const mmDepInput = form.querySelector('[name="mm_dep"]');
|
const mmDepInput = form.querySelector('[name="mm_dep"]');
|
||||||
|
const posWrap = document.getElementById('menu-position-wrap');
|
||||||
|
const posTop = document.getElementById('pos-top');
|
||||||
|
const posChild = document.getElementById('pos-child');
|
||||||
|
const parentSelect = document.getElementById('mm-parent-select');
|
||||||
|
|
||||||
|
// 상위/하위 위치 선택 → 숨은 mm_pidx / mm_dep 동기화
|
||||||
|
function applyParentFromSelect() {
|
||||||
|
const opt = parentSelect.options[parentSelect.selectedIndex];
|
||||||
|
if (opt && opt.value) {
|
||||||
|
mmPidxInput.value = opt.value;
|
||||||
|
mmDepInput.value = String((parseInt(opt.dataset.dep || '0', 10)) + 1);
|
||||||
|
} else {
|
||||||
|
mmPidxInput.value = '0';
|
||||||
|
mmDepInput.value = '1';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function setPosition(type, parentId) {
|
||||||
|
if (type === 'child') {
|
||||||
|
if (posChild) posChild.checked = true;
|
||||||
|
parentSelect.classList.remove('hidden');
|
||||||
|
if (parentId != null) parentSelect.value = String(parentId);
|
||||||
|
applyParentFromSelect();
|
||||||
|
} else {
|
||||||
|
if (posTop) posTop.checked = true;
|
||||||
|
parentSelect.classList.add('hidden');
|
||||||
|
mmPidxInput.value = '0';
|
||||||
|
mmDepInput.value = '0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (posTop) posTop.addEventListener('change', function () { if (posTop.checked) setPosition('top'); });
|
||||||
|
if (posChild) posChild.addEventListener('change', function () { if (posChild.checked) setPosition('child', parentSelect.value); });
|
||||||
|
if (parentSelect) parentSelect.addEventListener('change', applyParentFromSelect);
|
||||||
|
// 하위 선택인데 상위 미지정이면 등록 차단
|
||||||
|
function enableAllParentOptions() {
|
||||||
|
if (!parentSelect) return;
|
||||||
|
Array.prototype.forEach.call(parentSelect.options, function (o) { o.disabled = false; });
|
||||||
|
}
|
||||||
|
form.addEventListener('submit', function (e) {
|
||||||
|
if (posChild && posChild.checked && !parentSelect.value) {
|
||||||
|
e.preventDefault();
|
||||||
|
alert('하위 메뉴로 지정하려면 상위 메뉴를 선택해 주세요.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const levelAll = document.getElementById('mm_level_all');
|
const levelAll = document.getElementById('mm_level_all');
|
||||||
const levelCbs = document.querySelectorAll('.mm-level-cb');
|
const levelCbs = document.querySelectorAll('.mm-level-cb');
|
||||||
const isAdminType = '<?= esc($mtCode) ?>' === 'admin';
|
const isAdminType = '<?= esc($mtCode) ?>' === 'admin';
|
||||||
@@ -297,11 +363,23 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
|
|||||||
const level = (this.dataset.level || '').toString().trim();
|
const level = (this.dataset.level || '').toString().trim();
|
||||||
const view = this.dataset.view || 'Y';
|
const view = this.dataset.view || 'Y';
|
||||||
const dep = parseInt(this.dataset.dep || '0', 10);
|
const dep = parseInt(this.dataset.dep || '0', 10);
|
||||||
|
const pidx = parseInt(this.dataset.pidx || '0', 10);
|
||||||
form.action = '<?= base_url('admin/menus/update/') ?>' + id;
|
form.action = '<?= base_url('admin/menus/update/') ?>' + id;
|
||||||
form.querySelector('[name="mm_name"]').value = name;
|
form.querySelector('[name="mm_name"]').value = name;
|
||||||
form.querySelector('[name="mm_link"]').value = link;
|
form.querySelector('[name="mm_link"]').value = link;
|
||||||
mmPidxInput.value = '0';
|
// 위치(상위/하위) 선택 UI를 현재 위치로 표시 — 수정 시에도 상위↔하위 이동 가능
|
||||||
mmDepInput.value = String(dep);
|
if (posWrap) posWrap.style.display = '';
|
||||||
|
// 자기 자신은 상위 후보에서 제외
|
||||||
|
if (parentSelect) {
|
||||||
|
Array.prototype.forEach.call(parentSelect.options, function (o) {
|
||||||
|
o.disabled = (o.value === String(id));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (pidx > 0) {
|
||||||
|
setPosition('child', pidx);
|
||||||
|
} else {
|
||||||
|
setPosition('top');
|
||||||
|
}
|
||||||
if (!isAdminType && levelAll) {
|
if (!isAdminType && levelAll) {
|
||||||
levelAll.checked = (level === '');
|
levelAll.checked = (level === '');
|
||||||
levelCbs.forEach(function(cb){
|
levelCbs.forEach(function(cb){
|
||||||
@@ -339,6 +417,10 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
|
|||||||
formTitle.textContent = '하위 메뉴 등록';
|
formTitle.textContent = '하위 메뉴 등록';
|
||||||
btnSubmit.textContent = '등록';
|
btnSubmit.textContent = '등록';
|
||||||
btnCancel.style.display = 'inline-block';
|
btnCancel.style.display = 'inline-block';
|
||||||
|
// 위치 선택 UI를 '하위'로 반영(선택한 상위 메뉴 표시)
|
||||||
|
if (posWrap) posWrap.style.display = '';
|
||||||
|
enableAllParentOptions();
|
||||||
|
setPosition('child', parentId);
|
||||||
// 노출 기본값 재설정
|
// 노출 기본값 재설정
|
||||||
form.querySelector('[name="mm_is_view"]').checked = true;
|
form.querySelector('[name="mm_is_view"]').checked = true;
|
||||||
if (!isAdminType && levelAll) {
|
if (!isAdminType && levelAll) {
|
||||||
@@ -367,6 +449,10 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
|
|||||||
formTitle.textContent = '메뉴 등록';
|
formTitle.textContent = '메뉴 등록';
|
||||||
btnSubmit.textContent = '등록';
|
btnSubmit.textContent = '등록';
|
||||||
btnCancel.style.display = 'none';
|
btnCancel.style.display = 'none';
|
||||||
|
// 위치 선택 UI 복원 → 기본 '상위'
|
||||||
|
if (posWrap) posWrap.style.display = '';
|
||||||
|
enableAllParentOptions();
|
||||||
|
setPosition('top');
|
||||||
});
|
});
|
||||||
|
|
||||||
// 메뉴 목록 행 드래그 정렬 (마우스 이벤트 기반)
|
// 메뉴 목록 행 드래그 정렬 (마우스 이벤트 기반)
|
||||||
@@ -444,11 +530,43 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── 드래그 중 화면 상/하단 가장자리 근처에서 자동 스크롤 ──────────────
|
||||||
|
// 스크롤되는 컨테이너(.main-content-area)가 있으면 그걸, 없으면 window를 스크롤.
|
||||||
|
let autoScrollRaf = null;
|
||||||
|
const getScrollContainer = function() {
|
||||||
|
const main = tbody.closest('.main-content-area');
|
||||||
|
if (main && main.scrollHeight > main.clientHeight + 2) return main;
|
||||||
|
return null; // null이면 window 스크롤
|
||||||
|
};
|
||||||
|
const autoScrollTick = function() {
|
||||||
|
if (!draggingActive) { autoScrollRaf = null; return; }
|
||||||
|
const EDGE = 70; // 가장자리 감지 영역(px)
|
||||||
|
const STEP_MAX = 20; // 프레임당 최대 스크롤(px)
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
let dy = 0;
|
||||||
|
if (lastClientY < EDGE) {
|
||||||
|
dy = -Math.ceil((EDGE - lastClientY) / EDGE * STEP_MAX);
|
||||||
|
} else if (lastClientY > vh - EDGE) {
|
||||||
|
dy = Math.ceil((lastClientY - (vh - EDGE)) / EDGE * STEP_MAX);
|
||||||
|
}
|
||||||
|
if (dy !== 0) {
|
||||||
|
const sc = getScrollContainer();
|
||||||
|
if (sc) { sc.scrollTop += dy; } else { window.scrollBy(0, dy); }
|
||||||
|
// 스크롤로 행 위치가 바뀌었으니 placeholder 위치 재계산
|
||||||
|
if (!rafId) rafId = window.requestAnimationFrame(updatePlaceholderPosition);
|
||||||
|
}
|
||||||
|
autoScrollRaf = window.requestAnimationFrame(autoScrollTick);
|
||||||
|
};
|
||||||
|
|
||||||
const clearDragState = function() {
|
const clearDragState = function() {
|
||||||
if (rafId) {
|
if (rafId) {
|
||||||
window.cancelAnimationFrame(rafId);
|
window.cancelAnimationFrame(rafId);
|
||||||
rafId = null;
|
rafId = null;
|
||||||
}
|
}
|
||||||
|
if (autoScrollRaf) {
|
||||||
|
window.cancelAnimationFrame(autoScrollRaf);
|
||||||
|
autoScrollRaf = null;
|
||||||
|
}
|
||||||
document.body.classList.remove('menu-row-dragging');
|
document.body.classList.remove('menu-row-dragging');
|
||||||
window.removeEventListener('mousemove', onMouseMove);
|
window.removeEventListener('mousemove', onMouseMove);
|
||||||
window.removeEventListener('mouseup', onMouseUp);
|
window.removeEventListener('mouseup', onMouseUp);
|
||||||
@@ -463,41 +581,76 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
|
|||||||
draggingActive = false;
|
draggingActive = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 드래그된 행(하위 메뉴)의 새 상위 메뉴를 결정한다.
|
||||||
|
// 평면 목록에서 바로 위쪽으로 올라가며 dep이 (자신-1)인 가장 가까운 행을 상위로 삼는다.
|
||||||
|
const resolveNewParentIdx = function(row, draggedDep) {
|
||||||
|
var prev = row.previousElementSibling;
|
||||||
|
while (prev) {
|
||||||
|
if (prev.classList.contains('menu-row')) {
|
||||||
|
var pdep = parseInt(prev.dataset.mmDep || '0', 10);
|
||||||
|
if (pdep === draggedDep - 1) {
|
||||||
|
return parseInt(prev.dataset.mmIdx || '0', 10);
|
||||||
|
}
|
||||||
|
if (pdep < draggedDep - 1) {
|
||||||
|
return 0; // 유효한 상위가 없음
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prev = prev.previousElementSibling;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 현재 화면 순서 그대로 구조(순서/상위/깊이) 페이로드를 만든다.
|
||||||
|
const buildTreePayload = function() {
|
||||||
|
var arr = [];
|
||||||
|
tbody.querySelectorAll('tr.menu-row').forEach(function(row){
|
||||||
|
var idInput = row.querySelector('input[name="mm_idx[]"]');
|
||||||
|
if (!idInput) return;
|
||||||
|
arr.push({
|
||||||
|
idx: parseInt(idInput.value, 10),
|
||||||
|
pidx: parseInt(row.dataset.mmPidx || '0', 10),
|
||||||
|
dep: parseInt(row.dataset.mmDep || '0', 10)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return arr;
|
||||||
|
};
|
||||||
|
const payloadInput = document.getElementById('menu-tree-payload');
|
||||||
|
// 수동 "순서 적용" 버튼 제출 시에도 구조가 반영되도록 payload 채움
|
||||||
|
moveForm.addEventListener('submit', function(){
|
||||||
|
if (payloadInput) payloadInput.value = JSON.stringify(buildTreePayload());
|
||||||
|
});
|
||||||
|
|
||||||
var onMouseUp = function() {
|
var onMouseUp = function() {
|
||||||
if (!draggingActive || !draggingRow || !placeholderRow) {
|
if (!draggingActive || !draggingRow || !placeholderRow) {
|
||||||
clearDragState();
|
clearDragState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var prevRow = placeholderRow.previousElementSibling;
|
|
||||||
tbody.insertBefore(draggingRow, placeholderRow);
|
tbody.insertBefore(draggingRow, placeholderRow);
|
||||||
refreshOrderNos();
|
refreshOrderNos();
|
||||||
|
|
||||||
var draggedDep = parseInt(draggingRow.dataset.mmDep || '0', 10);
|
var draggedDep = parseInt(draggingRow.dataset.mmDep || '0', 10);
|
||||||
var draggedPidx = parseInt(draggingRow.dataset.mmPidx || '0', 10);
|
|
||||||
|
|
||||||
if (draggedDep > 0) {
|
if (draggedDep > 0) {
|
||||||
var valid = false;
|
// 하위 메뉴: 놓인 위치의 상위 메뉴를 새 부모로 재지정 (다른 상위 메뉴로 이동 허용)
|
||||||
if (prevRow && prevRow.classList.contains('menu-row')) {
|
var newPidx = resolveNewParentIdx(draggingRow, draggedDep);
|
||||||
var prevIdx = parseInt(prevRow.dataset.mmIdx || '0', 10);
|
if (newPidx <= 0) {
|
||||||
var prevPidx = parseInt(prevRow.dataset.mmPidx || '0', 10);
|
alert('하위 메뉴는 상위 메뉴 아래에만 놓을 수 있습니다.');
|
||||||
if (prevRow === draggingRow) {
|
|
||||||
valid = true;
|
|
||||||
} else if (prevIdx === draggedPidx || prevPidx === draggedPidx) {
|
|
||||||
valid = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!valid) {
|
|
||||||
alert('하위 메뉴는 다른 상위 메뉴에는 들어갈 수 없습니다.');
|
|
||||||
restoreOrderByIds(originalOrderIds);
|
restoreOrderByIds(originalOrderIds);
|
||||||
clearDragState();
|
clearDragState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
draggingRow.dataset.mmPidx = String(newPidx);
|
||||||
}
|
}
|
||||||
|
|
||||||
const approved = window.confirm('변경한 순서를 적용할까요?');
|
const approved = window.confirm('변경한 순서를 적용할까요?');
|
||||||
if (!approved) {
|
if (!approved) {
|
||||||
|
// 취소 시 원래 순서·상위로 되돌림
|
||||||
|
if (typeof draggingRow.dataset.mmPidxOrig !== 'undefined') {
|
||||||
|
draggingRow.dataset.mmPidx = draggingRow.dataset.mmPidxOrig;
|
||||||
|
}
|
||||||
restoreOrderByIds(originalOrderIds);
|
restoreOrderByIds(originalOrderIds);
|
||||||
} else {
|
} else {
|
||||||
|
if (payloadInput) payloadInput.value = JSON.stringify(buildTreePayload());
|
||||||
moveForm.submit();
|
moveForm.submit();
|
||||||
}
|
}
|
||||||
clearDragState();
|
clearDragState();
|
||||||
@@ -511,6 +664,8 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
originalOrderIds = collectCurrentOrderIds();
|
originalOrderIds = collectCurrentOrderIds();
|
||||||
draggingRow = row;
|
draggingRow = row;
|
||||||
|
// 취소 시 되돌리기 위해 원래 상위(부모) 보관
|
||||||
|
row.dataset.mmPidxOrig = row.dataset.mmPidx || '0';
|
||||||
placeholderRow = makePlaceholder();
|
placeholderRow = makePlaceholder();
|
||||||
draggingActive = true;
|
draggingActive = true;
|
||||||
row.classList.add('dragging');
|
row.classList.add('dragging');
|
||||||
@@ -518,6 +673,7 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
|
|||||||
tbody.insertBefore(placeholderRow, row.nextSibling);
|
tbody.insertBefore(placeholderRow, row.nextSibling);
|
||||||
lastClientY = e.clientY;
|
lastClientY = e.clientY;
|
||||||
updatePlaceholderPosition();
|
updatePlaceholderPosition();
|
||||||
|
autoScrollRaf = window.requestAnimationFrame(autoScrollTick);
|
||||||
window.addEventListener('mousemove', onMouseMove, { passive: true });
|
window.addEventListener('mousemove', onMouseMove, { passive: true });
|
||||||
window.addEventListener('mouseup', onMouseUp);
|
window.addEventListener('mouseup', onMouseUp);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<label class="block text-sm font-bold text-gray-700 w-32">새 비밀번호 <span class="text-red-500">*</span></label>
|
<label class="block text-sm font-bold text-gray-700 w-32">새 비밀번호 <span class="text-red-500">*</span></label>
|
||||||
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="new_password" type="password" required autocomplete="new-password"/>
|
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="new_password" type="password" required autocomplete="new-password"/>
|
||||||
|
<p class="w-full text-xs text-gray-500 pl-32">8자 이상, 영문·숫자·특수문자를 모두 포함해야 합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
|||||||
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>
|
||||||
@@ -111,7 +111,7 @@ $registerReason = $selectedGroup ? (string) ($selectedGroup['reason'] ?? '') : '
|
|||||||
|
|
||||||
<div class="grid grid-cols-1 xl:grid-cols-4 gap-2 p-2">
|
<div class="grid grid-cols-1 xl:grid-cols-4 gap-2 p-2">
|
||||||
<!-- 입출고 리스트 -->
|
<!-- 입출고 리스트 -->
|
||||||
<section class="border border-gray-300 rounded-lg bg-white xl:col-span-1">
|
<section class="border border-gray-300 rounded-lg overflow-hidden bg-white xl:col-span-1">
|
||||||
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">입출고 리스트</div>
|
<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-[520px]">
|
<div class="overflow-auto max-h-[520px]">
|
||||||
<table class="w-full data-table text-sm">
|
<table class="w-full data-table text-sm">
|
||||||
@@ -187,7 +187,7 @@ $registerReason = $selectedGroup ? (string) ($selectedGroup['reason'] ?? '') : '
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- 입출고 일자 (상세) -->
|
<!-- 입출고 일자 (상세) -->
|
||||||
<section class="border border-gray-300 rounded-lg bg-white">
|
<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="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">입출고 일자</div>
|
||||||
<div class="p-2 grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
|
<div class="p-2 grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
|
||||||
<?php if ($selectedGroup): ?>
|
<?php if ($selectedGroup): ?>
|
||||||
@@ -216,7 +216,7 @@ $registerReason = $selectedGroup ? (string) ($selectedGroup['reason'] ?? '') : '
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 입출고 봉투 코드 -->
|
<!-- 입출고 봉투 코드 -->
|
||||||
<section class="border border-gray-300 rounded-lg bg-white">
|
<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="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-[280px]">
|
<div class="overflow-auto max-h-[280px]">
|
||||||
<table class="w-full data-table text-sm">
|
<table class="w-full data-table text-sm">
|
||||||
@@ -265,7 +265,7 @@ $registerReason = $selectedGroup ? (string) ($selectedGroup['reason'] ?? '') : '
|
|||||||
|
|
||||||
<!-- 품목 등록 (동일 수불일자·구분·비고로 묶임) -->
|
<!-- 품목 등록 (동일 수불일자·구분·비고로 묶임) -->
|
||||||
<?php if ($tableExists ?? false): ?>
|
<?php if ($tableExists ?? false): ?>
|
||||||
<section class="border border-gray-300 rounded-lg bg-white no-print">
|
<section class="border border-gray-300 rounded-lg overflow-hidden bg-white no-print">
|
||||||
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">품목 등록</div>
|
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">품목 등록</div>
|
||||||
<form method="post" action="<?= mgmt_url('reports/misc-flow') ?>" class="p-2 flex flex-wrap items-end gap-2 text-sm">
|
<form method="post" action="<?= mgmt_url('reports/misc-flow') ?>" class="p-2 flex flex-wrap items-end gap-2 text-sm">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
|
|||||||
@@ -18,16 +18,15 @@ declare(strict_types=1);
|
|||||||
$printTitle = ($mode ?? 'daily') === 'daily' ? '[지정판매소] 일자별 판매대장' : '[지정판매소] 기간별 판매대장';
|
$printTitle = ($mode ?? 'daily') === 'daily' ? '[지정판매소] 일자별 판매대장' : '[지정판매소] 기간별 판매대장';
|
||||||
$printDate = date('Y-m-d');
|
$printDate = date('Y-m-d');
|
||||||
$printExtraLines = $printSubtitleLines ?? [];
|
$printExtraLines = $printSubtitleLines ?? [];
|
||||||
$catKeys = ['general', 'food', 'sticker', 'reuse', 'apt', 'public_use', 'container', 'waste'];
|
// 스티커·공동주택용(apt)·용기(container)는 품목 필터에서 제외
|
||||||
|
// 순서: 봉투(일반용·재사용·공공용) → 음식물 → 폐기물 (피드백 #16)
|
||||||
|
$catKeys = ['general', 'reuse', 'public_use', 'food', 'waste'];
|
||||||
$catLabels = [
|
$catLabels = [
|
||||||
'general' => '일반용',
|
'general' => '일반용 봉투',
|
||||||
'food' => '음식물',
|
'reuse' => '재사용 봉투',
|
||||||
'sticker' => '스티커',
|
'public_use' => '공공용 봉투',
|
||||||
'reuse' => '재사용',
|
'food' => '음식물 봉투',
|
||||||
'apt' => '공동주택용',
|
'waste' => '폐기물 봉투',
|
||||||
'public_use' => '공공용',
|
|
||||||
'container' => '용기',
|
|
||||||
'waste' => '폐기물',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
$exportParams = [
|
$exportParams = [
|
||||||
@@ -41,6 +40,9 @@ $exportParams = [
|
|||||||
if ($cats !== []) {
|
if ($cats !== []) {
|
||||||
$exportParams['cat'] = $cats;
|
$exportParams['cat'] = $cats;
|
||||||
}
|
}
|
||||||
|
if (($size ?? '') !== '') {
|
||||||
|
$exportParams['size'] = $size;
|
||||||
|
}
|
||||||
$excelUrl = mgmt_url('reports/sales-ledger?' . http_build_query($exportParams));
|
$excelUrl = mgmt_url('reports/sales-ledger?' . http_build_query($exportParams));
|
||||||
?>
|
?>
|
||||||
<?= view('components/print_header', [
|
<?= view('components/print_header', [
|
||||||
@@ -115,6 +117,20 @@ $excelUrl = mgmt_url('reports/sales-ledger?' . http_build_query($exportParams));
|
|||||||
<?= esc($catLabels[$ck] ?? $ck) ?>
|
<?= esc($catLabels[$ck] ?? $ck) ?>
|
||||||
</label>
|
</label>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
|
<span class="inline-flex items-center gap-1 ml-2 pl-3 border-l border-gray-200">
|
||||||
|
<span class="text-xs text-gray-600">크기</span>
|
||||||
|
<select name="size" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[10rem]">
|
||||||
|
<option value="">전체</option>
|
||||||
|
<?php foreach (($sizeGroups ?? []) as $g): ?>
|
||||||
|
<optgroup label="<?= esc($g['label'], 'attr') ?>">
|
||||||
|
<?php foreach (($g['sizes'] ?? []) as $sz): ?>
|
||||||
|
<?php $val = $g['key'] . '|' . $sz; ?>
|
||||||
|
<option value="<?= esc($val, 'attr') ?>" <?= ($size ?? '') === $val ? 'selected' : '' ?>><?= esc($sz) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</optgroup>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<div>
|
<div>
|
||||||
@@ -234,6 +250,80 @@ $excelUrl = mgmt_url('reports/sales-ledger?' . http_build_query($exportParams));
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-gray-700 mt-2 mb-0 no-print">판매건수(상세 행): <?= number_format((int) ($saleLineCount ?? 0)) ?>건</p>
|
<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>
|
</section>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -7,11 +7,18 @@
|
|||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<form action="<?= base_url('admin/select-local-government') ?>" method="POST" class="space-y-3">
|
<form action="<?= base_url('admin/select-local-government') ?>" method="POST" class="space-y-3">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
|
<?php $currentLgIdx = (int) ($currentLgIdx ?? 0); ?>
|
||||||
<ul class="space-y-2">
|
<ul class="space-y-2">
|
||||||
<?php foreach ($list as $lg): ?>
|
<?php foreach ($list as $lg): ?>
|
||||||
|
<?php $isCurrent = ((int) $lg->lg_idx === $currentLgIdx); ?>
|
||||||
<li class="flex items-center gap-2">
|
<li class="flex items-center gap-2">
|
||||||
<input type="radio" name="lg_idx" id="lg_<?= $lg->lg_idx ?>" value="<?= (int) $lg->lg_idx ?>" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"/>
|
<input type="radio" name="lg_idx" id="lg_<?= $lg->lg_idx ?>" value="<?= (int) $lg->lg_idx ?>" <?= $isCurrent ? 'checked' : '' ?> class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"/>
|
||||||
<label for="lg_<?= $lg->lg_idx ?>" class="text-sm font-medium text-gray-800 cursor-pointer"><?= esc($lg->lg_name) ?> (<?= esc($lg->lg_code) ?>)</label>
|
<label for="lg_<?= $lg->lg_idx ?>" class="text-sm font-medium text-gray-800 cursor-pointer"><?= esc($lg->lg_name) ?> (<?= esc($lg->lg_code) ?>)</label>
|
||||||
|
<?php if ($isCurrent): ?>
|
||||||
|
<span class="inline-flex items-center gap-1 text-xs font-semibold text-emerald-700 bg-emerald-50 border border-emerald-200 rounded-full px-2 py-0.5">
|
||||||
|
<i class="fa-solid fa-check"></i> 현재 선택
|
||||||
|
</span>
|
||||||
|
<?php endif; ?>
|
||||||
</li>
|
</li>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<label class="block text-sm font-bold text-gray-700 w-20">비밀번호 <span class="text-red-500">*</span></label>
|
<label class="block text-sm font-bold text-gray-700 w-20">비밀번호 <span class="text-red-500">*</span></label>
|
||||||
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-48" id="mb_passwd" name="mb_passwd" type="password" required/>
|
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-48" id="mb_passwd" name="mb_passwd" type="password" required/>
|
||||||
|
<p class="w-full text-xs text-gray-500 pl-20">8자 이상, 영문·숫자·특수문자를 모두 포함해야 합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<label class="block text-sm font-bold text-gray-700 w-20">이름 <span class="text-red-500">*</span></label>
|
<label class="block text-sm font-bold text-gray-700 w-20">이름 <span class="text-red-500">*</span></label>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ $editLoginLocked = $editLockUntil !== null && $editLockUntil !== '' && strtotime
|
|||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<label class="block text-sm font-bold text-gray-700 w-20">비밀번호</label>
|
<label class="block text-sm font-bold text-gray-700 w-20">비밀번호</label>
|
||||||
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-48" id="mb_passwd" name="mb_passwd" type="password" placeholder="변경 시에만 입력"/>
|
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-48" id="mb_passwd" name="mb_passwd" type="password" placeholder="변경 시에만 입력"/>
|
||||||
|
<p class="w-full text-xs text-gray-500 pl-20">변경 시 8자 이상, 영문·숫자·특수문자를 모두 포함해야 합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<label class="block text-sm font-bold text-gray-700 w-20">이름 <span class="text-red-500">*</span></label>
|
<label class="block text-sm font-bold text-gray-700 w-20">이름 <span class="text-red-500">*</span></label>
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ $subtitle = $subtitle ?? '종량제 쓰레기봉투 물류시스템';
|
|||||||
<meta charset="utf-8"/>
|
<meta charset="utf-8"/>
|
||||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||||
<title><?= esc($pageTitle ?? 'GBLS') ?></title>
|
<title><?= esc($pageTitle ?? 'GBLS') ?></title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="<?= base_url('favicon.svg') ?>"/>
|
||||||
|
<link rel="alternate icon" href="<?= base_url('favicon.ico') ?>"/>
|
||||||
|
<link rel="apple-touch-icon" href="<?= base_url('favicon.svg') ?>"/>
|
||||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"/>
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"/>
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"/>
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"/>
|
||||||
@@ -41,7 +44,7 @@ if (window.top !== window.self) { try { window.top.location.href = <?= json_enco
|
|||||||
<header class="bg-navy text-white h-12 flex items-center justify-between px-4 shrink-0 shadow">
|
<header class="bg-navy text-white h-12 flex items-center justify-between px-4 shrink-0 shadow">
|
||||||
<a href="<?= base_url() ?>" class="flex items-center gap-2 shrink-0 tracking-tight hover:opacity-90" title="GBLS (Garbage Bag Logistics System)">
|
<a href="<?= base_url() ?>" class="flex items-center gap-2 shrink-0 tracking-tight hover:opacity-90" title="GBLS (Garbage Bag Logistics System)">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="h-6 w-6 text-white shrink-0" aria-hidden="true" focusable="false">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="h-6 w-6 text-white shrink-0" aria-hidden="true" focusable="false">
|
||||||
<path fill="currentColor" d="M9 3a1 1 0 00-1 1v1H5.75a.75.75 0 000 1.5h12.5a.75.75 0 000-1.5H16V4a1 1 0 00-1-1H9zm9 4H6v11a2 2 0 002 2h8a2 2 0 002-2V7zM10 9a.75.75 0 01.75.75v6a.75.75 0 01-1.5 0v-6A.75.75 0 0110 9zm4 0a.75.75 0 01.75.75v6a.75.75 0 01-1.5 0v-6A.75.75 0 0114 9z"/>
|
<path fill="currentColor" fill-rule="evenodd" d="M6 7.5 H18 V19.5 A1.5 1.5 0 0 1 16.5 21 H7.5 A1.5 1.5 0 0 1 6 19.5 Z M6 7.5 L8.5 4.2 H15.5 L18 7.5 Z M10.2 5.2 h3.6 a0.5 0.5 0 0 1 0 1 h-3.6 a0.5 0.5 0 0 1 0 -1 z"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="leading-none flex flex-col">
|
<span class="leading-none flex flex-col">
|
||||||
<strong class="text-base font-extrabold tracking-wide">GBLS</strong>
|
<strong class="text-base font-extrabold tracking-wide">GBLS</strong>
|
||||||
|
|||||||
@@ -18,4 +18,11 @@
|
|||||||
<a href="<?= base_url('register') ?>" class="bg-white text-gray-700 border border-gray-300 px-4 py-2 rounded-lg text-sm shadow-sm hover:bg-gray-50 transition">회원가입</a>
|
<a href="<?= base_url('register') ?>" class="bg-white text-gray-700 border border-gray-300 px-4 py-2 rounded-lg text-sm shadow-sm hover:bg-gray-50 transition">회원가입</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
<script>
|
||||||
|
// 뒤로가기로 bfcache(뒤로/앞으로 캐시)에서 복원된 경우, 서버에 재요청하여
|
||||||
|
// 이미 로그인 상태면 대시보드로 리다이렉트되게 한다.
|
||||||
|
window.addEventListener('pageshow', function (e) {
|
||||||
|
if (e.persisted) { window.location.reload(); }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
<?= $this->endSection() ?>
|
<?= $this->endSection() ?>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-bold text-gray-700 mb-1" for="mb_passwd">비밀번호 <span class="text-red-500">*</span></label>
|
<label class="block text-sm font-bold text-gray-700 mb-1" for="mb_passwd">비밀번호 <span class="text-red-500">*</span></label>
|
||||||
<input class="<?= $inputCls ?>" id="mb_passwd" name="mb_passwd" type="password" autocomplete="new-password"/>
|
<input class="<?= $inputCls ?>" id="mb_passwd" name="mb_passwd" type="password" autocomplete="new-password"/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">8자 이상, 영문·숫자·특수문자를 모두 포함해야 합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-bold text-gray-700 mb-1" for="mb_passwd_confirm">비밀번호 확인 <span class="text-red-500">*</span></label>
|
<label class="block text-sm font-bold text-gray-700 mb-1" for="mb_passwd_confirm">비밀번호 확인 <span class="text-red-500">*</span></label>
|
||||||
|
|||||||
@@ -158,9 +158,32 @@ $mapId = 'mainKakaoMap';
|
|||||||
var bounds = new kakao.maps.LatLngBounds();
|
var bounds = new kakao.maps.LatLngBounds();
|
||||||
var placed = 0, pending = SHOPS.length;
|
var placed = 0, pending = SHOPS.length;
|
||||||
|
|
||||||
|
// 컨테이너 크기에 맞춰 지도를 다시 그리고 영역을 재설정한다.
|
||||||
|
function refit() {
|
||||||
|
if (!mapRef) return;
|
||||||
|
mapRef.relayout();
|
||||||
|
if (placed > 0) { mapRef.setBounds(bounds); }
|
||||||
|
else { mapRef.setCenter(new kakao.maps.LatLng(DEFAULT_CENTER.lat, DEFAULT_CENTER.lng)); }
|
||||||
|
}
|
||||||
|
var refitScheduled = false;
|
||||||
|
function scheduleRefit() {
|
||||||
|
if (refitScheduled) return;
|
||||||
|
refitScheduled = true;
|
||||||
|
(window.requestAnimationFrame || window.setTimeout)(function () { refitScheduled = false; refit(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 탭(iframe) 안에서 컨테이너 크기가 늦게 확정되거나 바뀌면(표시 전환·창 크기 변경 등)
|
||||||
|
// 지도가 반만 그려지거나 엉뚱한 위치(일본 등)로 보이므로, 크기 변화 때마다 다시 맞춘다.
|
||||||
|
try {
|
||||||
|
if (typeof ResizeObserver !== 'undefined') { new ResizeObserver(scheduleRefit).observe(el); }
|
||||||
|
} catch (e) {}
|
||||||
|
window.addEventListener('resize', scheduleRefit);
|
||||||
|
window.addEventListener('pageshow', function () { setTimeout(refit, 50); });
|
||||||
|
|
||||||
function done() {
|
function done() {
|
||||||
if (placed > 0) map.setBounds(bounds);
|
refit();
|
||||||
setTimeout(function () { map.relayout(); if (placed > 0) map.setBounds(bounds); }, 150);
|
setTimeout(refit, 150);
|
||||||
|
setTimeout(refit, 600);
|
||||||
}
|
}
|
||||||
if (pending === 0) { done(); return; }
|
if (pending === 0) { done(); return; }
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ $prevAvgLabel = ($trendBasis ?? '') === 'month' ? '전년 동월' : '전년 평
|
|||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="m-2 border border-gray-300 rounded-lg">
|
<div class="m-2 border border-gray-300 rounded-lg overflow-hidden">
|
||||||
<div class="bg-gray-100 px-3 py-1.5 text-sm font-bold border-b">월별 판매 추이 분석 조회 내역</div>
|
<div class="bg-gray-100 px-3 py-1.5 text-sm font-bold border-b">월별 판매 추이 분석 조회 내역</div>
|
||||||
<div class="overflow-auto max-h-[28rem]">
|
<div class="overflow-auto max-h-[28rem]">
|
||||||
<table class="w-full data-table text-sm">
|
<table class="w-full data-table text-sm">
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ $seasonScope = $seasonMonthsLabel !== ''
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="m-2 border border-gray-300 rounded-lg">
|
<div class="m-2 border border-gray-300 rounded-lg overflow-hidden">
|
||||||
<div class="bg-gray-100 px-3 py-1.5 text-sm font-bold border-b">
|
<div class="bg-gray-100 px-3 py-1.5 text-sm font-bold border-b">
|
||||||
계절별 판매 추이 분석 조회 내역
|
계절별 판매 추이 분석 조회 내역
|
||||||
<?php if ($queried): ?> — <?= esc($seasonScope) ?><?php endif; ?>
|
<?php if ($queried): ?> — <?= esc($seasonScope) ?><?php endif; ?>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ $stateBadge = static function (int $state): string {
|
|||||||
: '<span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium bg-gray-100 text-gray-500">미사용</span>';
|
: '<span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium bg-gray-100 text-gray-500">미사용</span>';
|
||||||
};
|
};
|
||||||
?>
|
?>
|
||||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
<!-- 기본코드 종류 -->
|
<!-- 기본코드 종류 -->
|
||||||
<section class="rounded-xl bg-white border border-gray-200 p-4 shadow-sm">
|
<section class="rounded-xl bg-white border border-gray-200 p-4 shadow-sm">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2 mb-3">
|
<div class="flex flex-wrap items-center justify-between gap-2 mb-3">
|
||||||
@@ -36,14 +36,14 @@ $stateBadge = static function (int $state): string {
|
|||||||
<table class="w-full text-[13px]">
|
<table class="w-full text-[13px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="text-left text-[11px] font-semibold text-gray-500 border-b border-gray-200">
|
<tr class="text-left text-[11px] font-semibold text-gray-500 border-b border-gray-200">
|
||||||
<th class="py-2.5 px-2 w-12 text-left">번호</th>
|
<th class="py-2.5 px-2 w-12 text-left whitespace-nowrap">번호</th>
|
||||||
<th class="py-2.5 px-2 w-20">코드</th>
|
<th class="py-2.5 px-2 w-20 whitespace-nowrap">코드</th>
|
||||||
<th class="py-2.5 px-2">코드명</th>
|
<th class="py-2.5 px-2 whitespace-nowrap">코드명</th>
|
||||||
<th class="py-2.5 px-2 w-20 text-left">세부코드</th>
|
<th class="py-2.5 px-2 w-20 text-left whitespace-nowrap">세부코드</th>
|
||||||
<th class="py-2.5 px-2 w-16 text-left">상태</th>
|
<th class="py-2.5 px-2 w-16 text-left whitespace-nowrap">상태</th>
|
||||||
<th class="py-2.5 px-2 w-32">등록일</th>
|
<th class="py-2.5 px-2 w-32 whitespace-nowrap">등록일</th>
|
||||||
<?php if ($showKindActions): ?>
|
<?php if ($showKindActions): ?>
|
||||||
<th class="py-2.5 px-2 w-28 text-left">작업</th>
|
<th class="py-2.5 px-2 w-28 text-left whitespace-nowrap">작업</th>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -56,14 +56,14 @@ $stateBadge = static function (int $state): string {
|
|||||||
?>
|
?>
|
||||||
<tr class="border-b border-gray-200 last:border-0 cursor-pointer hover:bg-blue-50/60 <?= $isSelected ? 'bg-blue-50' : '' ?>"
|
<tr class="border-b border-gray-200 last:border-0 cursor-pointer hover:bg-blue-50/60 <?= $isSelected ? 'bg-blue-50' : '' ?>"
|
||||||
onclick="window.location.href='<?= esc($detailUrl, 'attr') ?>'">
|
onclick="window.location.href='<?= esc($detailUrl, 'attr') ?>'">
|
||||||
<td class="py-2.5 px-2 text-left text-gray-500"><?= (string) $i ?></td>
|
<td class="py-2.5 px-2 text-left text-gray-500 whitespace-nowrap"><?= (string) $i ?></td>
|
||||||
<td class="py-2.5 px-2 text-left font-mono text-gray-700"><?= esc($row->ck_code) ?></td>
|
<td class="py-2.5 px-2 text-left font-mono text-gray-700 whitespace-nowrap"><?= esc($row->ck_code) ?></td>
|
||||||
<td class="py-2.5 px-2 font-medium text-gray-900"><?= esc($row->ck_name) ?></td>
|
<td class="py-2.5 px-2 font-medium text-gray-900 whitespace-nowrap"><?= esc($row->ck_name) ?></td>
|
||||||
<td class="py-2.5 px-2 text-left text-gray-600"><?= (int) ($countMap[$row->ck_idx] ?? 0) ?>개</td>
|
<td class="py-2.5 px-2 text-left text-gray-600 whitespace-nowrap"><?= (int) ($countMap[$row->ck_idx] ?? 0) ?>개</td>
|
||||||
<td class="py-2.5 px-2 text-left"><?= $stateBadge((int) ($row->ck_state ?? 0)) ?></td>
|
<td class="py-2.5 px-2 text-left whitespace-nowrap"><?= $stateBadge((int) ($row->ck_state ?? 0)) ?></td>
|
||||||
<td class="py-2.5 px-2 text-gray-500 text-[12px]"><?= esc($row->ck_regdate ?? '') ?></td>
|
<td class="py-2.5 px-2 text-gray-500 text-[12px] whitespace-nowrap"><?= esc($row->ck_regdate ?? '') ?></td>
|
||||||
<?php if ($showKindActions): ?>
|
<?php if ($showKindActions): ?>
|
||||||
<td class="py-2.5 px-2 text-left text-xs" onclick="event.stopPropagation()">
|
<td class="py-2.5 px-2 text-left text-xs whitespace-nowrap" onclick="event.stopPropagation()">
|
||||||
<a href="<?= base_url('admin/code-kinds/edit/' . (int) $row->ck_idx) ?>" class="text-blue-600 hover:underline mr-1">수정</a>
|
<a href="<?= base_url('admin/code-kinds/edit/' . (int) $row->ck_idx) ?>" class="text-blue-600 hover:underline mr-1">수정</a>
|
||||||
<form action="<?= base_url('admin/code-kinds/delete/' . (int) $row->ck_idx) ?>" method="POST" class="inline" onsubmit="return confirm('이 코드 종류를 삭제하시겠습니까?');">
|
<form action="<?= base_url('admin/code-kinds/delete/' . (int) $row->ck_idx) ?>" method="POST" class="inline" onsubmit="return confirm('이 코드 종류를 삭제하시겠습니까?');">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
@@ -102,15 +102,15 @@ $stateBadge = static function (int $state): string {
|
|||||||
<table class="w-full text-[13px]">
|
<table class="w-full text-[13px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="text-left text-[11px] font-semibold text-gray-500 border-b border-gray-200">
|
<tr class="text-left text-[11px] font-semibold text-gray-500 border-b border-gray-200">
|
||||||
<th class="py-2.5 px-2 w-12 text-left">번호</th>
|
<th class="py-2.5 px-2 w-12 text-left whitespace-nowrap">번호</th>
|
||||||
<th class="py-2.5 px-2 w-20">코드</th>
|
<th class="py-2.5 px-2 w-20 whitespace-nowrap">코드</th>
|
||||||
<th class="py-2.5 px-2">코드명</th>
|
<th class="py-2.5 px-2 whitespace-nowrap">코드명</th>
|
||||||
<th class="py-2.5 px-2 w-16 text-left">범위</th>
|
<th class="py-2.5 px-2 w-16 text-left whitespace-nowrap">범위</th>
|
||||||
<th class="py-2.5 px-2 w-14 text-left">정렬</th>
|
<th class="py-2.5 px-2 w-14 text-left whitespace-nowrap">정렬</th>
|
||||||
<th class="py-2.5 px-2 w-16 text-left">상태</th>
|
<th class="py-2.5 px-2 w-16 text-left whitespace-nowrap">상태</th>
|
||||||
<th class="py-2.5 px-2 w-32">등록일</th>
|
<th class="py-2.5 px-2 w-32 whitespace-nowrap">등록일</th>
|
||||||
<?php if ($canManageDetails): ?>
|
<?php if ($canManageDetails): ?>
|
||||||
<th class="py-2.5 px-2 w-24 text-left">작업</th>
|
<th class="py-2.5 px-2 w-24 text-left whitespace-nowrap">작업</th>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -122,17 +122,17 @@ $stateBadge = static function (int $state): string {
|
|||||||
$scopeLabel = $isPlatform ? '공통' : '지자체';
|
$scopeLabel = $isPlatform ? '공통' : '지자체';
|
||||||
?>
|
?>
|
||||||
<tr class="border-b border-gray-200 last:border-0 hover:bg-gray-50">
|
<tr class="border-b border-gray-200 last:border-0 hover:bg-gray-50">
|
||||||
<td class="py-2.5 px-2 text-left text-gray-500"><?= (string) $dNo ?></td>
|
<td class="py-2.5 px-2 text-left text-gray-500 whitespace-nowrap"><?= (string) $dNo ?></td>
|
||||||
<td class="py-2.5 px-2 text-left font-mono text-gray-700"><?= esc($row->cd_code) ?></td>
|
<td class="py-2.5 px-2 text-left font-mono text-gray-700 whitespace-nowrap"><?= esc($row->cd_code) ?></td>
|
||||||
<td class="py-2.5 px-2 font-medium text-gray-900"><?= esc($row->cd_name) ?></td>
|
<td class="py-2.5 px-2 font-medium text-gray-900 whitespace-nowrap"><?= esc($row->cd_name) ?></td>
|
||||||
<td class="py-2.5 px-2 text-left">
|
<td class="py-2.5 px-2 text-left whitespace-nowrap">
|
||||||
<span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium <?= $isPlatform ? 'bg-blue-50 text-blue-700' : 'bg-amber-50 text-amber-700' ?>"><?= esc($scopeLabel) ?></span>
|
<span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium <?= $isPlatform ? 'bg-blue-50 text-blue-700' : 'bg-amber-50 text-amber-700' ?>"><?= esc($scopeLabel) ?></span>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-2.5 px-2 text-left text-gray-600"><?= (int) ($row->cd_sort ?? 0) ?></td>
|
<td class="py-2.5 px-2 text-left text-gray-600 whitespace-nowrap"><?= (int) ($row->cd_sort ?? 0) ?></td>
|
||||||
<td class="py-2.5 px-2 text-left"><?= $stateBadge((int) ($row->cd_state ?? 0)) ?></td>
|
<td class="py-2.5 px-2 text-left whitespace-nowrap"><?= $stateBadge((int) ($row->cd_state ?? 0)) ?></td>
|
||||||
<td class="py-2.5 px-2 text-gray-500 text-[12px]"><?= esc($row->cd_regdate ?? '') ?></td>
|
<td class="py-2.5 px-2 text-gray-500 text-[12px] whitespace-nowrap"><?= esc($row->cd_regdate ?? '') ?></td>
|
||||||
<?php if ($canManageDetails): ?>
|
<?php if ($canManageDetails): ?>
|
||||||
<td class="py-2.5 px-2 text-left text-xs">
|
<td class="py-2.5 px-2 text-left text-xs whitespace-nowrap">
|
||||||
<?php if (! empty($rowCanEdit[$row->cd_idx])): ?>
|
<?php if (! empty($rowCanEdit[$row->cd_idx])): ?>
|
||||||
<a href="<?= base_url('admin/code-details/edit/' . (int) $row->cd_idx) ?>" class="text-blue-600 hover:underline">수정</a>
|
<a href="<?= base_url('admin/code-details/edit/' . (int) $row->cd_idx) ?>" class="text-blue-600 hover:underline">수정</a>
|
||||||
<form action="<?= base_url('admin/code-details/delete/' . (int) $row->cd_idx) ?>" method="POST" class="ml-1 inline" onsubmit="return confirm('이 세부코드를 삭제하시겠습니까?');">
|
<form action="<?= base_url('admin/code-details/delete/' . (int) $row->cd_idx) ?>" method="POST" class="ml-1 inline" onsubmit="return confirm('이 세부코드를 삭제하시겠습니까?');">
|
||||||
|
|||||||
@@ -114,7 +114,7 @@
|
|||||||
<tr class="<?= $rowNormal ? '' : 'opacity-60' ?>">
|
<tr class="<?= $rowNormal ? '' : 'opacity-60' ?>">
|
||||||
<td class="text-center align-middle">
|
<td class="text-center align-middle">
|
||||||
<?php if ($rowNormal): ?>
|
<?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 $deleteRadioFirst = false; ?>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<span class="text-gray-400" title="삭제할 수 없는 상태">—</span>
|
<span class="text-gray-400" title="삭제할 수 없는 상태">—</span>
|
||||||
@@ -135,7 +135,8 @@
|
|||||||
</section>
|
</section>
|
||||||
<section class="xl:col-span-7 border border-red-200 bg-red-50 p-4">
|
<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 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">
|
<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>
|
<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>
|
<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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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: ?>
|
<?php else: ?>
|
||||||
|
|
||||||
<form action="<?= base_url('bag/order/store') ?>" method="POST" class="mt-2 space-y-2" id="bag-order-store-form">
|
<form action="<?= base_url('bag/order/store') ?>" method="POST" class="mt-2 space-y-2" id="bag-order-store-form">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<?php if ($editMode): ?>
|
<?php if ($editMode): ?>
|
||||||
<input type="hidden" name="bo_source_idx" value="<?= esc((string) ($editDefaults['bo_source_idx'] ?? 0)) ?>" />
|
<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">
|
<input type="hidden" name="bo_change_mode" value="meta" />
|
||||||
<legend class="text-xs font-bold text-gray-600 px-1">변경 구분</legend>
|
<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">
|
||||||
<div class="flex flex-wrap gap-x-4 gap-y-1">
|
<span class="font-bold text-gray-700">원 발주번호(LOT)</span>
|
||||||
<label class="inline-flex items-center gap-1 cursor-pointer">
|
<span class="font-mono text-gray-900"><?= esc($orderLotNo !== '' ? $orderLotNo : '-') ?></span>
|
||||||
<input type="radio" name="bo_change_mode" value="price" <?= $changeMode === 'price' ? 'checked' : '' ?> />
|
<span class="text-gray-500">발주 #<?= (int) ($editDefaults['bo_source_idx'] ?? 0) ?></span>
|
||||||
<span>발주·도매·판매 단가</span>
|
<span class="text-gray-500">— 변경 저장 시 원 발주번호는 유지되고, 이전 내용은 이력(버전)으로 보존됩니다.</span>
|
||||||
</label>
|
<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>
|
||||||
<label class="inline-flex items-center gap-1 cursor-pointer">
|
</div>
|
||||||
<input type="radio" name="bo_change_mode" value="meta" <?= $changeMode === 'meta' ? 'checked' : '' ?> />
|
|
||||||
<span>업체·수수료·협회·발주</span>
|
|
||||||
</label>
|
|
||||||
</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 endif; ?>
|
||||||
<?php if ($hubReturn && $orderReturnMonth !== ''): ?>
|
<?php if ($hubReturn && $orderReturnMonth !== ''): ?>
|
||||||
<input type="hidden" name="order_return_hub" value="1" />
|
<input type="hidden" name="order_return_hub" value="1" />
|
||||||
@@ -188,48 +225,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 xl:grid-cols-12 gap-2">
|
<div class="grid grid-cols-1 xl:grid-cols-12 gap-2 items-start">
|
||||||
<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>
|
|
||||||
|
|
||||||
<section class="xl:col-span-7 border border-gray-300 rounded-lg overflow-hidden bg-white">
|
<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="p-2 space-y-2">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||||
<div class="flex items-center gap-2 text-sm">
|
<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>
|
<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" />
|
<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>
|
||||||
|
<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">
|
<div class="flex items-center gap-2 text-sm">
|
||||||
<label for="bo_association_idx" class="w-20 font-bold text-gray-700">협회</label>
|
<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">
|
<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-12 text-center">번호</th>
|
||||||
<th class="w-16 text-center">선택</th>
|
<th class="w-16 text-center">선택</th>
|
||||||
<th class="text-left">품명</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>
|
||||||
<th class="w-24 text-right">낱장환산</th>
|
<th class="w-24 text-right">낱장환산</th>
|
||||||
<?php if ($editMode): ?>
|
<?php if ($editMode): ?>
|
||||||
@@ -324,16 +339,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-2 pt-1">
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
|
||||||
|
|
||||||
<section class="border border-gray-300 rounded-lg overflow-hidden bg-white">
|
<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="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">발주품목 선택</div>
|
||||||
<div class="overflow-auto">
|
<div class="overflow-auto max-h-[560px]">
|
||||||
<table class="w-full data-table text-sm order-reference-table">
|
<table class="w-full data-table text-sm order-reference-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -365,6 +381,40 @@
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</form>
|
</form>
|
||||||
@@ -384,6 +434,96 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</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>
|
<script>
|
||||||
(() => {
|
(() => {
|
||||||
const bagMeta = <?= json_encode($bagMeta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
|
const bagMeta = <?= json_encode($bagMeta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
|
||||||
@@ -548,7 +688,7 @@
|
|||||||
const renderSelectedRows = () => {
|
const renderSelectedRows = () => {
|
||||||
const codes = Object.keys(bagMeta).filter((code) => selectedItems.has(code));
|
const codes = Object.keys(bagMeta).filter((code) => selectedItems.has(code));
|
||||||
if (codes.length === 0) {
|
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);
|
setActiveRow(null);
|
||||||
updateTotals();
|
updateTotals();
|
||||||
updateReferenceSelectionUi();
|
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) => {
|
referenceRows.forEach((row) => {
|
||||||
row.addEventListener('click', (event) => {
|
row.addEventListener('click', (event) => {
|
||||||
const button = event.target.closest('.js-toggle-bag');
|
const button = event.target.closest('.js-toggle-bag');
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ foreach ($subtotals as $subtotal) {
|
|||||||
|
|
||||||
<div class="mt-2 border border-gray-300 rounded-lg bg-white p-2 print:p-0 print:border-0">
|
<div class="mt-2 border border-gray-300 rounded-lg bg-white p-2 print:p-0 print:border-0">
|
||||||
<div class="overflow-auto">
|
<div class="overflow-auto">
|
||||||
<table class="w-full data-table text-sm">
|
<!-- 종류~계 사이 간격 축소 — 표 폭을 내용에 맞게 제한 (피드백 #18) -->
|
||||||
|
<table class="w-full max-w-4xl data-table text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="w-36 text-center">품 목 구 분</th>
|
<th class="w-36 text-center">품 목 구 분</th>
|
||||||
|
|||||||
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 = ''; });
|
document.addEventListener('mouseup', function () { if (!dragging) return; dragging = false; ov.style.display = 'none'; document.body.style.userSelect = ''; });
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// 글씨 크기(zoom) — 상단바에서 조절한 값을 적용. localStorage 공유 + storage 이벤트로 실시간 반영.
|
// 글씨 크기(zoom) — 계정별·메뉴별 저장값(서버 주입)을 이 화면에 적용.
|
||||||
function applyFontScale() {
|
<?php $__fmk = function_exists('current_nav_request_path') ? current_nav_request_path() : ''; $__fsc = function_exists('member_font_scale_for') ? member_font_scale_for($__fmk) : 100; ?>
|
||||||
try { var s = parseInt(localStorage.getItem('jrj_font_scale') || '100', 10); if (!(s >= 70 && s <= 150)) s = 100; document.documentElement.style.zoom = (s / 100); } catch (e) {}
|
(function () {
|
||||||
}
|
var s = <?= (int) $__fsc ?>;
|
||||||
applyFontScale();
|
if (!(s >= 70 && s <= 150)) s = 100;
|
||||||
window.addEventListener('storage', function (e) { if (e.key === 'jrj_font_scale') applyFontScale(); });
|
try { document.documentElement.style.zoom = (s / 100); } catch (e) {}
|
||||||
|
})();
|
||||||
|
|
||||||
document.addEventListener('click', function (e) {
|
document.addEventListener('click', function (e) {
|
||||||
var a = e.target.closest ? e.target.closest('a[href]') : null;
|
var a = e.target.closest ? e.target.closest('a[href]') : null;
|
||||||
@@ -196,8 +197,19 @@ tailwind.config = {
|
|||||||
document.addEventListener('submit', function (e) {
|
document.addEventListener('submit', function (e) {
|
||||||
var f = e.target;
|
var f = e.target;
|
||||||
if (!f || f.tagName !== 'FORM') return;
|
if (!f || f.tagName !== 'FORM') return;
|
||||||
var ne = withEmbed(f.getAttribute('action') || location.href);
|
var method = (f.getAttribute('method') || 'get').toLowerCase();
|
||||||
if (ne) f.setAttribute('action', ne);
|
if (method === 'get') {
|
||||||
|
// GET 폼은 제출 시 action의 쿼리스트링이 폼 필드로 대체되므로,
|
||||||
|
// action 수정 대신 hidden input 으로 embed=1 을 유지한다(중첩 셸 방지).
|
||||||
|
if (!f.querySelector('input[name="embed"]')) {
|
||||||
|
var h = document.createElement('input');
|
||||||
|
h.type = 'hidden'; h.name = 'embed'; h.value = '1';
|
||||||
|
f.appendChild(h);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var ne = withEmbed(f.getAttribute('action') || location.href);
|
||||||
|
if (ne) f.setAttribute('action', ne);
|
||||||
|
}
|
||||||
}, true);
|
}, true);
|
||||||
// 표 '번호' 컬럼 역순 채번 (사이트 레이아웃과 동일)
|
// 표 '번호' 컬럼 역순 채번 (사이트 레이아웃과 동일)
|
||||||
var run = function () {
|
var run = function () {
|
||||||
@@ -218,5 +230,20 @@ tailwind.config = {
|
|||||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run, { once: true }); else run();
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run, { once: true }); else run();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
<script>
|
||||||
|
// 네이티브 날짜 입력의 월 표기를 한글(예: 6월)로 — 요소 lang 지정(브라우저 영어 로케일 대응)
|
||||||
|
(function () {
|
||||||
|
function fixLang() {
|
||||||
|
document.querySelectorAll('input[type=date],input[type=month],input[type=datetime-local],input[type=week],input[type=time]')
|
||||||
|
.forEach(function (el) { el.setAttribute('lang', 'ko'); });
|
||||||
|
}
|
||||||
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fixLang); else fixLang();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<?= view('components/feedback_widget') ?>
|
||||||
|
<script>
|
||||||
|
/* 인쇄 시 활동 로그 기록(beacon) */
|
||||||
|
(function(){var last=0;window.addEventListener('beforeprint',function(){var now=Date.now();if(now-last<4000)return;last=now;try{var fd=new FormData();fd.append('title',document.title||'');fd.append('path',location.pathname||'');navigator.sendBeacon('<?= site_url('bag/activity/print-log') ?>',fd);}catch(e){}});})();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -196,5 +196,9 @@ body { overflow: hidden; }
|
|||||||
window.addEventListener('pagehide', closeStuckOverlays);
|
window.addEventListener('pagehide', closeStuckOverlays);
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
<script>
|
||||||
|
/* 인쇄 시 활동 로그 기록(beacon) */
|
||||||
|
(function(){var last=0;window.addEventListener('beforeprint',function(){var now=Date.now();if(now-last<4000)return;last=now;try{var fd=new FormData();fd.append('title',document.title||'');fd.append('path',location.pathname||'');navigator.sendBeacon('<?= site_url('bag/activity/print-log') ?>',fd);}catch(e){}});})();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -264,22 +264,55 @@ tailwind.config = {
|
|||||||
document.addEventListener('mousemove', function (e) { if (!dragging) return; var w = window.innerWidth - e.clientX; drawer.style.width = Math.min(window.innerWidth * 0.92, Math.max(300, w)) + 'px'; });
|
document.addEventListener('mousemove', function (e) { if (!dragging) return; var w = window.innerWidth - e.clientX; drawer.style.width = Math.min(window.innerWidth * 0.92, Math.max(300, w)) + 'px'; });
|
||||||
document.addEventListener('mouseup', function () { if (!dragging) return; dragging = false; ov.style.display = 'none'; document.body.style.userSelect = ''; });
|
document.addEventListener('mouseup', function () { if (!dragging) return; dragging = false; ov.style.display = 'none'; document.body.style.userSelect = ''; });
|
||||||
|
|
||||||
// 글씨 크기 조절 — 본문 + 상단 대메뉴 + 좌측 사이드바(메뉴)에 zoom 적용.
|
// 글씨 크기 조절 — 계정별·메뉴별 저장(서버). 현재 화면 경로 기준으로 개별 적용.
|
||||||
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 scaleSelectors = ['.portal-header', '.sidebar', '.work-main'];
|
var MENU_KEY = <?= json_encode($__fmk) ?>;
|
||||||
function curScale() { var s = parseInt(localStorage.getItem(FONT_KEY) || '100', 10); return (s >= 70 && s <= 150) ? s : 100; }
|
var scale = <?= (int) $__fsc ?>;
|
||||||
function applyScale(s) {
|
var SAVE_URL = <?= json_encode(site_url('bag/pref/font-scale')) ?>;
|
||||||
|
var CSRF_NAME = <?= json_encode(csrf_token()) ?>;
|
||||||
|
var csrfHash = <?= json_encode(csrf_hash()) ?>;
|
||||||
|
var scaleSelectors = ['.sidebar', '.work-main'];
|
||||||
|
var 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));
|
s = Math.min(150, Math.max(70, s));
|
||||||
try { localStorage.setItem(FONT_KEY, String(s)); } catch (e) {}
|
scale = s;
|
||||||
var z = s / 100;
|
var z = s / 100;
|
||||||
scaleSelectors.forEach(function (sel) { var el = document.querySelector(sel); if (el) el.style.zoom = z; });
|
scaleSelectors.forEach(function (sel) { var el = document.querySelector(sel); if (el) el.style.zoom = z; });
|
||||||
|
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 + '%';
|
var pct = document.getElementById('wsFontPct'); if (pct) pct.textContent = s + '%';
|
||||||
|
if (save) persist(s);
|
||||||
}
|
}
|
||||||
applyScale(curScale());
|
applyScale(scale, false);
|
||||||
var plus = document.getElementById('wsFontPlus'), minus = document.getElementById('wsFontMinus');
|
var plus = document.getElementById('wsFontPlus'), minus = document.getElementById('wsFontMinus');
|
||||||
if (plus) plus.addEventListener('click', function () { applyScale(curScale() + 10); });
|
if (plus) plus.addEventListener('click', function () { applyScale(scale + 10, true); });
|
||||||
if (minus) minus.addEventListener('click', function () { applyScale(curScale() - 10); });
|
if (minus) minus.addEventListener('click', function () { applyScale(scale - 10, true); });
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
<script>
|
||||||
|
// 네이티브 날짜 입력의 월 표기를 한글(예: 6월)로 — 요소 lang 지정(브라우저 영어 로케일 대응)
|
||||||
|
(function () {
|
||||||
|
function fixLang() {
|
||||||
|
document.querySelectorAll('input[type=date],input[type=month],input[type=datetime-local],input[type=week],input[type=time]')
|
||||||
|
.forEach(function (el) { el.setAttribute('lang', 'ko'); });
|
||||||
|
}
|
||||||
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fixLang); else fixLang();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<?= view('components/feedback_widget') ?>
|
||||||
|
<script>
|
||||||
|
/* 인쇄 시 활동 로그 기록(beacon) */
|
||||||
|
(function(){var last=0;window.addEventListener('beforeprint',function(){var now=Date.now();if(now-last<4000)return;last=now;try{var fd=new FormData();fd.append('title',document.title||'');fd.append('path',location.pathname||'');navigator.sendBeacon('<?= site_url('bag/activity/print-log') ?>',fd);}catch(e){}});})();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -191,18 +191,33 @@ if ($effectiveLgIdx) {
|
|||||||
|
|
||||||
var STORE_KEY = 'jrj_ws_tabs';
|
var STORE_KEY = 'jrj_ws_tabs';
|
||||||
var WS_OWNER = '<?= (string) (session()->get('mb_idx') ?? '') ?>'; // 탭 저장 소유자(로그인 사용자) 식별
|
var WS_OWNER = '<?= (string) (session()->get('mb_idx') ?? '') ?>'; // 탭 저장 소유자(로그인 사용자) 식별
|
||||||
|
var WS_SAVE_URL = '<?= site_url('bag/pref/workspace-tabs') ?>';
|
||||||
|
var WS_CSRF_NAME = '<?= csrf_token() ?>';
|
||||||
|
var wsCsrfHash = '<?= csrf_hash() ?>';
|
||||||
|
var wsSaveTimer = null;
|
||||||
|
function wsStateObj() {
|
||||||
|
return {
|
||||||
|
owner: WS_OWNER,
|
||||||
|
tabs: order.map(function (id) { return { url: tabs[id].url, title: tabs[id].title }; }),
|
||||||
|
layout: layout,
|
||||||
|
focused: focused,
|
||||||
|
vRatio: vRatio,
|
||||||
|
hRatio: hRatio,
|
||||||
|
slots: slots.map(function (id) { return (id && tabs[id]) ? tabs[id].url : null; })
|
||||||
|
};
|
||||||
|
}
|
||||||
function persist() {
|
function persist() {
|
||||||
try {
|
var obj = wsStateObj();
|
||||||
sessionStorage.setItem(STORE_KEY, JSON.stringify({
|
var json = JSON.stringify(obj);
|
||||||
owner: WS_OWNER,
|
try { sessionStorage.setItem(STORE_KEY, json); } catch (e) {}
|
||||||
tabs: order.map(function (id) { return { url: tabs[id].url, title: tabs[id].title }; }),
|
// 계정(DB)에 저장 → 다음 로그인/다른 기기에서도 자동 복원 (연속 변경은 디바운스)
|
||||||
layout: layout,
|
clearTimeout(wsSaveTimer);
|
||||||
focused: focused,
|
wsSaveTimer = setTimeout(function () {
|
||||||
vRatio: vRatio,
|
var b = new URLSearchParams();
|
||||||
hRatio: hRatio,
|
b.set(WS_CSRF_NAME, wsCsrfHash); b.set('state', json);
|
||||||
slots: slots.map(function (id) { return (id && tabs[id]) ? tabs[id].url : null; })
|
fetch(WS_SAVE_URL, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: b.toString() })
|
||||||
}));
|
.then(function (r) { return r.json(); }).then(function (d) { if (d && d.csrf) wsCsrfHash = d.csrf; }).catch(function () {});
|
||||||
} catch (e) {}
|
}, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 분할 칸 헤더·빈칸 안내 요소 4개 미리 생성
|
// 분할 칸 헤더·빈칸 안내 요소 4개 미리 생성
|
||||||
@@ -211,10 +226,11 @@ if ($effectiveLgIdx) {
|
|||||||
(function (idx) {
|
(function (idx) {
|
||||||
var h = document.createElement('div');
|
var h = document.createElement('div');
|
||||||
h.className = 'ws-slot-head';
|
h.className = 'ws-slot-head';
|
||||||
var nm = document.createElement('span'); nm.className = 'sh-name';
|
// 칸 제목(sh-name)은 표시하지 않음 — 칸 안 화면에 이미 제목이 있어 중복.
|
||||||
|
var sp = document.createElement('span'); sp.className = 'sh-spacer'; sp.style.flex = '1';
|
||||||
var rl = document.createElement('span'); rl.className = 'sh-btn sh-reload'; rl.textContent = '↻'; rl.title = '이 칸 새로고침';
|
var rl = document.createElement('span'); rl.className = 'sh-btn sh-reload'; rl.textContent = '↻'; rl.title = '이 칸 새로고침';
|
||||||
var cl = document.createElement('span'); cl.className = 'sh-btn sh-clear'; cl.textContent = '×'; cl.title = '이 칸 비우기';
|
var cl = document.createElement('span'); cl.className = 'sh-btn sh-clear'; cl.textContent = '×'; cl.title = '이 칸 비우기';
|
||||||
h.appendChild(nm); h.appendChild(rl); h.appendChild(cl);
|
h.appendChild(sp); h.appendChild(rl); h.appendChild(cl);
|
||||||
h.addEventListener('click', function (e) {
|
h.addEventListener('click', function (e) {
|
||||||
if (e.target === rl) { var sid = slots[idx]; if (sid) reloadTab(sid); return; }
|
if (e.target === rl) { var sid = slots[idx]; if (sid) reloadTab(sid); return; }
|
||||||
if (e.target === cl) { slots[idx] = null; render(); return; }
|
if (e.target === cl) { slots[idx] = null; render(); return; }
|
||||||
@@ -287,8 +303,6 @@ if ($effectiveLgIdx) {
|
|||||||
head.style.display = 'flex';
|
head.style.display = 'flex';
|
||||||
head.style.left = r.l; head.style.top = r.t; head.style.width = r.w;
|
head.style.left = r.l; head.style.top = r.t; head.style.width = r.w;
|
||||||
head.classList.toggle('focused', i === focused);
|
head.classList.toggle('focused', i === focused);
|
||||||
var sid = slots[i];
|
|
||||||
head.querySelector('.sh-name').textContent = (sid && tabs[sid]) ? tabs[sid].title : '비어 있음';
|
|
||||||
} else {
|
} else {
|
||||||
head.style.display = 'none';
|
head.style.display = 'none';
|
||||||
}
|
}
|
||||||
@@ -332,7 +346,7 @@ if ($effectiveLgIdx) {
|
|||||||
persist();
|
persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
function focusSlot(i) { focused = i; render(); }
|
function focusSlot(i) { focused = i; render(); try { updateFontPct(); } catch (e) {} }
|
||||||
|
|
||||||
// 포커스 칸에 탭 배치 (이미 다른 칸에 있으면 두 칸을 맞바꿈)
|
// 포커스 칸에 탭 배치 (이미 다른 칸에 있으면 두 칸을 맞바꿈)
|
||||||
function placeInFocused(id) {
|
function placeInFocused(id) {
|
||||||
@@ -344,6 +358,7 @@ if ($effectiveLgIdx) {
|
|||||||
else { slots[focused] = id; }
|
else { slots[focused] = id; }
|
||||||
}
|
}
|
||||||
render();
|
render();
|
||||||
|
try { updateFontPct(); } catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setLayout(mode) {
|
function setLayout(mode) {
|
||||||
@@ -424,6 +439,7 @@ if ($effectiveLgIdx) {
|
|||||||
d.addEventListener('keydown', handleShortcut);
|
d.addEventListener('keydown', handleShortcut);
|
||||||
d.addEventListener('mousedown', function () { var j = slots.indexOf(id); if (j >= 0 && j !== focused) focusSlot(j); }, true);
|
d.addEventListener('mousedown', function () { var j = slots.indexOf(id); if (j >= 0 && j !== focused) focusSlot(j); }, true);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
try { readTabScale(id); } catch (e) {}
|
||||||
});
|
});
|
||||||
panels.appendChild(frame);
|
panels.appendChild(frame);
|
||||||
|
|
||||||
@@ -480,26 +496,62 @@ if ($effectiveLgIdx) {
|
|||||||
}
|
}
|
||||||
document.addEventListener('keydown', handleShortcut);
|
document.addEventListener('keydown', handleShortcut);
|
||||||
|
|
||||||
// 글씨 크기 조절 — 각 탭(iframe) 내용에 zoom 적용. localStorage 공유 + storage 이벤트로 새 탭/실시간 반영.
|
// 글씨 크기 조절 — 포커스된 탭(메뉴) 내용에만 적용하고, 계정·메뉴별로 서버에 저장한다.
|
||||||
var FONT_KEY = 'jrj_font_scale';
|
// (예전엔 모든 탭·셸에 일괄 적용돼 "모든 메뉴가 함께 바뀌는" 문제가 있었음)
|
||||||
function curFontScale() { var s = parseInt(localStorage.getItem(FONT_KEY) || '100', 10); return (s >= 70 && s <= 150) ? s : 100; }
|
var FONT_MIN = 70, FONT_MAX = 150;
|
||||||
function setFontScale(s) {
|
var FONT_SAVE_URL = '<?= site_url('bag/pref/font-scale') ?>';
|
||||||
s = Math.min(150, Math.max(70, s));
|
var FONT_CSRF_NAME = '<?= csrf_token() ?>';
|
||||||
try { localStorage.setItem(FONT_KEY, String(s)); } catch (e) {}
|
var fontCsrfHash = '<?= csrf_hash() ?>';
|
||||||
var z = s / 100;
|
function menuKeyOf(id) {
|
||||||
|
var u = (tabs[id] && tabs[id].url) ? tabs[id].url : id;
|
||||||
|
try { return new URL(u, location.href).pathname.replace(/^\/+/, '').replace(/^index\.php\//, ''); } catch (e) { return String(u); }
|
||||||
|
}
|
||||||
|
function updateFontPct() {
|
||||||
|
var fid = slots[focused];
|
||||||
|
var s = (fid && tabs[fid] && tabs[fid].scale) ? tabs[fid].scale : 100;
|
||||||
var pct = document.getElementById('wsFontPct'); if (pct) pct.textContent = s + '%';
|
var pct = document.getElementById('wsFontPct'); if (pct) pct.textContent = s + '%';
|
||||||
// 탭(iframe) 내용
|
}
|
||||||
Object.keys(tabs).forEach(function (k) { try { tabs[k].frame.contentDocument.documentElement.style.zoom = z; } catch (e) {} });
|
// 새 탭 로드 시: embed 레이아웃이 적용한 저장 배율을 읽어 탭 상태로 보관.
|
||||||
// 셸 메뉴(상단 대메뉴 + 좌측 사이드바)도 함께 확대
|
function readTabScale(id) {
|
||||||
['.portal-header', '.sidebar'].forEach(function (sel) {
|
var s = 100;
|
||||||
var el = document.querySelector(sel); if (el) el.style.zoom = z;
|
try { var z = parseFloat(tabs[id].frame.contentDocument.documentElement.style.zoom || '1'); if (z > 0) s = Math.round(z * 100); } catch (e) {}
|
||||||
});
|
if (!(s >= FONT_MIN && s <= FONT_MAX)) s = 100;
|
||||||
|
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 () {
|
(function () {
|
||||||
setFontScale(curFontScale()); // 저장된 배율을 셸 메뉴에도 적용(초기 로드)
|
|
||||||
var plus = document.getElementById('wsFontPlus'), minus = document.getElementById('wsFontMinus');
|
var plus = document.getElementById('wsFontPlus'), minus = document.getElementById('wsFontMinus');
|
||||||
if (plus) plus.addEventListener('click', function () { setFontScale(curFontScale() + 10); });
|
function bump(delta) {
|
||||||
if (minus) minus.addEventListener('click', function () { setFontScale(curFontScale() - 10); });
|
var fid = slots[focused];
|
||||||
|
if (!fid || !tabs[fid]) { showToast('먼저 메뉴 탭을 여세요.'); return; }
|
||||||
|
applyTabFont(fid, (tabs[fid].scale || 100) + delta, true);
|
||||||
|
}
|
||||||
|
if (plus) plus.addEventListener('click', function () { bump(10); });
|
||||||
|
if (minus) minus.addEventListener('click', function () { bump(-10); });
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// 좌측 사이드바 소메뉴 클릭 → 포커스 칸에 열기
|
// 좌측 사이드바 소메뉴 클릭 → 포커스 칸에 열기
|
||||||
@@ -523,11 +575,19 @@ if ($effectiveLgIdx) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 첫 화면: 세션 저장 복원(레이아웃·분할 배치 포함), 없으면 대시보드 1분할
|
// 첫 화면: ① 계정(DB) 저장 자동복원 → ② 세션 저장 → ③ 없으면 대시보드 1분할
|
||||||
|
var SERVER_STATE = <?= $savedWorkspaceJson ?? 'null' ?>;
|
||||||
(function restore() {
|
(function restore() {
|
||||||
var saved = null;
|
var saved = null;
|
||||||
try { saved = JSON.parse(sessionStorage.getItem(STORE_KEY) || 'null'); } catch (e) {}
|
// ① 계정 DB에 저장된 상태 우선(다음 로그인·다른 기기에서도 복원)
|
||||||
if (saved && saved.owner !== WS_OWNER) { try { sessionStorage.removeItem(STORE_KEY); } catch (e) {} saved = null; }
|
if (SERVER_STATE && SERVER_STATE.tabs && SERVER_STATE.tabs.length) {
|
||||||
|
saved = SERVER_STATE;
|
||||||
|
}
|
||||||
|
// ② 없으면 이 브라우저 세션 저장 사용
|
||||||
|
if (!saved) {
|
||||||
|
try { saved = JSON.parse(sessionStorage.getItem(STORE_KEY) || 'null'); } catch (e) {}
|
||||||
|
if (saved && saved.owner !== WS_OWNER) { try { sessionStorage.removeItem(STORE_KEY); } catch (e) {} saved = null; }
|
||||||
|
}
|
||||||
if (saved && saved.tabs && saved.tabs.length) {
|
if (saved && saved.tabs && saved.tabs.length) {
|
||||||
saved.tabs.forEach(function (t) { if (t && t.url) openTab(t.url, t.title, { noFocus: true }); });
|
saved.tabs.forEach(function (t) { if (t && t.url) openTab(t.url, t.title, { noFocus: true }); });
|
||||||
layout = (saved.layout === 'lr' || saved.layout === 'tb' || saved.layout === 'quad') ? saved.layout : 'single';
|
layout = (saved.layout === 'lr' || saved.layout === 'tb' || saved.layout === 'quad') ? saved.layout : 'single';
|
||||||
|
|||||||
@@ -44,11 +44,15 @@ $searchQ = (string) (service('request')->getGet('q') ?? '');
|
|||||||
.manual-prose tbody tr:nth-child(even) td { background: #f9fafb; }
|
.manual-prose tbody tr:nth-child(even) td { background: #f9fafb; }
|
||||||
.manual-prose hr { margin: 1.6rem 0; border: 0; border-top: 1px solid #e5e7eb; }
|
.manual-prose hr { margin: 1.6rem 0; border: 0; border-top: 1px solid #e5e7eb; }
|
||||||
.manual-toc a.active { background: #1a2b4b; color: #fff; font-weight: 700; }
|
.manual-toc a.active { background: #1a2b4b; color: #fff; font-weight: 700; }
|
||||||
|
/* 소제목 옆 "화면 열기" 바로가기 */
|
||||||
|
.manual-jump { display: inline-flex; align-items: center; gap: 4px; margin-left: 8px; vertical-align: middle; font-size: 0.72rem; font-weight: 700; color: #1d4ed8; background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 9999px; padding: 2px 9px; text-decoration: none; cursor: pointer; white-space: nowrap; }
|
||||||
|
.manual-jump:hover { background: #1d4ed8; color: #fff; border-color: #1d4ed8; }
|
||||||
|
.manual-jump i { font-size: 0.65rem; }
|
||||||
/* "이 화면 설명"으로 들어왔을 때 해당 소제목 강조 */
|
/* "이 화면 설명"으로 들어왔을 때 해당 소제목 강조 */
|
||||||
.manual-prose h2.hl-flash, .manual-prose h3.hl-flash { background: #fef9c3; box-shadow: -6px 0 0 #fef9c3, 6px 0 0 #fef9c3; border-radius: 4px; animation: hlFlash 2.6s ease-out 1; }
|
.manual-prose h2.hl-flash, .manual-prose h3.hl-flash { background: #fef9c3; box-shadow: -6px 0 0 #fef9c3, 6px 0 0 #fef9c3; border-radius: 4px; animation: hlFlash 2.6s ease-out 1; }
|
||||||
@keyframes hlFlash { 0%, 45% { background: #fde047; box-shadow: -6px 0 0 #fde047, 6px 0 0 #fde047; } 100% { background: transparent; box-shadow: -6px 0 0 transparent, 6px 0 0 transparent; } }
|
@keyframes hlFlash { 0%, 45% { background: #fde047; box-shadow: -6px 0 0 #fde047, 6px 0 0 #fde047; } 100% { background: transparent; box-shadow: -6px 0 0 transparent, 6px 0 0 transparent; } }
|
||||||
@media print {
|
@media print {
|
||||||
.manual-toc, .manual-actions, .manual-nav { display: none !important; }
|
.manual-toc, .manual-actions, .manual-nav, .manual-jump { display: none !important; }
|
||||||
.manual-layout { display: block !important; }
|
.manual-layout { display: block !important; }
|
||||||
.manual-prose { max-width: none; }
|
.manual-prose { max-width: none; }
|
||||||
}
|
}
|
||||||
@@ -111,6 +115,8 @@ $searchQ = (string) (service('request')->getGet('q') ?? '');
|
|||||||
var SEARCH_URL = location.origin + <?= json_encode((string) parse_url(base_url('bag/manual/search'), PHP_URL_PATH), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
|
var SEARCH_URL = location.origin + <?= json_encode((string) parse_url(base_url('bag/manual/search'), PHP_URL_PATH), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
|
||||||
var BASE = location.origin + <?= json_encode((string) parse_url(base_url('bag/manual/'), PHP_URL_PATH), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
|
var BASE = location.origin + <?= json_encode((string) parse_url(base_url('bag/manual/'), PHP_URL_PATH), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
|
||||||
var Q0 = <?= json_encode($searchQ, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS) ?>;
|
var Q0 = <?= json_encode($searchQ, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS) ?>;
|
||||||
|
// 이 매뉴얼 페이지가 설명하는 화면 바로가기 목록 [{hint, url}, ...]
|
||||||
|
var JUMPS = <?= json_encode($jumps ?? [], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS) ?>;
|
||||||
// "이 화면 설명"으로 들어온 경우: 해당 소제목으로 스크롤 + 강조.
|
// "이 화면 설명"으로 들어온 경우: 해당 소제목으로 스크롤 + 강조.
|
||||||
// 드로어를 껐다 다시 켜도(같은 URL이라 iframe 재로드 안 됨) 부모가 보낸 메시지로 다시 강조한다.
|
// 드로어를 껐다 다시 켜도(같은 URL이라 iframe 재로드 안 됨) 부모가 보낸 메시지로 다시 강조한다.
|
||||||
var hlFlashTimer = null;
|
var hlFlashTimer = null;
|
||||||
@@ -140,6 +146,41 @@ $searchQ = (string) (service('request')->getGet('q') ?? '');
|
|||||||
if (e.origin !== location.origin) return;
|
if (e.origin !== location.origin) return;
|
||||||
if (e.data && e.data.type === 'manual-hl') runHl();
|
if (e.data && e.data.type === 'manual-hl') runHl();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 해당 화면으로 이동: 워크스페이스(탭) 안이면 새 탭으로, 아니면 최상위 창을 이동.
|
||||||
|
function openScreen(url, title) {
|
||||||
|
try { if (window.top && typeof window.top.wsOpenTab === 'function') { window.top.wsOpenTab(url, title); return; } } catch (e) {}
|
||||||
|
try { if (window.parent && typeof window.parent.wsOpenTab === 'function') { window.parent.wsOpenTab(url, title); return; } } catch (e) {}
|
||||||
|
try { window.top.location.href = url; } catch (e) { window.location.href = url; }
|
||||||
|
}
|
||||||
|
// 각 소제목(h2/h3) 옆에 "화면 열기" 바로가기 버튼을 붙인다(hint 매칭).
|
||||||
|
(function attachJumps() {
|
||||||
|
if (!JUMPS || !JUMPS.length) return;
|
||||||
|
var prose = document.querySelector('.manual-prose');
|
||||||
|
if (!prose) return;
|
||||||
|
var norm = function (s) { return String(s || '').replace(/\s+/g, ' ').trim().toLowerCase(); };
|
||||||
|
var heads = prose.querySelectorAll('h2, h3');
|
||||||
|
JUMPS.forEach(function (j) {
|
||||||
|
var needle = norm(j.hint);
|
||||||
|
if (!needle || !j.url) return;
|
||||||
|
for (var i = 0; i < heads.length; i++) {
|
||||||
|
var h = heads[i];
|
||||||
|
if (h.getAttribute('data-jump')) continue; // 한 제목엔 하나만
|
||||||
|
if (norm(h.textContent).indexOf(needle) < 0) continue;
|
||||||
|
var label = (h.textContent || '화면').trim(); // 버튼 추가 전 제목 확보
|
||||||
|
var url = j.url;
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.className = 'manual-jump';
|
||||||
|
a.href = url;
|
||||||
|
a.title = '이 화면으로 이동';
|
||||||
|
a.innerHTML = '<i class="fa-solid fa-arrow-up-right-from-square"></i> 화면 열기';
|
||||||
|
a.addEventListener('click', function (ev) { ev.preventDefault(); openScreen(url, label); });
|
||||||
|
h.appendChild(a);
|
||||||
|
h.setAttribute('data-jump', '1');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
var input = document.getElementById('manualSearchInput');
|
var input = document.getElementById('manualSearchInput');
|
||||||
var box = document.getElementById('manualSearchResults');
|
var box = document.getElementById('manualSearchResults');
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ for ($year = $baseYear - 2; $year <= $baseYear + 2; $year++) {
|
|||||||
?>
|
?>
|
||||||
|
|
||||||
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
||||||
<span class="text-sm font-bold text-gray-700">LOT-No 디스켓 불출</span>
|
<span class="text-sm font-bold text-gray-700">발주파일 생성</span>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="border border-gray-300 rounded-lg p-2 mt-2 bg-white">
|
<section class="border border-gray-300 rounded-lg p-2 mt-2 bg-white">
|
||||||
@@ -92,11 +92,12 @@ for ($year = $baseYear - 2; $year <= $baseYear + 2; $year++) {
|
|||||||
?>
|
?>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<form method="post" action="<?= base_url('bag/order/lot-seed/generate') ?>" class="inline">
|
<button type="button"
|
||||||
<?= csrf_field() ?>
|
class="lot-preview-btn text-blue-600 hover:underline text-xs"
|
||||||
<input type="hidden" name="bo_idx" value="<?= $boIdx ?>" />
|
data-bo-idx="<?= $boIdx ?>"
|
||||||
<button type="submit" class="text-blue-600 hover:underline text-xs">seed 생성</button>
|
data-lot="<?= esc((string) ($order->bo_lot_no ?? ''), 'attr') ?>">
|
||||||
</form>
|
내용 확인
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center"><?= esc((string) ($order->bo_order_date ?? '')) ?></td>
|
<td class="text-center"><?= esc((string) ($order->bo_order_date ?? '')) ?></td>
|
||||||
<td class="text-center font-mono"><?= esc((string) ($order->bo_lot_no ?? '')) ?></td>
|
<td class="text-center font-mono"><?= esc((string) ($order->bo_lot_no ?? '')) ?></td>
|
||||||
@@ -128,3 +129,133 @@ for ($year = $baseYear - 2; $year <= $baseYear + 2; $year++) {
|
|||||||
<?php if (isset($pager)): ?>
|
<?php if (isset($pager)): ?>
|
||||||
<div class="mt-2 mb-2 no-print"><?= $pager->links() ?></div>
|
<div class="mt-2 mb-2 no-print"><?= $pager->links() ?></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- 발주파일 생성 전 '내용 확인' 모달 -->
|
||||||
|
<div id="lot-preview-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-3xl 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>
|
||||||
|
<button type="button" id="lot-preview-close" class="text-gray-400 hover:text-gray-700 text-lg leading-none">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 overflow-auto text-sm">
|
||||||
|
<div id="lot-preview-loading" class="text-center text-gray-400 py-8">불러오는 중…</div>
|
||||||
|
<div id="lot-preview-error" class="hidden text-center text-red-600 py-8"></div>
|
||||||
|
<div id="lot-preview-body" class="hidden">
|
||||||
|
<p class="text-xs text-gray-500 mb-2">아래 내용으로 <b>암호화된 발주파일(.seed.json)</b>이 생성·다운로드됩니다. 생성 전 내용을 확인하세요.</p>
|
||||||
|
<dl class="grid grid-cols-[6rem_1fr] gap-y-1 text-[13px] mb-3 border border-gray-200 rounded p-2 bg-gray-50">
|
||||||
|
<dt class="font-semibold text-gray-600">LOT-No</dt><dd class="font-mono" id="lp-lot"></dd>
|
||||||
|
<dt class="font-semibold text-gray-600">발주일</dt><dd id="lp-date"></dd>
|
||||||
|
<dt class="font-semibold text-gray-600">제작업체</dt><dd id="lp-company"></dd>
|
||||||
|
<dt class="font-semibold text-gray-600">버전</dt><dd id="lp-version"></dd>
|
||||||
|
<dt class="font-semibold text-gray-600">무결성해시</dt><dd class="font-mono text-[11px] text-gray-500 break-all" id="lp-hash"></dd>
|
||||||
|
</dl>
|
||||||
|
<div class="overflow-auto border border-gray-200 rounded">
|
||||||
|
<table class="w-full data-table text-[13px]">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="text-left pl-2">봉투코드</th>
|
||||||
|
<th class="text-left">봉투명</th>
|
||||||
|
<th class="w-20 text-right">단가</th>
|
||||||
|
<th class="w-16 text-right">박스</th>
|
||||||
|
<th class="w-16 text-right">팩</th>
|
||||||
|
<th class="w-20 text-right">낱장</th>
|
||||||
|
<th class="w-24 text-right pr-2">금액</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="lp-items"></tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="font-semibold bg-gray-50">
|
||||||
|
<td class="pl-2" colspan="3">합계 (<span id="lp-line-count">0</span>품목)</td>
|
||||||
|
<td class="text-right" id="lp-sum-box">0</td>
|
||||||
|
<td class="text-right">—</td>
|
||||||
|
<td class="text-right" id="lp-sum-sheet">0</td>
|
||||||
|
<td class="text-right pr-2" id="lp-sum-amount">0</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-gray-200 px-4 py-2 flex items-center justify-end gap-2">
|
||||||
|
<button type="button" id="lot-preview-cancel" class="border border-gray-300 text-gray-600 px-3 py-1.5 rounded-sm text-sm hover:bg-gray-50">닫기</button>
|
||||||
|
<form method="post" action="<?= base_url('bag/order/lot-seed/generate') ?>" class="inline" id="lot-generate-form">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="bo_idx" id="lot-generate-bo-idx" value="" />
|
||||||
|
<button type="submit" id="lot-generate-submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm shadow hover:opacity-90 disabled:opacity-50" disabled>발주파일 생성(다운로드)</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var previewUrl = '<?= base_url('bag/order/lot-seed/preview') ?>';
|
||||||
|
var overlay = document.getElementById('lot-preview-overlay');
|
||||||
|
var elLoading = document.getElementById('lot-preview-loading');
|
||||||
|
var elError = document.getElementById('lot-preview-error');
|
||||||
|
var elBody = document.getElementById('lot-preview-body');
|
||||||
|
var elSubmit = document.getElementById('lot-generate-submit');
|
||||||
|
var elBoIdx = document.getElementById('lot-generate-bo-idx');
|
||||||
|
var nf = function (n) { return new Intl.NumberFormat('ko-KR').format(n || 0); };
|
||||||
|
var esc = function (s) { return String(s == null ? '' : s).replace(/[&<>]/g, function (c) { return ({ '&': '&', '<': '<', '>': '>' })[c]; }); };
|
||||||
|
|
||||||
|
function openModal() { overlay.classList.remove('hidden'); overlay.classList.add('flex'); }
|
||||||
|
function closeModal() { overlay.classList.add('hidden'); overlay.classList.remove('flex'); }
|
||||||
|
|
||||||
|
function showState(state) {
|
||||||
|
elLoading.classList.toggle('hidden', state !== 'loading');
|
||||||
|
elError.classList.toggle('hidden', state !== 'error');
|
||||||
|
elBody.classList.toggle('hidden', state !== 'body');
|
||||||
|
elSubmit.disabled = state !== 'body';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.lot-preview-btn').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var boIdx = btn.getAttribute('data-bo-idx');
|
||||||
|
elBoIdx.value = boIdx;
|
||||||
|
openModal();
|
||||||
|
showState('loading');
|
||||||
|
fetch(previewUrl + '?bo_idx=' + encodeURIComponent(boIdx), { credentials: 'same-origin' })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (d) {
|
||||||
|
if (!d || !d.ok) {
|
||||||
|
elError.textContent = (d && d.error) || '내용을 불러오지 못했습니다.';
|
||||||
|
showState('error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('lp-lot').textContent = d.lot_no || '';
|
||||||
|
document.getElementById('lp-date').textContent = d.order_date || '';
|
||||||
|
document.getElementById('lp-company').textContent = d.company || '-';
|
||||||
|
document.getElementById('lp-version').textContent = 'v' + (d.version || 1);
|
||||||
|
document.getElementById('lp-hash').textContent = d.order_hash || '';
|
||||||
|
var rows = (d.items || []).map(function (it) {
|
||||||
|
return '<tr>'
|
||||||
|
+ '<td class="pl-2 font-mono">' + esc(it.bag_code) + '</td>'
|
||||||
|
+ '<td>' + esc(it.bag_name) + '</td>'
|
||||||
|
+ '<td class="text-right">' + nf(it.unit_price) + '</td>'
|
||||||
|
+ '<td class="text-right">' + nf(it.qty_box) + '</td>'
|
||||||
|
+ '<td class="text-right">' + nf(it.qty_pack) + '</td>'
|
||||||
|
+ '<td class="text-right">' + nf(it.qty_sheet) + '</td>'
|
||||||
|
+ '<td class="text-right pr-2">' + nf(it.amount) + '</td>'
|
||||||
|
+ '</tr>';
|
||||||
|
}).join('');
|
||||||
|
document.getElementById('lp-items').innerHTML = rows || '<tr><td colspan="7" class="text-center text-gray-400 py-4">품목이 없습니다.</td></tr>';
|
||||||
|
var t = d.totals || {};
|
||||||
|
document.getElementById('lp-line-count').textContent = nf(t.line_count);
|
||||||
|
document.getElementById('lp-sum-box').textContent = nf(t.qty_box);
|
||||||
|
document.getElementById('lp-sum-sheet').textContent = nf(t.qty_sheet);
|
||||||
|
document.getElementById('lp-sum-amount').textContent = nf(t.amount);
|
||||||
|
showState('body');
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
elError.textContent = '통신 오류가 발생했습니다.';
|
||||||
|
showState('error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('lot-preview-close').addEventListener('click', closeModal);
|
||||||
|
document.getElementById('lot-preview-cancel').addEventListener('click', closeModal);
|
||||||
|
overlay.addEventListener('click', function (e) { if (e.target === overlay) closeModal(); });
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -18,19 +18,22 @@
|
|||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="return_to" value="bag/order/phone"/>
|
<input type="hidden" name="return_to" value="bag/order/phone"/>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 xl:grid-cols-3 gap-4">
|
<input type="hidden" name="so_delivery_date" value="<?= esc(date('Y-m-d', strtotime('+1 day'))) ?>"/>
|
||||||
<div class="xl:col-span-2 space-y-3">
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="grid grid-cols-1 xl:grid-cols-3 gap-4 items-start">
|
||||||
<label class="block text-sm font-bold text-gray-700 w-28">판매소 검색</label>
|
<!-- 좌측(좁게): 판매소 선택 → 접수정보 → 지정판매소 정보(+결제/가상계좌) -->
|
||||||
<div class="relative flex-1 min-w-[20rem]">
|
<div id="phone-left-col" class="xl:col-span-1 space-y-3">
|
||||||
<input id="shop-search" class="border border-gray-300 rounded px-3 py-1.5 text-sm w-[34rem] max-w-full" type="text" autocomplete="off" placeholder="코드/사업자번호/대표자명/상호/전화/주소 중 하나 입력"/>
|
<div>
|
||||||
<div id="shop-search-suggest" class="hidden absolute left-0 top-full mt-1 w-[48rem] max-w-[90vw] max-h-72 overflow-auto border border-gray-300 rounded-lg bg-white shadow-lg z-30"></div>
|
<label class="block text-sm font-bold text-gray-700 mb-1">판매소 검색</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input id="shop-search" class="border border-gray-300 rounded px-3 py-1.5 text-sm w-full" type="text" autocomplete="off" placeholder="코드/사업자번호/대표자명/상호/전화/주소"/>
|
||||||
|
<div id="shop-search-suggest" class="hidden absolute left-0 top-full mt-1 w-[28rem] max-w-[90vw] max-h-72 overflow-auto border border-gray-300 rounded-lg bg-white shadow-lg z-30"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div>
|
||||||
<label class="block text-sm font-bold text-gray-700 w-28">판매소 선택 <span class="text-red-500">*</span></label>
|
<label class="block text-sm font-bold text-gray-700 mb-1">판매소 선택 <span class="text-red-500">*</span></label>
|
||||||
<select id="shop-select" class="border border-gray-300 rounded px-3 py-1.5 text-sm w-[34rem] max-w-full" name="so_ds_idx" required>
|
<select id="shop-select" class="border border-gray-300 rounded px-3 py-1.5 text-sm w-full" name="so_ds_idx" required>
|
||||||
<option value="">선택</option>
|
<option value="">선택</option>
|
||||||
<?php foreach ($shops as $shop): ?>
|
<?php foreach ($shops as $shop): ?>
|
||||||
<option
|
<option
|
||||||
@@ -50,120 +53,190 @@
|
|||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="border border-gray-300 rounded-lg p-2 bg-gray-50">
|
<div class="border border-gray-300 rounded-lg p-2 bg-gray-50">
|
||||||
<div class="text-sm font-bold text-gray-700 mb-2">접수 정보</div>
|
<div class="text-sm font-bold text-gray-700 mb-2">접수 정보</div>
|
||||||
<table class="w-full text-sm">
|
<table class="w-full text-sm">
|
||||||
<tr><th class="text-left w-28 py-1">접수번호</th><td class="py-1 text-gray-800 font-semibold"><?= esc((string) ($receiptNo ?? 1)) ?></td></tr>
|
<tr><th class="text-left w-28 py-1">접수번호</th><td class="py-1 text-gray-800 font-semibold"><?= esc((string) ($receiptNo ?? 1)) ?></td></tr>
|
||||||
<tr><th class="text-left py-1">접수일</th><td class="py-1 text-gray-700"><?= esc(date('Y-m-d')) ?></td></tr>
|
<tr><th class="text-left py-1">접수일</th><td class="py-1 text-gray-700"><?= esc(date('Y-m-d')) ?></td></tr>
|
||||||
<tr><th class="text-left py-1">배달일</th><td class="py-1 text-gray-700"><?= esc(date('Y-m-d', strtotime('+1 day'))) ?> (자동)</td></tr>
|
<tr><th class="text-left py-1">배달일</th><td class="py-1 text-gray-700"><?= esc(date('Y-m-d', strtotime('+1 day'))) ?> (자동)</td></tr>
|
||||||
<tr><th class="text-left py-1">담당자</th><td class="py-1 text-gray-700"><?= esc((string) (session()->get('mb_name') ?? '담당자')) ?></td></tr>
|
<tr><th class="text-left py-1">담당자</th><td class="py-1 text-gray-700"><?= esc((string) (session()->get('mb_name') ?? '담당자')) ?></td></tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
|
||||||
<div class="border border-gray-300 rounded-lg p-2 bg-gray-50">
|
|
||||||
<div class="text-sm font-bold text-gray-700 mb-2">지정판매소 정보</div>
|
|
||||||
<table class="w-full text-sm">
|
|
||||||
<tr><th class="text-left w-28 py-1">코드</th><td id="shop-info-code" class="py-1 text-gray-700">-</td></tr>
|
|
||||||
<tr><th class="text-left py-1">사업자번호</th><td id="shop-info-biz" class="py-1 text-gray-700">-</td></tr>
|
|
||||||
<tr><th class="text-left py-1">대표자명</th><td id="shop-info-rep" class="py-1 text-gray-700">-</td></tr>
|
|
||||||
<tr><th class="text-left py-1">상호명</th><td id="shop-info-name" class="py-1 text-gray-700">-</td></tr>
|
|
||||||
<tr><th class="text-left py-1">전화번호</th><td id="shop-info-tel" class="py-1 text-gray-700">-</td></tr>
|
|
||||||
<tr><th class="text-left py-1">주소</th><td id="shop-info-addr" class="py-1 text-gray-700">-</td></tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="border border-gray-300 rounded-lg p-2 bg-gray-50">
|
|
||||||
<div class="text-sm font-bold text-gray-700 mb-2">결제/가상계좌</div>
|
|
||||||
<div class="flex flex-wrap items-center gap-2 mb-2">
|
|
||||||
<label class="block text-sm font-bold text-gray-700 w-24">결제구분 <span class="text-red-500">*</span></label>
|
|
||||||
<select id="payment-type" class="border border-gray-300 rounded px-3 py-1.5 text-sm w-40" name="so_payment_type" required>
|
|
||||||
<option value="">선택</option>
|
|
||||||
<option value="이체">이체</option>
|
|
||||||
<option value="가상계좌">가상계좌</option>
|
|
||||||
</select>
|
|
||||||
<span id="payment-guide" class="text-xs text-gray-500"></span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm">
|
|
||||||
<span class="font-semibold text-gray-700">가상계좌:</span>
|
<div class="border border-gray-300 rounded-lg p-2 bg-gray-50">
|
||||||
<span id="shop-info-va" class="text-gray-700">-</span>
|
<div class="text-sm font-bold text-gray-700 mb-2">지정판매소 정보</div>
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<tr><th class="text-left w-28 py-1">코드</th><td id="shop-info-code" class="py-1 text-gray-700">-</td></tr>
|
||||||
|
<tr><th class="text-left py-1">사업자번호</th><td id="shop-info-biz" class="py-1 text-gray-700">-</td></tr>
|
||||||
|
<tr><th class="text-left py-1">대표자명</th><td id="shop-info-rep" class="py-1 text-gray-700">-</td></tr>
|
||||||
|
<tr><th class="text-left py-1">상호명</th><td id="shop-info-name" class="py-1 text-gray-700">-</td></tr>
|
||||||
|
<tr><th class="text-left py-1">전화번호</th><td id="shop-info-tel" class="py-1 text-gray-700">-</td></tr>
|
||||||
|
<tr><th class="text-left py-1">주소</th><td id="shop-info-addr" class="py-1 text-gray-700">-</td></tr>
|
||||||
|
</table>
|
||||||
|
<div class="mt-3 pt-3 border-t border-gray-200">
|
||||||
|
<div class="text-sm font-bold text-gray-700 mb-2">결제/가상계좌</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2 mb-2">
|
||||||
|
<label class="block text-sm font-bold text-gray-700 w-24">결제구분 <span class="text-red-500">*</span></label>
|
||||||
|
<select id="payment-type" class="border border-gray-300 rounded px-3 py-1.5 text-sm w-40" name="so_payment_type" required>
|
||||||
|
<option value="">선택</option>
|
||||||
|
<option value="이체">이체</option>
|
||||||
|
<option value="가상계좌" selected>가상계좌</option>
|
||||||
|
</select>
|
||||||
|
<span id="payment-guide" class="text-xs text-gray-500"></span>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm">
|
||||||
|
<span class="font-semibold text-gray-700">가상계좌:</span>
|
||||||
|
<span id="shop-info-va" class="text-gray-700">-</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div><!-- /좌측 컬럼 -->
|
||||||
</div>
|
|
||||||
|
|
||||||
<input type="hidden" name="so_delivery_date" value="<?= esc(date('Y-m-d', strtotime('+1 day'))) ?>"/>
|
<?php
|
||||||
|
// 일괄표: 봉투코드를 카테고리(일반용·재사용·음식물 스티커·대형폐기물 스티커)로 묶어
|
||||||
<div class="mt-4">
|
// 봉투류는 리터 오름차순, 폐기물 스티커는 금액 오름차순으로 정렬한다.
|
||||||
<div class="flex items-center justify-between mb-2">
|
$catOf = static function (string $name): ?array {
|
||||||
<label class="block text-sm font-bold text-gray-700">전화 주문접수표</label>
|
if (mb_strpos($name, '일반용') === 0) { return [1, '일반용', 'liter']; }
|
||||||
<button type="button" id="add-order-row" class="border border-gray-300 bg-white px-3 py-1 rounded-sm text-xs text-gray-700 hover:bg-gray-50">행 추가</button>
|
if (mb_strpos($name, '재사용') === 0) { return [2, '재사용', 'liter']; }
|
||||||
</div>
|
if (mb_strpos($name, '음식물 스티커') === 0) { return [3, '음식물 스티커', 'liter']; }
|
||||||
<div class="border border-gray-300 rounded-lg p-4 overflow-auto">
|
if (mb_strpos($name, '폐기물 스티커') === 0) { return [4, '대형폐기물 스티커', 'amount']; }
|
||||||
<table class="w-full data-table text-sm">
|
return null;
|
||||||
|
};
|
||||||
|
$orderGroups = [];
|
||||||
|
foreach (($bagCodes ?? []) as $cd) {
|
||||||
|
$name = (string) ($cd->cd_name ?? '');
|
||||||
|
$cat = $catOf($name);
|
||||||
|
if ($cat === null) { continue; }
|
||||||
|
$code = (string) $cd->cd_code;
|
||||||
|
$price = $priceMap[$code] ?? null;
|
||||||
|
$unit = $unitMap[$code] ?? null;
|
||||||
|
if ($cat[2] === 'amount') {
|
||||||
|
preg_match('/([\d,]+)\s*원/u', $name, $m);
|
||||||
|
$sortVal = (int) str_replace(',', '', $m[1] ?? '0');
|
||||||
|
} else {
|
||||||
|
preg_match('/(\d+)\s*[lL]/u', $name, $m);
|
||||||
|
$sortVal = (int) ($m[1] ?? 0);
|
||||||
|
}
|
||||||
|
$orderGroups[$cat[0]]['label'] = $cat[1];
|
||||||
|
$orderGroups[$cat[0]]['rows'][] = [
|
||||||
|
'code' => $code,
|
||||||
|
'name' => $name,
|
||||||
|
'price' => (int) ($price->bp_consumer ?? 0),
|
||||||
|
'box' => (int) ($unit->pu_total_per_box ?? 0),
|
||||||
|
'pack' => (int) ($unit->pu_pack_per_sheet ?? 0),
|
||||||
|
'sort' => $sortVal,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
ksort($orderGroups);
|
||||||
|
foreach ($orderGroups as &$g) {
|
||||||
|
usort($g['rows'], static fn ($a, $b) => $a['sort'] <=> $b['sort']);
|
||||||
|
}
|
||||||
|
unset($g);
|
||||||
|
?>
|
||||||
|
<!-- 우측(넓게): 전화 주문접수표(일괄) — 최상단부터, 좌측 컬럼 높이에 맞춤 -->
|
||||||
|
<div id="phone-right-col" class="xl:col-span-2 flex flex-col">
|
||||||
|
<label class="block text-sm font-bold text-gray-700 mb-2">전화 주문접수표 (일괄)</label>
|
||||||
|
<style>
|
||||||
|
/* 그룹 헤더(rowspan/colspan) 경계선이 단가까지 이어지는 현상 방지 — 헤더 맨 아래 한 줄만 */
|
||||||
|
/* table-layout: fixed — 판매수량 입력 시 숫자 자릿수 변화로 열 너비가 흔들리는 현상 방지 */
|
||||||
|
table.phone-batch-table { border-collapse: separate; border-spacing: 0; table-layout: fixed; }
|
||||||
|
table.phone-batch-table thead th { border-bottom: 0; }
|
||||||
|
table.phone-batch-table thead tr:last-child th { border-bottom: 1px solid #e5e7eb; } /* 수량/금액 등 하위 헤더 */
|
||||||
|
table.phone-batch-table thead tr:first-child th[rowspan] { border-bottom: 1px solid #e5e7eb; } /* 구분·봉투종류·단가 */
|
||||||
|
table.phone-batch-table tbody td { border-bottom: 1px solid #f1f3f5; }
|
||||||
|
/* 행·열 간격 축소 — 한 화면에 전체 품목이 보이도록 (피드백 #13) */
|
||||||
|
table.phone-batch-table th, table.phone-batch-table td { padding: 0.2rem 0.35rem; }
|
||||||
|
table.phone-batch-table .item-qty-input { padding-top: 0.1rem; padding-bottom: 0.1rem; }
|
||||||
|
/* 스크롤 시 2행 그룹 헤더 + 합계 행 고정. --hdr-row1-h는 JS가 첫 헤더행 높이로 설정.
|
||||||
|
표 높이는 JS가 좌측 컬럼 높이에 맞춰 우측 컬럼에 지정하고, 래퍼(flex-1)가 그 높이를 채운다. */
|
||||||
|
table.phone-batch-table thead th { position: sticky; z-index: 3; background: #f3f4f6; }
|
||||||
|
table.phone-batch-table thead tr:first-child th { top: 0; }
|
||||||
|
table.phone-batch-table thead tr:last-child th { top: var(--hdr-row1-h, 1.6rem); }
|
||||||
|
table.phone-batch-table tfoot td { position: sticky; bottom: 0; z-index: 2; background: #f9fafb; box-shadow: inset 0 1px 0 #e5e7eb; }
|
||||||
|
</style>
|
||||||
|
<div class="phone-batch-wrap flex-1 min-h-0 border border-gray-300 rounded-lg overflow-auto">
|
||||||
|
<table class="w-full data-table text-sm phone-batch-table">
|
||||||
|
<colgroup>
|
||||||
|
<col style="width:8rem"/> <!-- 구분: 최장 라벨("대형폐기물 스티커") 기준 고정폭 -->
|
||||||
|
<col style="width:11rem"/> <!-- 봉투종류: 최장 품목명(예: "폐기물 스티커 10,000원") 기준 고정폭.
|
||||||
|
width:auto는 화면이 좁거나 텍스트 확대 시 0으로 찌부러질 수 있어 사용 안 함. -->
|
||||||
|
<col style="width:4rem"/> <!-- Pack 수량 -->
|
||||||
|
<col style="width:5.5rem"/> <!-- Pack 금액 -->
|
||||||
|
<col style="width:4rem"/> <!-- Box 수량 -->
|
||||||
|
<col style="width:5.5rem"/> <!-- Box 금액 -->
|
||||||
|
<col style="width:5rem"/> <!-- 단가 -->
|
||||||
|
<col style="width:6rem"/> <!-- 판매수량 -->
|
||||||
|
<col style="width:6rem"/> <!-- 금액 -->
|
||||||
|
<col style="width:3rem"/> <!-- B -->
|
||||||
|
<col style="width:3rem"/> <!-- P -->
|
||||||
|
<col style="width:3rem"/> <!-- E -->
|
||||||
|
</colgroup>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="w-14 text-center">구분</th>
|
<th class="text-center" rowspan="2">구분</th>
|
||||||
<th class="w-56">품목</th>
|
<th rowspan="2">봉투종류</th>
|
||||||
<th class="w-40 text-right">1박스(낱장/판매가)</th>
|
<th class="text-center" colspan="2">Pack</th>
|
||||||
<th class="w-40 text-right">1팩(낱장/판매가)</th>
|
<th class="text-center" colspan="2">Box</th>
|
||||||
<th class="w-24 text-right">단가</th>
|
<th class="text-right" rowspan="2">단가</th>
|
||||||
<th class="w-28 text-right">주문수량</th>
|
<th class="text-center" colspan="2">주문내용</th>
|
||||||
<th class="w-28 text-right">금액</th>
|
<th class="text-center" colspan="3">포장</th>
|
||||||
<th class="w-44 text-right">포장(박스/팩/낱장)</th>
|
</tr>
|
||||||
<th class="w-20 text-center">삭제</th>
|
<tr>
|
||||||
|
<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>
|
||||||
|
<th class="text-center">E</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="order-rows">
|
<tbody id="order-rows">
|
||||||
<?php for ($i = 0; $i < 3; $i++): ?>
|
<?php if (empty($orderGroups)): ?>
|
||||||
<tr class="order-row">
|
<tr><td colspan="12" class="text-center text-gray-400 py-6">등록된 봉투 품목이 없습니다.</td></tr>
|
||||||
<td class="text-center item-kind-cell">-</td>
|
<?php else: ?>
|
||||||
<td>
|
<?php foreach ($orderGroups as $g): ?>
|
||||||
<select class="border border-gray-300 rounded px-2 py-1 text-sm w-full bag-code-select" name="item_bag_code[]">
|
<?php $rc = count($g['rows']); $firstRow = true; ?>
|
||||||
<option value="">선택</option>
|
<?php foreach ($g['rows'] as $r): ?>
|
||||||
<?php foreach ($bagCodes as $cd): ?>
|
<?php $boxPrice = $r['box'] * $r['price']; $packPrice = $r['pack'] * $r['price']; ?>
|
||||||
<?php
|
<tr class="order-row" data-unit-price="<?= esc((string) $r['price'], 'attr') ?>" data-box-sheets="<?= esc((string) $r['box'], 'attr') ?>" data-pack-sheets="<?= esc((string) $r['pack'], 'attr') ?>">
|
||||||
$code = (string) $cd->cd_code;
|
<?php if ($firstRow): ?>
|
||||||
$name = (string) ($cd->cd_name ?? '');
|
<td class="text-center align-top font-semibold text-gray-700" rowspan="<?= (int) $rc ?>"><?= esc($g['label']) ?></td>
|
||||||
$price = $priceMap[$code] ?? null;
|
<?php endif; ?>
|
||||||
$unit = $unitMap[$code] ?? null;
|
<td class="text-left pl-2"><?= esc($r['name']) ?><input type="hidden" name="item_bag_code[]" value="<?= esc($r['code'], 'attr') ?>"/></td>
|
||||||
$unitPrice = (int) ($price->bp_consumer ?? 0);
|
<td class="text-right px-2"><?= number_format($r['pack']) ?></td>
|
||||||
$boxSheets = (int) ($unit->pu_total_per_box ?? 0);
|
<td class="text-right px-2"><?= number_format($packPrice) ?></td>
|
||||||
$packSheets = (int) ($unit->pu_pack_per_sheet ?? 0);
|
<td class="text-right px-2"><?= number_format($r['box']) ?></td>
|
||||||
$kindLabel = (mb_strpos($name, '스티커') !== false) ? '스티커' : '봉투';
|
<td class="text-right px-2"><?= number_format($boxPrice) ?></td>
|
||||||
?>
|
<td class="text-right px-2"><?= number_format($r['price']) ?></td>
|
||||||
<option value="<?= esc($code) ?>" data-name="<?= esc($name, 'attr') ?>" data-kind-label="<?= esc($kindLabel, 'attr') ?>" data-unit-price="<?= esc((string) $unitPrice, 'attr') ?>" data-box-sheets="<?= esc((string) $boxSheets, 'attr') ?>" data-pack-sheets="<?= esc((string) $packSheets, 'attr') ?>">
|
<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>
|
||||||
<?= esc($code) ?> — <?= esc($name) ?>
|
<td class="text-right px-2 item-amount-cell">0</td>
|
||||||
</option>
|
<td class="text-center item-box-cell">0</td>
|
||||||
<?php endforeach; ?>
|
<td class="text-center item-pack-cell">0</td>
|
||||||
</select>
|
<td class="text-center item-sheet-cell">0</td>
|
||||||
</td>
|
</tr>
|
||||||
<td class="text-right px-2 box-info-cell">0 / 0</td>
|
<?php $firstRow = false; ?>
|
||||||
<td class="text-right px-2 pack-info-cell">0 / 0</td>
|
<?php endforeach; ?>
|
||||||
<td class="text-right px-2 unit-price-cell">0</td>
|
<?php endforeach; ?>
|
||||||
<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>
|
<?php endif; ?>
|
||||||
<td class="text-right px-2 item-amount-cell">0</td>
|
|
||||||
<td class="text-right px-2 pack-result-cell">박스=0, 팩=0, 낱장=0</td>
|
|
||||||
<td class="text-center px-2">
|
|
||||||
<button type="button" class="remove-order-row border border-red-300 text-red-600 px-2 py-0.5 rounded text-xs hover:bg-red-50">삭제</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<?php endfor; ?>
|
|
||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr class="font-semibold bg-gray-50">
|
<tr class="font-semibold bg-gray-50">
|
||||||
<td colspan="5" class="text-right px-2 py-1">합계</td>
|
<td colspan="7" class="text-right px-2 py-1">합계</td>
|
||||||
<td class="text-right px-2 py-1" id="sum-qty">0</td>
|
<td class="text-right px-2 py-1" id="sum-qty">0</td>
|
||||||
<td class="text-right px-2 py-1" id="sum-amount">0</td>
|
<td class="text-right px-2 py-1" id="sum-amount">0</td>
|
||||||
<td class="text-right px-2 py-1" id="sum-pack">박스=0, 팩=0, 낱장=0</td>
|
<td class="text-center px-2 py-1" id="sum-box">0</td>
|
||||||
<td></td>
|
<td class="text-center px-2 py-1" id="sum-pack">0</td>
|
||||||
|
<td class="text-center px-2 py-1" id="sum-sheet">0</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div><!-- /우측 컬럼 -->
|
||||||
|
</div><!-- /2열 그리드 -->
|
||||||
|
|
||||||
<div class="flex gap-2 pt-2">
|
<div class="flex gap-2 pt-2">
|
||||||
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">저장</button>
|
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">저장</button>
|
||||||
@@ -172,40 +245,6 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template id="order-row-template">
|
|
||||||
<tr class="order-row">
|
|
||||||
<td class="text-center item-kind-cell">-</td>
|
|
||||||
<td>
|
|
||||||
<select class="border border-gray-300 rounded px-2 py-1 text-sm w-full bag-code-select" name="item_bag_code[]">
|
|
||||||
<option value="">선택</option>
|
|
||||||
<?php foreach ($bagCodes as $cd): ?>
|
|
||||||
<?php
|
|
||||||
$code = (string) $cd->cd_code;
|
|
||||||
$name = (string) ($cd->cd_name ?? '');
|
|
||||||
$price = $priceMap[$code] ?? null;
|
|
||||||
$unit = $unitMap[$code] ?? null;
|
|
||||||
$unitPrice = (int) ($price->bp_consumer ?? 0);
|
|
||||||
$boxSheets = (int) ($unit->pu_total_per_box ?? 0);
|
|
||||||
$packSheets = (int) ($unit->pu_pack_per_sheet ?? 0);
|
|
||||||
$kindLabel = (mb_strpos($name, '스티커') !== false) ? '스티커' : '봉투';
|
|
||||||
?>
|
|
||||||
<option value="<?= esc($code) ?>" data-name="<?= esc($name, 'attr') ?>" data-kind-label="<?= esc($kindLabel, 'attr') ?>" data-unit-price="<?= esc((string) $unitPrice, 'attr') ?>" data-box-sheets="<?= esc((string) $boxSheets, 'attr') ?>" data-pack-sheets="<?= esc((string) $packSheets, 'attr') ?>">
|
|
||||||
<?= esc($code) ?> — <?= esc($name) ?>
|
|
||||||
</option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td class="text-right px-2 box-info-cell">0 / 0</td>
|
|
||||||
<td class="text-right px-2 pack-info-cell">0 / 0</td>
|
|
||||||
<td class="text-right px-2 unit-price-cell">0</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 class="text-right px-2 item-amount-cell">0</td>
|
|
||||||
<td class="text-right px-2 pack-result-cell">박스=0, 팩=0, 낱장=0</td>
|
|
||||||
<td class="text-center px-2">
|
|
||||||
<button type="button" class="remove-order-row border border-red-300 text-red-600 px-2 py-0.5 rounded text-xs hover:bg-red-50">삭제</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(() => {
|
(() => {
|
||||||
@@ -214,9 +253,7 @@
|
|||||||
const shopSuggest = document.getElementById('shop-search-suggest');
|
const shopSuggest = document.getElementById('shop-search-suggest');
|
||||||
const paymentType = document.getElementById('payment-type');
|
const paymentType = document.getElementById('payment-type');
|
||||||
const paymentGuide = document.getElementById('payment-guide');
|
const paymentGuide = document.getElementById('payment-guide');
|
||||||
const addRowButton = document.getElementById('add-order-row');
|
|
||||||
const orderRows = document.getElementById('order-rows');
|
const orderRows = document.getElementById('order-rows');
|
||||||
const rowTemplate = document.getElementById('order-row-template');
|
|
||||||
const form = document.getElementById('phone-order-form');
|
const form = document.getElementById('phone-order-form');
|
||||||
|
|
||||||
const nf = (n) => new Intl.NumberFormat('ko-KR').format(n || 0);
|
const nf = (n) => new Intl.NumberFormat('ko-KR').format(n || 0);
|
||||||
@@ -293,15 +330,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function calcRow(row) {
|
function calcRow(row) {
|
||||||
const select = row.querySelector('.bag-code-select');
|
const qtyInput = row.querySelector('.item-pack-input');
|
||||||
const qtyInput = row.querySelector('.item-qty-input');
|
const hiddenQty = row.querySelector('.item-qty-hidden');
|
||||||
const selected = select.options[select.selectedIndex];
|
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;
|
||||||
|
|
||||||
const qty = parseInt(qtyInput.value || '0', 10) || 0;
|
// 팩당 낱장 수(팩 매수 미설정 봉투는 1:1로 처리)
|
||||||
const unitPrice = parseInt(selected?.dataset?.unitPrice || '0', 10) || 0;
|
const mult = packSheets > 0 ? packSheets : 1;
|
||||||
const boxSheets = parseInt(selected?.dataset?.boxSheets || '0', 10) || 0;
|
const qty = packQty * mult; // 실제 낱장 수 → 서버로 전송
|
||||||
const packSheets = parseInt(selected?.dataset?.packSheets || '0', 10) || 0;
|
if (hiddenQty) hiddenQty.value = String(qty);
|
||||||
const kindLabel = selected?.dataset?.kindLabel || '-';
|
|
||||||
|
|
||||||
let box = 0;
|
let box = 0;
|
||||||
let pack = 0;
|
let pack = 0;
|
||||||
@@ -321,24 +360,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const amount = unitPrice * qty;
|
const amount = unitPrice * qty;
|
||||||
const boxPrice = boxSheets * unitPrice;
|
|
||||||
const packPrice = packSheets * unitPrice;
|
|
||||||
|
|
||||||
row.querySelector('.item-kind-cell').textContent = kindLabel;
|
|
||||||
row.querySelector('.box-info-cell').textContent = nf(boxSheets) + ' / ' + nf(boxPrice);
|
|
||||||
row.querySelector('.pack-info-cell').textContent = nf(packSheets) + ' / ' + nf(packPrice);
|
|
||||||
row.querySelector('.unit-price-cell').textContent = nf(unitPrice);
|
|
||||||
row.querySelector('.item-amount-cell').textContent = nf(amount);
|
row.querySelector('.item-amount-cell').textContent = nf(amount);
|
||||||
row.querySelector('.pack-result-cell').textContent = '박스=' + nf(box) + ', 팩=' + nf(pack) + ', 낱장=' + nf(sheet);
|
row.querySelector('.item-box-cell').textContent = box ? nf(box) : '';
|
||||||
|
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() {
|
function recalcAllRows() {
|
||||||
let sumQty = 0, sumAmount = 0, sumBox = 0, sumPack = 0, sumSheet = 0;
|
let sumQty = 0, sumAmount = 0, sumBox = 0, sumPack = 0, sumSheet = 0;
|
||||||
document.querySelectorAll('.order-row').forEach((row) => {
|
document.querySelectorAll('.order-row').forEach((row) => {
|
||||||
const r = calcRow(row);
|
const r = calcRow(row);
|
||||||
sumQty += r.qty;
|
sumQty += r.packQty;
|
||||||
sumAmount += r.amount;
|
sumAmount += r.amount;
|
||||||
sumBox += r.box;
|
sumBox += r.box;
|
||||||
sumPack += r.pack;
|
sumPack += r.pack;
|
||||||
@@ -346,7 +380,9 @@
|
|||||||
});
|
});
|
||||||
document.getElementById('sum-qty').textContent = nf(sumQty);
|
document.getElementById('sum-qty').textContent = nf(sumQty);
|
||||||
document.getElementById('sum-amount').textContent = nf(sumAmount);
|
document.getElementById('sum-amount').textContent = nf(sumAmount);
|
||||||
document.getElementById('sum-pack').textContent = '박스=' + nf(sumBox) + ', 팩=' + nf(sumPack) + ', 낱장=' + nf(sumSheet);
|
document.getElementById('sum-box').textContent = nf(sumBox);
|
||||||
|
document.getElementById('sum-pack').textContent = nf(sumPack);
|
||||||
|
document.getElementById('sum-sheet').textContent = nf(sumSheet);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearSearchInputOnly() {
|
function clearSearchInputOnly() {
|
||||||
@@ -394,51 +430,66 @@
|
|||||||
shopSelect?.addEventListener('change', updateShopInfo);
|
shopSelect?.addEventListener('change', updateShopInfo);
|
||||||
paymentType?.addEventListener('change', updateShopInfo);
|
paymentType?.addEventListener('change', updateShopInfo);
|
||||||
|
|
||||||
orderRows?.addEventListener('change', function (e) {
|
|
||||||
if (e.target.closest('.bag-code-select') || e.target.closest('.item-qty-input')) {
|
|
||||||
recalcAllRows();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
orderRows?.addEventListener('input', function (e) {
|
orderRows?.addEventListener('input', function (e) {
|
||||||
if (e.target.closest('.item-qty-input')) {
|
if (e.target.closest('.item-qty-input')) {
|
||||||
recalcAllRows();
|
recalcAllRows();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
orderRows?.addEventListener('click', function (e) {
|
|
||||||
const removeButton = e.target.closest('.remove-order-row');
|
|
||||||
if (!removeButton) return;
|
|
||||||
const row = removeButton.closest('.order-row');
|
|
||||||
if (!row) return;
|
|
||||||
if (orderRows.querySelectorAll('.order-row').length <= 1) {
|
|
||||||
alert('최소 1개 행은 유지해야 합니다.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
row.remove();
|
|
||||||
recalcAllRows();
|
|
||||||
});
|
|
||||||
|
|
||||||
addRowButton?.addEventListener('click', function () {
|
// 판매수량 입력 중 엔터키 → 다음 봉투 종류의 수량 입력칸으로 이동
|
||||||
if (!rowTemplate || !orderRows) return;
|
orderRows?.addEventListener('keydown', function (e) {
|
||||||
const fragment = rowTemplate.content.cloneNode(true);
|
if (e.key !== 'Enter' || !e.target.classList.contains('item-qty-input')) return;
|
||||||
orderRows.appendChild(fragment);
|
e.preventDefault();
|
||||||
recalcAllRows();
|
const inputs = Array.from(orderRows.querySelectorAll('.item-qty-input'));
|
||||||
|
const next = inputs[inputs.indexOf(e.target) + 1];
|
||||||
|
if (next) { next.focus(); next.select(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
form?.addEventListener('submit', function (e) {
|
form?.addEventListener('submit', function (e) {
|
||||||
let hasItem = false;
|
let hasItem = false;
|
||||||
document.querySelectorAll('.order-row').forEach((row) => {
|
document.querySelectorAll('.order-row').forEach((row) => {
|
||||||
const code = row.querySelector('.bag-code-select').value;
|
|
||||||
const qty = parseInt(row.querySelector('.item-qty-input').value || '0', 10) || 0;
|
const qty = parseInt(row.querySelector('.item-qty-input').value || '0', 10) || 0;
|
||||||
if (code && qty > 0) hasItem = true;
|
if (qty > 0) hasItem = true;
|
||||||
});
|
});
|
||||||
if (!hasItem) {
|
if (!hasItem) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
alert('주문 품목과 수량을 1개 이상 입력해 주세요.');
|
alert('주문 수량을 1개 이상 입력해 주세요.');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
updateShopInfo();
|
updateShopInfo();
|
||||||
recalcAllRows();
|
recalcAllRows();
|
||||||
|
|
||||||
|
// 스크롤 시 두 번째 헤더행이 첫 번째 헤더행 바로 아래에 붙도록 오프셋 계산.
|
||||||
|
// offsetHeight는 화면 zoom(텍스트 크기)과 무관한 레이아웃 px라 배율이 바뀌어도 정확하다.
|
||||||
|
(function () {
|
||||||
|
const tbl = document.querySelector('table.phone-batch-table');
|
||||||
|
if (!tbl) return;
|
||||||
|
const r1 = tbl.querySelector('thead tr:first-child');
|
||||||
|
function setHeaderOffset() {
|
||||||
|
if (r1) tbl.style.setProperty('--hdr-row1-h', r1.offsetHeight + 'px');
|
||||||
|
}
|
||||||
|
setHeaderOffset();
|
||||||
|
window.addEventListener('resize', setHeaderOffset);
|
||||||
|
window.addEventListener('storage', function (e) { if (e.key === 'jrj_font_scale') setHeaderOffset(); });
|
||||||
|
})();
|
||||||
|
|
||||||
|
// 전화 주문접수표(우측 컬럼) 높이를 좌측 컬럼(지정판매소 정보 하단까지)에 맞춘다.
|
||||||
|
// offsetHeight/offsetTop은 zoom과 무관한 레이아웃 px라 배율이 바뀌어도 정확하다.
|
||||||
|
(function () {
|
||||||
|
const leftCol = document.getElementById('phone-left-col');
|
||||||
|
const rightCol = document.getElementById('phone-right-col');
|
||||||
|
if (!leftCol || !rightCol) return;
|
||||||
|
function syncTableHeight() {
|
||||||
|
// 2열(나란히)일 때만 높이 맞춤. 1열로 쌓이면 자동 높이로 둔다.
|
||||||
|
const sideBySide = Math.abs(leftCol.offsetTop - rightCol.offsetTop) < 4;
|
||||||
|
rightCol.style.height = sideBySide ? (leftCol.offsetHeight + 'px') : '';
|
||||||
|
}
|
||||||
|
syncTableHeight();
|
||||||
|
window.addEventListener('resize', syncTableHeight);
|
||||||
|
window.addEventListener('storage', function (e) { if (e.key === 'jrj_font_scale') syncTableHeight(); });
|
||||||
|
if ('ResizeObserver' in window) { new ResizeObserver(syncTableHeight).observe(leftCol); }
|
||||||
|
})();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,84 +1,110 @@
|
|||||||
|
<?php $startDate = (string) ($startDate ?? ''); $endDate = (string) ($endDate ?? ''); ?>
|
||||||
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
<span class="text-sm font-bold text-gray-700">전화 주문 접수 관리</span>
|
<span class="text-sm font-bold text-gray-700">주문접수 관리</span>
|
||||||
<a href="<?= base_url('bag/order/phone') ?>" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm shadow hover:opacity-90">전화 주문 접수</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
<form method="get" action="<?= base_url('bag/order/phone/manage') ?>" class="mt-2 flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
<label class="font-bold text-gray-700">접수일</label>
|
||||||
|
<input type="date" lang="ko" name="start_date" value="<?= esc($startDate, 'attr') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
|
||||||
|
<span class="text-gray-400">~</span>
|
||||||
|
<input type="date" lang="ko" name="end_date" value="<?= esc($endDate, 'attr') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
|
||||||
|
<button type="submit" class="bg-btn-search text-white px-3 py-1 rounded-sm text-sm">조회</button>
|
||||||
|
<?php if ($startDate !== '' || $endDate !== ''): ?>
|
||||||
|
<a href="<?= base_url('bag/order/phone/manage') ?>" class="border border-gray-300 text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">초기화</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 xl:grid-cols-5 gap-3 mt-2">
|
<div id="order-split" class="flex flex-col xl:flex-row gap-3 mt-2 items-stretch">
|
||||||
<section class="xl:col-span-2 border border-gray-300 rounded-lg bg-white overflow-hidden">
|
<section id="list-card" class="border border-gray-300 rounded-lg bg-white overflow-hidden flex flex-col min-w-0 xl:shrink-0">
|
||||||
<div class="px-3 py-2 border-b border-gray-200 bg-gray-50 text-sm font-semibold text-gray-700">접수 리스트(전화)</div>
|
<div class="px-3 py-2 border-b border-gray-200 bg-gray-50 text-sm font-semibold text-gray-700">접수 리스트(전화)</div>
|
||||||
<div class="max-h-[72vh] overflow-auto">
|
<div id="list-scroll" class="overflow-auto">
|
||||||
<table class="w-full data-table text-sm">
|
<table class="w-full data-table text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="w-14 text-center">번호</th>
|
<th class="w-12 text-center sort-th cursor-pointer select-none" data-sort="so_idx" data-type="num">번호<span class="sort-ind"></span></th>
|
||||||
<th class="text-left">판매소</th>
|
<th class="w-24 text-center sort-th cursor-pointer select-none" data-sort="so_order_date" data-type="str">접수일<span class="sort-ind"></span></th>
|
||||||
<th class="w-28 text-center">접수일</th>
|
<th class="w-24 text-center sort-th cursor-pointer select-none" data-sort="so_delivery_date" data-type="str">배달일<span class="sort-ind"></span></th>
|
||||||
<th class="w-24 text-center">상태</th>
|
<th class="w-14 text-center sort-th cursor-pointer select-none" data-sort="so_channel" data-type="str">구분<span class="sort-ind"></span></th>
|
||||||
|
<th class="text-left sort-th cursor-pointer select-none" data-sort="so_ds_name" data-type="str">판매소<span class="sort-ind"></span></th>
|
||||||
|
<th class="w-16 text-center sort-th cursor-pointer select-none" data-sort="so_payment_type" data-type="str">결제<span class="sort-ind"></span></th>
|
||||||
|
<th class="w-14 text-center sort-th cursor-pointer select-none" data-sort="so_received" data-type="num">포장<span class="sort-ind"></span></th>
|
||||||
|
<th class="w-14 text-center sort-th cursor-pointer select-none" data-sort="so_paid" data-type="num">입금<span class="sort-ind"></span></th>
|
||||||
|
<th class="w-24 text-right sort-th cursor-pointer select-none" data-sort="so_total_amount" data-type="num">총금액<span class="sort-ind"></span></th>
|
||||||
|
<th class="w-14 text-center sort-th cursor-pointer select-none" data-sort="so_status" data-type="str">취소<span class="sort-ind"></span></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="order-list-body">
|
<tbody id="order-list-body"></tbody>
|
||||||
<?php foreach (($orders ?? []) as $row): ?>
|
|
||||||
<?php $isCancelled = (($row['so_status'] ?? 'normal') === 'cancelled'); ?>
|
|
||||||
<tr class="order-list-row cursor-pointer hover:bg-blue-50 <?= $isCancelled ? 'bg-gray-50 text-gray-400' : '' ?>" data-order-id="<?= esc((string) ($row['so_idx'] ?? 0), 'attr') ?>">
|
|
||||||
<td class="text-center"><?= esc((string) ($row['so_idx'] ?? 0)) ?></td>
|
|
||||||
<td class="text-left pl-2"><?= esc((string) ($row['so_ds_name'] ?? '')) ?></td>
|
|
||||||
<td class="text-center"><?= esc((string) ($row['so_order_date'] ?? '')) ?></td>
|
|
||||||
<td class="text-center"><?= $isCancelled ? '취소' : '정상' ?></td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
<?php if (empty($orders)): ?>
|
|
||||||
<tr>
|
|
||||||
<td colspan="4" class="text-center py-8 text-gray-400">전화 주문 데이터가 없습니다.</td>
|
|
||||||
</tr>
|
|
||||||
<?php endif; ?>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="list-summary" class="px-3 py-2 border-t border-gray-200 bg-gray-50 text-sm font-semibold text-gray-700 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span>건수 <span id="list-sum-count" class="text-blue-700">0</span>건</span>
|
||||||
|
<span>금액 <span id="list-sum-amount" class="text-blue-700">0</span>원</span>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="xl:col-span-3 border border-gray-300 rounded-lg bg-white overflow-hidden">
|
<!-- 드래그 가능한 너비 조절 손잡이 (xl 이상 좌우 배치일 때만 표시) -->
|
||||||
<div class="px-3 py-2 border-b border-gray-200 bg-gray-50 text-sm font-semibold text-gray-700">상세 정보</div>
|
<div id="split-handle" class="hidden xl:flex items-center justify-center cursor-col-resize select-none shrink-0" style="width:8px;touch-action:none;" role="separator" aria-orientation="vertical" title="드래그하여 표 너비를 조절합니다">
|
||||||
|
<div class="w-1.5 h-16 rounded-full bg-gray-300 hover:bg-blue-400 transition-colors"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section id="detail-card" class="border border-gray-300 rounded-lg bg-white overflow-hidden min-w-0 xl:flex-1">
|
||||||
|
<div class="px-3 py-2 border-b border-gray-200 bg-gray-50 text-sm font-semibold text-gray-700">지정판매소 정보</div>
|
||||||
|
|
||||||
<form id="order-detail-form" action="<?= base_url('bag/order/phone/manage/update') ?>" method="POST" class="p-3 space-y-3">
|
<form id="order-detail-form" action="<?= base_url('bag/order/phone/manage/update') ?>" method="POST" class="p-3 space-y-3">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="so_idx" id="detail-so-idx" value=""/>
|
<input type="hidden" name="so_idx" id="detail-so-idx" value=""/>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm">
|
<div class="border border-gray-200 rounded-lg p-3 bg-gray-50 text-sm">
|
||||||
<div class="border border-gray-200 rounded-lg p-2 bg-gray-50">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-1">
|
||||||
<div><span class="font-semibold text-gray-700">접수번호:</span> <span id="detail-so-no">-</span></div>
|
<div><span class="font-semibold text-gray-700 inline-block w-20">접수번호</span> <span id="detail-so-no">-</span></div>
|
||||||
<div><span class="font-semibold text-gray-700">판매소:</span> <span id="detail-shop-name">-</span></div>
|
<div><span class="font-semibold text-gray-700 inline-block w-20">판매소코드</span> <span id="detail-shop-code">-</span></div>
|
||||||
<div><span class="font-semibold text-gray-700">결제구분:</span> <span id="detail-payment">-</span></div>
|
<div><span class="font-semibold text-gray-700 inline-block w-20">판매소명</span> <span id="detail-shop-name">-</span></div>
|
||||||
</div>
|
<div><span class="font-semibold text-gray-700 inline-block w-20">담당자</span> <span id="detail-shop-rep">-</span></div>
|
||||||
<div class="border border-gray-200 rounded-lg p-2 bg-gray-50">
|
<div><span class="font-semibold text-gray-700 inline-block w-20">전화번호</span> <span id="detail-shop-tel">-</span></div>
|
||||||
<div><span class="font-semibold text-gray-700">접수일:</span> <span id="detail-order-date">-</span></div>
|
<div><span class="font-semibold text-gray-700 inline-block w-20">결제수단</span> <span id="detail-payment">-</span></div>
|
||||||
<div><span class="font-semibold text-gray-700">배달일:</span> <span id="detail-delivery-date">-</span></div>
|
<div class="md:col-span-2"><span class="font-semibold text-gray-700 inline-block w-20">주소</span> <span id="detail-shop-addr">-</span></div>
|
||||||
<div><span class="font-semibold text-gray-700">상태:</span> <span id="detail-status">-</span></div>
|
<div><span class="font-semibold text-gray-700 inline-block w-20">접수일</span> <span id="detail-order-date">-</span></div>
|
||||||
|
<div class="flex items-center"><span class="font-semibold text-gray-700 inline-block w-20">배달일</span>
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
<div class="border border-gray-300 rounded-lg p-4 overflow-auto">
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="border border-gray-300 rounded-lg overflow-auto max-h-[179px]">
|
||||||
<table class="w-full data-table text-sm">
|
<table class="w-full data-table text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="w-14 text-center">번호</th>
|
<th class="w-14 text-center sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">번호</th>
|
||||||
<th class="text-left">품목</th>
|
<th class="text-left sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">품목</th>
|
||||||
<th class="w-24 text-right">단가</th>
|
<th class="w-24 text-right sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">단가</th>
|
||||||
<th class="w-24 text-right">접수량</th>
|
<th class="w-24 text-right sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">접수량</th>
|
||||||
<th class="w-28 text-right">접수금액</th>
|
<th class="w-24 text-right sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">포장량</th>
|
||||||
<th class="w-40 text-right">포장단위(박스/팩/낱장)</th>
|
<th class="w-28 text-right sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">접수금액</th>
|
||||||
|
<th class="w-40 text-right sticky top-0 z-10" style="background:#fff;box-shadow:inset 0 -1px 0 #e5e7eb;">포장단위(박스/팩/낱장)</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="detail-items-body">
|
<tbody id="detail-items-body">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="6" class="text-center py-6 text-gray-400">왼쪽 리스트에서 주문을 선택해 주세요.</td>
|
<td colspan="7" class="text-center py-6 text-gray-400">왼쪽 리스트에서 주문을 선택해 주세요.</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr class="font-semibold bg-gray-50">
|
<tr class="font-semibold bg-gray-50">
|
||||||
<td colspan="3" class="text-right px-2 py-1">합계</td>
|
<td colspan="3" class="text-right px-2 py-1">합계</td>
|
||||||
<td class="text-right px-2 py-1" id="detail-sum-qty">0</td>
|
<td class="text-right px-2 py-1" id="detail-sum-qty">0</td>
|
||||||
|
<td class="text-right px-2 py-1" id="detail-sum-packed">0</td>
|
||||||
<td class="text-right px-2 py-1" id="detail-sum-amount">0</td>
|
<td class="text-right px-2 py-1" id="detail-sum-amount">0</td>
|
||||||
<td class="text-right px-2 py-1" id="detail-sum-pack">박스=0, 팩=0, 낱장=0</td>
|
<td class="text-right px-2 py-1" id="detail-sum-pack">박스=0, 팩=0, 낱장=0</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -86,22 +112,125 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-2">
|
<!-- 봉투코드 스캔 → 즉시 판매·포장 처리 (선택된 주문의 판매소로 판매) -->
|
||||||
<button type="submit" id="btn-save" class="bg-btn-search text-white px-5 py-1.5 rounded-sm text-sm shadow hover:opacity-90" disabled>주문 수정 저장</button>
|
<div class="mt-3 border border-gray-300 rounded-lg p-2 bg-gray-50">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="text-sm font-semibold text-gray-700 whitespace-nowrap"><i class="fa-solid fa-barcode text-emerald-600 mr-1"></i>봉투코드 스캔</span>
|
||||||
|
<input type="text" id="scan-input" autocomplete="off" placeholder="봉투 바코드를 스캔/입력 후 Enter" class="border border-gray-300 rounded px-2 py-1 text-sm flex-1 min-w-[12rem]" disabled/>
|
||||||
|
<span id="scan-msg" class="text-xs text-gray-500"></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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="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 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 class="border border-gray-300 rounded-lg overflow-hidden">
|
||||||
|
<div class="px-2 py-1 bg-amber-50 border-b border-amber-200 text-xs font-semibold text-amber-800">앞으로 찍을 목록 (남은 수량)</div>
|
||||||
|
<div id="remaining-list" class="p-2 text-xs text-gray-400 max-h-[9rem] overflow-auto min-h-[3.5rem]">주문을 선택해 주세요.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div><!-- /스캔 현황 -->
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<form id="order-cancel-form" method="POST" class="px-3 pb-3">
|
<!-- 주문 취소 폼 — 버튼은 접수 품목 내역 타이틀 옆(form 속성으로 연결) -->
|
||||||
|
<form id="order-cancel-form" method="POST" class="hidden">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<button type="submit" id="btn-cancel-order" class="border border-red-300 text-red-600 px-5 py-1.5 rounded-sm text-sm hover:bg-red-50" disabled>주문 취소</button>
|
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 포장 명세(전체 봉투코드) 팝업 -->
|
||||||
|
<div id="packing-modal-overlay" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/40 p-4">
|
||||||
|
<div class="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[85vh] flex flex-col">
|
||||||
|
<div class="flex items-center justify-between border-b border-gray-200 px-4 py-2">
|
||||||
|
<span class="text-sm font-bold text-gray-800">포장 명세 — <span id="packing-modal-title">-</span></span>
|
||||||
|
<button type="button" id="packing-modal-close" class="text-gray-400 hover:text-gray-700 text-lg leading-none">×</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">
|
||||||
|
<strong class="text-amber-950">[개발용 임시]</strong> 선택 주문의 판매소 기준 판매 테스트용 바코드 후보. <strong>바코드를 클릭하면 봉투코드 스캔칸에 입력</strong>됩니다. 개발 완료 후 이 블록과 API를 제거하세요.
|
||||||
|
</p>
|
||||||
|
<div class="max-h-52 overflow-auto border border-amber-300 bg-white">
|
||||||
|
<table class="w-full data-table text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="w-28 text-left">출처</th>
|
||||||
|
<th class="w-44 text-center">바코드(대표)</th>
|
||||||
|
<th class="text-left">봉투 종류</th>
|
||||||
|
<th class="w-20 text-center">포장</th>
|
||||||
|
<th class="w-12 text-right">수량</th>
|
||||||
|
<th class="w-14 text-center">상태</th>
|
||||||
|
<th class="text-left">비고(낱장범위 등)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="dev-saleable-tbody">
|
||||||
|
<tr><td colspan="7" class="text-center text-gray-400 py-4">주문을 선택하면 목록이 표시됩니다.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(() => {
|
(() => {
|
||||||
const orders = <?= json_encode($orders ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
|
const orders = <?= json_encode($orders ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
|
||||||
const orderMap = new Map(orders.map((o) => [String(o.so_idx), o]));
|
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/다른 호스트 접근에도 안전
|
||||||
|
const scanApi = new URL('<?= base_url('bag/order/phone/manage/scan') ?>', window.location.href).pathname;
|
||||||
|
// [개발용 임시] 판매 가능 바코드 후보 API (지정판매소 판매의 dev API 재사용)
|
||||||
|
const devSaleableApi = new URL('<?= base_url('bag/sale/designated/dev-saleable-barcodes') ?>', window.location.href).pathname;
|
||||||
|
const csrfName = '<?= csrf_token() ?>';
|
||||||
|
let csrfHash = '<?= csrf_hash() ?>';
|
||||||
|
|
||||||
const listBody = document.getElementById('order-list-body');
|
const listBody = document.getElementById('order-list-body');
|
||||||
const form = document.getElementById('order-detail-form');
|
const form = document.getElementById('order-detail-form');
|
||||||
@@ -110,16 +239,87 @@
|
|||||||
const inputSoIdx = document.getElementById('detail-so-idx');
|
const inputSoIdx = document.getElementById('detail-so-idx');
|
||||||
const btnSave = document.getElementById('btn-save');
|
const btnSave = document.getElementById('btn-save');
|
||||||
const btnCancel = document.getElementById('btn-cancel-order');
|
const btnCancel = document.getElementById('btn-cancel-order');
|
||||||
|
const btnReceipt = document.getElementById('btn-receipt');
|
||||||
|
const 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;
|
||||||
|
if (!id) return;
|
||||||
|
const o = orderMap.get(String(id));
|
||||||
|
// 이미 포장(수령)이 완료된 건의 영수증 재발행 시 안내
|
||||||
|
if (o && Number(o.so_received) === 1) {
|
||||||
|
if (!confirm('포장이 완료된 건입니다.\n그래도 영수증을 다시 출력할까요?')) return;
|
||||||
|
}
|
||||||
|
window.open(receiptBase + '/' + encodeURIComponent(id), '_blank');
|
||||||
|
});
|
||||||
|
|
||||||
const nf = (n) => new Intl.NumberFormat('ko-KR').format(n || 0);
|
const nf = (n) => new Intl.NumberFormat('ko-KR').format(n || 0);
|
||||||
|
|
||||||
|
function paintPaidButton(order) {
|
||||||
|
if (!btnTogglePaid) return;
|
||||||
|
const paid = order && Number(order.so_paid) === 1;
|
||||||
|
btnTogglePaid.textContent = paid ? '입금확인 취소' : '입금확인';
|
||||||
|
btnTogglePaid.className = paid
|
||||||
|
? 'border border-gray-300 text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50'
|
||||||
|
: 'border border-emerald-300 text-emerald-700 px-3 py-1 rounded-sm text-sm hover:bg-emerald-50';
|
||||||
|
}
|
||||||
|
|
||||||
|
btnTogglePaid?.addEventListener('click', async () => {
|
||||||
|
const id = inputSoIdx.value;
|
||||||
|
if (!id) return;
|
||||||
|
const o = orderMap.get(String(id));
|
||||||
|
if (!o) return;
|
||||||
|
const nextPaid = Number(o.so_paid) === 1 ? 0 : 1;
|
||||||
|
if (nextPaid === 0 && !confirm('입금확인을 취소하면 이 주문은 포장(스캔)이 차단됩니다.\n계속할까요?')) return;
|
||||||
|
const body = new URLSearchParams();
|
||||||
|
body.set(csrfName, csrfHash);
|
||||||
|
body.set('so_idx', String(id));
|
||||||
|
body.set('paid', String(nextPaid));
|
||||||
|
btnTogglePaid.disabled = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(paidApi, { method: 'POST', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' }, body });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data && data.csrf) csrfHash = data.csrf;
|
||||||
|
if (!data || !data.ok) { alert((data && data.message) || '처리에 실패했습니다.'); btnTogglePaid.disabled = false; return; }
|
||||||
|
o.so_paid = nextPaid;
|
||||||
|
setHeader(o);
|
||||||
|
paintPaidButton(o);
|
||||||
|
btnTogglePaid.disabled = false;
|
||||||
|
renderList(); // 리스트의 입금 표시 갱신
|
||||||
|
} catch (e) {
|
||||||
|
alert('네트워크 오류로 처리하지 못했습니다.');
|
||||||
|
btnTogglePaid.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function setHeader(order) {
|
function setHeader(order) {
|
||||||
document.getElementById('detail-so-no').textContent = order ? String(order.so_idx || '-') : '-';
|
var t = function (id, v) { document.getElementById(id).textContent = (v === undefined || v === null || v === '') ? '-' : v; };
|
||||||
document.getElementById('detail-shop-name').textContent = order ? (order.so_ds_name || '-') : '-';
|
t('detail-so-no', order ? displayNo(order) : '');
|
||||||
document.getElementById('detail-payment').textContent = order ? (order.so_payment_type || '-') : '-';
|
t('detail-shop-code', order ? order.ds_shop_no : '');
|
||||||
document.getElementById('detail-order-date').textContent = order ? (order.so_order_date || '-') : '-';
|
t('detail-shop-name', order ? order.so_ds_name : '');
|
||||||
document.getElementById('detail-delivery-date').textContent = order ? (order.so_delivery_date || '-') : '-';
|
t('detail-shop-rep', order ? order.ds_rep_name : '');
|
||||||
document.getElementById('detail-status').textContent = order ? ((order.so_status === 'cancelled') ? '취소' : '정상') : '-';
|
t('detail-shop-tel', order ? order.ds_tel : '');
|
||||||
|
t('detail-payment', order ? order.so_payment_type : '');
|
||||||
|
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) {
|
||||||
|
var v = order && order.so_delivery_date ? String(order.so_delivery_date) : '';
|
||||||
|
dd.value = /^\d{4}-\d{2}-\d{2}/.test(v) ? v.slice(0, 10) : '';
|
||||||
|
dd.disabled = !order || order.so_status === 'cancelled';
|
||||||
|
// 배달일은 접수일 이후로만 선택 가능
|
||||||
|
var od = order && order.so_order_date ? String(order.so_order_date).slice(0, 10) : '';
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(od)) { dd.min = od; } else { dd.removeAttribute('min'); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function calcRow(tr) {
|
function calcRow(tr) {
|
||||||
@@ -148,14 +348,18 @@
|
|||||||
sheet = qty % packSheets;
|
sheet = qty % packSheets;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const packedCell = tr.querySelector('.item-packed-cell');
|
||||||
|
const packed = packedCell ? Math.max(0, parseInt((packedCell.textContent || '0').replace(/[^0-9]/g, '') || '0', 10) || 0) : 0;
|
||||||
|
|
||||||
const amount = qty * unitPrice;
|
const amount = qty * unitPrice;
|
||||||
tr.querySelector('.item-amount-cell').textContent = nf(amount);
|
tr.querySelector('.item-amount-cell').textContent = nf(amount);
|
||||||
tr.querySelector('.item-pack-cell').textContent = `박스=${nf(box)}, 팩=${nf(pack)}, 낱장=${nf(sheet)}`;
|
tr.querySelector('.item-pack-cell').textContent = `박스=${nf(box)}, 팩=${nf(pack)}, 낱장=${nf(sheet)}`;
|
||||||
return { qty, amount, box, pack, sheet };
|
return { qty, packed, amount, box, pack, sheet };
|
||||||
}
|
}
|
||||||
|
|
||||||
function recalcTotals() {
|
function recalcTotals() {
|
||||||
let sumQty = 0;
|
let sumQty = 0;
|
||||||
|
let sumPacked = 0;
|
||||||
let sumAmount = 0;
|
let sumAmount = 0;
|
||||||
let sumBox = 0;
|
let sumBox = 0;
|
||||||
let sumPack = 0;
|
let sumPack = 0;
|
||||||
@@ -164,6 +368,7 @@
|
|||||||
detailBody.querySelectorAll('tr.order-item-row').forEach((tr) => {
|
detailBody.querySelectorAll('tr.order-item-row').forEach((tr) => {
|
||||||
const r = calcRow(tr);
|
const r = calcRow(tr);
|
||||||
sumQty += r.qty;
|
sumQty += r.qty;
|
||||||
|
sumPacked += r.packed;
|
||||||
sumAmount += r.amount;
|
sumAmount += r.amount;
|
||||||
sumBox += r.box;
|
sumBox += r.box;
|
||||||
sumPack += r.pack;
|
sumPack += r.pack;
|
||||||
@@ -171,6 +376,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('detail-sum-qty').textContent = nf(sumQty);
|
document.getElementById('detail-sum-qty').textContent = nf(sumQty);
|
||||||
|
document.getElementById('detail-sum-packed').textContent = nf(sumPacked);
|
||||||
document.getElementById('detail-sum-amount').textContent = nf(sumAmount);
|
document.getElementById('detail-sum-amount').textContent = nf(sumAmount);
|
||||||
document.getElementById('detail-sum-pack').textContent = `박스=${nf(sumBox)}, 팩=${nf(sumPack)}, 낱장=${nf(sumSheet)}`;
|
document.getElementById('detail-sum-pack').textContent = `박스=${nf(sumBox)}, 팩=${nf(sumPack)}, 낱장=${nf(sumSheet)}`;
|
||||||
}
|
}
|
||||||
@@ -186,10 +392,14 @@
|
|||||||
const isCancelled = order.so_status === 'cancelled';
|
const isCancelled = order.so_status === 'cancelled';
|
||||||
btnSave.disabled = isCancelled;
|
btnSave.disabled = isCancelled;
|
||||||
btnCancel.disabled = isCancelled;
|
btnCancel.disabled = isCancelled;
|
||||||
|
if (btnReceipt) btnReceipt.disabled = false;
|
||||||
|
if (btnTogglePaid) { btnTogglePaid.disabled = isCancelled; paintPaidButton(order); }
|
||||||
|
|
||||||
|
renderPacking(order); // 포장 명세 탭 동기화
|
||||||
|
|
||||||
const items = Array.isArray(order.items) ? order.items : [];
|
const items = Array.isArray(order.items) ? order.items : [];
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
detailBody.innerHTML = '<tr><td colspan="6" class="text-center py-6 text-gray-400">품목 정보가 없습니다.</td></tr>';
|
detailBody.innerHTML = '<tr><td colspan="7" class="text-center py-6 text-gray-400">품목 정보가 없습니다.</td></tr>';
|
||||||
recalcTotals();
|
recalcTotals();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -198,6 +408,7 @@
|
|||||||
const itemId = String(item.soi_idx || '');
|
const itemId = String(item.soi_idx || '');
|
||||||
const bagName = `${item.soi_bag_code || ''} ${item.soi_bag_name || ''}`.trim();
|
const bagName = `${item.soi_bag_code || ''} ${item.soi_bag_name || ''}`.trim();
|
||||||
const qty = parseInt(item.soi_qty || 0, 10) || 0;
|
const qty = parseInt(item.soi_qty || 0, 10) || 0;
|
||||||
|
const packedQty = parseInt(item.soi_packed_qty || 0, 10) || 0;
|
||||||
const unitPrice = parseInt(item.soi_unit_price || 0, 10) || 0;
|
const unitPrice = parseInt(item.soi_unit_price || 0, 10) || 0;
|
||||||
const amount = parseInt(item.soi_amount || 0, 10) || 0;
|
const amount = parseInt(item.soi_amount || 0, 10) || 0;
|
||||||
const box = parseInt(item.soi_box_count || 0, 10) || 0;
|
const box = parseInt(item.soi_box_count || 0, 10) || 0;
|
||||||
@@ -214,6 +425,7 @@
|
|||||||
<td class="text-right pr-2">
|
<td class="text-right pr-2">
|
||||||
<input type="number" min="0" class="item-qty-input border border-gray-300 rounded px-2 py-1 w-24 text-right" name="item_qty[${itemId}]" value="${qty}" ${isCancelled ? 'disabled' : ''}/>
|
<input type="number" min="0" class="item-qty-input border border-gray-300 rounded px-2 py-1 w-24 text-right" name="item_qty[${itemId}]" value="${qty}" ${isCancelled ? 'disabled' : ''}/>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="text-right pr-2 item-packed-cell font-semibold ${packedQty >= qty && qty > 0 ? 'text-emerald-600' : 'text-gray-700'}" data-bag-code="${esc(item.soi_bag_code || '')}">${nf(packedQty)}</td>
|
||||||
<td class="text-right pr-2 item-amount-cell">${nf(amount)}</td>
|
<td class="text-right pr-2 item-amount-cell">${nf(amount)}</td>
|
||||||
<td class="text-right pr-2 item-pack-cell">박스=${nf(box)}, 팩=${nf(pack)}, 낱장=${nf(sheet)}</td>
|
<td class="text-right pr-2 item-pack-cell">박스=${nf(box)}, 팩=${nf(pack)}, 낱장=${nf(sheet)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -221,18 +433,381 @@
|
|||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
recalcTotals();
|
recalcTotals();
|
||||||
|
|
||||||
|
// 봉투코드 스캔 입력: 정상 주문일 때만 활성화
|
||||||
|
const scanInput = document.getElementById('scan-input');
|
||||||
|
if (scanInput) {
|
||||||
|
scanInput.disabled = isCancelled;
|
||||||
|
const msg = document.getElementById('scan-msg');
|
||||||
|
if (msg) msg.textContent = '';
|
||||||
|
}
|
||||||
|
// [개발용 임시] 선택 주문 판매소 기준 후보 바코드 표
|
||||||
|
loadDevSaleable(order.so_ds_idx, order.so_idx);
|
||||||
|
|
||||||
|
syncListHeight(); // 상세 카드 높이 확정 후 접수 리스트 높이를 맞춘다
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [개발용 임시] 스캔 가능 바코드 후보 로딩(선택 주문 판매소·주문 기준)
|
||||||
|
const devPanelEl = document.getElementById('dev-saleable-panel');
|
||||||
|
const devTbodyEl = document.getElementById('dev-saleable-tbody');
|
||||||
|
async function loadDevSaleable(dsIdx, soIdx) {
|
||||||
|
if (!devPanelEl || !devTbodyEl) return;
|
||||||
|
const idx = Number(dsIdx || 0);
|
||||||
|
if (!idx) { devPanelEl.classList.add('hidden'); return; }
|
||||||
|
devPanelEl.classList.remove('hidden');
|
||||||
|
devTbodyEl.innerHTML = '<tr><td colspan="7" class="text-center text-gray-400 py-4">불러오는 중…</td></tr>';
|
||||||
|
try {
|
||||||
|
let url = devSaleableApi + '?ds_idx=' + encodeURIComponent(String(idx));
|
||||||
|
if (Number(soIdx || 0) > 0) url += '&so_idx=' + encodeURIComponent(String(soIdx));
|
||||||
|
const res = await fetch(url, { credentials: 'same-origin' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.ok) { devTbodyEl.innerHTML = '<tr><td colspan="7" class="text-center text-red-600 py-4">' + esc(data.message || '조회 실패') + '</td></tr>'; return; }
|
||||||
|
const rows = Array.isArray(data.rows) ? data.rows : [];
|
||||||
|
if (!rows.length) { devTbodyEl.innerHTML = '<tr><td colspan="7" class="text-center text-gray-400 py-4">표시할 바코드가 없습니다.</td></tr>'; return; }
|
||||||
|
devTbodyEl.innerHTML = rows.map((r) => {
|
||||||
|
const code = String(r.code || '');
|
||||||
|
const codeCell = (code && code !== '-')
|
||||||
|
? '<button type="button" class="dev-pick text-blue-600 hover:underline font-mono" data-code="' + esc(code) + '">' + esc(code) + '</button>'
|
||||||
|
: '<span class="text-gray-400">' + esc(code) + '</span>';
|
||||||
|
return '<tr>'
|
||||||
|
+ '<td class="text-left pl-1">' + esc(r.source || '') + '</td>'
|
||||||
|
+ '<td class="text-center">' + codeCell + '</td>'
|
||||||
|
+ '<td class="text-left pl-1">' + esc((r.bag_code || '') + ' ' + (r.bag_name || '')) + '</td>'
|
||||||
|
+ '<td class="text-center">' + esc(r.unit || '') + '</td>'
|
||||||
|
+ '<td class="text-right pr-1">' + nf(r.qty || 0) + '</td>'
|
||||||
|
+ '<td class="text-center">' + esc(r.state || '') + '</td>'
|
||||||
|
+ '<td class="text-left pl-1">' + esc(r.extra || '') + '</td>'
|
||||||
|
+ '</tr>';
|
||||||
|
}).join('');
|
||||||
|
} catch (e) {
|
||||||
|
devTbodyEl.innerHTML = '<tr><td colspan="7" class="text-center text-red-600 py-4">네트워크 오류</td></tr>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 후보 바코드 클릭 → 스캔칸에 입력(개발 편의)
|
||||||
|
devTbodyEl?.addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('.dev-pick');
|
||||||
|
if (!btn) return;
|
||||||
|
const inp = document.getElementById('scan-input');
|
||||||
|
if (inp && !inp.disabled) { inp.value = btn.getAttribute('data-code') || ''; inp.focus(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 봉투코드 스캔 → 즉시 판매·포장 처리 후 화면 갱신
|
||||||
|
const scanInputEl = document.getElementById('scan-input');
|
||||||
|
const scanMsgEl = document.getElementById('scan-msg');
|
||||||
|
function setScanMsg(text, isErr) {
|
||||||
|
if (!scanMsgEl) return;
|
||||||
|
scanMsgEl.textContent = text || '';
|
||||||
|
scanMsgEl.className = 'text-xs ' + (isErr ? 'text-red-600 font-semibold' : 'text-emerald-600');
|
||||||
|
}
|
||||||
|
async function submitScan() {
|
||||||
|
if (!scanInputEl) return;
|
||||||
|
const code = (scanInputEl.value || '').trim();
|
||||||
|
if (!code) return;
|
||||||
|
if (!selectedId) { setScanMsg('먼저 접수 리스트에서 주문을 선택해 주세요.', true); return; }
|
||||||
|
|
||||||
|
const body = new URLSearchParams();
|
||||||
|
body.set(csrfName, csrfHash);
|
||||||
|
body.set('so_idx', String(selectedId));
|
||||||
|
body.set('barcode', code);
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
const res = await fetch(scanApi, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: body.toString(),
|
||||||
|
});
|
||||||
|
data = await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
setScanMsg('통신 오류가 발생했습니다.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data && data.csrf) csrfHash = data.csrf; // 다음 스캔용 토큰 갱신
|
||||||
|
if (!data || !data.ok) {
|
||||||
|
setScanMsg((data && data.message) || '스캔 처리 실패', true);
|
||||||
|
scanInputEl.select();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 선택된 주문 객체에 스캔 반영(재선택 시 유지)
|
||||||
|
const order = orderMap.get(String(data.so_idx));
|
||||||
|
if (order) {
|
||||||
|
if (!Array.isArray(order.scans)) order.scans = [];
|
||||||
|
order.scans.push({ bag_code: data.bag_code, bag_name: data.bag_name, code: data.code, unit: data.unit, qty: Number(data.qty || 0) });
|
||||||
|
const it = (order.items || []).find((x) => String(x.soi_bag_code) === String(data.bag_code));
|
||||||
|
if (it) it.soi_packed_qty = Number(data.packed_qty || 0);
|
||||||
|
order.so_received = Number(data.so_received || 0);
|
||||||
|
// 현재 이 주문을 보고 있으면 상세·포장명세 즉시 갱신
|
||||||
|
if (String(selectedId) === String(data.so_idx)) {
|
||||||
|
renderPacking(order);
|
||||||
|
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));
|
||||||
|
const row = cell.closest('tr');
|
||||||
|
const qtyInp = row ? row.querySelector('.item-qty-input') : null;
|
||||||
|
const rq = qtyInp ? (parseInt(qtyInp.value || '0', 10) || 0) : 0;
|
||||||
|
cell.classList.toggle('text-emerald-600', Number(data.packed_qty || 0) >= rq && rq > 0);
|
||||||
|
cell.classList.toggle('text-gray-700', !(Number(data.packed_qty || 0) >= rq && rq > 0));
|
||||||
|
}
|
||||||
|
recalcTotals();
|
||||||
|
}
|
||||||
|
// 접수 리스트의 '포장' 표시(O/X) 갱신
|
||||||
|
renderList();
|
||||||
|
// [개발용 임시] 후보 목록 갱신(방금 판매된 팩 제외)
|
||||||
|
loadDevSaleable(order.so_ds_idx, order.so_idx);
|
||||||
|
}
|
||||||
|
scanInputEl.value = '';
|
||||||
|
scanInputEl.focus();
|
||||||
|
setScanMsg('등록: ' + data.bag_code + ' / ' + data.unit + ' / ' + nf(data.qty) + ' (잔량 ' + nf(data.remain) + ')', false);
|
||||||
|
}
|
||||||
|
scanInputEl?.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); submitScan(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 접수 리스트 스크롤 높이를 지정판매소 정보(상세) 카드 높이에 맞춘다.
|
||||||
|
// offsetHeight는 화면 zoom(텍스트 줄이기)과 무관한 레이아웃 px라, 배율과 상관없이 두 카드가 같은 높이가 되고 하단 공백이 생기지 않는다.
|
||||||
|
const listScrollEl = document.getElementById('list-scroll');
|
||||||
|
const detailCardEl = document.getElementById('detail-card');
|
||||||
|
function syncListHeight() {
|
||||||
|
if (!listScrollEl || !detailCardEl) return;
|
||||||
|
const listCard = listScrollEl.closest('section');
|
||||||
|
const header = listCard ? listCard.firstElementChild : null;
|
||||||
|
const summary = document.getElementById('list-summary');
|
||||||
|
listScrollEl.style.height = '0px'; // 리스트를 접어 상세 카드의 본래 높이를 측정
|
||||||
|
const target = detailCardEl.offsetHeight
|
||||||
|
- (header ? header.offsetHeight : 0)
|
||||||
|
- (summary ? summary.offsetHeight : 0);
|
||||||
|
listScrollEl.style.height = Math.max(160, target) + 'px';
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', syncListHeight);
|
||||||
|
window.addEventListener('storage', function (e) { if (e.key === 'jrj_font_scale') syncListHeight(); });
|
||||||
|
if ('ResizeObserver' in window && detailCardEl) {
|
||||||
|
new ResizeObserver(function () { syncListHeight(); }).observe(detailCardEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 접수 리스트 ↔ 지정판매소 정보 사이 너비 조절(드래그 스플리터) ──────
|
||||||
|
// 손잡이를 접수 리스트 쪽(왼쪽)으로 끌면 리스트 폭이 줄고 지정판매소 정보 폭이 넓어진다.
|
||||||
|
const splitEl = document.getElementById('order-split');
|
||||||
|
const listCardEl = document.getElementById('list-card');
|
||||||
|
const splitHandleEl = document.getElementById('split-handle');
|
||||||
|
const SPLIT_KEY = 'jrj_order_split_pct';
|
||||||
|
const SPLIT_MIN = 25, SPLIT_MAX = 75, SPLIT_DEFAULT = 55.5; // 기본값 ≈ 기존 5:4 비율
|
||||||
|
const xlMedia = window.matchMedia('(min-width: 1280px)');
|
||||||
|
function clampPct(p) { return Math.min(SPLIT_MAX, Math.max(SPLIT_MIN, p)); }
|
||||||
|
function currentPct() {
|
||||||
|
const saved = parseFloat(localStorage.getItem(SPLIT_KEY) || '');
|
||||||
|
return clampPct(isFinite(saved) ? saved : SPLIT_DEFAULT);
|
||||||
|
}
|
||||||
|
function applySplit(pct) {
|
||||||
|
if (!listCardEl) return;
|
||||||
|
if (xlMedia.matches) {
|
||||||
|
// 좌우 배치일 때만 폭 지정(세로로 쌓이는 좁은 화면에서는 전체폭 사용)
|
||||||
|
listCardEl.style.flex = '0 0 ' + pct + '%';
|
||||||
|
listCardEl.style.maxWidth = pct + '%';
|
||||||
|
} else {
|
||||||
|
listCardEl.style.flex = '';
|
||||||
|
listCardEl.style.maxWidth = '';
|
||||||
|
}
|
||||||
|
syncListHeight();
|
||||||
|
}
|
||||||
|
if (splitHandleEl && splitEl && listCardEl) {
|
||||||
|
let dragging = false, rafId = 0, pendingPct = null;
|
||||||
|
function onMove(clientX) {
|
||||||
|
const rect = splitEl.getBoundingClientRect();
|
||||||
|
if (rect.width <= 0) return;
|
||||||
|
pendingPct = clampPct((clientX - rect.left) / rect.width * 100);
|
||||||
|
if (!rafId) {
|
||||||
|
rafId = requestAnimationFrame(function () {
|
||||||
|
rafId = 0;
|
||||||
|
if (pendingPct != null) applySplit(pendingPct);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function pointerMove(e) { if (dragging) { e.preventDefault(); onMove(e.clientX); } }
|
||||||
|
function pointerUp() {
|
||||||
|
if (!dragging) return;
|
||||||
|
dragging = false;
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
window.removeEventListener('pointermove', pointerMove);
|
||||||
|
window.removeEventListener('pointerup', pointerUp);
|
||||||
|
if (pendingPct != null) localStorage.setItem(SPLIT_KEY, String(Math.round(pendingPct * 10) / 10));
|
||||||
|
}
|
||||||
|
splitHandleEl.addEventListener('pointerdown', function (e) {
|
||||||
|
if (!xlMedia.matches) return; // 좁은 화면에서는 비활성
|
||||||
|
e.preventDefault();
|
||||||
|
dragging = true;
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
document.body.style.cursor = 'col-resize';
|
||||||
|
window.addEventListener('pointermove', pointerMove);
|
||||||
|
window.addEventListener('pointerup', pointerUp);
|
||||||
|
});
|
||||||
|
// 더블클릭 시 기본 비율로 초기화
|
||||||
|
splitHandleEl.addEventListener('dblclick', function () {
|
||||||
|
localStorage.removeItem(SPLIT_KEY);
|
||||||
|
applySplit(SPLIT_DEFAULT);
|
||||||
|
});
|
||||||
|
// 브레이크포인트 전환 시 폭 지정 적용/해제
|
||||||
|
(xlMedia.addEventListener ? xlMedia.addEventListener('change', function () { applySplit(currentPct()); })
|
||||||
|
: xlMedia.addListener(function () { applySplit(currentPct()); }));
|
||||||
|
applySplit(currentPct());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 스캔 현황(최근 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])); }
|
||||||
|
|
||||||
|
// 스캔 코드 전체 → 표 행(HTML) + 건수/합계 (팝업용). 최근이 위로.
|
||||||
|
function scanFullRows(order) {
|
||||||
|
const scans = Array.isArray(order.scans) ? order.scans.slice().reverse() : [];
|
||||||
|
let no = 0, total = 0;
|
||||||
|
const rows = scans.map((s) => {
|
||||||
|
no++;
|
||||||
|
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>';
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
const esc = (s) => String(s == null ? '' : s).replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c]));
|
||||||
|
|
||||||
|
function rowHtml(o) {
|
||||||
|
const cancelled = o.so_status === 'cancelled';
|
||||||
|
const dDate = o.so_delivery_date ? String(o.so_delivery_date).slice(0, 10) : '';
|
||||||
|
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">${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>
|
||||||
|
<td class="text-left pl-2">${esc(o.so_ds_name)}</td>
|
||||||
|
<td class="text-center">${esc(o.so_payment_type || '-')}</td>
|
||||||
|
<td class="text-center" style="text-align:center;">${packed ? '<span style="color:#2563eb;font-weight:700;">O</span>' : '<span class="text-gray-400">X</span>'}</td>
|
||||||
|
<td class="text-center" style="text-align:center;">${Number(o.so_paid) === 1 ? '<span style="color:#e11d48;font-size:11px;" title="입금">●</span>' : ''}</td>
|
||||||
|
<td class="text-right pr-2">${nf(o.so_total_amount || 0)}</td>
|
||||||
|
<td class="text-center">${cancelled ? '취소' : '정상'}</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList() {
|
||||||
|
const th = document.querySelector('.sort-th[data-sort="' + sortKey + '"]');
|
||||||
|
const type = th ? th.dataset.type : 'str';
|
||||||
|
const arr = orders.slice().sort((a, b) => {
|
||||||
|
let va = a[sortKey], vb = b[sortKey];
|
||||||
|
if (type === 'num') { va = Number(va || 0); vb = Number(vb || 0); return sortDir === 'asc' ? va - vb : vb - va; }
|
||||||
|
va = String(va || ''); vb = String(vb || '');
|
||||||
|
return sortDir === 'asc' ? va.localeCompare(vb, 'ko') : vb.localeCompare(va, 'ko');
|
||||||
|
});
|
||||||
|
listBody.innerHTML = arr.length
|
||||||
|
? arr.map(rowHtml).join('')
|
||||||
|
: '<tr><td colspan="10" class="text-center py-8 text-gray-400">전화 주문 데이터가 없습니다.</td></tr>';
|
||||||
|
document.querySelectorAll('.sort-th .sort-ind').forEach((s) => { s.textContent = ''; });
|
||||||
|
if (th) th.querySelector('.sort-ind').textContent = sortDir === 'asc' ? ' ▲' : ' ▼';
|
||||||
|
|
||||||
|
// 하단 합계 — 취소 주문은 제외
|
||||||
|
const normals = arr.filter((o) => o.so_status !== 'cancelled');
|
||||||
|
const sumCountEl = document.getElementById('list-sum-count');
|
||||||
|
const sumAmountEl = document.getElementById('list-sum-amount');
|
||||||
|
if (sumCountEl) sumCountEl.textContent = nf(normals.length);
|
||||||
|
if (sumAmountEl) sumAmountEl.textContent = nf(normals.reduce((s, o) => s + (Number(o.so_total_amount) || 0), 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.sort-th').forEach((th) => {
|
||||||
|
th.addEventListener('click', () => {
|
||||||
|
const k = th.dataset.sort;
|
||||||
|
if (sortKey === k) { sortDir = sortDir === 'asc' ? 'desc' : 'asc'; } else { sortKey = k; sortDir = 'asc'; }
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
listBody?.addEventListener('click', (e) => {
|
listBody?.addEventListener('click', (e) => {
|
||||||
const tr = e.target.closest('.order-list-row');
|
const tr = e.target.closest('.order-list-row');
|
||||||
if (!tr) return;
|
if (!tr) return;
|
||||||
|
selectedId = tr.dataset.orderId;
|
||||||
listBody.querySelectorAll('.order-list-row').forEach((row) => row.classList.remove('bg-blue-100'));
|
listBody.querySelectorAll('.order-list-row').forEach((row) => row.classList.remove('bg-blue-100'));
|
||||||
tr.classList.add('bg-blue-100');
|
tr.classList.add('bg-blue-100');
|
||||||
renderDetail(tr.dataset.orderId);
|
renderDetail(selectedId);
|
||||||
|
openPackingModal(); // 행 클릭 시 해당 주문 봉투코드 팝업 즉시 표시
|
||||||
});
|
});
|
||||||
|
|
||||||
detailBody?.addEventListener('input', (e) => {
|
detailBody?.addEventListener('input', (e) => {
|
||||||
if (e.target.closest('.item-qty-input')) {
|
if (e.target.closest('.item-qty-input') || e.target.closest('.item-packed-input')) {
|
||||||
recalcTotals();
|
recalcTotals();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -250,11 +825,14 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
renderList();
|
||||||
const firstRow = listBody?.querySelector('.order-list-row');
|
const firstRow = listBody?.querySelector('.order-list-row');
|
||||||
if (firstRow) {
|
if (firstRow) {
|
||||||
|
selectedId = firstRow.dataset.orderId;
|
||||||
firstRow.classList.add('bg-blue-100');
|
firstRow.classList.add('bg-blue-100');
|
||||||
renderDetail(firstRow.dataset.orderId);
|
renderDetail(selectedId);
|
||||||
}
|
}
|
||||||
|
syncListHeight();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user