Compare commits
86 Commits
feature/go
...
b9cc810f97
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
71534fb198 | ||
|
|
e6a49e379a | ||
|
|
77c7d3e119 | ||
|
|
b9dd24082c | ||
|
|
56dadb3478 | ||
|
|
287691328e | ||
|
|
fd3da428ab | ||
|
|
a8afaf4af2 | ||
|
|
912ffdbe23 | ||
|
|
4d9343e980 | ||
|
|
7e32f579e8 | ||
|
|
1a443de02e | ||
|
|
e8d58b5837 | ||
|
|
c15e01bfa7 | ||
|
|
600a79788e | ||
|
|
6b1c118651 | ||
|
|
abc8a405e1 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -176,3 +176,6 @@ blob-report/
|
||||
/phpunit*.xml
|
||||
# 최상위 개발 문서/스크린샷 폴더만 제외 (app/Docs 등 하위 docs 경로는 추적).
|
||||
/docs/
|
||||
|
||||
# Claude Code 개인 권한 설정(비밀 포함) — 커밋 금지
|
||||
.claude/settings.local.json
|
||||
|
||||
@@ -133,7 +133,7 @@ class App extends BaseConfig
|
||||
* @see https://www.php.net/manual/en/timezones.php for list of timezones
|
||||
* supported by PHP.
|
||||
*/
|
||||
public string $appTimezone = 'UTC';
|
||||
public string $appTimezone = 'Asia/Seoul';
|
||||
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
|
||||
@@ -27,6 +27,8 @@ class Filters extends BaseFilters
|
||||
public array $aliases = [
|
||||
'adminAuth' => \App\Filters\AdminAuthFilter::class,
|
||||
'loginAuth' => \App\Filters\LoginAuthFilter::class,
|
||||
'embedRedirect' => \App\Filters\EmbedRedirectFilter::class,
|
||||
'sessionGuard' => \App\Filters\SessionGuardFilter::class,
|
||||
'csrf' => CSRF::class,
|
||||
'toolbar' => DebugToolbar::class,
|
||||
'honeypot' => Honeypot::class,
|
||||
@@ -77,10 +79,12 @@ class Filters extends BaseFilters
|
||||
// 'honeypot',
|
||||
// 'csrf',
|
||||
// 'invalidchars',
|
||||
'sessionGuard', // 중복 로그인 방지(나중 로그인 우선)
|
||||
],
|
||||
'after' => [
|
||||
// 'honeypot',
|
||||
// 'secureheaders',
|
||||
'embedRedirect', // 임베드(탭) 리다이렉트에 embed=1 유지 → 중첩 셸 방지
|
||||
],
|
||||
];
|
||||
|
||||
@@ -108,5 +112,10 @@ class Filters extends BaseFilters
|
||||
*
|
||||
* @var array<string, array<string, list<string>>>
|
||||
*/
|
||||
public array $filters = [];
|
||||
public array $filters = [
|
||||
// 모든 업무(bag) 화면은 로그인 필요. 세션 만료 후 어떤 버튼을 눌러도
|
||||
// 깨진 화면 대신 로그인으로 리다이렉트되도록 일괄 보호한다.
|
||||
// (login/logout/register 는 bag/* 가 아니므로 영향 없음. 관리자 화면은 adminAuth 가 별도 처리)
|
||||
'loginAuth' => ['before' => ['bag', 'bag/*']],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -23,12 +23,68 @@ class Manual extends BaseConfig
|
||||
*/
|
||||
public array $pages = [
|
||||
'overview' => ['title' => '시작하기·시스템 개요', 'file' => '00_overview.md'],
|
||||
'account' => ['title' => '로그인·회원가입·계정', 'file' => '01_account.md'],
|
||||
'workspace' => ['title' => '화면 구성·워크스페이스·단축키', 'file' => '05_workspace.md'],
|
||||
'flow' => ['title' => '핵심 업무 흐름', 'file' => '10_workflow.md'],
|
||||
'order' => ['title' => '발주·입고', 'file' => '20_order_receiving.md'],
|
||||
'inventory' => ['title' => '재고·실사', 'file' => '30_inventory.md'],
|
||||
'sales' => ['title' => '판매·불출', 'file' => '40_sales_issue.md'],
|
||||
'reports' => ['title' => '판매현황·수불·통계', 'file' => '50_reports.md'],
|
||||
'sales' => ['title' => '판매·반품·불출·주문', 'file' => '40_sales_issue.md'],
|
||||
'reports' => ['title' => '현황·리포트·수불', 'file' => '50_reports.md'],
|
||||
'basic' => ['title' => '기본정보(판매소·단가·코드)', 'file' => '60_basic_info.md'],
|
||||
'codes' => ['title' => '봉투·LOT·바코드 코드체계', 'file' => '90_code_system.md'],
|
||||
'faq' => ['title' => '자주 묻는 질문·문의', 'file' => '99_faq.md'],
|
||||
];
|
||||
|
||||
/**
|
||||
* 화면 경로(접두) → 그 화면을 설명하는 매뉴얼 slug.
|
||||
* "이 화면 설명" 버튼이 현재 경로로 알맞은 매뉴얼 페이지를 연다.
|
||||
* 더 긴(구체적) 접두가 우선하도록 길이 내림차순으로 매칭한다.
|
||||
*
|
||||
* 값에 "slug#소제목힌트" 형식으로 적으면, 매뉴얼 창이 열릴 때 해당 소제목으로
|
||||
* 자동 스크롤하고 잠시 강조 표시한다(힌트는 그 페이지의 H2/H3 텍스트 일부와 일치).
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
public array $screenHelp = [
|
||||
'bag/order/phone' => 'sales#전화 주문 접수',
|
||||
'bag/order/lot-seed' => 'order#발주파일 생성',
|
||||
'bag/order' => 'order#발주 등록',
|
||||
'bag/password-change' => 'account#비밀번호 변경',
|
||||
'bag/bag-orders' => 'order#발주 현황',
|
||||
'bag/receiving' => 'order#입고 처리',
|
||||
'bag/bag-receivings' => 'order#입고 현황',
|
||||
'bag/inventory' => 'inventory#재고 현황',
|
||||
'bag/sale' => 'sales#지정판매소 판매',
|
||||
'bag/sales' => 'sales#지정판매소 판매',
|
||||
'bag/issue' => 'sales#무료용 불출 처리',
|
||||
'bag/bag-issues' => 'sales#무료용 불출 처리',
|
||||
'bag/bag-sales' => 'sales#판매/반품 현황',
|
||||
'bag/shop-orders' => 'sales#전화 주문 접수',
|
||||
'bag/flow' => 'reports#기간별 봉투 수불 현황',
|
||||
'bag/reports/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/analytics' => 'reports#그 밖의 현황',
|
||||
'bag/designated-shops/status' => 'basic#지정 판매소 신규/취소 현황',
|
||||
'bag/designated-shops/district-new-cancel' => 'basic#지정 판매소 신규/취소 현황',
|
||||
'bag/designated-shops' => 'basic#지정판매소 관리',
|
||||
'bag/bag-prices' => 'basic#단가 관리',
|
||||
'bag/prices' => 'basic#단가 관리',
|
||||
'bag/packaging-units' => 'basic#포장 단위 관리',
|
||||
'bag/code-kinds' => 'basic#기본코드 관리',
|
||||
'bag/code-details' => 'basic#기본코드 관리',
|
||||
'bag/managers' => 'basic#그 밖의 기본정보',
|
||||
'bag/companies' => 'basic#그 밖의 기본정보',
|
||||
'bag/sales-agencies' => 'basic#그 밖의 기본정보',
|
||||
'bag/free-recipients' => 'basic#그 밖의 기본정보',
|
||||
'bag/number-lookup' => 'codes',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use CodeIgniter\Router\RouteCollection;
|
||||
* @var RouteCollection $routes
|
||||
*/
|
||||
$routes->get('/', 'Home::index');
|
||||
$routes->get('workspace', 'Home::workspace');
|
||||
$routes->get('dashboard', 'Home::dashboard');
|
||||
$routes->get('dashboard/simple', 'Home::dashboardSimple');
|
||||
$routes->get('dashboard/compact', 'Home::dashboardCompact');
|
||||
@@ -59,7 +60,9 @@ $routes->get('bag/help', 'Bag::help');
|
||||
// 사용자 매뉴얼(설명서) — 로그인 사용자 전용
|
||||
$routes->group('bag', ['filter' => 'loginAuth'], static function ($routes): void {
|
||||
$routes->get('manual', 'Bag::manual');
|
||||
$routes->get('manual/search', 'Bag::manualSearch'); // (:segment) 보다 먼저
|
||||
$routes->get('manual/(:segment)', 'Bag::manualPage/$1');
|
||||
$routes->post('activity/print-log', 'Bag::printLog'); // 인쇄 활동 기록(beacon)
|
||||
});
|
||||
|
||||
$routes->get('bag/number-lookup', 'Bag::numberLookup');
|
||||
@@ -71,13 +74,17 @@ $routes->post('bag/issue/store', 'Bag::issueStore');
|
||||
$routes->post('bag/issue/cancel/(:num)', 'Bag::issueCancel/$1');
|
||||
$routes->post('bag/issue/cancel-save', 'Bag::issueCancelSave');
|
||||
$routes->get('bag/order/create', 'Bag::orderCreate');
|
||||
$routes->get('bag/order/detail-json/(:num)', 'Bag::orderDetailJson/$1');
|
||||
$routes->get('bag/order/phone', 'Bag::phoneOrderCreate');
|
||||
$routes->get('bag/order/phone/manage', 'Bag::phoneOrderManage');
|
||||
$routes->post('bag/order/phone/manage/update', 'Bag::phoneOrderUpdate');
|
||||
$routes->post('bag/order/phone/manage/scan', 'Bag::phoneOrderManageScan');
|
||||
$routes->post('bag/order/phone/manage/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/revise/(:num)', 'Bag::orderRevise/$1');
|
||||
$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/store', 'Bag::orderStore');
|
||||
$routes->post('bag/order/cancel/(:num)', 'Bag::orderCancel/$1');
|
||||
@@ -149,6 +156,7 @@ $routes->group('bag', ['filter' => 'adminAuth'], static function ($routes): void
|
||||
$routes->get('designated-shops/browse', 'Admin\DesignatedShop::browse');
|
||||
$routes->get('designated-shops', 'Admin\DesignatedShop::index');
|
||||
$routes->get('designated-shops/create', 'Admin\DesignatedShop::create');
|
||||
$routes->post('designated-shops/resolve-address-codes', 'Admin\DesignatedShop::resolveAddressCodes');
|
||||
$routes->post('designated-shops/store', 'Admin\DesignatedShop::store');
|
||||
$routes->get('designated-shops/edit/(:num)', 'Admin\DesignatedShop::edit/$1');
|
||||
$routes->post('designated-shops/update/(:num)', 'Admin\DesignatedShop::update/$1');
|
||||
@@ -219,6 +227,9 @@ $routes->group('bag', ['filter' => 'adminAuth'], static function ($routes): void
|
||||
$routes->post('password-change', 'Admin\PasswordChange::update');
|
||||
});
|
||||
|
||||
// 사용자 의견 접수 (로그인 사용자)
|
||||
$routes->post('feedback', 'Feedback::store', ['filter' => 'loginAuth']);
|
||||
|
||||
// Auth
|
||||
$routes->get('login', 'Auth::showLoginForm');
|
||||
$routes->post('login', 'Auth::login');
|
||||
@@ -234,6 +245,14 @@ $routes->post('register', 'Auth::register');
|
||||
$routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): void {
|
||||
$routes->get('select-local-government', 'Admin\SelectLocalGovernment::index');
|
||||
$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->get('/', 'Admin\Dashboard::index');
|
||||
$routes->get('users', 'Admin\User::index');
|
||||
$routes->get('users/create', 'Admin\User::create');
|
||||
@@ -243,6 +262,8 @@ $routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): vo
|
||||
$routes->post('users/unlock-login/(:num)', 'Admin\User::unlockLogin/$1');
|
||||
$routes->post('users/delete/(:num)', 'Admin\User::delete/$1');
|
||||
$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->post('access/approve/(:num)', 'Admin\Access::approve/$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'),
|
||||
];
|
||||
|
||||
// 발주담당(manager.mg_idx) — 컬럼이 있을 때만 저장
|
||||
if (\Config\Database::connect()->fieldExists('bo_manager_idx', 'bag_order')) {
|
||||
$orderData['bo_manager_idx'] = $this->request->getPost('bo_manager_idx') ?: null;
|
||||
}
|
||||
|
||||
// 품목 입력 후 최종 payload 기준으로 해시를 계산하므로 우선 빈값으로 생성
|
||||
$orderData['bo_hash'] = '';
|
||||
|
||||
|
||||
@@ -56,14 +56,11 @@ class CodeDetail extends BaseController
|
||||
|
||||
helper('admin');
|
||||
|
||||
return view('admin/layout', [
|
||||
'title' => '세부코드 등록 — ' . $kind->ck_name,
|
||||
'content' => view('admin/code_detail/create', [
|
||||
'kind' => $kind,
|
||||
'canPlatformScope' => $canPlatformScope,
|
||||
'localGovernments' => $govs,
|
||||
'effectiveLgIdx' => admin_effective_lg_idx(),
|
||||
]),
|
||||
return $this->renderWorkPage('세부코드 등록 — ' . $kind->ck_name, 'admin/code_detail/create', [
|
||||
'kind' => $kind,
|
||||
'canPlatformScope' => $canPlatformScope,
|
||||
'localGovernments' => $govs,
|
||||
'effectiveLgIdx' => admin_effective_lg_idx(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -156,12 +153,9 @@ class CodeDetail extends BaseController
|
||||
|
||||
$kind = $this->kindModel->find($item->cd_ck_idx);
|
||||
|
||||
return view('admin/layout', [
|
||||
'title' => '세부코드 수정 — ' . ($kind->ck_name ?? ''),
|
||||
'content' => view('admin/code_detail/edit', [
|
||||
'item' => $item,
|
||||
'kind' => $kind,
|
||||
]),
|
||||
return $this->renderWorkPage('세부코드 수정 — ' . ($kind->ck_name ?? ''), 'admin/code_detail/edit', [
|
||||
'item' => $item,
|
||||
'kind' => $kind,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -34,10 +34,7 @@ class CodeKind extends BaseController
|
||||
return $r;
|
||||
}
|
||||
|
||||
return view('admin/layout', [
|
||||
'title' => '기본코드 종류 등록',
|
||||
'content' => view('admin/code_kind/create'),
|
||||
]);
|
||||
return $this->renderWorkPage('기본코드 종류 등록', 'admin/code_kind/create');
|
||||
}
|
||||
|
||||
public function store()
|
||||
@@ -76,10 +73,7 @@ class CodeKind extends BaseController
|
||||
return redirect()->to(site_url('bag/code-kinds'))->with('error', '코드 종류를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
return view('admin/layout', [
|
||||
'title' => '기본코드 종류 수정',
|
||||
'content' => view('admin/code_kind/edit', ['item' => $item]),
|
||||
]);
|
||||
return $this->renderWorkPage('기본코드 종류 수정', 'admin/code_kind/edit', ['item' => $item]);
|
||||
}
|
||||
|
||||
public function update(int $id)
|
||||
|
||||
@@ -14,6 +14,7 @@ class DesignatedShop extends BaseController
|
||||
private DesignatedShopModel $shopModel;
|
||||
private LocalGovernmentModel $lgModel;
|
||||
private Roles $roles;
|
||||
private ?bool $dongColumnExists = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
@@ -223,9 +224,10 @@ class DesignatedShop extends BaseController
|
||||
/**
|
||||
* @param list<object> $list
|
||||
* @param array<int, string> $lgMap
|
||||
* @param array<int, array{code: string, name: string}> $dongMap ds_idx => [동코드, 동명]
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function buildDesignatedShopDetailPayload(array $list, array $lgMap): array
|
||||
private function buildDesignatedShopDetailPayload(array $list, array $lgMap, array $dongMap = []): array
|
||||
{
|
||||
helper('admin');
|
||||
$lgIdx = admin_effective_lg_idx() ?? 0;
|
||||
@@ -244,6 +246,18 @@ class DesignatedShop extends BaseController
|
||||
$stateMap = [1 => '정상', 2 => '폐업', 3 => '직권해지'];
|
||||
$da = $row->ds_designated_at ?? null;
|
||||
$daOut = ($da !== null && $da !== '' && $da !== '0000-00-00') ? (string) $da : '';
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
$payload[] = [
|
||||
'ds_idx' => (int) $row->ds_idx,
|
||||
'ds_shop_no' => $sn,
|
||||
@@ -266,7 +280,10 @@ class DesignatedShop extends BaseController
|
||||
'ds_rep_phone' => (string) ($row->ds_rep_phone ?? ''),
|
||||
'ds_email' => (string) ($row->ds_email ?? ''),
|
||||
'ds_gugun_code' => (string) ($row->ds_gugun_code ?? ''),
|
||||
'gugun_name' => $gugunMap[(string) ($row->ds_gugun_code ?? '')] ?? (string) ($row->ds_gugun_code ?? ''),
|
||||
'gugun_name' => $gugunName,
|
||||
'ds_dong_code' => $dongCode,
|
||||
'dong_name' => $dongName,
|
||||
'dong_display' => $dongDisplay,
|
||||
'ds_zone_code' => $this->designatedShopScalar($row, 'ds_zone_code'),
|
||||
'ds_branch_no' => $this->designatedShopScalar($row, 'ds_branch_no'),
|
||||
'ds_designated_at' => $daOut,
|
||||
@@ -300,8 +317,9 @@ class DesignatedShop extends BaseController
|
||||
$dsState = $this->request->getGet('ds_state');
|
||||
$this->applyDesignatedShopListFilters($this->shopModel, $lgIdx, $dsName, $dsGugunCode, $dsState);
|
||||
|
||||
$list = $this->shopModel->orderBy('ds_idx', 'DESC')->paginate(20);
|
||||
$pager = $this->shopModel->pager;
|
||||
// 페이징 대신 전체 목록을 한 번에 로드 — 리스트 패널 내부 스크롤로 표시
|
||||
$list = $this->shopModel->orderBy('ds_idx', 'DESC')->findAll();
|
||||
$pager = null;
|
||||
|
||||
// 지자체 이름 매핑용
|
||||
$lgMap = [];
|
||||
@@ -311,7 +329,21 @@ class DesignatedShop extends BaseController
|
||||
|
||||
$stateCounts = $this->countDesignatedShopsByState($lgIdx, $dsName, $dsGugunCode, $dsState);
|
||||
$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();
|
||||
@@ -327,6 +359,7 @@ class DesignatedShop extends BaseController
|
||||
'gugunCodes' => $gugunCodes,
|
||||
'stateCounts' => $stateCounts,
|
||||
'gugunNameMap' => $gugunNameMap,
|
||||
'dongDisplayMap' => $dongDisplayMap,
|
||||
'detailRowsJson' => json_encode($detailRows, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE),
|
||||
'kakaoJavascriptKey' => $this->kakaoJavascriptKey(),
|
||||
];
|
||||
@@ -477,14 +510,25 @@ class DesignatedShop extends BaseController
|
||||
|
||||
$list = $this->shopModel->where('ds_lg_idx', $lgIdx)->orderBy('ds_idx', 'DESC')->findAll();
|
||||
|
||||
// 동코드(구·군 동코드) 산출
|
||||
$dongMap = $this->resolveDongCodesForShops($lgIdx, $list);
|
||||
|
||||
$rows = [];
|
||||
foreach ($list as $row) {
|
||||
$stateMap = [1 => '정상', 2 => '폐업', 3 => '직권해지'];
|
||||
$idxKey = (int) ($row->ds_idx ?? 0);
|
||||
$dCode = (string) ($dongMap[$idxKey]['code'] ?? '');
|
||||
$dName = (string) ($dongMap[$idxKey]['name'] ?? '');
|
||||
$dongDisp = $dCode !== '' ? ($dCode . ($dName !== '' ? ' (' . $dName . ')' : '')) : '';
|
||||
$da = $row->ds_designated_at ?? null;
|
||||
$daDisp = ($da !== null && $da !== '' && (string) $da !== '0000-00-00') ? substr((string) $da, 0, 10) : '';
|
||||
$rows[] = [
|
||||
$row->ds_idx,
|
||||
$row->ds_shop_no,
|
||||
$row->ds_name,
|
||||
$row->ds_rep_name,
|
||||
$dongDisp,
|
||||
$daDisp,
|
||||
$row->ds_biz_no,
|
||||
$this->designatedShopScalar($row, 'ds_biz_type'),
|
||||
$this->designatedShopScalar($row, 'ds_biz_kind'),
|
||||
@@ -504,7 +548,7 @@ class DesignatedShop extends BaseController
|
||||
export_csv(
|
||||
'지정판매소_' . date('Ymd') . '.csv',
|
||||
[
|
||||
'번호', '판매소번호', '상호명', '대표자', '사업자번호', '업태', '업종',
|
||||
'번호', '판매소번호', '상호명', '대표자', '구·군(동코드)', '지정일', '사업자번호', '업태', '업종',
|
||||
'가상계좌은행', '계좌번호', '전화번호', '주소', '구역', '종사업장번호',
|
||||
'변경일자', '변경사유', '상태', '등록일',
|
||||
],
|
||||
@@ -648,6 +692,8 @@ class DesignatedShop extends BaseController
|
||||
'ds_branch_no' => (string) $this->request->getPost('ds_branch_no'),
|
||||
'ds_designated_at' => $this->request->getPost('ds_designated_at') ?: null,
|
||||
'ds_state' => 1,
|
||||
// 동코드(컬럼이 있을 때만 저장)
|
||||
...($this->designatedShopHasDongColumn() ? ['ds_dong_code' => (string) ($resolvedNo['dong_code'] ?? '')] : []),
|
||||
'ds_state_changed_at' => $this->normalizeOptionalDate($this->request->getPost('ds_state_changed_at')),
|
||||
'ds_change_reason' => (string) $this->request->getPost('ds_change_reason'),
|
||||
'ds_regdate' => date('Y-m-d H:i:s'),
|
||||
@@ -770,6 +816,23 @@ class DesignatedShop extends BaseController
|
||||
|
||||
$va = $this->resolveVirtualAccountFromRequest();
|
||||
|
||||
// 주소로 동코드 재산출(컬럼이 있을 때만 저장). 매칭 실패 시 동코드는 갱신하지 않음.
|
||||
$dongCodeData = [];
|
||||
if ($this->designatedShopHasDongColumn()) {
|
||||
$resolvedDong = $this->resolveDesignatedShopNumberFromAddress(
|
||||
$lgIdx,
|
||||
$addrSido,
|
||||
$addrSigungu,
|
||||
$dsAddr,
|
||||
$dsAddrJibun,
|
||||
$dsZip,
|
||||
$lg
|
||||
);
|
||||
if (($resolvedDong['ok'] ?? false) && ($resolvedDong['dong_code'] ?? '') !== '') {
|
||||
$dongCodeData = ['ds_dong_code' => (string) $resolvedDong['dong_code']];
|
||||
}
|
||||
}
|
||||
|
||||
$data = [
|
||||
'ds_name' => (string) $this->request->getPost('ds_name'),
|
||||
'ds_biz_no' => (string) $this->request->getPost('ds_biz_no'),
|
||||
@@ -786,6 +849,7 @@ class DesignatedShop extends BaseController
|
||||
'ds_tel' => (string) $this->request->getPost('ds_tel'),
|
||||
'ds_rep_phone' => (string) $this->request->getPost('ds_rep_phone'),
|
||||
'ds_email' => (string) $this->request->getPost('ds_email'),
|
||||
...$dongCodeData,
|
||||
'ds_zone_code' => (string) $this->request->getPost('ds_zone_code'),
|
||||
'ds_branch_no' => (string) $this->request->getPost('ds_branch_no'),
|
||||
'ds_designated_at' => $this->request->getPost('ds_designated_at') ?: null,
|
||||
@@ -1532,9 +1596,11 @@ class DesignatedShop extends BaseController
|
||||
usort($dCandidates, static fn (array $a, array $b): int => $b['len'] <=> $a['len']);
|
||||
|
||||
$dCode = null;
|
||||
$dName = '';
|
||||
foreach ($dCandidates as $cand) {
|
||||
if ($this->addressHaystackContainsRegionName($blob, $cand['nm'])) {
|
||||
$dCode = $cand['cd'];
|
||||
$dName = $cand['nm'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1557,9 +1623,203 @@ class DesignatedShop extends BaseController
|
||||
'ok' => true,
|
||||
'shop_no' => $prefix . sprintf('%03d', $maxSerial + 1),
|
||||
'gugun_code' => $cCode,
|
||||
'dong_code' => $dCode,
|
||||
'dong_name' => $dName,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* [AJAX] 주소 선택 시, 해당 주소의 동 기본코드(D)와 동명을 반환.
|
||||
* 등록 폼에서 주소 검색 직후 「구·군(동코드)」 표시에 사용한다.
|
||||
*/
|
||||
public function resolveAddressCodes()
|
||||
{
|
||||
if (! $this->isSuperAdmin() && ! $this->isLocalAdmin()) {
|
||||
return $this->response->setStatusCode(403)->setJSON(['ok' => false, 'error' => '권한이 없습니다.']);
|
||||
}
|
||||
|
||||
helper('admin');
|
||||
$lgIdx = admin_effective_lg_idx();
|
||||
if ($lgIdx === null || $lgIdx <= 0) {
|
||||
return $this->response->setJSON(['ok' => false, 'error' => '작업할 지자체가 선택되지 않았습니다.']);
|
||||
}
|
||||
$lg = $this->lgModel->find($lgIdx);
|
||||
if ($lg === null) {
|
||||
return $this->response->setJSON(['ok' => false, 'error' => '지자체 정보를 찾을 수 없습니다.']);
|
||||
}
|
||||
|
||||
$addrSido = (string) $this->request->getPost('addr_search_sido');
|
||||
$addrSigungu = (string) $this->request->getPost('addr_search_sigungu');
|
||||
$dsAddr = (string) $this->request->getPost('ds_addr');
|
||||
$dsAddrJibun = (string) $this->request->getPost('ds_addr_jibun');
|
||||
$dsZip = (string) $this->request->getPost('ds_zip');
|
||||
|
||||
$resolved = $this->resolveDesignatedShopNumberFromAddress(
|
||||
$lgIdx,
|
||||
$addrSido,
|
||||
$addrSigungu,
|
||||
$dsAddr,
|
||||
$dsAddrJibun,
|
||||
$dsZip,
|
||||
$lg
|
||||
);
|
||||
if (! $resolved['ok']) {
|
||||
return $this->response->setJSON(['ok' => false, 'error' => $resolved['error']]);
|
||||
}
|
||||
|
||||
return $this->response->setJSON([
|
||||
'ok' => true,
|
||||
'dong_code' => $resolved['dong_code'] ?? '',
|
||||
'dong_name' => $resolved['dong_name'] ?? '',
|
||||
'gugun_code' => $resolved['gugun_code'] ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* designated_shop 테이블에 ds_dong_code 컬럼이 존재하는지(1회 조회 후 캐시).
|
||||
*/
|
||||
private function designatedShopHasDongColumn(): bool
|
||||
{
|
||||
if ($this->dongColumnExists === null) {
|
||||
$this->dongColumnExists = \Config\Database::connect()->fieldExists('ds_dong_code', 'designated_shop');
|
||||
}
|
||||
|
||||
return $this->dongColumnExists;
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 지정판매소의 동 기본코드(D)를 주소로 일괄 산출한다.
|
||||
* code_detail(B·C·D) 행을 한 번만 로드해 각 판매소를 매칭한다.
|
||||
*
|
||||
* @param list<object> $shops
|
||||
* @return array<int, array{code: string, name: string}> ds_idx => [동코드, 동명]
|
||||
*/
|
||||
private function resolveDongCodesForShops(int $lgIdx, array $shops): array
|
||||
{
|
||||
$out = [];
|
||||
if ($shops === []) {
|
||||
return $out;
|
||||
}
|
||||
|
||||
$bCk = $this->codeKindIdxByCkCode('B');
|
||||
$cCk = $this->codeKindIdxByCkCode('C');
|
||||
$dCk = $this->codeKindIdxByCkCode('D');
|
||||
if ($bCk === null || $cCk === null || $dCk === null) {
|
||||
return $out;
|
||||
}
|
||||
|
||||
$detailModel = model(CodeDetailModel::class);
|
||||
$bRows = $detailModel->getByKind($bCk, true, $lgIdx);
|
||||
$cRows = $detailModel->getByKind($cCk, true, $lgIdx);
|
||||
$dRows = $detailModel->getByKind($dCk, true, $lgIdx);
|
||||
|
||||
foreach ($shops as $row) {
|
||||
$idx = (int) ($row->ds_idx ?? 0);
|
||||
$road = (string) ($row->ds_addr ?? '');
|
||||
$jibun = (string) ($row->ds_addr_jibun ?? '');
|
||||
$zip = (string) ($row->ds_zip ?? '');
|
||||
$blob = trim($road . ' ' . $jibun . ' ' . $zip);
|
||||
if ($blob === '') {
|
||||
$out[$idx] = ['code' => '', 'name' => ''];
|
||||
continue;
|
||||
}
|
||||
|
||||
// B: 시·도
|
||||
$bCode = null;
|
||||
foreach ($bRows as $r) {
|
||||
$nm = trim((string) $r->cd_name);
|
||||
$cd = trim((string) $r->cd_code);
|
||||
if ($nm === '' || $cd === '') {
|
||||
continue;
|
||||
}
|
||||
if ($this->koreanRegionTokenMatches($nm, '', $blob)) {
|
||||
$bCode = $cd;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($bCode === null || $bCode === '') {
|
||||
$out[$idx] = ['code' => '', 'name' => ''];
|
||||
continue;
|
||||
}
|
||||
|
||||
// C: 구·군
|
||||
$cCode = null;
|
||||
foreach ($cRows as $r) {
|
||||
$cd = trim((string) $r->cd_code);
|
||||
if ($cd === '' || ! str_starts_with($cd, $bCode)) {
|
||||
continue;
|
||||
}
|
||||
$nm = trim((string) $r->cd_name);
|
||||
if ($nm === '') {
|
||||
continue;
|
||||
}
|
||||
if ($this->koreanRegionTokenMatches($nm, '', $blob)) {
|
||||
$cCode = $cd;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($cCode === null || $cCode === '') {
|
||||
$out[$idx] = ['code' => '', 'name' => ''];
|
||||
continue;
|
||||
}
|
||||
|
||||
// D: 동 — 긴 이름 우선 매칭(예: '노원동1가'가 '노원동'보다 먼저)
|
||||
$cands = [];
|
||||
foreach ($dRows as $r) {
|
||||
$cd = trim((string) $r->cd_code);
|
||||
if ($cd === '' || ! str_starts_with($cd, $cCode)) {
|
||||
continue;
|
||||
}
|
||||
$nm = trim((string) $r->cd_name);
|
||||
if ($nm === '') {
|
||||
continue;
|
||||
}
|
||||
$cands[] = ['len' => mb_strlen($nm, 'UTF-8'), 'nm' => $nm, 'cd' => $cd];
|
||||
}
|
||||
usort($cands, static fn (array $a, array $b): int => $b['len'] <=> $a['len']);
|
||||
|
||||
$dCode = '';
|
||||
$dName = '';
|
||||
foreach ($cands as $cand) {
|
||||
if ($this->addressHaystackContainsRegionName($blob, $cand['nm'])) {
|
||||
$dCode = $cand['cd'];
|
||||
$dName = $cand['nm'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
$out[$idx] = ['code' => $dCode, 'name' => $dName];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 산출된 동코드를 ds_dong_code 컬럼에 저장(값이 다를 때만). 컬럼이 없으면 아무것도 하지 않음.
|
||||
*
|
||||
* @param list<object> $shops
|
||||
* @param array<int, array{code: string, name: string}> $dongMap
|
||||
*/
|
||||
private function persistDesignatedShopDongCodes(array $shops, array $dongMap): void
|
||||
{
|
||||
if ($dongMap === [] || ! $this->designatedShopHasDongColumn()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db = \Config\Database::connect();
|
||||
foreach ($shops as $row) {
|
||||
$idx = (int) ($row->ds_idx ?? 0);
|
||||
if ($idx <= 0 || ! isset($dongMap[$idx])) {
|
||||
continue;
|
||||
}
|
||||
$code = $dongMap[$idx]['code'];
|
||||
$current = (string) ($row->ds_dong_code ?? '');
|
||||
if ($code !== '' && $code !== $current) {
|
||||
$db->table('designated_shop')->where('ds_idx', $idx)->update(['ds_dong_code' => $code]);
|
||||
$row->ds_dong_code = $code; // 뷰 페이로드에도 반영
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 카카오맵 JavaScript SDK용 키 (.env kakao.javascriptKey) */
|
||||
private function kakaoJavascriptKey(): string
|
||||
{
|
||||
|
||||
193
app/Controllers/Admin/Feedback.php
Normal file
193
app/Controllers/Admin/Feedback.php
Normal file
@@ -0,0 +1,193 @@
|
||||
<?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', '처리 상태를 저장했습니다.');
|
||||
}
|
||||
|
||||
/** 스크린샷 이미지 서빙 (관리자만, 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 [
|
||||
'company' => '제작업체',
|
||||
'district' => '구·군',
|
||||
'district' => '지자체',
|
||||
'agency' => '대행소',
|
||||
];
|
||||
}
|
||||
@@ -77,6 +77,8 @@ class Manager extends BaseController
|
||||
$rules = [
|
||||
'mg_name' => 'required|max_length[50]',
|
||||
'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_phone' => 'permit_empty|max_length[20]',
|
||||
'mg_email' => 'permit_empty|valid_email|max_length[100]',
|
||||
@@ -88,6 +90,8 @@ class Manager extends BaseController
|
||||
$this->model->insert([
|
||||
'mg_lg_idx' => admin_effective_lg_idx(),
|
||||
'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_position_code' => $this->request->getPost('mg_position_code') ?? '',
|
||||
'mg_tel' => $this->request->getPost('mg_tel') ?? '',
|
||||
@@ -126,6 +130,8 @@ class Manager extends BaseController
|
||||
$rules = [
|
||||
'mg_name' => 'required|max_length[50]',
|
||||
'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]',
|
||||
];
|
||||
if (! $this->validate($rules)) {
|
||||
@@ -134,6 +140,8 @@ class Manager extends BaseController
|
||||
|
||||
$this->model->update($id, [
|
||||
'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_position_code' => $this->request->getPost('mg_position_code') ?? '',
|
||||
'mg_tel' => $this->request->getPost('mg_tel') ?? '',
|
||||
|
||||
@@ -256,8 +256,31 @@ class Menu extends BaseController
|
||||
return redirect()->to(base_url('admin/select-local-government'))
|
||||
->with('error', '지자체를 선택하세요.');
|
||||
}
|
||||
$ids = $this->request->getPost('mm_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)) {
|
||||
return $this->menusRedirect($postMtIdx)->with('error', '순서를 적용할 메뉴가 없습니다.');
|
||||
}
|
||||
|
||||
@@ -19,14 +19,15 @@ class PasswordChange extends BaseController
|
||||
helper('admin');
|
||||
$rules = [
|
||||
'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]',
|
||||
];
|
||||
$messages = [
|
||||
'current_password' => ['required' => '현재 비밀번호를 입력해 주세요.'],
|
||||
'new_password' => [
|
||||
'required' => '새 비밀번호를 입력해 주세요.',
|
||||
'min_length' => '비밀번호는 4자 이상이어야 합니다.',
|
||||
'required' => '새 비밀번호를 입력해 주세요.',
|
||||
'min_length' => '비밀번호는 8자 이상이어야 합니다.',
|
||||
'regex_match' => '비밀번호는 영문·숫자·특수문자를 모두 포함해야 합니다.',
|
||||
],
|
||||
'new_password_confirm' => [
|
||||
'required' => '비밀번호 확인을 입력해 주세요.',
|
||||
|
||||
@@ -45,7 +45,8 @@ class SalesAgency extends BaseController
|
||||
$builder->where('sa_idx', (int) $saIdx);
|
||||
}
|
||||
|
||||
$list = $builder->orderForDisplay()->paginate(20);
|
||||
// 방금 등록한 대행소가 맨 위에 보이도록 최신 등록순(번호 내림차순)으로 정렬
|
||||
$list = $builder->orderBy('sa_idx', 'DESC')->paginate(20);
|
||||
$pager = $this->model->pager;
|
||||
|
||||
$queryForPager = [
|
||||
|
||||
@@ -77,6 +77,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') {
|
||||
$built = $this->buildDailyLedgerPresentation($filtered, $hasBsFee);
|
||||
} else {
|
||||
@@ -169,6 +237,10 @@ class SalesReport extends BaseController
|
||||
'dsIdx' => $dsIdx,
|
||||
'saIdx' => $saIdx,
|
||||
'cats' => $cats,
|
||||
'sizeGroups' => $sizeGroups,
|
||||
'size' => $size,
|
||||
'sizeStats' => $sizeStats,
|
||||
'catStats' => $catStats,
|
||||
'shops' => $shops,
|
||||
'agencies' => $agencies,
|
||||
'lgName' => $lgName,
|
||||
@@ -220,6 +292,68 @@ class SalesReport extends BaseController
|
||||
return $builder->get()->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* 판매대장 하단 통계: 용량별(리터별)·품목별(봉투/음식물/폐기물) 집계.
|
||||
*
|
||||
* @param list<object> $rows 필터가 적용된 상세 판매행
|
||||
* @param callable $sizeOf 품목명 → 크기 토큰(예: '10L', '1000원', '')
|
||||
* @return array{0: list<array{label:string,qty:int,amount:int}>, 1: list<array{label:string,qty:int,amount:int}>}
|
||||
*/
|
||||
private function buildLedgerBottomStats(array $rows, callable $sizeOf): array
|
||||
{
|
||||
$bySize = []; // 크기토큰 => [qty, amount]
|
||||
$byCat = []; // '봉투'|'음식물'|'폐기물' => [qty, amount]
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$name = (string) ($row->bs_bag_name ?? '');
|
||||
$qty = (int) ($row->line_qty ?? 0);
|
||||
$amt = (float) ($row->line_amount ?? 0);
|
||||
|
||||
// ① 용량별(리터별) — 크기 토큰이 없으면 '기타'
|
||||
$sz = (string) $sizeOf($name);
|
||||
if ($sz === '') {
|
||||
$sz = '기타';
|
||||
}
|
||||
$bySize[$sz]['qty'] = ($bySize[$sz]['qty'] ?? 0) + $qty;
|
||||
$bySize[$sz]['amount'] = ($bySize[$sz]['amount'] ?? 0) + $amt;
|
||||
|
||||
// ② 품목별 — 봉투/음식물/폐기물 3분류
|
||||
if (mb_strpos($name, '음식물') !== false) {
|
||||
$cat = '음식물';
|
||||
} elseif (mb_strpos($name, '폐기물') !== false || mb_strpos($name, '대형') !== false) {
|
||||
$cat = '폐기물';
|
||||
} else {
|
||||
$cat = '봉투';
|
||||
}
|
||||
$byCat[$cat]['qty'] = ($byCat[$cat]['qty'] ?? 0) + $qty;
|
||||
$byCat[$cat]['amount'] = ($byCat[$cat]['amount'] ?? 0) + $amt;
|
||||
}
|
||||
|
||||
// 용량별: 숫자(리터/금액) 오름차순, '기타'는 맨 뒤
|
||||
$sizeStats = [];
|
||||
foreach ($bySize as $label => $v) {
|
||||
$sizeStats[] = ['label' => (string) $label, 'qty' => (int) $v['qty'], 'amount' => (int) round($v['amount'])];
|
||||
}
|
||||
usort($sizeStats, static function (array $a, array $b): int {
|
||||
$ao = $a['label'] === '기타' ? 1 : 0;
|
||||
$bo = $b['label'] === '기타' ? 1 : 0;
|
||||
if ($ao !== $bo) {
|
||||
return $ao <=> $bo;
|
||||
}
|
||||
return (int) preg_replace('/\D/', '', $a['label']) <=> (int) preg_replace('/\D/', '', $b['label']);
|
||||
});
|
||||
|
||||
// 품목별: 봉투 → 음식물 → 폐기물 고정 순서
|
||||
$catStats = [];
|
||||
foreach (['봉투', '음식물', '폐기물'] as $c) {
|
||||
if (isset($byCat[$c])) {
|
||||
$catStats[] = ['label' => $c, 'qty' => (int) $byCat[$c]['qty'], 'amount' => (int) round($byCat[$c]['amount'])];
|
||||
}
|
||||
}
|
||||
|
||||
return [$sizeStats, $catStats];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $cats 빈 배열이면 전체
|
||||
*/
|
||||
@@ -1038,7 +1172,8 @@ class SalesReport extends BaseController
|
||||
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'));
|
||||
if ($startDate > $endDate) {
|
||||
[$startDate, $endDate] = [$endDate, $startDate];
|
||||
@@ -1216,6 +1351,30 @@ class SalesReport extends BaseController
|
||||
|
||||
if ($export) {
|
||||
$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 = [];
|
||||
foreach ($built['itemBlocks'] as $block) {
|
||||
foreach ($block['lines'] as $li) {
|
||||
@@ -1238,7 +1397,8 @@ class SalesReport extends BaseController
|
||||
'년판매현황_' . $year . '_' . $exportStamp,
|
||||
$year . '년판매',
|
||||
$headers,
|
||||
$exportRows
|
||||
$exportRows,
|
||||
$topRows
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2560,7 +2720,8 @@ class SalesReport extends BaseController
|
||||
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'));
|
||||
$endDate = (string) ($this->request->getGet('end_date') ?? date('Y-m-d'));
|
||||
if ($startDate > $endDate) {
|
||||
@@ -3189,7 +3350,8 @@ class SalesReport extends BaseController
|
||||
$salesScope = 'all';
|
||||
}
|
||||
|
||||
$queried = $this->request->getGet('search') === '1';
|
||||
// 첫 진입(search 파라미터 없음)에도 기본 조건(오늘·40일·ALL)으로 자동 조회해 바로 표시한다.
|
||||
$queried = ($this->request->getGet('search') ?? '1') === '1';
|
||||
$built = (new \App\Libraries\BagSupplyPlanBuilder())->build(
|
||||
$lgIdx,
|
||||
$refDate,
|
||||
|
||||
@@ -22,10 +22,13 @@ class SelectLocalGovernment extends BaseController
|
||||
->orderBy('lg_name', 'ASC')
|
||||
->findAll();
|
||||
|
||||
$currentLgIdx = (int) (session()->get('admin_selected_lg_idx') ?? 0);
|
||||
|
||||
return view('admin/layout', [
|
||||
'title' => '지자체 선택',
|
||||
'content' => view('admin/select_local_government/index', [
|
||||
'list' => $list,
|
||||
'list' => $list,
|
||||
'currentLgIdx' => $currentLgIdx,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -83,13 +83,13 @@ class User extends BaseController
|
||||
{
|
||||
$rules = [
|
||||
'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_email' => 'permit_empty|valid_email|max_length[100]',
|
||||
'mb_phone' => 'permit_empty|max_length[20]',
|
||||
'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());
|
||||
}
|
||||
$allowedLevels = array_keys($this->getAssignableLevels());
|
||||
@@ -154,9 +154,9 @@ class User extends BaseController
|
||||
];
|
||||
$passwd = $this->request->getPost('mb_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());
|
||||
}
|
||||
$allowedLevels = array_keys($this->getAssignableLevels());
|
||||
|
||||
@@ -19,15 +19,42 @@ class Auth extends BaseController
|
||||
public function showLoginForm()
|
||||
{
|
||||
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', [
|
||||
'pageTitle' => '로그인 - 종량제 시스템',
|
||||
'pageTitle' => '로그인 - GBLS',
|
||||
'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()
|
||||
{
|
||||
$rules = [
|
||||
@@ -160,7 +187,7 @@ class Auth extends BaseController
|
||||
|
||||
return view('auth/login_two_factor', [
|
||||
'memberId' => $member->mb_id,
|
||||
'pageTitle' => '2차 인증 - 종량제 시스템',
|
||||
'pageTitle' => '2차 인증 - GBLS',
|
||||
'cardMax' => 'max-w-md',
|
||||
]);
|
||||
}
|
||||
@@ -244,7 +271,7 @@ class Auth extends BaseController
|
||||
'memberId' => $member->mb_id,
|
||||
'qrDataUri' => $qrDataUri,
|
||||
'secret' => $secret,
|
||||
'pageTitle' => '2차 인증 등록 - 종량제 시스템',
|
||||
'pageTitle' => '2차 인증 등록 - GBLS',
|
||||
'cardMax' => 'max-w-lg',
|
||||
]);
|
||||
}
|
||||
@@ -348,7 +375,7 @@ class Auth extends BaseController
|
||||
|
||||
return view('auth/register', [
|
||||
'localGovernments' => $localGovernments,
|
||||
'pageTitle' => '회원가입 - 종량제 시스템',
|
||||
'pageTitle' => '회원가입 - GBLS',
|
||||
'cardMax' => 'max-w-md',
|
||||
]);
|
||||
}
|
||||
@@ -357,7 +384,7 @@ class Auth extends BaseController
|
||||
{
|
||||
$rules = [
|
||||
'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_name' => 'required|max_length[100]',
|
||||
'mb_email' => 'permit_empty|valid_email|max_length[100]',
|
||||
@@ -374,9 +401,10 @@ class Auth extends BaseController
|
||||
'is_unique' => '이미 사용 중인 아이디입니다.',
|
||||
],
|
||||
'mb_passwd' => [
|
||||
'required' => '비밀번호를 입력해 주세요.',
|
||||
'min_length' => '비밀번호는 4자 이상이어야 합니다.',
|
||||
'required' => '비밀번호를 입력해 주세요.',
|
||||
'min_length' => '비밀번호는 8자 이상이어야 합니다.',
|
||||
'max_length' => '비밀번호는 255자 이하여야 합니다.',
|
||||
'regex_match' => '비밀번호는 영문·숫자·특수문자를 모두 포함해야 합니다.',
|
||||
],
|
||||
'mb_passwd_confirm' => [
|
||||
'required' => '비밀번호 확인을 입력해 주세요.',
|
||||
@@ -548,6 +576,8 @@ class Auth extends BaseController
|
||||
private function completeLogin(object $member, array $logData): RedirectResponse
|
||||
{
|
||||
$this->clearPending2faSession();
|
||||
// 중복 로그인 방지(나중 로그인 우선): 새 세션 토큰 발급 → 기존 다른 세션은 다음 요청 때 무효화됨
|
||||
$sessionToken = bin2hex(random_bytes(16));
|
||||
$sessionData = [
|
||||
'mb_idx' => $member->mb_idx,
|
||||
'mb_id' => $member->mb_id,
|
||||
@@ -555,6 +585,7 @@ class Auth extends BaseController
|
||||
'mb_level' => $member->mb_level,
|
||||
'mb_lg_idx' => $member->mb_lg_idx ?? null,
|
||||
'logged_in' => true,
|
||||
'session_token' => $sessionToken,
|
||||
];
|
||||
session()->set($sessionData);
|
||||
|
||||
@@ -562,18 +593,42 @@ class Auth extends BaseController
|
||||
'mb_latestdate' => date('Y-m-d H:i:s'),
|
||||
'mb_login_fail_count' => 0,
|
||||
'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);
|
||||
|
||||
$successMsg = '로그인되었습니다.' . $loginNotice;
|
||||
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)) {
|
||||
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
|
||||
|
||||
@@ -212,12 +212,15 @@ class Bag extends BaseController
|
||||
return $row ? trim((string) ($row->mg_name ?? '')) : '';
|
||||
}
|
||||
|
||||
private function render(string $title, string $viewFile, array $data = []): string
|
||||
private function render(string $title, string $viewFile, array $data = [], bool $bare = false): string
|
||||
{
|
||||
// 사이트 업무 페이지 공통 셸: gov-portal 디자인(헤더+대메뉴+클릭형 좌측 사이드바).
|
||||
return view('bag/layout/portal', [
|
||||
// /workspace 탭(iframe) 안에서는 임베드 레이아웃(헤더·사이드바 없이 본문만).
|
||||
$layout = $this->isEmbeddedRequest() ? 'bag/layout/embed' : 'bag/layout/portal';
|
||||
|
||||
return view($layout, [
|
||||
'title' => $title,
|
||||
'content' => view($viewFile, $data),
|
||||
'bare' => $bare, // true면 바깥 카드 래퍼 없이 본문을 그대로(이미 카드형 화면용)
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -589,7 +592,8 @@ class Bag extends BaseController
|
||||
'selectedKind' => $selectedKind,
|
||||
'detailList' => $detailList,
|
||||
'rowCanEdit' => $rowCanEdit,
|
||||
]);
|
||||
'isEmbed' => $this->isEmbeddedRequest(), // 행 클릭 시 embed 유지(중첩 셸 방지)
|
||||
], true); // 본문이 이미 카드 2개라 바깥 래퍼 생략
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3298,7 +3302,8 @@ SQL);
|
||||
public function flow(): string
|
||||
{
|
||||
$lgIdx = $this->lgIdx();
|
||||
$queried = $this->request->getGet('search') === '1'
|
||||
// 첫 진입(search 파라미터 없음)에도 기본 조건(이번 달·전체)으로 자동 조회해 바로 표시한다.
|
||||
$queried = ($this->request->getGet('search') ?? '1') === '1'
|
||||
|| $this->request->getGet('start_date') !== null
|
||||
|| $this->request->getGet('end_date') !== null;
|
||||
|
||||
@@ -3575,11 +3580,46 @@ SQL);
|
||||
/**
|
||||
* 사용자 매뉴얼(설명서) — 목차 첫 페이지로 이동.
|
||||
*/
|
||||
/**
|
||||
* 인쇄 활동 기록 (클라이언트 beforeprint beacon 수신).
|
||||
* 화면(경로·제목)을 활동 로그에 'print' 액션으로 남긴다.
|
||||
*/
|
||||
public function printLog(): \CodeIgniter\HTTP\ResponseInterface
|
||||
{
|
||||
$title = trim((string) $this->request->getPost('title'));
|
||||
$path = trim((string) $this->request->getPost('path'));
|
||||
if (mb_strlen($title) > 120) {
|
||||
$title = mb_substr($title, 0, 120);
|
||||
}
|
||||
if (mb_strlen($path) > 200) {
|
||||
$path = mb_substr($path, 0, 200);
|
||||
}
|
||||
helper('audit');
|
||||
audit_log('print', 'print', 0, null, ['화면' => $title, '경로' => $path]);
|
||||
|
||||
return $this->response->setStatusCode(204);
|
||||
}
|
||||
|
||||
public function manual(): \CodeIgniter\HTTP\RedirectResponse
|
||||
{
|
||||
$first = (new \App\Libraries\ManualRenderer())->firstSlug();
|
||||
$url = site_url('bag/manual/' . $first);
|
||||
if ($this->isEmbeddedRequest()) {
|
||||
$url .= '?embed=1'; // 워크스페이스 탭 안에서는 임베드 유지(중첩 헤더 방지)
|
||||
}
|
||||
|
||||
return redirect()->to(site_url('bag/manual/' . $first));
|
||||
return redirect()->to($url);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 매뉴얼 전체 검색 (JSON). q 와 일치하는 페이지·스니펫 목록.
|
||||
*/
|
||||
public function manualSearch(): \CodeIgniter\HTTP\ResponseInterface
|
||||
{
|
||||
$q = (string) ($this->request->getGet('q') ?? '');
|
||||
$results = (new \App\Libraries\ManualRenderer())->search($q);
|
||||
|
||||
return $this->response->setJSON(['q' => $q, 'results' => $results]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3594,11 +3634,36 @@ SQL);
|
||||
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound('매뉴얼 문서를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
// 이 매뉴얼 페이지가 설명하는 화면 → 바로가기 링크(screenHelp 역방향).
|
||||
// screenHelp 에는 "현재경로 매칭"용 비(非)라우트 prefix(bag/sale, bag/order 등)도 섞여 있어
|
||||
// 그대로 쓰면 404가 난다. 같은 소제목(hint)의 후보 중 "실제 GET 라우트"인 첫 경로만 채택.
|
||||
$routeCollection = service('routes');
|
||||
$routeCollection->loadRoutes();
|
||||
$validGet = $routeCollection->getRoutes('GET'); // [경로 => 핸들러] (정적 경로는 키가 곧 경로)
|
||||
|
||||
$screenHelp = config(\Config\Manual::class)->screenHelp ?? [];
|
||||
$jumpByHint = [];
|
||||
foreach ($screenHelp as $path => $val) {
|
||||
[$pSlug, $hint] = array_pad(explode('#', (string) $val, 2), 2, null);
|
||||
if ($pSlug !== $slug || $hint === null || trim($hint) === '') {
|
||||
continue;
|
||||
}
|
||||
if (isset($jumpByHint[$hint]) || ! isset($validGet[(string) $path])) {
|
||||
continue; // 이미 채택했거나, 실제 라우트가 아니면 건너뜀
|
||||
}
|
||||
$jumpByHint[$hint] = (string) $path;
|
||||
}
|
||||
$jumps = [];
|
||||
foreach ($jumpByHint as $hint => $path) {
|
||||
$jumps[] = ['hint' => $hint, 'url' => base_url($path)];
|
||||
}
|
||||
|
||||
return $this->render('사용자 매뉴얼 · ' . $page['title'], 'bag/manual', [
|
||||
'pages' => $renderer->pages(),
|
||||
'current' => $slug,
|
||||
'title' => $page['title'],
|
||||
'body' => $body,
|
||||
'jumps' => $jumps,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -3808,6 +3873,66 @@ SQL);
|
||||
return redirect()->to(site_url('bag/issue/cancel'))->with('success', session()->getFlashdata('success') ?? '취소되었습니다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* [AJAX] 발주 1건의 내역(헤더+품목)을 JSON으로 반환 — 발주 이력 "보기" 모달·삭제 화면 상세용.
|
||||
*/
|
||||
public function orderDetailJson(int $id)
|
||||
{
|
||||
helper('admin');
|
||||
$lgIdx = $this->lgIdx();
|
||||
if (! $lgIdx) {
|
||||
return $this->response->setStatusCode(403)->setJSON(['ok' => false, 'error' => '지자체가 선택되지 않았습니다.']);
|
||||
}
|
||||
$order = model(BagOrderModel::class)->find($id);
|
||||
if ($order === null || (int) $order->bo_lg_idx !== (int) $lgIdx) {
|
||||
return $this->response->setStatusCode(404)->setJSON(['ok' => false, 'error' => '발주를 찾을 수 없습니다.']);
|
||||
}
|
||||
|
||||
$companyName = '';
|
||||
if ($order->bo_company_idx) {
|
||||
$c = model(CompanyModel::class)->find($order->bo_company_idx);
|
||||
$companyName = $c ? (string) $c->cp_name : '';
|
||||
}
|
||||
$agencyName = '';
|
||||
if ($order->bo_agency_idx) {
|
||||
$a = model(SalesAgencyModel::class)->find($order->bo_agency_idx);
|
||||
if ($a) {
|
||||
$agencyName = '[' . ($a->sa_kind ?? '') . '] ' . ($a->sa_code ?? '') . ' — ' . ($a->sa_name ?? '');
|
||||
}
|
||||
}
|
||||
$managerName = '';
|
||||
if (\Config\Database::connect()->fieldExists('bo_manager_idx', 'bag_order') && ($order->bo_manager_idx ?? null)) {
|
||||
$mg = model(ManagerModel::class)->find($order->bo_manager_idx);
|
||||
$managerName = $mg ? (string) $mg->mg_name : '';
|
||||
}
|
||||
|
||||
$items = [];
|
||||
foreach (model(BagOrderItemModel::class)->where('boi_bo_idx', $id)->orderBy('boi_idx', 'ASC')->findAll() as $it) {
|
||||
$items[] = [
|
||||
'bag_code' => (string) ($it->boi_bag_code ?? ''),
|
||||
'bag_name' => (string) ($it->boi_bag_name ?? ''),
|
||||
'unit_price' => (float) ($it->boi_unit_price ?? 0),
|
||||
'qty_box' => (int) ($it->boi_qty_box ?? 0),
|
||||
'qty_sheet' => (int) ($it->boi_qty_sheet ?? 0),
|
||||
'amount' => (float) ($it->boi_amount ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
return $this->response->setJSON([
|
||||
'ok' => true,
|
||||
'bo_idx' => (int) $order->bo_idx,
|
||||
'bo_lot_no' => (string) ($order->bo_lot_no ?? ''),
|
||||
'bo_version' => (int) ($order->bo_version ?? 1),
|
||||
'order_date' => (string) ($order->bo_order_date ?? ''),
|
||||
'fee_rate' => (float) ($order->bo_fee_rate ?? 0),
|
||||
'status' => (string) ($order->bo_status ?? ''),
|
||||
'company' => $companyName,
|
||||
'agency' => $agencyName,
|
||||
'manager' => $managerName,
|
||||
'items' => $items,
|
||||
]);
|
||||
}
|
||||
|
||||
// --- 발주 등록 ---
|
||||
public function orderCreate(): string
|
||||
{
|
||||
@@ -3820,6 +3945,7 @@ SQL);
|
||||
? model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', '협회')->where('cp_state', 1)->findAll()
|
||||
: [];
|
||||
$agencies = $lgIdx ? model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->orderForDisplay()->findAll() : [];
|
||||
$managers = $lgIdx ? model(ManagerModel::class)->where('mg_lg_idx', $lgIdx)->where('mg_state', 1)->orderBy('mg_name', 'ASC')->findAll() : [];
|
||||
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
|
||||
$bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
|
||||
$priceMapRows = $lgIdx ? model(BagPriceModel::class)->latestActiveMapByBagCode($lgIdx) : [];
|
||||
@@ -3876,6 +4002,7 @@ SQL);
|
||||
'companies',
|
||||
'associations',
|
||||
'agencies',
|
||||
'managers',
|
||||
'bagCodes',
|
||||
'recentOrders',
|
||||
'companyMap',
|
||||
@@ -3888,7 +4015,7 @@ SQL);
|
||||
}
|
||||
|
||||
/**
|
||||
* LOT-No 디스켓 불출: 발주 건을 선택해 암호화 seed 파일 생성/다운로드.
|
||||
* 발주파일 생성(구 'LOT-No 디스켓 불출'): 발주 건을 선택해 암호화 seed 파일 생성/다운로드.
|
||||
*/
|
||||
public function orderLotSeed(): string|RedirectResponse
|
||||
{
|
||||
@@ -3966,7 +4093,7 @@ SQL);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('LOT-No 디스켓 불출', 'bag/order_lot_seed', [
|
||||
return $this->render('발주파일 생성', 'bag/order_lot_seed', [
|
||||
'orders' => $orders,
|
||||
'pager' => $pager,
|
||||
'startMonth' => $startMonth,
|
||||
@@ -3979,17 +4106,16 @@ SQL);
|
||||
]);
|
||||
}
|
||||
|
||||
public function orderLotSeedGenerate(): RedirectResponse|ResponseInterface
|
||||
/**
|
||||
* 발주파일(seed) 생성·미리보기가 공용으로 쓰는 데이터 구성.
|
||||
* 발주 메타(order_data)와 품목(items, 팩/박스 환산 포함), 무결성 해시를 만든다.
|
||||
*
|
||||
* @return array{ok: bool, error?: string, order?: object, order_data?: array<string,mixed>, items?: array<int,array<string,mixed>>, lot_no?: string, uuid?: string, version?: int, order_hash?: string}
|
||||
*/
|
||||
private function buildLotSeedData(int $lgIdx, int $boIdx): array
|
||||
{
|
||||
helper('admin');
|
||||
$lgIdx = $this->lgIdx();
|
||||
if (! $lgIdx) {
|
||||
return redirect()->to(site_url('bag/purchase-inbound'))->with('error', '지자체를 선택해 주세요.');
|
||||
}
|
||||
|
||||
$boIdx = (int) ($this->request->getPost('bo_idx') ?? 0);
|
||||
if ($boIdx <= 0) {
|
||||
return redirect()->back()->with('error', '발주 건을 선택해 주세요.');
|
||||
return ['ok' => false, 'error' => '발주 건을 선택해 주세요.'];
|
||||
}
|
||||
|
||||
$order = model(BagOrderModel::class)
|
||||
@@ -3997,14 +4123,14 @@ SQL);
|
||||
->where('bo_idx', $boIdx)
|
||||
->first();
|
||||
if (! $order) {
|
||||
return redirect()->back()->with('error', '발주 정보를 찾을 수 없습니다.');
|
||||
return ['ok' => false, 'error' => '발주 정보를 찾을 수 없습니다.'];
|
||||
}
|
||||
|
||||
$lotNo = trim((string) ($order->bo_lot_no ?? ''));
|
||||
$uuid = trim((string) ($order->bo_uuid ?? ''));
|
||||
$version = max(1, (int) ($order->bo_version ?? 1));
|
||||
if ($lotNo === '' || $uuid === '') {
|
||||
return redirect()->back()->with('error', '발주의 LOT/UUID 정보가 없어 seed 파일을 생성할 수 없습니다.');
|
||||
return ['ok' => false, 'error' => '발주의 LOT/UUID 정보가 없어 발주파일을 생성할 수 없습니다.'];
|
||||
}
|
||||
|
||||
$items = model(BagOrderItemModel::class)
|
||||
@@ -4012,7 +4138,7 @@ SQL);
|
||||
->orderBy('boi_idx', 'ASC')
|
||||
->findAll();
|
||||
if ($items === []) {
|
||||
return redirect()->back()->with('error', '발주 품목이 없어 seed 파일을 생성할 수 없습니다.');
|
||||
return ['ok' => false, 'error' => '발주 품목이 없어 발주파일을 생성할 수 없습니다.'];
|
||||
}
|
||||
|
||||
$unitRows = model(PackagingUnitModel::class)
|
||||
@@ -4077,10 +4203,100 @@ SQL);
|
||||
$orderHash = hash('sha256', $payloadJson);
|
||||
}
|
||||
|
||||
$seedPath = $this->generateLotSeedFile($uuid, $version, $lotNo, $orderData, $hashItems, $orderHash);
|
||||
return [
|
||||
'ok' => true,
|
||||
'order' => $order,
|
||||
'order_data' => $orderData,
|
||||
'items' => $hashItems,
|
||||
'lot_no' => $lotNo,
|
||||
'uuid' => $uuid,
|
||||
'version' => $version,
|
||||
'order_hash' => $orderHash,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 발주파일 생성 전 '내용 확인'(미리보기): 암호화 전 평문 내용을 JSON 으로 반환.
|
||||
*/
|
||||
public function orderLotSeedPreview(): ResponseInterface
|
||||
{
|
||||
helper('admin');
|
||||
$lgIdx = $this->lgIdx();
|
||||
if (! $lgIdx) {
|
||||
return $this->response->setJSON(['ok' => false, 'error' => '지자체를 선택해 주세요.']);
|
||||
}
|
||||
|
||||
$boIdx = (int) ($this->request->getGet('bo_idx') ?? 0);
|
||||
$data = $this->buildLotSeedData($lgIdx, $boIdx);
|
||||
if (! ($data['ok'] ?? false)) {
|
||||
return $this->response->setJSON(['ok' => false, 'error' => (string) ($data['error'] ?? '내용을 확인할 수 없습니다.')]);
|
||||
}
|
||||
|
||||
$order = $data['order'];
|
||||
$companyIdx = (int) ($order->bo_company_idx ?? 0);
|
||||
$companyName = '';
|
||||
if ($companyIdx > 0) {
|
||||
$company = model(CompanyModel::class)
|
||||
->where('cp_lg_idx', $lgIdx)
|
||||
->where('cp_idx', $companyIdx)
|
||||
->first();
|
||||
$companyName = (string) ($company->cp_name ?? '');
|
||||
}
|
||||
|
||||
$items = array_map(static fn (array $it): array => [
|
||||
'bag_code' => (string) $it['boi_bag_code'],
|
||||
'bag_name' => (string) $it['boi_bag_name'],
|
||||
'unit_price' => (float) $it['boi_unit_price'],
|
||||
'qty_box' => (int) $it['boi_qty_box'],
|
||||
'qty_pack' => (int) $it['boi_qty_pack'],
|
||||
'qty_sheet' => (int) $it['boi_qty_sheet'],
|
||||
'amount' => (float) $it['boi_amount'],
|
||||
], $data['items']);
|
||||
|
||||
return $this->response->setJSON([
|
||||
'ok' => true,
|
||||
'bo_idx' => $boIdx,
|
||||
'lot_no' => $data['lot_no'],
|
||||
'uuid' => $data['uuid'],
|
||||
'version' => $data['version'],
|
||||
'order_date' => (string) ($order->bo_order_date ?? ''),
|
||||
'company' => $companyName,
|
||||
'order_hash' => $data['order_hash'],
|
||||
'items' => $items,
|
||||
'totals' => [
|
||||
'line_count' => count($items),
|
||||
'qty_box' => array_sum(array_map(static fn ($i) => (int) $i['qty_box'], $items)),
|
||||
'qty_sheet' => array_sum(array_map(static fn ($i) => (int) $i['qty_sheet'], $items)),
|
||||
'amount' => array_sum(array_map(static fn ($i) => (float) $i['amount'], $items)),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function orderLotSeedGenerate(): RedirectResponse|ResponseInterface
|
||||
{
|
||||
helper('admin');
|
||||
$lgIdx = $this->lgIdx();
|
||||
if (! $lgIdx) {
|
||||
return redirect()->to(site_url('bag/purchase-inbound'))->with('error', '지자체를 선택해 주세요.');
|
||||
}
|
||||
|
||||
$boIdx = (int) ($this->request->getPost('bo_idx') ?? 0);
|
||||
$data = $this->buildLotSeedData($lgIdx, $boIdx);
|
||||
if (! ($data['ok'] ?? false)) {
|
||||
return redirect()->back()->with('error', (string) ($data['error'] ?? '발주파일을 생성할 수 없습니다.'));
|
||||
}
|
||||
|
||||
$seedPath = $this->generateLotSeedFile(
|
||||
$data['uuid'],
|
||||
$data['version'],
|
||||
$data['lot_no'],
|
||||
$data['order_data'],
|
||||
$data['items'],
|
||||
$data['order_hash']
|
||||
);
|
||||
$seedBinary = @file_get_contents($seedPath);
|
||||
if (! is_string($seedBinary) || $seedBinary === '') {
|
||||
return redirect()->back()->with('error', 'seed 파일 생성에는 성공했으나 파일을 읽을 수 없습니다.');
|
||||
return redirect()->back()->with('error', '발주파일 생성에는 성공했으나 파일을 읽을 수 없습니다.');
|
||||
}
|
||||
|
||||
return $this->response
|
||||
@@ -4248,6 +4464,7 @@ SQL);
|
||||
? model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', '협회')->where('cp_state', 1)->findAll()
|
||||
: [];
|
||||
$agencies = $lgIdx ? model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->orderForDisplay()->findAll() : [];
|
||||
$managers = $lgIdx ? model(ManagerModel::class)->where('mg_lg_idx', $lgIdx)->where('mg_state', 1)->orderBy('mg_name', 'ASC')->findAll() : [];
|
||||
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
|
||||
$bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
|
||||
$priceMapRows = $lgIdx ? model(BagPriceModel::class)->latestActiveMapByBagCode($lgIdx) : [];
|
||||
@@ -4338,6 +4555,7 @@ SQL);
|
||||
'bo_association_idx' => (string) ($target->bo_association_idx ?? ''),
|
||||
'bo_company_idx' => (string) ($target->bo_company_idx ?? ''),
|
||||
'bo_agency_idx' => (string) ($target->bo_agency_idx ?? ''),
|
||||
'bo_manager_idx' => (string) ($target->bo_manager_idx ?? ''),
|
||||
'item_bag_code' => $itemCodes,
|
||||
'item_qty_box' => $itemQtyBoxes,
|
||||
'item_qty_sheet' => $itemQtySheets,
|
||||
@@ -4350,6 +4568,7 @@ SQL);
|
||||
'companies',
|
||||
'associations',
|
||||
'agencies',
|
||||
'managers',
|
||||
'bagCodes',
|
||||
'recentOrders',
|
||||
'companyMap',
|
||||
@@ -4435,16 +4654,22 @@ SQL);
|
||||
}
|
||||
$month = substr((string) ($order->bo_order_date ?? date('Y-m-d')), 0, 7);
|
||||
|
||||
// 삭제 후 돌아갈 화면: 발주 등록에서 삭제한 경우 등록 화면으로 복귀
|
||||
$returnTo = (string) ($this->request->getPost('return_to') ?? '');
|
||||
$redirectUrl = $returnTo === 'create'
|
||||
? site_url('bag/order/create')
|
||||
: site_url('bag/order/change?month=' . $month);
|
||||
|
||||
$admin = new \App\Controllers\Admin\BagOrder();
|
||||
$admin->initController($this->request, $this->response, service('logger'));
|
||||
$response = $admin->delete($id);
|
||||
if ($response instanceof RedirectResponse) {
|
||||
$msg = session()->getFlashdata('success') ?? '발주가 삭제 처리되었습니다.';
|
||||
|
||||
return redirect()->to(site_url('bag/order/change?month=' . $month))->with('success', $msg);
|
||||
return redirect()->to($redirectUrl)->with('success', $msg);
|
||||
}
|
||||
|
||||
return redirect()->to(site_url('bag/order/change?month=' . $month))->with('success', '처리되었습니다.');
|
||||
return redirect()->to($redirectUrl)->with('success', '처리되었습니다.');
|
||||
}
|
||||
|
||||
public function orderCancel(int $id)
|
||||
@@ -4750,14 +4975,18 @@ SQL);
|
||||
$receiverIdx = $this->parseReceiverRefToStoredIdx($lgIdx, $receiverRef);
|
||||
$senderIdx = (int) ($this->request->getPost('br_sender_idx') ?? 0);
|
||||
|
||||
// 오류 시 명시적으로 일괄 입고 화면으로 복귀(back()은 도움말 드로어 등이 남긴
|
||||
// previous_url 로 잘못 이동할 수 있어 사용하지 않는다)
|
||||
$batchUrl = site_url('bag/receiving/batch?company_idx=' . $companyIdx . '&bag_code=' . rawurlencode($bagCode));
|
||||
|
||||
if (empty($selected)) {
|
||||
return redirect()->back()->withInput()->with('error', '일괄 입고할 행을 선택해 주세요.');
|
||||
return redirect()->to($batchUrl)->withInput()->with('error', '일괄 입고할 행을 선택해 주세요.');
|
||||
}
|
||||
if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $receiveDate)) {
|
||||
return redirect()->back()->withInput()->with('error', '입고일 형식을 확인해 주세요.');
|
||||
return redirect()->to($batchUrl)->withInput()->with('error', '입고일 형식을 확인해 주세요.');
|
||||
}
|
||||
if ($receiverIdx <= 0) {
|
||||
return redirect()->back()->withInput()->with('error', '인수자를 선택해 주세요.');
|
||||
return redirect()->to($batchUrl)->withInput()->with('error', '인수자를 선택해 주세요.');
|
||||
}
|
||||
|
||||
$senderResolved = $this->resolveCompanySenderName($lgIdx, $senderIdx);
|
||||
@@ -4798,7 +5027,7 @@ SQL);
|
||||
}
|
||||
|
||||
if (empty($insertRows)) {
|
||||
return redirect()->back()->withInput()->with('error', '선택한 행에 입고할 수량이 없습니다.');
|
||||
return redirect()->to($batchUrl)->withInput()->with('error', '선택한 행에 입고할 수량이 없습니다.');
|
||||
}
|
||||
|
||||
$recvModel = model(BagReceivingModel::class);
|
||||
@@ -4828,7 +5057,7 @@ SQL);
|
||||
$db->transComplete();
|
||||
|
||||
if (! $db->transStatus()) {
|
||||
return redirect()->back()->withInput()->with('error', '일괄 입고 처리 중 오류가 발생했습니다.');
|
||||
return redirect()->to($batchUrl)->withInput()->with('error', '일괄 입고 처리 중 오류가 발생했습니다.');
|
||||
}
|
||||
|
||||
return redirect()->to(site_url('bag/receiving/batch?company_idx=' . $companyIdx . '&bag_code=' . rawurlencode($bagCode)))
|
||||
@@ -6979,9 +7208,13 @@ SQL;
|
||||
$unitMap[$code] = $unit;
|
||||
}
|
||||
|
||||
// 접수번호 — 매일 1번부터 시작(당일 접수 건수 + 1)
|
||||
$receiptNo = 1;
|
||||
if ($lgIdx) {
|
||||
$receiptNo = (int) model(ShopOrderModel::class)->where('so_lg_idx', $lgIdx)->countAllResults() + 1;
|
||||
$receiptNo = (int) model(ShopOrderModel::class)
|
||||
->where('so_lg_idx', $lgIdx)
|
||||
->where('so_order_date', date('Y-m-d'))
|
||||
->countAllResults() + 1;
|
||||
}
|
||||
|
||||
return $this->render('전화 주문 접수', 'bag/order_phone', compact('shops', 'bagCodes', 'priceMap', 'unitMap', 'receiptNo'));
|
||||
@@ -6998,12 +7231,32 @@ SQL;
|
||||
return redirect()->to(site_url('bag/sales'))->with('error', '지자체를 선택해 주세요.');
|
||||
}
|
||||
|
||||
// 일자별·기간별 조회 (접수일 so_order_date 기준) — 파라미터 없이 진입하면 기본값 = 오늘
|
||||
$startRaw = $this->request->getGet('start_date');
|
||||
$endRaw = $this->request->getGet('end_date');
|
||||
$startDate = trim((string) ($startRaw ?? ''));
|
||||
$endDate = trim((string) ($endRaw ?? ''));
|
||||
if ($startRaw === null && $endRaw === null) {
|
||||
$startDate = date('Y-m-d');
|
||||
$endDate = date('Y-m-d');
|
||||
}
|
||||
$isYmd = static fn (string $d): bool => preg_match('/^\d{4}-\d{2}-\d{2}$/', $d) === 1;
|
||||
if ($isYmd($startDate) && $isYmd($endDate) && $startDate > $endDate) {
|
||||
[$startDate, $endDate] = [$endDate, $startDate];
|
||||
}
|
||||
|
||||
$db = \Config\Database::connect();
|
||||
$orderModel = model(ShopOrderModel::class);
|
||||
$builder = $orderModel->where('so_lg_idx', $lgIdx);
|
||||
if ($db->fieldExists('so_channel', 'shop_order')) {
|
||||
$builder->where('so_channel', 'phone');
|
||||
}
|
||||
if ($isYmd($startDate)) {
|
||||
$builder->where('so_order_date >=', $startDate);
|
||||
}
|
||||
if ($isYmd($endDate)) {
|
||||
$builder->where('so_order_date <=', $endDate);
|
||||
}
|
||||
$orders = $builder->orderBy('so_idx', 'DESC')->limit(200)->findAll();
|
||||
|
||||
$orderIds = array_values(array_map(static fn ($o): int => (int) ($o->so_idx ?? 0), $orders));
|
||||
@@ -7032,17 +7285,65 @@ SQL;
|
||||
];
|
||||
}
|
||||
|
||||
// 주문의 지정판매소 상세(코드·담당자·전화·주소) — 우측 '지정판매소 정보' 표시용
|
||||
$shopMap = [];
|
||||
$dsIdxs = array_values(array_unique(array_filter(array_map(static fn ($o): int => (int) ($o->so_ds_idx ?? 0), $orders), static fn ($v): bool => $v > 0)));
|
||||
if ($dsIdxs !== [] && $db->tableExists('designated_shop')) {
|
||||
$shopRows = $db->table('designated_shop')
|
||||
->select('ds_idx, ds_shop_no, ds_name, ds_rep_name, ds_tel, ds_addr, ds_addr_detail')
|
||||
->whereIn('ds_idx', $dsIdxs)
|
||||
->get()->getResultArray();
|
||||
foreach ($shopRows as $s) {
|
||||
$shopMap[(int) $s['ds_idx']] = [
|
||||
'shop_no' => (string) ($s['ds_shop_no'] ?? ''),
|
||||
'name' => (string) ($s['ds_name'] ?? ''),
|
||||
'rep_name' => (string) ($s['ds_rep_name'] ?? ''),
|
||||
'tel' => (string) ($s['ds_tel'] ?? ''),
|
||||
'addr' => trim((string) ($s['ds_addr'] ?? '') . ' ' . (string) ($s['ds_addr_detail'] ?? '')),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 스캔(=포장·판매)된 봉투코드 내역 — 포장 명세 표시 + 품목별 포장량(soi_packed_qty) 집계에 사용
|
||||
$scansByOrder = [];
|
||||
$packedByOrderBag = [];
|
||||
if ($orderIds !== [] && $db->tableExists('bag_sale_scan_code')) {
|
||||
$scanRows = $db->table('bag_sale_scan_code')
|
||||
->select('bssc_so_idx, bssc_bag_code, bssc_bag_name, bssc_code, bssc_unit, bssc_qty')
|
||||
->where('bssc_lg_idx', $lgIdx)
|
||||
->whereIn('bssc_so_idx', $orderIds)
|
||||
->where('bssc_state', 'sold')
|
||||
->orderBy('bssc_idx', 'ASC')
|
||||
->get()->getResultArray();
|
||||
foreach ($scanRows as $s) {
|
||||
$oid = (int) ($s['bssc_so_idx'] ?? 0);
|
||||
$code = (string) ($s['bssc_bag_code'] ?? '');
|
||||
$qty = (int) ($s['bssc_qty'] ?? 0);
|
||||
$scansByOrder[$oid][] = [
|
||||
'bag_code' => $code,
|
||||
'bag_name' => (string) ($s['bssc_bag_name'] ?? ''),
|
||||
'code' => (string) ($s['bssc_code'] ?? ''),
|
||||
'unit' => (string) ($s['bssc_unit'] ?? ''),
|
||||
'qty' => $qty,
|
||||
];
|
||||
$packedByOrderBag[$oid][$code] = ($packedByOrderBag[$oid][$code] ?? 0) + $qty;
|
||||
}
|
||||
}
|
||||
|
||||
$itemsByOrder = [];
|
||||
foreach ($itemRows as $item) {
|
||||
$orderId = (int) ($item->soi_so_idx ?? 0);
|
||||
$code = (string) ($item->soi_bag_code ?? '');
|
||||
$pack = $unitMap[$code] ?? ['boxSheets' => 0, 'packSheets' => 0];
|
||||
// 포장량 = 스캔 집계 우선(포장 명세와 일치), 없으면 DB 저장값
|
||||
$packedQty = $packedByOrderBag[$orderId][$code] ?? (int) ($item->soi_packed_qty ?? 0);
|
||||
$itemsByOrder[$orderId][] = [
|
||||
'soi_idx' => (int) ($item->soi_idx ?? 0),
|
||||
'soi_bag_code' => $code,
|
||||
'soi_bag_name' => (string) ($item->soi_bag_name ?? ''),
|
||||
'soi_unit_price' => (int) ($item->soi_unit_price ?? 0),
|
||||
'soi_qty' => (int) ($item->soi_qty ?? 0),
|
||||
'soi_packed_qty' => (int) $packedQty,
|
||||
'soi_amount' => (int) ($item->soi_amount ?? 0),
|
||||
'soi_box_count' => (int) ($item->soi_box_count ?? 0),
|
||||
'soi_pack_count' => (int) ($item->soi_pack_count ?? 0),
|
||||
@@ -7055,21 +7356,75 @@ SQL;
|
||||
$payload = [];
|
||||
foreach ($orders as $order) {
|
||||
$id = (int) ($order->so_idx ?? 0);
|
||||
$dsIdx = (int) ($order->so_ds_idx ?? 0);
|
||||
$shop = $shopMap[$dsIdx] ?? ['shop_no' => '', 'name' => '', 'rep_name' => '', 'tel' => '', 'addr' => ''];
|
||||
$payload[] = [
|
||||
'so_idx' => $id,
|
||||
'so_ds_idx' => $dsIdx,
|
||||
'so_ds_name' => (string) ($order->so_ds_name ?? ''),
|
||||
'ds_shop_no' => $shop['shop_no'],
|
||||
'ds_rep_name' => $shop['rep_name'],
|
||||
'ds_tel' => $shop['tel'],
|
||||
'ds_addr' => $shop['addr'],
|
||||
'so_order_date' => (string) ($order->so_order_date ?? ''),
|
||||
'so_delivery_date' => (string) ($order->so_delivery_date ?? ''),
|
||||
'so_channel' => (string) ($order->so_channel ?? ''),
|
||||
'so_payment_type' => (string) ($order->so_payment_type ?? ''),
|
||||
'so_received' => (int) ($order->so_received ?? 0),
|
||||
'so_paid' => (int) ($order->so_paid ?? 0),
|
||||
'so_total_qty' => (int) ($order->so_total_qty ?? 0),
|
||||
'so_total_amount' => (int) ($order->so_total_amount ?? 0),
|
||||
'so_status' => (string) ($order->so_status ?? 'normal'),
|
||||
'items' => $itemsByOrder[$id] ?? [],
|
||||
'scans' => $scansByOrder[$id] ?? [],
|
||||
];
|
||||
}
|
||||
|
||||
return $this->render('전화접수 관리', 'bag/order_phone_manage', [
|
||||
'orders' => $payload,
|
||||
return $this->render('주문접수 관리', 'bag/order_phone_manage', [
|
||||
'orders' => $payload,
|
||||
'startDate' => $isYmd($startDate) ? $startDate : '',
|
||||
'endDate' => $isYmd($endDate) ? $endDate : '',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 입금자 영수증 출력용 — 단일 접수건의 인쇄용 영수증(독립 HTML, 자동 인쇄).
|
||||
*/
|
||||
public function phoneOrderReceipt(int $id): string|RedirectResponse
|
||||
{
|
||||
helper('admin');
|
||||
$lgIdx = $this->lgIdx();
|
||||
if (! $lgIdx) {
|
||||
return redirect()->to(site_url('bag/order/phone/manage'))->with('error', '지자체를 선택해 주세요.');
|
||||
}
|
||||
|
||||
$order = model(ShopOrderModel::class)->where('so_idx', $id)->where('so_lg_idx', $lgIdx)->first();
|
||||
if (! $order) {
|
||||
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
|
||||
}
|
||||
|
||||
$items = model(ShopOrderItemModel::class)
|
||||
->where('soi_so_idx', $id)
|
||||
->orderBy('soi_idx', 'ASC')
|
||||
->findAll();
|
||||
|
||||
$shop = null;
|
||||
$dsIdx = (int) ($order->so_ds_idx ?? 0);
|
||||
if ($dsIdx > 0) {
|
||||
$shop = \Config\Database::connect()->table('designated_shop')
|
||||
->select('ds_shop_no, ds_name, ds_rep_name, ds_tel, ds_addr, ds_addr_detail')
|
||||
->where('ds_idx', $dsIdx)
|
||||
->get()->getRowArray();
|
||||
}
|
||||
|
||||
$lgRow = model(\App\Models\LocalGovernmentModel::class)->find($lgIdx);
|
||||
$lgName = $lgRow ? (string) $lgRow->lg_name : '';
|
||||
|
||||
return view('bag/order_phone_receipt', [
|
||||
'order' => $order,
|
||||
'items' => $items,
|
||||
'shop' => $shop,
|
||||
'lgName' => $lgName,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -7108,6 +7463,10 @@ SQL;
|
||||
if (! is_array($qtyInput)) {
|
||||
$qtyInput = [];
|
||||
}
|
||||
$packedInput = $this->request->getPost('item_packed') ?? [];
|
||||
if (! is_array($packedInput)) {
|
||||
$packedInput = [];
|
||||
}
|
||||
|
||||
$codeSet = [];
|
||||
foreach ($items as $item) {
|
||||
@@ -7164,8 +7523,10 @@ SQL;
|
||||
$sheetCount = $qty % $packSheets;
|
||||
}
|
||||
|
||||
$packedQty = isset($packedInput[$itemId]) ? max(0, (int) $packedInput[$itemId]) : (int) ($item->soi_packed_qty ?? 0);
|
||||
$itemModel->update($itemId, [
|
||||
'soi_qty' => $qty,
|
||||
'soi_packed_qty' => $packedQty,
|
||||
'soi_amount' => $amount,
|
||||
'soi_box_count' => $boxCount,
|
||||
'soi_pack_count' => $packCount,
|
||||
@@ -7176,16 +7537,177 @@ SQL;
|
||||
$sumAmt += $amount;
|
||||
}
|
||||
|
||||
$orderModel->update($soIdx, [
|
||||
$orderUpdate = [
|
||||
'so_total_qty' => $sumQty,
|
||||
'so_total_amount' => $sumAmt,
|
||||
]);
|
||||
];
|
||||
// 배달일 수정 — 입력값이 유효한 날짜면 변경 반영(수정일자로 변경 처리)
|
||||
// 단, 접수일 이전 날짜로는 변경 불가.
|
||||
$deliveryDate = trim((string) ($this->request->getPost('so_delivery_date') ?? ''));
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $deliveryDate) === 1) {
|
||||
$orderDate = substr((string) ($order->so_order_date ?? ''), 0, 10);
|
||||
if ($orderDate !== '' && $deliveryDate < $orderDate) {
|
||||
$db->transRollback();
|
||||
|
||||
return redirect()->back()->with('error', '배달일은 접수일(' . $orderDate . ') 이후로만 변경할 수 있습니다.');
|
||||
}
|
||||
$orderUpdate['so_delivery_date'] = $deliveryDate;
|
||||
}
|
||||
$orderModel->update($soIdx, $orderUpdate);
|
||||
|
||||
$db->transComplete();
|
||||
|
||||
return redirect()->to(site_url('bag/order/phone/manage'))->with('success', '주문 수정 저장이 완료되었습니다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 전화접수 관리 — 봉투코드 스캔으로 즉시 판매·포장 처리(JSON 응답).
|
||||
* 선택된 주문의 판매소로 판매 기록(bag_sale) + 재고 차감 + 팩코드 sold 전환 +
|
||||
* 해당 품목의 포장량(soi_packed_qty) 반영. 잔량 한도는 접수량(soi_qty).
|
||||
*/
|
||||
public function phoneOrderManageScan()
|
||||
{
|
||||
helper('admin');
|
||||
$lgIdx = $this->lgIdx();
|
||||
if (! $lgIdx) {
|
||||
return $this->response->setJSON(['ok' => false, 'message' => '지자체를 선택해 주세요.']);
|
||||
}
|
||||
|
||||
$soIdx = (int) ($this->request->getPost('so_idx') ?? 0);
|
||||
$barcode = trim((string) ($this->request->getPost('barcode') ?? ''));
|
||||
if ($soIdx <= 0 || $barcode === '') {
|
||||
return $this->response->setJSON(['ok' => false, 'message' => '주문 또는 바코드 값이 올바르지 않습니다.', 'csrf' => csrf_hash()]);
|
||||
}
|
||||
|
||||
$orderModel = model(ShopOrderModel::class);
|
||||
$order = $orderModel->where('so_idx', $soIdx)->where('so_lg_idx', $lgIdx)->first();
|
||||
if (! $order || (string) ($order->so_status ?? '') !== 'normal') {
|
||||
return $this->response->setJSON(['ok' => false, 'message' => '선택한 주문을 사용할 수 없습니다.', 'csrf' => csrf_hash()]);
|
||||
}
|
||||
|
||||
$scan = $this->resolveDesignatedSaleBarcode($lgIdx, $barcode);
|
||||
if (! ($scan['ok'] ?? false)) {
|
||||
return $this->response->setJSON(array_merge($scan, ['csrf' => csrf_hash()]));
|
||||
}
|
||||
|
||||
$bagCode = (string) ($scan['bag_code'] ?? '');
|
||||
$orderItem = model(ShopOrderItemModel::class)
|
||||
->where('soi_so_idx', $soIdx)
|
||||
->where('soi_bag_code', $bagCode)
|
||||
->first();
|
||||
if (! $orderItem) {
|
||||
return $this->response->setJSON(['ok' => false, 'message' => '선택 주문에 없는 봉투 종류입니다.', 'csrf' => csrf_hash()]);
|
||||
}
|
||||
|
||||
// 이미 판매(=포장)된 순수량
|
||||
$soldRow = model(BagSaleModel::class)
|
||||
->select('COALESCE(SUM(bs_qty),0) AS sold_qty')
|
||||
->where('bs_lg_idx', $lgIdx)
|
||||
->where('bs_so_idx', $soIdx)
|
||||
->where('bs_bag_code', $bagCode)
|
||||
->whereIn('bs_type', ['sale', 'cancel', 'return', 'return_cancel'])
|
||||
->first();
|
||||
$soldQty = (int) ($soldRow->sold_qty ?? 0);
|
||||
$receiptQty = (int) ($orderItem->soi_qty ?? 0);
|
||||
$scanQty = (int) ($scan['qty'] ?? 0);
|
||||
$remain = max(0, $receiptQty - $soldQty);
|
||||
if ($scanQty <= 0) {
|
||||
return $this->response->setJSON(['ok' => false, 'message' => '수량 계산에 실패했습니다.', 'csrf' => csrf_hash()]);
|
||||
}
|
||||
if ($scanQty > $remain) {
|
||||
return $this->response->setJSON(['ok' => false, 'message' => '접수 잔량을 초과합니다. (잔량: ' . number_format($remain) . ')', 'csrf' => csrf_hash()]);
|
||||
}
|
||||
|
||||
$dsIdx = (int) ($order->so_ds_idx ?? 0);
|
||||
$bagName = (string) (($scan['bag_name'] ?? '') !== '' ? $scan['bag_name'] : ($orderItem->soi_bag_name ?? ''));
|
||||
$unit = (string) ($scan['unit'] ?? '');
|
||||
$packIds = is_array($scan['pack_ids'] ?? null) ? $scan['pack_ids'] : [];
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
$this->ensureDesignatedSaleScanCodeTable($lgIdx);
|
||||
$db = \Config\Database::connect();
|
||||
$db->transStart();
|
||||
|
||||
// 스캔 코드 기록(sold)
|
||||
$scanCodeRow = $db->table('bag_sale_scan_code')
|
||||
->where('bssc_lg_idx', $lgIdx)->where('bssc_code', $barcode)->get()->getRowArray();
|
||||
if (is_array($scanCodeRow)) {
|
||||
$db->table('bag_sale_scan_code')->where('bssc_idx', (int) ($scanCodeRow['bssc_idx'] ?? 0))->update([
|
||||
'bssc_so_idx' => $soIdx, 'bssc_ds_idx' => $dsIdx, 'bssc_bag_code' => $bagCode,
|
||||
'bssc_bag_name' => $bagName, 'bssc_unit' => $unit, 'bssc_qty' => $scanQty,
|
||||
'bssc_state' => 'sold', 'bssc_regdate' => $now,
|
||||
]);
|
||||
} else {
|
||||
$db->table('bag_sale_scan_code')->insert([
|
||||
'bssc_lg_idx' => $lgIdx, 'bssc_so_idx' => $soIdx, 'bssc_ds_idx' => $dsIdx,
|
||||
'bssc_bag_code' => $bagCode, 'bssc_bag_name' => $bagName, 'bssc_code' => $barcode,
|
||||
'bssc_unit' => $unit, 'bssc_qty' => $scanQty, 'bssc_state' => 'sold', 'bssc_regdate' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
// 팩/박스는 수신 팩 상태를 sold로 전환(낱장은 유지)
|
||||
if ($packIds !== [] && $unit !== '낱장') {
|
||||
$db->table('bag_receiving_pack_code')
|
||||
->where('brpc_lg_idx', $lgIdx)
|
||||
->whereIn('brpc_idx', array_map(static fn ($v): int => (int) $v, $packIds))
|
||||
->set(['brpc_state' => 'sold'])->update();
|
||||
}
|
||||
|
||||
// 판매 기록 + 재고 차감
|
||||
$unitPrice = (float) ($orderItem->soi_unit_price ?? 0);
|
||||
model(BagSaleModel::class)->insert([
|
||||
'bs_lg_idx' => $lgIdx, 'bs_so_idx' => $soIdx, 'bs_ds_idx' => $dsIdx,
|
||||
'bs_ds_name' => (string) ($order->so_ds_name ?? ''), 'bs_sale_date' => date('Y-m-d'),
|
||||
'bs_bag_code' => $bagCode, 'bs_bag_name' => $bagName, 'bs_qty' => $scanQty,
|
||||
'bs_unit_price' => $unitPrice, 'bs_amount' => $unitPrice * $scanQty,
|
||||
'bs_type' => 'sale', 'bs_regdate' => $now,
|
||||
]);
|
||||
model(BagInventoryModel::class)->adjustQty($lgIdx, $bagCode, $bagName, -$scanQty);
|
||||
|
||||
// 포장량(soi_packed_qty) 반영
|
||||
$newPacked = $soldQty + $scanQty;
|
||||
model(ShopOrderItemModel::class)->update((int) ($orderItem->soi_idx ?? 0), ['soi_packed_qty' => $newPacked]);
|
||||
|
||||
// 전량 판매 시 so_received=1
|
||||
$soldByBag = [];
|
||||
$sumRows = model(BagSaleModel::class)
|
||||
->select('bs_bag_code, COALESCE(SUM(bs_qty),0) AS sold_qty')
|
||||
->where('bs_lg_idx', $lgIdx)->where('bs_so_idx', $soIdx)->where('bs_type', 'sale')
|
||||
->groupBy('bs_bag_code')->findAll();
|
||||
foreach ($sumRows as $r) {
|
||||
$soldByBag[(string) ($r->bs_bag_code ?? '')] = (int) ($r->sold_qty ?? 0);
|
||||
}
|
||||
$allItems = model(ShopOrderItemModel::class)->where('soi_so_idx', $soIdx)->findAll();
|
||||
$allSold = true;
|
||||
foreach ($allItems as $it) {
|
||||
if ((int) ($soldByBag[(string) ($it->soi_bag_code ?? '')] ?? 0) < (int) ($it->soi_qty ?? 0)) {
|
||||
$allSold = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$orderModel->update($soIdx, ['so_received' => $allSold ? 1 : 0]);
|
||||
|
||||
$db->transComplete();
|
||||
if ($db->transStatus() === false) {
|
||||
return $this->response->setJSON(['ok' => false, 'message' => '판매 처리 중 오류가 발생했습니다.', 'csrf' => csrf_hash()]);
|
||||
}
|
||||
|
||||
return $this->response->setJSON([
|
||||
'ok' => true,
|
||||
'csrf' => csrf_hash(),
|
||||
'so_idx' => $soIdx,
|
||||
'soi_idx' => (int) ($orderItem->soi_idx ?? 0),
|
||||
'bag_code' => $bagCode,
|
||||
'bag_name' => $bagName,
|
||||
'code' => $barcode,
|
||||
'unit' => $unit,
|
||||
'qty' => $scanQty,
|
||||
'packed_qty' => $newPacked,
|
||||
'so_received' => $allSold ? 1 : 0,
|
||||
'remain' => max(0, $receiptQty - $newPacked),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 전화 주문 취소(삭제가 아닌 상태값 변경).
|
||||
*/
|
||||
|
||||
@@ -48,6 +48,20 @@ abstract class BaseController extends Controller
|
||||
*
|
||||
* @param array<string, mixed> $contentData
|
||||
*/
|
||||
/**
|
||||
* 워크스페이스 탭(iframe) 안에서 열린 요청인지. ?embed=1 또는 Sec-Fetch-Dest=iframe.
|
||||
* iframe 내 링크 이동·폼 전송·리다이렉트까지 모두 임베드로 처리되도록 헤더로도 판정한다.
|
||||
*/
|
||||
protected function isEmbeddedRequest(): bool
|
||||
{
|
||||
if ($this->request->getGet('embed') !== null) {
|
||||
return true;
|
||||
}
|
||||
$dest = strtolower(trim((string) $this->request->getHeaderLine('Sec-Fetch-Dest')));
|
||||
|
||||
return $dest === 'iframe' || $dest === 'frame';
|
||||
}
|
||||
|
||||
protected function renderWorkPage(string $title, string $contentView, array $contentData = []): string
|
||||
{
|
||||
$content = view($contentView, $contentData);
|
||||
@@ -60,8 +74,16 @@ abstract class BaseController extends Controller
|
||||
while (str_starts_with($path, '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/')) {
|
||||
// 사이트 업무 페이지: gov-portal 디자인 셸 적용
|
||||
return view('bag/layout/portal', [
|
||||
'title' => $title,
|
||||
'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]);
|
||||
}
|
||||
}
|
||||
@@ -10,21 +10,45 @@ class Home extends BaseController
|
||||
public function index()
|
||||
{
|
||||
if (session()->get('logged_in')) {
|
||||
// 메인(/) — gov-portal 디자인 셸 + 종량제 실데이터 대시보드.
|
||||
helper('admin');
|
||||
|
||||
return view('bag/layout/portal', [
|
||||
'title' => '업무 현황',
|
||||
'bare' => true,
|
||||
'content' => view('bag/dashboard_portal', $this->portalDashboardData()),
|
||||
]);
|
||||
// 워크스페이스 탭(iframe) 안: 대시보드 본문을 임베드로.
|
||||
if ($this->isEmbeddedRequest()) {
|
||||
return view('bag/layout/embed', [
|
||||
'title' => '업무 현황',
|
||||
'bare' => true,
|
||||
'content' => view('bag/dashboard_portal', $this->portalDashboardData()),
|
||||
]);
|
||||
}
|
||||
|
||||
// 로그인 후 기본 화면 = 워크스페이스(탭). 메뉴를 탭으로 열어 작업 상태 유지.
|
||||
return view('bag/layout/workspace');
|
||||
}
|
||||
|
||||
// 워크스페이스 탭(iframe) 안에서 세션이 만료된 경우: 공개 랜딩 대신 로그인으로 보내
|
||||
// 로그인 페이지의 프레임 이탈 스크립트가 상위 창 전체를 로그인으로 전환하게 한다.
|
||||
if ($this->isEmbeddedRequest()) {
|
||||
return redirect()->to(base_url('login'));
|
||||
}
|
||||
|
||||
return view('welcome_message');
|
||||
}
|
||||
|
||||
/**
|
||||
* 메인 대시보드용 — 종량제 시스템에 실제 존재하는 데이터만 집계.
|
||||
* 워크스페이스 — 메뉴를 탭(iframe)으로 열어두고 작업 상태를 유지하는 화면.
|
||||
*/
|
||||
public function workspace()
|
||||
{
|
||||
if (! session()->get('logged_in')) {
|
||||
return redirect()->to(base_url('login'));
|
||||
}
|
||||
helper('admin');
|
||||
|
||||
return view('bag/layout/workspace');
|
||||
}
|
||||
|
||||
/**
|
||||
* 메인 대시보드용 — GBLS에 실제 존재하는 데이터만 집계.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
@@ -104,6 +128,34 @@ class Home extends BaseController
|
||||
$pendingApprovals = 0;
|
||||
}
|
||||
|
||||
// 지도용 — 현재 지자체 지정판매소(이름·주소). 좌표는 클라이언트(카카오 지오코딩)에서 변환.
|
||||
$mapShops = [];
|
||||
try {
|
||||
if ($lgIdx !== null && $db->tableExists('designated_shop')) {
|
||||
$rows = $db->table('designated_shop')
|
||||
->select('ds_name, ds_addr, ds_addr_jibun')
|
||||
->where('ds_lg_idx', $lgIdx)
|
||||
->where('ds_addr IS NOT NULL')
|
||||
->where('ds_addr <>', '')
|
||||
->orderBy('ds_idx', 'ASC')
|
||||
->limit(40)
|
||||
->get()->getResultArray();
|
||||
foreach ($rows as $r) {
|
||||
$addr = trim((string) ($r['ds_addr'] ?? ''));
|
||||
if ($addr === '') {
|
||||
continue;
|
||||
}
|
||||
$mapShops[] = [
|
||||
'name' => (string) ($r['ds_name'] ?? ''),
|
||||
'addr' => $addr,
|
||||
'jibun' => trim((string) ($r['ds_addr_jibun'] ?? '')),
|
||||
];
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$mapShops = [];
|
||||
}
|
||||
|
||||
// 최근 활동(activity_log) — 실제 변경 이력
|
||||
$actionLabel = ['create' => '등록', 'update' => '수정', 'delete' => '삭제', 'cancel' => '취소'];
|
||||
$tableLabel = [
|
||||
@@ -142,9 +194,44 @@ class Home extends BaseController
|
||||
'stockMix' => $stockMix,
|
||||
'lowStock' => $lowStock,
|
||||
'recentActivity' => $recent,
|
||||
'mapShops' => $mapShops,
|
||||
'kakaoJsKey' => config(\Config\Kakao::class)->javascriptKey,
|
||||
'menuSearchOptions' => (function_exists('gov_portal_nav_context') && function_exists('gov_portal_menu_search_options'))
|
||||
? gov_portal_menu_search_options(gov_portal_nav_context(false)['navItems'])
|
||||
: [],
|
||||
'menuFlat' => $this->buildMenuFlat(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴검색 자동완성용 — 사이트 메뉴를 (상위·메뉴명·URL) 평탄 목록으로.
|
||||
*
|
||||
* @return list<array{parent:string,name:string,url:string}>
|
||||
*/
|
||||
private function buildMenuFlat(): array
|
||||
{
|
||||
if (! function_exists('gov_portal_nav_context')) {
|
||||
return [];
|
||||
}
|
||||
$flat = [];
|
||||
foreach (gov_portal_nav_context(false)['navItems'] as $parent) {
|
||||
$pName = (string) ($parent['name'] ?? '');
|
||||
if (! empty($parent['children'])) {
|
||||
foreach ($parent['children'] as $child) {
|
||||
$url = (string) ($child['url'] ?? '');
|
||||
if ($url === '') {
|
||||
continue;
|
||||
}
|
||||
$flat[] = ['parent' => $pName, 'name' => (string) ($child['name'] ?? ''), 'url' => $url];
|
||||
}
|
||||
} elseif (! empty($parent['url'])) {
|
||||
$flat[] = ['parent' => '', 'name' => $pName, 'url' => (string) $parent['url']];
|
||||
}
|
||||
}
|
||||
|
||||
return $flat;
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 후 메인 — site 메뉴 레이아웃 + 종합·그래프(blend) 본문
|
||||
*/
|
||||
@@ -376,9 +463,14 @@ class Home extends BaseController
|
||||
protected function resolveLgLabel(): string
|
||||
{
|
||||
try {
|
||||
$idx = session()->get('mb_lg_idx');
|
||||
if ($idx === null || $idx === '') {
|
||||
return '로그인 지자체 (미지정)';
|
||||
helper('admin');
|
||||
$idx = function_exists('admin_effective_lg_idx') ? admin_effective_lg_idx() : null;
|
||||
if ($idx === null) {
|
||||
$raw = session()->get('mb_lg_idx');
|
||||
$idx = ($raw !== null && $raw !== '') ? (int) $raw : null;
|
||||
}
|
||||
if ($idx === null) {
|
||||
return '지자체 미지정';
|
||||
}
|
||||
$row = model(LocalGovernmentModel::class)->find((int) $idx);
|
||||
if ($row && isset($row->lg_name) && $row->lg_name !== '') {
|
||||
@@ -388,7 +480,7 @@ class Home extends BaseController
|
||||
// 테이블 미생성 등
|
||||
}
|
||||
|
||||
return '북구 (데모)';
|
||||
return '지자체';
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,49 +1,52 @@
|
||||
# 시작하기 · 시스템 개요
|
||||
|
||||
종량제 쓰레기봉투 물류 시스템 사용자 매뉴얼입니다. 이 문서는 실제 업무를 처리하는 담당자(지자체 관리자·지정판매소)를 위한 **빠른 시작 안내서**입니다.
|
||||
종량제 쓰레기봉투 물류 시스템 사용자 매뉴얼입니다. **이 시스템을 처음 쓰는 분**도 화면을 이해하고 업무를 처리할 수 있도록, 화면마다 쓰이는 용어와 버튼의 의미를 설명합니다.
|
||||
|
||||
## 1. 시스템은 무엇을 하나요?
|
||||
## 1. 이 시스템은 무엇을 하나요?
|
||||
|
||||
지자체 종량제 쓰레기봉투의 **발주 → 입고 → 재고 → 판매/불출 → 정산·통계** 전 과정을 관리합니다.
|
||||
지자체가 주민에게 파는 **종량제 쓰레기봉투**가 ① 제작업체에 **주문(발주)** 되고 → ② 창고로 **들어오고(입고)** → ③ **재고**로 보관되다가 → ④ 동네 가게(지정판매소)에 **팔리거나(판매)** 무료 대상자에게 **나눠지는(불출)** 전 과정을 컴퓨터로 관리합니다. 마지막엔 ⑤ 얼마나 팔렸는지 **집계·정산(현황/리포트)** 합니다.
|
||||
|
||||
- 봉투를 제작업체에 **발주**하고, 들어온 물량을 **입고** 처리합니다.
|
||||
- 현재 **재고**를 확인하고, 실사로 실제 수량과 맞춥니다.
|
||||
- 지정판매소에 **판매**하거나 무료 대상자에게 **불출**합니다.
|
||||
- 일계표·기간별·연간 등 **판매현황**과 **수불·통계**를 조회합니다.
|
||||
## 2. 꼭 알아야 할 기본 용어 (용어 사전)
|
||||
|
||||
## 2. 로그인과 화면 구성
|
||||
| 용어 | 쉬운 설명 |
|
||||
|---|---|
|
||||
| **발주** | 봉투를 제작업체에 "이만큼 만들어 주세요"라고 **주문**하는 것 |
|
||||
| **입고** | 주문한 봉투가 창고에 **도착해 들여놓는** 것 |
|
||||
| **재고** | 지금 창고에 **남아 있는 봉투 수량** |
|
||||
| **불출** | 봉투를 창고에서 **꺼내 내보내는** 것 (주로 무료 배부) |
|
||||
| **수불(受拂)** | **들어오고(수입)·나가는(불출)** 움직임을 적은 장부 |
|
||||
| **지정판매소** | 봉투를 파는 **동네 가게**(편의점·마트 등) |
|
||||
| **대행소(판매대행소)** | 봉투 **배송·유통을 대행**하는 업체 |
|
||||
| **실사** | 컴퓨터 기록과 **실제 창고 수량이 맞는지 직접 세어 확인**하는 것 |
|
||||
| **박스 / 팩 / 낱장** | 포장 단위. **박스 > 팩 > 낱장(봉투 1장)**. 1박스 = 여러 팩, 1팩 = 여러 낱장 |
|
||||
| **LOT(로트)** | 한 번의 발주 묶음에 부여되는 **추적용 일련번호** |
|
||||
| **바코드(봉투번호)** | 박스·팩·낱장마다 붙는 **고유 번호**(스캔용) |
|
||||
| **무료용 / 공공용** | 주민 무료 배부용 / 공공기관 사용용 봉투 구분 |
|
||||
|
||||
1. 발급받은 아이디·비밀번호로 로그인합니다.
|
||||
2. 관리자(지자체/슈퍼)는 보안을 위해 **2차 인증(OTP)** 이 적용될 수 있습니다.
|
||||
3. 로그인하면 상단에 **대메뉴**가 보이고, 각 대메뉴에 마우스를 올리면 **소메뉴**가 펼쳐집니다.
|
||||
4. 화면 상단의 제목 바에 현재 보고 있는 화면 이름이 표시됩니다.
|
||||
> 박스·팩·낱장·LOT·바코드의 자세한 규칙은 좌측 목차 **[봉투·LOT·바코드 코드체계]** 참고.
|
||||
|
||||
> 슈퍼 관리자는 작업할 **지자체를 먼저 선택**해야 업무 화면이 열립니다.
|
||||
## 3. 화면 구성과 사용법
|
||||
|
||||
## 3. 사용자 역할(권한)
|
||||
- 로그인하면 **워크스페이스**(탭 작업공간)가 열립니다. 상단에 대분류 메뉴, **대분류를 클릭하면 왼쪽에 소메뉴**가 펼쳐집니다.
|
||||
- 메뉴를 클릭하면 **탭으로 열립니다.** 여러 화면을 열어두고 전환해도 입력하던 내용이 유지됩니다.
|
||||
- 각 화면에서 **"이 화면 설명"(❓) 버튼**을 누르면, 그 화면에 해당하는 매뉴얼이 새 탭으로 열립니다.
|
||||
|
||||
시스템은 4단계 역할로 접근 권한을 구분합니다.
|
||||
> 탭 사용법, **분할 보기(2·4분할)·구분선 드래그로 크기 조절**, **키보드 단축키**(Alt+1~9 / Alt+W / Alt+[ / Alt+]) 등 자세한 내용은 좌측 목차 **[화면 구성·워크스페이스·단축키]** 를 참고하세요.
|
||||
|
||||
| 레벨 | 역할 | 할 수 있는 일 |
|
||||
|---|---|---|
|
||||
| 1 | 일반 사용자 | 기본 조회 |
|
||||
| 2 | 지정판매소 | 봉투 판매·반품, 자기 판매 현황 조회 |
|
||||
| 3 | 지자체 관리자 | 소속 지자체의 발주·입고·재고·불출·판매·리포트 전반 |
|
||||
| 4 | 슈퍼 관리자 | 전체 시스템 관리 + 기본코드 등 마스터 관리 (지자체 선택 후 작업) |
|
||||
## 4. 사용자 역할(권한)
|
||||
|
||||
### 역할별 접근 한눈에 보기
|
||||
| 역할 | 할 수 있는 일 |
|
||||
|---|---|
|
||||
| 일반 사용자 | 기본 조회 |
|
||||
| 지정판매소 | 봉투 판매·반품, 자기 판매 현황 조회 |
|
||||
| 지자체 관리자 | 소속 지자체의 발주·입고·재고·불출·판매·리포트 전반 |
|
||||
| 슈퍼 관리자 | 전체 + 기본코드 등 마스터 관리(지자체 선택 후 작업) |
|
||||
|
||||
| 기능군 | 일반 | 판매소 | 지자체관리자 | 슈퍼 |
|
||||
|---|:--:|:--:|:--:|:--:|
|
||||
| 기본정보 조회(코드·단가·포장) | △ | ○ | ○ | ○ |
|
||||
| 기본코드·마스터 편집 | ✕ | ✕ | △(지자체분) | ○ |
|
||||
| 발주·입고·재고·불출 | ✕ | ✕ | ○ | ○ |
|
||||
| 판매·반품 등록 | ✕ | ○ | ○ | ○ |
|
||||
| 판매현황·수불·통계 | ✕ | △(자기분) | ○ | ○ |
|
||||
> **활동 로그(사용자 작업 기록)**: **지자체 관리자·슈퍼 관리자만** 접근할 수 있습니다. (일반 사용자·지정판매소는 볼 수 없습니다.)
|
||||
|
||||
(○ 사용 가능 · △ 제한적 · ✕ 불가)
|
||||
## 5. 화면별 설명은 어디에?
|
||||
|
||||
## 4. 다음 단계
|
||||
좌측 목차에서 업무군을 고르면 그 안에 **화면(소메뉴)별 설명**이 있습니다.
|
||||
- **발주·입고** / **재고·실사** / **판매·반품·불출·주문** / **현황·리포트·수불** / **기본정보(판매소·단가·코드)**
|
||||
|
||||
- 전체 업무가 어떻게 이어지는지 먼저 보려면 **[핵심 업무 흐름]** 으로 이동하세요.
|
||||
- 특정 작업 방법은 좌측 목차에서 해당 항목(발주·입고 / 재고·실사 / 판매·불출 / 판매현황·수불·통계)을 선택하세요.
|
||||
각 화면 설명은 **그 화면 고유의 용어·입력 항목·버튼·작업 순서**만 담았습니다.
|
||||
|
||||
69
app/Docs/manual/01_account.md
Normal file
69
app/Docs/manual/01_account.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# 로그인 · 회원가입 · 계정
|
||||
|
||||
시스템을 쓰려면 **계정(아이디)** 이 필요합니다. 이 페이지는 회원가입부터 로그인, 2차 인증, 로그아웃까지의 과정을 설명합니다.
|
||||
|
||||
## 1. 회원가입
|
||||
|
||||
로그인 화면 아래쪽 **[회원가입]** 을 눌러 신청합니다. 입력 항목은 다음과 같습니다.
|
||||
|
||||
| 항목 | 필수 | 설명 |
|
||||
|---|:---:|---|
|
||||
| **아이디** | 필수 | 로그인에 쓰는 ID. **4자 이상**, 이미 쓰는 아이디는 사용할 수 없습니다. |
|
||||
| **비밀번호** | 필수 | **4자 이상**. 안전을 위해 영문·숫자·기호를 섞는 것을 권장합니다. |
|
||||
| **비밀번호 확인** | 필수 | 위 비밀번호와 똑같이 한 번 더 입력(오타 방지). |
|
||||
| **이름** | 필수 | 담당자 이름. |
|
||||
| **이메일** | 선택 | 안내용. 입력 시 형식 검사를 합니다. |
|
||||
| **연락처** | 선택 | 전화번호. |
|
||||
| **지자체** | 선택 | 소속 지자체. 해당되면 선택합니다. |
|
||||
| **사용자 역할** | 필수 | 신청할 권한(아래 표 참고). 실제 권한은 **관리자 승인 시 확정**됩니다. |
|
||||
|
||||
> 이메일·연락처 같은 개인정보는 **암호화되어 저장**됩니다.
|
||||
|
||||
### 신청할 수 있는 역할
|
||||
|
||||
| 역할 | 주로 하는 일 |
|
||||
|---|---|
|
||||
| **일반 사용자** | 기본 조회 |
|
||||
| **지정판매소** | 봉투 판매·반품, 자기 판매 현황 조회 |
|
||||
| **지자체 관리자** | 소속 지자체의 발주·입고·재고·불출·판매·리포트 전반 |
|
||||
|
||||
### 가입 후 — 관리자 승인 대기
|
||||
|
||||
회원가입을 제출하면 **바로 로그인되지 않고 "승인 대기" 상태**가 됩니다.
|
||||
|
||||
- **관리자가 승인하면** 그때부터 로그인할 수 있습니다.
|
||||
- 승인 전에 로그인하면 *"관리자 승인 후 로그인 가능합니다."* 안내가 나옵니다.
|
||||
- 승인이 **반려**되면 *"승인이 반려되었습니다. 관리자에게 문의해 주세요."* 가 나옵니다 — 담당 관리자에게 문의하세요.
|
||||
|
||||
## 2. 로그인
|
||||
|
||||
로그인 화면에서 **아이디**와 **비밀번호**를 입력합니다.
|
||||
|
||||
- 성공하면 **워크스페이스**(탭 작업공간)로 들어갑니다.
|
||||
- 아이디·비밀번호가 틀리면 안내 메시지가 나옵니다. (승인 대기/반려 상태도 위와 같이 안내됩니다.)
|
||||
|
||||
### 2차 인증(OTP)
|
||||
|
||||
보안 설정에 따라 비밀번호 확인 뒤 **2차 인증** 단계가 나올 수 있습니다.
|
||||
|
||||
- **이미 OTP를 등록한 경우** — 스마트폰 인증 앱(예: Google Authenticator)에 표시되는 **6자리 숫자**를 입력합니다.
|
||||
- **처음 사용하는 경우** — 화면의 **QR코드 또는 설정 키**를 인증 앱에 등록한 뒤, 앱에 나온 6자리 숫자로 설정을 완료합니다. 이후 로그인부터 OTP를 입력하게 됩니다.
|
||||
|
||||
> OTP 숫자는 일정 시간마다 바뀌므로, **현재 표시된 숫자**를 입력해야 합니다. 휴대폰 시간이 자동(네트워크 시간)으로 맞춰져 있어야 정확합니다.
|
||||
|
||||
## 3. 로그아웃
|
||||
|
||||
화면 오른쪽 위 **[로그아웃]** 을 누르면 안전하게 종료됩니다. 공용 PC라면 사용 후 꼭 로그아웃하세요.
|
||||
|
||||
## 4. 비밀번호 변경 · *비밀번호 변경*
|
||||
|
||||
로그인한 본인이 직접 비밀번호를 바꾸는 화면입니다.
|
||||
|
||||
**입력**: 현재 비밀번호 → 새 비밀번호 → 새 비밀번호 확인.
|
||||
**순서**: 현재 비밀번호가 맞아야 변경되며, 새 비밀번호는 확인란과 동일해야 합니다.
|
||||
> 주기적으로 변경하고, 다른 사이트와 같은 비밀번호는 피하세요.
|
||||
|
||||
## 5. 비밀번호 분실 · 계정·권한 문제
|
||||
|
||||
- **비밀번호를 분실**해 로그인 자체가 안 되는 경우에는 **담당 관리자**가 초기화해 줍니다. 관리자에게 문의하세요.
|
||||
- 권한(역할)을 바꾸고 싶을 때도 관리자에게 요청하면 됩니다.
|
||||
92
app/Docs/manual/05_workspace.md
Normal file
92
app/Docs/manual/05_workspace.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# 화면 구성 · 워크스페이스 · 단축키
|
||||
|
||||
이 시스템은 **여러 작업 화면을 탭으로 열어 두고 오가며** 일할 수 있도록 만들어졌습니다(웹 브라우저의 탭과 비슷합니다). 이 페이지는 화면이 어떻게 구성되는지, 탭을 어떻게 쓰는지, 그리고 빠르게 쓰는 단축키를 설명합니다.
|
||||
|
||||
## 1. 전체 화면 구성
|
||||
|
||||
로그인하면 **워크스페이스**(탭 작업공간)가 기본으로 열립니다. 화면은 크게 네 부분입니다.
|
||||
|
||||
| 영역 | 위치 | 설명 |
|
||||
|---|---|---|
|
||||
| **상단 헤더** | 맨 위 | 로고, **대분류 메뉴**, 오른쪽에 소속 지자체·접속자·관리자·로그아웃 |
|
||||
| **왼쪽 사이드바** | 좌측 | 현재 선택한 대분류의 **소메뉴 목록**. 아래에는 매뉴얼·FAQ 링크 |
|
||||
| **탭바** | 본문 위 | 지금 열어 둔 작업 화면들의 **탭 목록** |
|
||||
| **본문** | 가운데 | 현재 탭의 실제 작업 화면 |
|
||||
|
||||
> **대분류를 클릭하면** 왼쪽 사이드바에 그 안의 소메뉴가 펼쳐집니다. 현재 위치한 메뉴는 왼쪽에서 **▸** 로 표시됩니다.
|
||||
|
||||
## 2. 탭(워크스페이스) 사용법
|
||||
|
||||
- **메뉴를 클릭하면 탭으로 열립니다.** 같은 메뉴를 다시 누르면 새 탭을 또 만들지 않고 **이미 열린 탭으로 이동**합니다.
|
||||
- **탭을 전환해도 작업 내용이 유지됩니다.** A 화면에 무언가 입력하다가 B 화면을 잠깐 보고 와도, A 탭의 입력은 그대로 남아 있습니다.
|
||||
- **탭 ↔ 왼쪽 메뉴 연동:** 탭을 전환하면 왼쪽 사이드바의 강조 위치도 그 탭의 메뉴로 **자동으로 따라갑니다.**
|
||||
- 탭이 많아지면 가로로 스크롤되며, **최대 12개**까지 열 수 있습니다. 12개를 넘기면 가장 오래된 탭이 자동으로 닫힙니다.
|
||||
|
||||
### 탭의 버튼
|
||||
|
||||
| 버튼 | 동작 |
|
||||
|---|---|
|
||||
| **↻** (탭 위) | 그 **탭만** 새로고침합니다. 다른 탭은 그대로 둡니다. |
|
||||
| **×** (탭 위) | 그 탭을 닫습니다. |
|
||||
| 탭을 **가운데(휠) 클릭** | 마우스 휠을 누르면 그 탭이 닫힙니다(브라우저와 동일). |
|
||||
| 탭에 **마우스를 올리면** | 이름이 길어 잘렸을 때 **전체 제목**이 말풍선으로 보입니다. |
|
||||
|
||||
### 탭이 유지되는 범위
|
||||
|
||||
- **브라우저를 새로고침**하거나 **관리자 페이지에 갔다가 돌아와도** 열어 두었던 탭이 **다시 복원**됩니다.
|
||||
- 단, **브라우저 탭(창)을 완전히 닫으면** 작업공간은 초기화됩니다. (이 유지는 "이번 접속 동안"만 적용됩니다.)
|
||||
- **다른 아이디로 로그인하면** 이전 사용자의 탭은 복원되지 않고 **기본 화면으로 초기화**됩니다. (계정별로 분리됩니다.)
|
||||
- 복원되는 것은 **열려 있던 화면 목록**입니다. 관리자 페이지를 거치는 등 작업공간을 완전히 벗어났던 경우, 각 화면은 새로 불러와지므로 **입력 중이던 폼 내용까지 그대로 살아나지는 않습니다.**
|
||||
|
||||
## 3. 분할 보기 (여러 화면 한눈에)
|
||||
|
||||
여러 화면을 **동시에 펼쳐 놓고** 비교하거나 함께 작업할 수 있습니다. 탭바 오른쪽의 **분할 버튼**으로 화면을 나눕니다.
|
||||
|
||||
| 버튼 | 모양 | 설명 |
|
||||
|---|---|---|
|
||||
| **1분할** | □ | 한 화면만 크게 (기본) |
|
||||
| **2분할 (좌우)** | ◫ | 화면을 왼쪽/오른쪽 두 칸으로 |
|
||||
| **2분할 (상하)** | ⬓ | 화면을 위/아래 두 칸으로 |
|
||||
| **4분할** | ⊞ | 2×2 네 칸으로 |
|
||||
|
||||
### 칸에 화면 배치하기
|
||||
|
||||
- 분할하면 열어 둔 화면들이 칸에 자동으로 채워집니다. 빈 칸에는 안내 문구가 표시됩니다.
|
||||
- **칸을 클릭하면 그 칸이 "선택"**(파란 테두리)됩니다. 이 상태에서 **위 탭바의 탭을 클릭**하면 그 화면이 **선택된 칸**에 들어갑니다.
|
||||
- 각 칸 위의 **헤더**에는 화면 이름과 함께 **↻(이 칸 새로고침)·×(이 칸 비우기)** 버튼이 있습니다.
|
||||
|
||||
### 칸 크기 조절
|
||||
|
||||
- 칸 사이의 **구분선에 마우스를 올리면 ↔/↕ 커서**가 나타납니다. **드래그**하면 칸 크기(비율)를 자유롭게 조절할 수 있습니다.
|
||||
- 구분선을 **더블클릭**하면 **50:50으로 초기화**됩니다.
|
||||
- 조절한 분할 모양·크기도 새로고침·관리자 왕복 후 **그대로 복원**됩니다.
|
||||
|
||||
> 분할 상태에서도 각 화면의 작업 내용은 그대로 유지됩니다. 마음껏 나눴다 합쳤다 해도 입력하던 내용이 사라지지 않습니다.
|
||||
|
||||
## 4. 키보드 단축키
|
||||
|
||||
자주 쓰는 동작은 키보드로 더 빠르게 할 수 있습니다. 브라우저 기본 단축키와 겹치지 않도록 **Alt 키**를 함께 누르는 방식입니다.
|
||||
|
||||
동작은 **현재 선택된 칸**을 기준으로 적용됩니다(1분할이면 그 한 화면).
|
||||
|
||||
| 단축키 | 동작 |
|
||||
|---|---|
|
||||
| **Alt + 1 ~ 9** | **n번째 탭**을 선택된 칸에 표시 |
|
||||
| **Alt + W** | 선택된 칸의 **탭 닫기** |
|
||||
| **Alt + ]** | 선택된 칸을 **다음 탭**으로 |
|
||||
| **Alt + [** | 선택된 칸을 **이전 탭**으로 |
|
||||
|
||||
> macOS 에서도 동일하게 **Option(⌥)** 키가 Alt 역할을 합니다 (예: ⌥ + 1).
|
||||
>
|
||||
> 참고: `Ctrl/⌘ + W`, `Ctrl/⌘ + 숫자`, `Ctrl + Tab` 은 **브라우저 자체가 먼저 가로채기** 때문에 이 시스템에서 다른 용도로 바꿀 수 없어, 위와 같이 Alt 조합을 사용합니다.
|
||||
|
||||
## 5. 그 밖의 이동
|
||||
|
||||
- **관리자** 버튼(헤더 오른쪽, 관리자 권한일 때) — 메뉴·코드·판매소 등 **관리자 설정 화면**으로 이동합니다. 갔다가 워크스페이스로 돌아오면 열어 두었던 탭이 복원됩니다.
|
||||
- **지자체 선택**(왼쪽 사이드바 아래) — 슈퍼 관리자가 **작업할 지자체를 바꿀 때** 사용합니다.
|
||||
- **로그아웃**(헤더 오른쪽) — 시스템에서 나갑니다.
|
||||
|
||||
## 6. 도움말 보는 법
|
||||
|
||||
- 각 작업 화면의 **"이 화면 설명"(❓) 버튼** — 지금 보고 있는 화면에 해당하는 매뉴얼이 새 탭으로 열립니다.
|
||||
- 이 매뉴얼 왼쪽 위 **검색창** — 모든 매뉴얼 페이지에서 단어를 찾아, 결과를 누르면 해당 페이지의 그 단어 위치로 이동해 **노란색으로 표시**해 줍니다.
|
||||
@@ -1,41 +1,83 @@
|
||||
# 발주 · 입고
|
||||
|
||||
제작업체에 봉투를 주문(발주)하고, 도착한 물량을 시스템에 등록(입고)하는 단계입니다. **지자체 관리자** 이상이 사용합니다.
|
||||
봉투를 제작업체에 **주문(발주)** 하고, 도착한 봉투를 창고에 **들여놓는(입고)** 단계입니다.
|
||||
|
||||
## 발주
|
||||
---
|
||||
|
||||
### 발주 등록
|
||||
## 발주 등록 · *발주 입고 관리 › 발주 등록*
|
||||
|
||||
**발주 입고 관리 › 발주 등록**
|
||||
봉투를 **얼마나 주문할지** 입력해 발주서를 만드는 화면입니다. 저장하면 추적용 **LOT 번호**가 자동으로 붙습니다.
|
||||
|
||||
1. 봉투 **품목**(종류·용량)과 **수량**, 납품 관련 정보를 입력합니다.
|
||||
2. 박스/낱장 수량과 금액·총계가 자동으로 계산됩니다.
|
||||
3. 저장하면 발주 건이 생성되고, 추적용 **LOT 번호**가 자동 부여됩니다.
|
||||
**이 화면의 용어**
|
||||
- **발주가능봉투**: 조달청(나라장터)에 등록되어 주문할 수 있는 봉투 종류.
|
||||
- **입고처**: 들어온 봉투를 받을 창고/장소.
|
||||
- **조달수수료**: 발주 금액에 붙는 수수료율(%).
|
||||
- **Box당 팩 / 팩당 낱장 / 1박스 총 낱장**: 포장 환산 정보(참고용 표).
|
||||
|
||||
> 발주 내용은 무결성 보호를 위해 버전·해시로 관리됩니다. 수정(재발주) 시 기존 LOT는 유지됩니다.
|
||||
**입력 항목**: 발주월, 발주일, 협회, 제작업체, 입고처, **봉투 품목별 수량(박스 단위)**.
|
||||
|
||||
### 발주 변경 · 현황
|
||||
**버튼**: `발주`(저장) · `변경 저장`(수정 시) · `취소`.
|
||||
|
||||
| 작업 | 메뉴 | 설명 |
|
||||
|---|---|---|
|
||||
| 발주 변경 | 발주 변경 | 기존 발주 수정·재발주 |
|
||||
| 발주 현황 | 발주 현황 | 발주 목록을 기간·상태로 조회, 엑셀 내보내기 |
|
||||
| 발주 상세 | (현황에서 행 선택) | 개별 발주 상세 확인, 취소 처리 |
|
||||
**작업 순서**
|
||||
1. 발주월·발주일, 제작업체·입고처를 고릅니다.
|
||||
2. 아래 봉투 종류별로 **주문할 박스 수량**을 입력합니다(금액·총 낱장은 자동 계산).
|
||||
3. `발주` 를 누르면 발주가 생성되고 LOT 번호가 부여됩니다.
|
||||
|
||||
## 입고
|
||||
---
|
||||
|
||||
발주분이 실제 도착하면 입고로 등록합니다. 입고 시 **박스·팩·낱장 바코드**가 생성되어 재고에 반영됩니다.
|
||||
## 발주 현황 · *발주 입고 관리 › 발주 현황*
|
||||
|
||||
| 방식 | 메뉴 | 언제 사용 |
|
||||
|---|---|---|
|
||||
| 스캐너 입고 | 발주 입고[스캐너] | 바코드를 스캔하며 입고 |
|
||||
| 일괄 입고 | 일괄입고 | 다량을 한 번에 입고 |
|
||||
| 입고 현황 | 입고 현황 | 입고 기록 조회, 엑셀 내보내기 |
|
||||
낸 발주를 **조회·관리**하는 목록 화면입니다.
|
||||
|
||||
### 입고 처리 순서
|
||||
**필터**: 발주기간(월~월) · 제작업체 · 품명 · **입고구분(전체/입고완료/미입고)**.
|
||||
|
||||
1. 입고할 발주 건(LOT)을 선택합니다.
|
||||
2. 도착 수량(박스/낱장)을 확인·입력합니다.
|
||||
3. 저장하면 재고가 증가하고, 단위별 바코드가 부여됩니다.
|
||||
**표 컬럼**: 발주일자 · 제작업체 · 품명 · **발주수량 · 입고수량 · 미입고수량** · 발주금액 · 입고처 · 비고.
|
||||
- **미입고수량** = 발주했지만 아직 안 들어온 수량.
|
||||
|
||||
> 입고가 끝나면 **재고 관리**에서 수량이 정상 반영됐는지 확인하세요.
|
||||
**버튼**: `엑셀저장` · `인쇄` · `발주등록`. (목록에서 개별 발주의 상세·취소 가능)
|
||||
|
||||
---
|
||||
|
||||
## 입고 처리 · *발주 입고 관리 › 입고[스캐너] / 일괄입고*
|
||||
|
||||
도착한 봉투를 시스템에 **들여놓는** 화면입니다. 입고하면 박스·팩·낱장 **바코드가 생성**되고 재고가 늘어납니다.
|
||||
|
||||
**이 화면의 용어**
|
||||
- **인계자(제작업체)** / **인수자(대행소)**: 봉투를 넘기는 쪽 / 받는 쪽.
|
||||
- **입고량(매)**: 실제로 들어온 **낱장 수**("매" = 장).
|
||||
- **LOT NO / 발주 NO**: 어떤 발주분인지 식별하는 번호.
|
||||
|
||||
**입고[스캐너]**: 발주 건을 보고 행마다 **입고량(매)** 을 직접 입력 → `입고처리`.
|
||||
**일괄입고**: 여러 발주 건을 **체크박스로 골라** 한 번에 입고. 미입고량은 파란색으로 강조됩니다.
|
||||
|
||||
**작업 순서**
|
||||
1. 제작업체·인수자·인계자·입고일을 고릅니다.
|
||||
2. 들어온 만큼 **입고량(매)** 을 입력(또는 일괄 선택)합니다.
|
||||
3. `입고처리` → 재고 반영. **재고 관리**에서 수량이 늘었는지 확인하세요.
|
||||
|
||||
---
|
||||
|
||||
## 입고 현황 · *발주 입고 관리 › 입고 현황*
|
||||
|
||||
입고 기록을 기간별로 조회합니다.
|
||||
|
||||
**필터**: 입고기간 · 제작업체 · 품명 · 입고구분(전체/완료/미완료).
|
||||
**표 컬럼**: 입고일자 · 품명 · 입고수량 · 발주일자 · 발주수량 · 발주번호 · 제작업체 · **입고여부(완료/미완료)** · 입고처 · 비고.
|
||||
**버튼**: `엑셀저장` · `인쇄`.
|
||||
|
||||
---
|
||||
|
||||
## 발주파일 생성 · *발주 입고 관리 › 발주파일 생성*
|
||||
|
||||
선택한 발주 건의 내용을 **봉투 생산기(바코드 인쇄) 전달용 파일**로 만드는 화면입니다. (예전 이름: *LOT-No 디스켓 불출*) 생성되는 파일은 발주 정보와 품목이 담긴 **암호화된 발주파일**(`.seed.json`)이며, 봉투 생산 장비가 이 파일을 읽어 그 발주의 바코드를 인쇄합니다.
|
||||
|
||||
**LOT-No란?** 발주를 등록할 때 그 건에 자동 부여되는 **6자리 배치(묶음) 번호**입니다. 이후 그 발주로 인쇄되는 모든 봉투 바코드의 앞자리(예: `ZLCH2M-000006-…`)가 되어, 어느 발주에서 나온 봉투인지 구분하는 기준이 됩니다.
|
||||
|
||||
**필터**: 시작월 · 종료월 · LOT-No · 제작업체.
|
||||
**순서**
|
||||
1. 기간·조건으로 발주 목록을 조회합니다.
|
||||
2. 대상 발주 행의 **`내용 확인`** 을 누르면, 파일에 담길 **발주 정보와 품목 목록(봉투코드·수량·금액 등)** 을 팝업으로 미리 볼 수 있습니다.
|
||||
3. 내용을 확인한 뒤 **`발주파일 생성(다운로드)`** 을 누르면 암호화된 발주파일이 내려받아집니다.
|
||||
|
||||
**표 컬럼**: 발주일자 · LOT-No · 품명 · 수량 · 제작업체 · (내용 확인 버튼).
|
||||
> 정상·취소 발주가 함께 조회되며, 파일 생성은 발주 단위로 동작합니다. 생성 전 **`내용 확인`** 단계에서 반드시 품목·수량을 검토하세요.
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
# 재고 · 실사
|
||||
|
||||
현재 보유 봉투 수량을 확인하고, 정기적으로 실사를 통해 실제 수량과 맞추는 단계입니다.
|
||||
지금 창고에 **남은 봉투**를 확인하고, 컴퓨터 기록과 **실제 수량을 맞추는(실사)** 단계입니다.
|
||||
|
||||
## 재고 현황
|
||||
---
|
||||
|
||||
**재고 관리 › 재고 현황**
|
||||
## 재고 현황 · *재고 관리 › 재고 현황*
|
||||
|
||||
- 품목별·상태별 현재 재고를 조회합니다.
|
||||
- 지자체·봉투 종류 등으로 필터링할 수 있습니다.
|
||||
- **엑셀 내보내기**로 목록을 저장할 수 있습니다.
|
||||
품목별로 **현재 남은 수량**을 봅니다.
|
||||
|
||||
| 항목 | 설명 |
|
||||
|---|---|
|
||||
| 품목 | 봉투 종류·용량 |
|
||||
| 재고 수량 | 입고 − (판매 + 불출 + 파기) |
|
||||
| 상태 | 재고/판매 등 단위별 상태 |
|
||||
**이 화면의 용어**
|
||||
- **시군구재고**: 지자체(시·군·구) **창고**에 있는 재고.
|
||||
- **대행소재고**: 배송 **대행소**가 보유 중인 재고.
|
||||
- **계**: 둘을 합친 총 재고.
|
||||
|
||||
## 실사 (재고 조사)
|
||||
**필터**: 기준일자 · 대행소(전체/선택).
|
||||
**표 컬럼**: 품목구분 · 봉투/스티커종류 · **계 · 시군구재고 · 대행소재고**.
|
||||
**버튼**: `조회` · `엑셀저장` · `인쇄` · `실사선별조회`.
|
||||
|
||||
장부상 재고와 실제 창고 수량을 맞추는 작업입니다. 다음 순서로 진행합니다.
|
||||
---
|
||||
|
||||
```
|
||||
실사 선별 ─→ 실사 등록(작업) ─→ 적용
|
||||
```
|
||||
## 실사 (재고 확인) · *재고 관리 › 실사 선별 조회 / 실사 선별 관리*
|
||||
|
||||
| 단계 | 메뉴 | 하는 일 |
|
||||
|---|---|---|
|
||||
| ① 선별 | 실사 선별 조회 | 실사 대상 범위를 골라 선별 |
|
||||
| ② 작업 | 실사 선별 관리 | 실제 수량을 입력·기록 |
|
||||
| ③ 상세·적용 | 실사 상세 | 실사 결과를 확인하고 재고에 **적용** |
|
||||
**실사**는 시스템에 적힌 수량(전산재고)과 **창고에서 직접 센 수량(실사재고)** 을 비교해 차이를 바로잡는 작업입니다.
|
||||
|
||||
### 실사 진행 순서
|
||||
**이 화면의 용어**
|
||||
- **전산재고**: 시스템 기록상 수량.
|
||||
- **실사재고**: 현장에서 직접 센 수량(직접 입력).
|
||||
- **차이**: 실사 − 전산. (양수 = 더 많음, 음수 = 부족)
|
||||
- **박스 / 팩 / 낱장**: 셀 단위. 팩코드·낱장(시작~끝) 구간으로 표시됩니다.
|
||||
|
||||
1. **선별**: 실사할 품목·구간을 선택해 실사 건을 만듭니다.
|
||||
2. **등록**: 실제 센 수량을 입력합니다. 장부 수량과의 차이가 표시됩니다.
|
||||
3. **적용**: 검토 후 적용하면 차이가 재고에 반영됩니다.
|
||||
**작업 순서**
|
||||
1. **실사 선별**: 실사할 기간·품목을 골라 대상 목록을 만듭니다(팝업에서 작업일자·품목 선택).
|
||||
2. **실사재고 입력**: 팩/박스별로 실제 센 수량을 입력하면 **차이**가 자동 표시됩니다.
|
||||
3. **저장(적용)**: 검토 후 적용하면 차이가 재고에 반영됩니다.
|
||||
|
||||
> 적용 전까지는 재고에 영향을 주지 않으므로, 입력 도중 중단해도 안전합니다.
|
||||
**주요 표 컬럼**: 팩코드 · 포장량 · 재고(전산) · **실사재고(입력)** · 차이 · 낱장(시작) · 낱장(끝).
|
||||
|
||||
> 적용 전까지는 재고에 영향을 주지 않으므로, 세는 도중 중단해도 안전합니다.
|
||||
|
||||
@@ -1,46 +1,83 @@
|
||||
# 판매 · 불출
|
||||
# 판매 · 반품 · 불출 · 주문
|
||||
|
||||
재고를 외부로 내보내는 두 가지 경로입니다. **판매**는 지정판매소 대상 유상 거래, **불출**은 무료 대상자에 대한 무상 지급입니다.
|
||||
재고를 외부로 내보내는 단계입니다. **판매**(가게에 유상 공급)·**불출**(무료 배부)·**주문 접수**(전화 등).
|
||||
|
||||
## 판매 (지정판매소)
|
||||
---
|
||||
|
||||
**판매 관리** 메뉴에서 처리합니다.
|
||||
## 지정판매소 판매 · *판매 관리 › 지정 판매소 판매*
|
||||
|
||||
| 작업 | 메뉴 | 설명 |
|
||||
|---|---|---|
|
||||
| 판매 등록 | 지정 판매소 판매 | 판매소를 선택해 판매 거래 기록 |
|
||||
| 스캔 판매 | 지정 판매소 판매[스캔] | 바코드를 스캔하며 판매(판매소 현장용) |
|
||||
| 판매 취소 | 지정 판매소 판매 취소 | 기존 판매 거래 취소 |
|
||||
| 반품 | 지정 판매소 반품 | 판매분 반품 등록 |
|
||||
| 반품 취소 | 지정 판매소 반품 취소 | 반품 취소 처리 |
|
||||
동네 가게(지정판매소)에 봉투를 **판매**하고, 어떤 봉투를 줬는지 **바코드로 기록**합니다.
|
||||
|
||||
### 판매 등록 순서
|
||||
**이 화면의 용어**
|
||||
- **판매소코드/상호/대표자**: 판매하는 가게 정보(검색해서 선택).
|
||||
- **봉투코드(스캔)**: 내보내는 봉투의 바코드. 스캔/입력하면 어떤 LOT·포장단위인지 식별됩니다.
|
||||
- **포장단위(Box/Pack/Sheet)**: 박스/팩/낱장.
|
||||
|
||||
1. 판매할 **지정판매소**를 선택합니다.
|
||||
2. 봉투 **품목·수량**을 입력(또는 바코드 스캔)합니다.
|
||||
3. 저장하면 재고가 감소하고 판매 내역이 기록됩니다.
|
||||
**입력/순서**
|
||||
1. 위에서 **판매소를 검색·선택**합니다(코드·상호·전화·주소로 검색).
|
||||
2. 판매할 봉투 종류·수량을 고르거나 **봉투코드를 스캔**합니다.
|
||||
3. `판매저장` → 재고가 줄고 판매 내역이 기록됩니다.
|
||||
|
||||
### 전화 접수(주문)
|
||||
**표 컬럼**: (판매내역) 봉투종류·접수량·판매량·단가·판매금액 / (상세) 봉투종류·봉투코드·수량·포장단위.
|
||||
|
||||
| 작업 | 메뉴 |
|
||||
|---|---|
|
||||
| 전화 접수(신규) | 전화 접수 |
|
||||
| 전화 접수 관리 | 전화 접수 관리(수정·취소) |
|
||||
---
|
||||
|
||||
## 불출 (무료 대상자)
|
||||
## 지정 판매소 반품 / 판매·반품 취소 · *판매 관리*
|
||||
|
||||
**불출 관리** 메뉴에서 무료 대상자에게 봉투를 무상 지급합니다.
|
||||
- **반품**: 가게가 안 팔린 봉투를 **되돌려 받는** 것. 스캔/선택 후 저장하면 재고가 다시 늘어납니다.
|
||||
- **판매 취소 / 반품 취소**: 잘못 처리한 건을 되돌립니다.
|
||||
|
||||
| 작업 | 메뉴 | 설명 |
|
||||
|---|---|---|
|
||||
| 불출 처리 | 무료용 불출 처리 | 무료 배분 등록(재고 감소) |
|
||||
| 불출 취소 | 무료용 불출 취소 | 불출 취소(재고 복원) |
|
||||
| 불출 현황 | 무료 불출 현황 | 기간별 불출 기록 조회 |
|
||||
---
|
||||
|
||||
### 불출 처리 순서
|
||||
## 판매/반품 현황 · *판매 관리* 또는 *판매 현황*
|
||||
|
||||
1. 무료 **대상처**와 봉투 **품목·수량**을 선택합니다.
|
||||
2. 저장하면 재고가 감소하고 불출 내역이 기록됩니다.
|
||||
3. 잘못 처리한 경우 **무료용 불출 취소**로 되돌리면 재고가 복원됩니다.
|
||||
기간별 판매·반품 내역을 봅니다.
|
||||
|
||||
> 판매·불출 모두 재고를 감소시키므로, 처리 후 **재고 현황**에서 반영 결과를 확인하세요.
|
||||
**필터**: 조회기간.
|
||||
**표 컬럼**: 판매소 · 판매일 · 봉투코드 · 봉투명 · 수량 · 단가 · 금액 · **구분(판매/반품/취소)**.
|
||||
**버튼**: `조회` · `초기화` · `주문등록` · `판매등록`.
|
||||
|
||||
---
|
||||
|
||||
## 전화 주문 접수 · *판매 관리 › 전화 접수*
|
||||
|
||||
가게가 전화로 주문한 내용을 **접수**합니다(실제 출고/판매는 이후 단계).
|
||||
|
||||
**이 화면의 용어**
|
||||
- **접수일 / 배달일**: 주문 받은 날 / 가져다줄 날(보통 다음날 자동).
|
||||
- **결제구분**: 이체 / 가상계좌.
|
||||
- **1박스·1팩(낱장/판매가)**: 포장별 수량·가격 참고값.
|
||||
|
||||
**입력/순서**
|
||||
1. **판매소 검색·선택**(코드·사업자번호·상호·전화·주소).
|
||||
2. 결제구분을 고르고, 봉투 **품목·주문수량·포장단위(박스/팩/낱장)** 를 입력(`행추가`로 여러 품목).
|
||||
3. `등록` → 주문 접수 완료.
|
||||
|
||||
> **주문 접수(간편)**: 판매소·배달일·결제방법과 봉투별 수량만 입력하는 간단 버전.
|
||||
|
||||
---
|
||||
|
||||
## 무료용 불출 처리 · *불출 관리 › 무료용 불출 처리*
|
||||
|
||||
무료 대상자(동사무소 등)에게 봉투를 **무상으로 내보내는(불출)** 화면입니다.
|
||||
|
||||
**이 화면의 용어**
|
||||
- **불출구분(무료용/공공용)**: 주민 무료 배부용 / 공공기관용.
|
||||
- **불출처**: 봉투를 최종 전달할 곳(동사무소·구청·기타).
|
||||
- **재고(낱장) / 환산(낱장)**: 현재 재고 / 입력 수량을 낱장으로 환산한 값.
|
||||
|
||||
**입력/순서**
|
||||
1. 불출년도·분기, 불출구분, 불출일, **불출처(동)** 를 고릅니다.
|
||||
2. **바코드 스캔** 또는 `행추가`로 봉투 종류·수량·포장단위를 입력합니다.
|
||||
3. `저장` → 재고가 줄고 불출 내역이 기록됩니다.
|
||||
|
||||
**표 컬럼**: 봉투코드 · 봉투종류 · 수량 · 포장 · 재고(낱장) · 환산(낱장).
|
||||
|
||||
---
|
||||
|
||||
## 무료용 불출 취소 · *불출 관리 › 무료용 불출 취소*
|
||||
|
||||
잘못 불출한 건을 **되돌려 재고를 복원**합니다.
|
||||
|
||||
**필터**: 불출월 · 불출처 · 불출구분 · 봉투종류.
|
||||
**순서**: 불출 목록에서 건을 고르고, 품목 내역에서 **취소할 항목을 체크 → 취소수량 입력** 후 처리.
|
||||
|
||||
@@ -1,42 +1,104 @@
|
||||
# 판매현황 · 수불 · 통계
|
||||
# 현황 · 리포트 · 수불
|
||||
|
||||
판매·재고 흐름을 집계해 보여주는 조회·리포트 화면 모음입니다. 대부분 **기간을 지정해 조회**하고 인쇄·엑셀로 내보낼 수 있습니다.
|
||||
입고·판매·불출 기록을 **모아 보여주는** 조회 화면들입니다. 대부분 **기간을 지정해 조회**하고 `엑셀저장`·`인쇄`로 내보낼 수 있습니다.
|
||||
|
||||
## 판매 현황
|
||||
---
|
||||
|
||||
| 메뉴 | 내용 |
|
||||
|---|---|
|
||||
| 지정 판매소 일/기간 판매대장 | 판매소별 일자·기간 거래 장부 |
|
||||
| 일계표 | 하루 전체 판매 요약(일계 + 월 누계) |
|
||||
| 기간별 판매현황 | 지정 기간의 판매 집계(일집계/기간집계) |
|
||||
| 년 판매 현황 | 연간 판매 통계(월별/분기별) |
|
||||
| 지정 판매소별 판매현황 | 판매소별 수량·금액 비교 |
|
||||
| 홈텍스 처리 | 세금계산서용 데이터(엑셀) 생성 |
|
||||
## 기간별 봉투 수불 현황 · *봉투 수불 관리 › 기간별 봉투 수불 현황*
|
||||
|
||||
## 봉투 수불 관리
|
||||
**수불(受拂)** = 들어오고 나간 움직임. 기간 동안 봉투가 얼마나 들어오고(입고) 나갔는지(판매·불출 등)를 한 표로 봅니다.
|
||||
|
||||
입고·판매·불출·반품·파기를 한데 모아 **수불(수입·불출)** 흐름을 봅니다.
|
||||
**이 화면의 용어**
|
||||
- **전일재고**: 조회 시작일 **전날**의 재고.
|
||||
- **입고**: 입고량 + 반품 + 기타.
|
||||
- **출고**: 판매 + 무료불출 + 반품 + 기타.
|
||||
- **잔량**: 전일재고 + 입고 − 출고.
|
||||
|
||||
| 메뉴 | 내용 |
|
||||
|---|---|
|
||||
| 기간별 봉투 수불 현황 | 기간 내 재고/입고/판매/불출 종합(엑셀 가능) |
|
||||
| 기타 입출고 | 손상·기증·폐기 등 기타 입출고 등록·조회 |
|
||||
| 반품/파기 현황 | 반품 및 파기 내역 |
|
||||
| LOT 수불 조회 | 봉투번호(바코드)·LOT 단위 이력 추적 |
|
||||
| 쓰레기 봉투 수급 계획 | 공급·수요 계획 |
|
||||
**필터**: 조회기간 · 봉투형식 · 봉투구분 · 대행소 · **집계방식(일자별/기간별)**.
|
||||
**표 컬럼**: 일자 · 품목 · 전일재고 · 입고(소계) · 출고(소계) · 잔량.
|
||||
|
||||
### LOT 수불 조회 사용법
|
||||
---
|
||||
|
||||
1. **봉투번호(바코드)** 또는 LOT 번호를 입력합니다.
|
||||
2. 조회하면 해당 단위의 입고·판매·반품 이력이 표시됩니다.
|
||||
3. 입력할 코드 형식이 헷갈리면 **도움말 › 번호알기**로 먼저 확인하세요.
|
||||
## 일계표 · *판매 현황 › 일계표*
|
||||
|
||||
## 통계 분석
|
||||
하루치 판매를 **일계(당일)** 와 **누계(월 누적)** 로 집계합니다.
|
||||
|
||||
| 메뉴 | 내용 |
|
||||
|---|---|
|
||||
| 전년 대비 판매 분석 | 작년 동기 대비 비교(차트) |
|
||||
| 월별 판매 추이 분석 | 월별 추이 시각화 |
|
||||
| 계절별 판매 추이 분석 | 계절 패턴 분석 |
|
||||
**용어**: **일계** = 그날 합계, **누계(월)** = 월초~당일 누적, **징수액** = 판매금액 − 수수료.
|
||||
**필터**: 조회일자 · 대행소 · 구분.
|
||||
**표**: 봉투종류별 — 일계(수량·판매금액·수수료·징수액) / 누계(월) 동일 항목.
|
||||
|
||||
> 리포트 화면은 조회 조건(기간·품목·판매소)을 바꿔가며 반복 조회할 수 있습니다. 결과는 인쇄 버튼 또는 엑셀 내보내기로 저장하세요.
|
||||
---
|
||||
|
||||
## 지정 판매소별 판매현황 · *판매 현황 › 판매소별 판매현황*
|
||||
|
||||
판매소마다 **얼마나 팔았는지**(수량 또는 금액)를 월별로 비교합니다.
|
||||
|
||||
**필터**: 기간 · 읍면동 · 봉투종류 · **지표(수량/금액)**.
|
||||
**표**: 판매소명 · 판매소코드 · 월별 값 · 합계.
|
||||
|
||||
---
|
||||
|
||||
## LOT 수불 조회 · *봉투 수불 관리 › LOT 수불 조회*
|
||||
|
||||
특정 **봉투번호(바코드)** 또는 **LOT**의 입고·판매·반품 **이력**을 추적합니다.
|
||||
|
||||
**입력**: 봉투번호(바코드/팩코드/박스코드/낱장코드).
|
||||
**표**: 일자 · 품목 · 포장단위 · **구분(입고/판매/반품)** · 수량 · LOT번호.
|
||||
> 입력할 코드 형식이 헷갈리면 좌측 **[봉투·LOT·바코드 코드체계]** 또는 도움말의 **번호알기**를 참고하세요.
|
||||
|
||||
---
|
||||
|
||||
## 반품/파기 현황 · *봉투 수불 관리 › 반품/파기 현황*
|
||||
|
||||
**용어**: **반품** = 판매소가 되돌린 봉투(출고 탭) / **파기** = 반품분의 폐기(입고 탭).
|
||||
**필터**: 조회기간 · 입출고구분.
|
||||
**표**: 일자 · 판매소명 · 봉투종류 · 수량 · 구분(반품/파기).
|
||||
|
||||
---
|
||||
|
||||
## 기타 입출고 · *봉투 수불 관리 › 기타 입출고*
|
||||
|
||||
정상적인 **발주입고 · 판매 · 불출** 흐름에 속하지 않는 **그 밖의 재고 증감**을 수동으로 기록하는 화면입니다.
|
||||
(예: 분실·파손, 실사 차이 보정, 행정상 조정, 타 부서 이관 등)
|
||||
|
||||
**구분**
|
||||
- **입고(in)**: 재고가 **늘어나는** 조정 (예: 누락분 추가, 반입).
|
||||
- **출고(out)**: 재고가 **줄어드는** 조정 (예: 파손·분실 차감).
|
||||
|
||||
**등록 입력값** *(모두 필수)*
|
||||
- **구분**: 입고 / 출고
|
||||
- **봉투**: 봉투코드(봉투구분으로 먼저 좁혀서 선택)
|
||||
- **수량**: 1 이상
|
||||
- **일자**: 처리 기준일
|
||||
- **사유**: 왜 조정하는지(필수, 최대 200자) — 추후 추적·감사를 위해 구체적으로 적습니다.
|
||||
|
||||
**조회·표시**
|
||||
- **수불년월 · 구분**으로 묶여 좌측 **목록(마스터)** → 우측 **상세 내역**으로 표시됩니다.
|
||||
- 봉투구분으로 필터링할 수 있습니다.
|
||||
|
||||
**반영**: 등록한 입고/출고 수량은 **재고와 봉투 수불 집계에 그대로 반영**되므로, 정확한 사유와 수량으로 신중히 입력하세요. 잘못 등록한 행은 삭제로 정정합니다.
|
||||
|
||||
> 정상 업무(발주·판매·불출)는 각 전용 화면에서 처리하고, **여기서는 그 흐름으로 설명되지 않는 예외적 증감만** 기록합니다.
|
||||
|
||||
---
|
||||
|
||||
## 쓰레기봉투 수급 계획 · *봉투 수불 관리 › 쓰레기봉투 수급 계획*
|
||||
|
||||
봉투 품목별로 **현재고가 며칠 버티는지, 언제·얼마나 발주할지**를 보여 주는 수급·발주 계획표입니다. 처음 들어가면 기본 조건으로 자동 조회됩니다.
|
||||
|
||||
**필터**
|
||||
- **기준일**: 계산 기준 날짜.
|
||||
- **제작기일(리드타임, 일)**: 발주 후 입고까지 걸리는 기간(기본 40일). 재고 소진 전에 미리 발주하려는 여유.
|
||||
- **재고/판매 범위**: 기존(수기·바코드 미등록) / 바코드(등록) / 전체.
|
||||
|
||||
**표 구성(3그룹)**: ① 최근 발주 내역 ② 현재고 및 예상 판매일수 ③ 추가발주 예정내역.
|
||||
**핵심 계산**: **발주예정일 = 기준일 + 소진일수 − 보유일수**. 발주예정일이 지난(긴급) 품목은 **빨간색**으로 표시되고, 그 시점에 맞춘 **추가 발주 제안 장수**가 함께 나옵니다.
|
||||
> 발주 타이밍을 놓치지 않도록 돕는 화면입니다. 빨간 품목부터 발주를 검토하세요.
|
||||
|
||||
---
|
||||
|
||||
## 그 밖의 현황
|
||||
- **기간별 판매현황 / 년 판매 현황**: 기간·연도 단위 판매 집계.
|
||||
- **지정 판매소 (일/기간) 판매대장**: 판매소별 거래 장부.
|
||||
- **홈택스 처리**: 세금계산서용 데이터(엑셀) 생성.
|
||||
- **통계 분석(전년대비·월별·계절 추이)**: 판매 추세를 그래프로.
|
||||
|
||||
107
app/Docs/manual/60_basic_info.md
Normal file
107
app/Docs/manual/60_basic_info.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# 기본정보 (판매소 · 단가 · 코드)
|
||||
|
||||
업무의 **기준이 되는 정보**를 관리하는 화면들입니다. 발주·판매가 이 값을 사용하므로 먼저 정확히 등록되어 있어야 합니다.
|
||||
|
||||
---
|
||||
|
||||
## 지정판매소 관리 · *기본정보관리 › 지정 판매소 관리/조회*
|
||||
|
||||
봉투를 파는 **가게(지정판매소)** 를 등록·조회합니다.
|
||||
|
||||
**이 화면의 용어**
|
||||
- **판매소번호**: 가게 고유 번호(지역코드 + 일련번호).
|
||||
- **도로명주소 / 지번주소**: 두 가지 주소 체계(지도 표시·검색에 사용).
|
||||
- **은행/계좌, 가상계좌**: 봉투 대금 결제용 계좌.
|
||||
|
||||
**목록 표 컬럼**: 번호 · 판매소번호 · 상호명 · 대표자명 · 지역/읍면동 · 전화번호 · 주소.
|
||||
**상세**: 사업자번호 · 우편번호 · 도로명/지번주소 · 이메일 · 결제 계좌 등.
|
||||
> 목록에서 가게를 고르면 우측에 상세가 표시됩니다. (등록·수정은 관리자)
|
||||
|
||||
**함께 제공되는 화면**
|
||||
- **지정판매소 바코드 발행**: 판매소를 골라 봉투 바코드 라벨을 인쇄용으로 출력합니다.
|
||||
- **지정 판매소 신규/취소 현황**: 한 해 동안 군·구별로 몇 곳이 새로 생기고 없어졌는지 **건수**를 집계하는 보고서입니다. (아래 별도 설명)
|
||||
- **지정판매소 조회(브라우즈)**: 등록된 판매소를 검색·열람만 하는 보기 전용 화면입니다.
|
||||
|
||||
---
|
||||
|
||||
## 지정 판매소 신규/취소 현황 · *기본정보관리 › 지정 판매소 신규/취소 현황*
|
||||
|
||||
선택한 **한 해 동안** 지정판매소가 **몇 곳 새로 생기고(지정)**, **몇 곳 없어졌는지(취소)**, 그래서 **연말에 몇 곳이 남았는지**를 **군·구별 건수**로 집계해 보여주는 현황판입니다. 개별 가게 명단이 아니라 **숫자 집계표**입니다.
|
||||
|
||||
**핵심 개념 — 4개의 기둥 컬럼**
|
||||
- **종전(전년도말)**: **전년 12월 31일** 기준 정상 운영 중이던 판매소 수 → *작년 말 잔고*.
|
||||
- **지정(당해년)**: 조회년도 중 **새로 지정**된(신규) 판매소 수.
|
||||
- **취소(당해년)**: 조회년도 중 **취소**(폐업·해지)된 판매소 수.
|
||||
- **현행(금년도말)**: **조회년도 12월 31일** 기준 정상 운영 중인 판매소 수 → *올해 말 잔고*.
|
||||
|
||||
> 관계식: `현행 ≈ 종전 + 지정 − 취소`.
|
||||
> 예) 종전 120 · 지정 15 · 취소 8 → 현행 127. “올해 15곳 새로 지정하고 8곳이 취소되어 순증 7곳, 연말 기준 127곳이 운영 중”이라는 뜻입니다.
|
||||
|
||||
**그 밖의 계산 컬럼**
|
||||
- **구코드**: 판매소에 저장된 구·군 코드 값.
|
||||
- **증감(현행−종전)**: 작년 말 대비 올해 말 **순증감 곳 수**.
|
||||
- **지정−취소**: 그 해 신규에서 취소를 뺀 값(당해 순증).
|
||||
- **현행비중(%)**: 전체 현행 합계에서 그 군·구가 차지하는 비율.
|
||||
- **전년대비 증감률(%)**: `((현행−종전) ÷ 종전) × 100` (종전이 0이면 표시 안 함).
|
||||
- 맨 아래 **합계** 행: 전 군·구 총계.
|
||||
|
||||
**함께 표시되는 것**
|
||||
- **동별 현행 요약**: 선택 군·구 안에서 동(구역)별 현행 수를 칩(badge)과 표로 보여줍니다.
|
||||
- **연도별 요약(참고, 접이식)**: 활성/비활성 합계와 연도별 신규등록·취소 건수.
|
||||
|
||||
**사용법**: 상단에서 **조회년도**를 고르고 **[조회]**. 군·구는 로그인한 지자체 기준으로 자동 고정됩니다. 우측 상단 **엑셀저장 · 인쇄**로 내보낼 수 있습니다. (각 컬럼 제목 옆 **?** 배지에 마우스를 올리면 설명이 나옵니다.)
|
||||
|
||||
> **비슷한 이름과 혼동 주의**
|
||||
> - **지정 판매소 신규/취소 현황**(이 화면): 군·구별 신규/취소 **건수 집계·통계**.
|
||||
> - **지정판매소 관리 / 조회**: **개별 가게**를 등록·검색·열람하는 화면.
|
||||
|
||||
---
|
||||
|
||||
## 단가 관리 · *기본정보관리 › 단가 관리*
|
||||
|
||||
봉투 **가격**을 기간별로 관리합니다.
|
||||
|
||||
**이 화면의 용어**
|
||||
- **발주단가**: 제작업체에 주는 가격(살 때).
|
||||
- **도매단가**: 대행소·판매소에 넘기는 도매 가격.
|
||||
- **판매단가**: 최종 소비자 판매가.
|
||||
- **수수료율**: 판매수수료율(%).
|
||||
- **적용시작/종료**: 그 단가가 유효한 기간.
|
||||
|
||||
**필터**: 봉투구분 · 봉투코드 · 조회기간.
|
||||
**표 컬럼**: 봉투코드 · 봉투명 · 발주단가 · 도매단가 · 판매단가 · 수수료율 · 적용시작 · 적용종료 · 상태.
|
||||
> 조회 전용이며, 등록·수정은 `단가관리(CRUD)` 화면에서 합니다(이력 보존).
|
||||
|
||||
---
|
||||
|
||||
## 포장 단위 관리 · *기본정보관리 › 포장 단위 관리*
|
||||
|
||||
봉투 1박스·1팩에 **몇 장이 들어가는지** 정의합니다. 이 값으로 박스↔낱장이 환산됩니다.
|
||||
|
||||
**이 화면의 용어**
|
||||
- **박스당 팩수**: 1박스 안의 팩 개수.
|
||||
- **팩당 낱장수**: 1팩 안의 낱장(봉투) 수.
|
||||
- **1박스 총 낱장** = 박스당 팩수 × 팩당 낱장수.
|
||||
|
||||
**표 컬럼**: 봉투코드 · 봉투명 · 박스당팩수 · 팩당낱장수 · 1박스총낱장 · 적용시작/종료 · 상태(사용/만료).
|
||||
|
||||
---
|
||||
|
||||
## 기본코드 관리 · *기본정보관리 › 기본 코드 관리*
|
||||
|
||||
시스템 곳곳의 **선택 항목(드롭다운)** 값을 관리합니다. 왼쪽에 **코드 종류**, 오른쪽에 그 종류의 **세부코드**가 나옵니다.
|
||||
|
||||
**이 화면의 용어**
|
||||
- **코드 종류**: 분류(예: 봉투구분, 동코드, 결제구분, 불출구분).
|
||||
- **세부코드**: 그 분류의 실제 값(예: 봉투구분 → 봉투/스티커).
|
||||
|
||||
**자주 쓰는 코드 종류**
|
||||
- **봉투구분**(봉투/스티커) · **동코드**(지역 동) · **결제구분**(이체/가상계좌) · **불출구분**(무료용/공공용).
|
||||
|
||||
**표 컬럼**: 코드 · 코드명 · (세부코드 개수) · 상태(사용/미사용) · 작업(수정/삭제 — 관리자).
|
||||
> 등록·수정·삭제는 슈퍼 관리자만 가능합니다. 조회는 누구나 가능합니다.
|
||||
|
||||
---
|
||||
|
||||
## 그 밖의 기본정보
|
||||
- **판매 대행소 / 담당자 / 업체(제작·협회·회수) / 무료용 대상자 관리**: 각각 거래처·담당자·대상처를 등록·조회하는 목록 화면입니다(등록·수정은 관리자).
|
||||
54
app/Filters/EmbedRedirectFilter.php
Normal file
54
app/Filters/EmbedRedirectFilter.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filters;
|
||||
|
||||
use CodeIgniter\Filters\FilterInterface;
|
||||
use CodeIgniter\HTTP\RequestInterface;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
/**
|
||||
* 워크스페이스 탭(iframe, embed=1) 안에서 발생한 리다이렉트가 embed 파라미터를 잃지 않도록
|
||||
* 응답 Location 헤더에 embed=1 을 유지시킨다.
|
||||
*
|
||||
* 배경: 폼 전송 후 redirect()->to(...) 하면 Location 에 embed 가 빠지고, iframe 안에서
|
||||
* 그 주소로 다시 GET 하면(특히 HTTP 환경에서 Sec-Fetch-Dest 미전송) 전체 셸(헤더·사이드바)이
|
||||
* 한 번 더 렌더되어 "화면 안에 전체 화면이 또" 생긴다. 이를 전역에서 한 번에 방지한다.
|
||||
*/
|
||||
class EmbedRedirectFilter implements FilterInterface
|
||||
{
|
||||
public function before(RequestInterface $request, $arguments = null)
|
||||
{
|
||||
// no-op
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string>|null $arguments
|
||||
*/
|
||||
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
|
||||
{
|
||||
// 임베드 요청에서 시작된 리다이렉트만 처리
|
||||
if ($request->getGet('embed') === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$location = $response->getHeaderLine('Location');
|
||||
if ($location === '') {
|
||||
return; // 리다이렉트 응답이 아님
|
||||
}
|
||||
|
||||
// 이미 embed 파라미터가 있으면 그대로 둔다
|
||||
if (preg_match('/[?&]embed=/', $location) === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 인증 흐름(로그인/로그아웃/회원가입)은 상위 프레임에서 처리하므로 embed 를 붙이지 않는다
|
||||
if (preg_match('#/(login|logout|register)(/|\?|$)#', $location) === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sep = strpos($location, '?') !== false ? '&' : '?';
|
||||
$response->setHeader('Location', $location . $sep . 'embed=1');
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -936,3 +936,42 @@ if (! function_exists('gov_portal_nav_partial_vars')) {
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('manual_help_url_for_path')) {
|
||||
/**
|
||||
* 현재(또는 주어진) 화면 경로에 대응하는 매뉴얼 URL. 매칭 없으면 ''.
|
||||
* Config\Manual::$screenHelp 의 가장 긴(구체적) 접두를 우선 매칭한다.
|
||||
*/
|
||||
function manual_help_url_for_path(?string $path = null): string
|
||||
{
|
||||
helper('url');
|
||||
$path = strtolower(trim($path ?? current_nav_request_path(), '/'));
|
||||
if ($path === '') {
|
||||
return '';
|
||||
}
|
||||
$map = config(\Config\Manual::class)->screenHelp ?? [];
|
||||
$bestVal = '';
|
||||
$bestLen = -1;
|
||||
foreach ($map as $prefix => $val) {
|
||||
$p = strtolower((string) $prefix);
|
||||
if ($path === $p || str_starts_with($path . '/', $p . '/')) {
|
||||
if (strlen($p) > $bestLen) {
|
||||
$bestLen = strlen($p);
|
||||
$bestVal = (string) $val;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($bestVal === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// 값은 "slug" 또는 "slug#소제목힌트" 형식. 힌트가 있으면 ?hl= 로 전달해 해당 소제목으로 스크롤·강조.
|
||||
[$slug, $hint] = array_pad(explode('#', $bestVal, 2), 2, null);
|
||||
$url = base_url('bag/manual/' . $slug);
|
||||
if ($hint !== null && trim($hint) !== '') {
|
||||
$url .= '?hl=' . rawurlencode(trim($hint));
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,22 @@ declare(strict_types=1);
|
||||
* 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')) {
|
||||
/**
|
||||
* CSV 파일을 브라우저로 다운로드 전송
|
||||
@@ -18,6 +34,7 @@ if (! function_exists('export_csv')) {
|
||||
*/
|
||||
function export_csv(string $filename, array $headers, array $rows): void
|
||||
{
|
||||
audit_export_log($filename);
|
||||
// 파일명에 .csv 확장자 보장
|
||||
if (! str_ends_with($filename, '.csv')) {
|
||||
$filename .= '.csv';
|
||||
@@ -76,9 +93,11 @@ if (! function_exists('export_excel_2003_xml')) {
|
||||
* @param string $sheetName 시트 이름(Excel 제한: 길이·일부 문자)
|
||||
* @param string[] $headers 컬럼 헤더
|
||||
* @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';
|
||||
|
||||
$safeSheet = str_replace(['/', '\\', '?', '*', '[', ']'], '', $sheetName);
|
||||
@@ -97,6 +116,15 @@ if (! function_exists('export_excel_2003_xml')) {
|
||||
$parts[] = '<Worksheet ss:Name="' . $esc($safeSheet) . '">';
|
||||
$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>';
|
||||
foreach ($headers as $h) {
|
||||
$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
|
||||
{
|
||||
audit_export_log($filename);
|
||||
$filename = preg_replace('/\.[^.]+$/u', '', $filename) . '.xls';
|
||||
|
||||
$esc = static function (mixed $v): string {
|
||||
@@ -392,6 +421,7 @@ if (! function_exists('export_bag_flow_report_excel')) {
|
||||
array $metaLines,
|
||||
array $reportRows
|
||||
): void {
|
||||
audit_export_log($filename);
|
||||
$baseName = preg_replace('/\.[^.]+$/u', '', $filename);
|
||||
$baseName = preg_replace('/[^\p{L}\p{N}_\-]+/u', '_', $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
|
||||
{
|
||||
audit_export_log($filename);
|
||||
$filename = preg_replace('/\.[^.]+$/u', '', $filename) . '.xlsx';
|
||||
|
||||
$safeSheet = str_replace(['/', '\\', '?', '*', '[', ']'], '', $sheetName);
|
||||
|
||||
@@ -58,6 +58,61 @@ class ManualRenderer
|
||||
return $this->config->pages[$slug] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 본문을 일반 텍스트로(검색용). 미존재 시 ''.
|
||||
*/
|
||||
public function plainText(string $slug): string
|
||||
{
|
||||
$html = $this->render($slug);
|
||||
if ($html === null || $html === '') {
|
||||
return '';
|
||||
}
|
||||
$text = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
|
||||
return trim((string) preg_replace('/\s+/u', ' ', $text));
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 매뉴얼 페이지 본문에서 질의어를 찾아 결과 반환(일치 많은 순).
|
||||
*
|
||||
* @return list<array{slug:string,title:string,snippet:string,hits:int}>
|
||||
*/
|
||||
public function search(string $q): array
|
||||
{
|
||||
$q = trim($q);
|
||||
if ($q === '' || mb_strlen($q) < 1) {
|
||||
return [];
|
||||
}
|
||||
$needle = mb_strtolower($q);
|
||||
$out = [];
|
||||
foreach ($this->pages() as $slug => $page) {
|
||||
$text = $this->plainText((string) $slug);
|
||||
if ($text === '') {
|
||||
continue;
|
||||
}
|
||||
$hay = mb_strtolower($text);
|
||||
$pos = mb_strpos($hay, $needle);
|
||||
if ($pos === false) {
|
||||
continue;
|
||||
}
|
||||
$hits = mb_substr_count($hay, $needle);
|
||||
$start = max(0, $pos - 30);
|
||||
$snippet = mb_substr($text, $start, 100);
|
||||
if ($start > 0) {
|
||||
$snippet = '…' . $snippet;
|
||||
}
|
||||
$out[] = [
|
||||
'slug' => (string) $slug,
|
||||
'title' => (string) $page['title'],
|
||||
'snippet' => trim($snippet),
|
||||
'hits' => $hits,
|
||||
];
|
||||
}
|
||||
usort($out, static fn ($a, $b): int => $b['hits'] <=> $a['hits']);
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* slug 페이지를 HTML 로 변환해 반환. 미등록 slug·파일 없음·변환 실패 시 null.
|
||||
*/
|
||||
|
||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class BagInventoryModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'bag_inventory';
|
||||
protected $primaryKey = 'bi_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class BagIssueItemCodeModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'bag_issue_item_code';
|
||||
protected $primaryKey = 'bic_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class BagIssueModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'bag_issue';
|
||||
protected $primaryKey = 'bi2_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class BagOrderItemModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'bag_order_item';
|
||||
protected $primaryKey = 'boi_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class BagOrderModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'bag_order';
|
||||
protected $primaryKey = 'bo_idx';
|
||||
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_company_idx', 'bo_agency_idx', 'bo_fee_rate', 'bo_order_date',
|
||||
'bo_bag_types', 'bo_unit_prices', 'bo_qty_boxes',
|
||||
'bo_lot_no', 'bo_hash', 'bo_status', 'bo_orderer_idx',
|
||||
'bo_lot_no', 'bo_hash', 'bo_status', 'bo_orderer_idx', 'bo_manager_idx',
|
||||
'bo_regdate', 'bo_moddate',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class BagPriceHistoryModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'bag_price_history';
|
||||
protected $primaryKey = 'bph_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class BagPriceModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'bag_price';
|
||||
protected $primaryKey = 'bp_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class BagReceivingModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'bag_receiving';
|
||||
protected $primaryKey = 'br_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class BagSaleModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'bag_sale';
|
||||
protected $primaryKey = 'bs_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class BlockchainLedgerModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'blockchain_ledger';
|
||||
protected $primaryKey = 'bl_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -8,6 +8,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class CodeDetailModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'code_detail';
|
||||
protected $primaryKey = 'cd_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class CodeKindModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'code_kind';
|
||||
protected $primaryKey = 'ck_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class CompanyModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'company';
|
||||
protected $primaryKey = 'cp_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class DesignatedShopModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'designated_shop';
|
||||
protected $primaryKey = 'ds_idx';
|
||||
protected $returnType = 'object';
|
||||
@@ -31,6 +33,7 @@ class DesignatedShopModel extends Model
|
||||
'ds_rep_phone',
|
||||
'ds_email',
|
||||
'ds_gugun_code',
|
||||
'ds_dong_code',
|
||||
'ds_zone_code',
|
||||
'ds_branch_no',
|
||||
'ds_designated_at',
|
||||
|
||||
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
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'free_recipient';
|
||||
protected $primaryKey = 'fr_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class LocalGovernmentModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'local_government';
|
||||
protected $primaryKey = 'lg_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -6,12 +6,15 @@ use CodeIgniter\Model;
|
||||
|
||||
class ManagerModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'manager';
|
||||
protected $primaryKey = 'mg_idx';
|
||||
protected $returnType = 'object';
|
||||
protected $useTimestamps = false;
|
||||
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',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class MemberApprovalRequestModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_APPROVED = 'approved';
|
||||
public const STATUS_REJECTED = 'rejected';
|
||||
|
||||
@@ -6,6 +6,14 @@ use CodeIgniter\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 $primaryKey = 'mb_idx';
|
||||
protected $returnType = 'object';
|
||||
@@ -28,6 +36,7 @@ class MemberModel extends Model
|
||||
'mb_leavedate',
|
||||
'mb_login_fail_count',
|
||||
'mb_locked_until',
|
||||
'mb_session_token',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class MenuModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'menu';
|
||||
protected $primaryKey = 'mm_idx';
|
||||
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 결정 (동일 지자체·메뉴종류·부모·깊이 기준)
|
||||
*/
|
||||
|
||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class MenuTypeModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'menu_type';
|
||||
protected $primaryKey = 'mt_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class PackagingUnitHistoryModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'packaging_unit_history';
|
||||
protected $primaryKey = 'puh_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class PackagingUnitModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'packaging_unit';
|
||||
protected $primaryKey = 'pu_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -8,6 +8,8 @@ use CodeIgniter\Model;
|
||||
|
||||
class SalesAgencyModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'sales_agency';
|
||||
protected $primaryKey = 'sa_idx';
|
||||
protected $returnType = 'object';
|
||||
|
||||
@@ -6,12 +6,14 @@ use CodeIgniter\Model;
|
||||
|
||||
class ShopOrderItemModel extends Model
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'shop_order_item';
|
||||
protected $primaryKey = 'soi_idx';
|
||||
protected $returnType = 'object';
|
||||
protected $useTimestamps = false;
|
||||
protected $allowedFields = [
|
||||
'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
|
||||
{
|
||||
use \App\Models\Traits\Auditable;
|
||||
|
||||
protected $table = 'shop_order';
|
||||
protected $primaryKey = 'so_idx';
|
||||
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>
|
||||
@@ -5,8 +5,26 @@
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
<div class="border border-gray-300 p-4 mt-2">
|
||||
<form method="get" action="<?= base_url('admin/access/approvals') ?>" class="mb-4 flex flex-wrap items-center gap-2 text-sm">
|
||||
<style>
|
||||
/* 인쇄 시 승인 대기 표를 A4 너비에 맞춤(잘림 방지) */
|
||||
@media print {
|
||||
@page { size: A4 portrait; margin: 10mm; }
|
||||
.approval-print-wrap { border: 0 !important; padding: 0 !important; overflow: visible !important; }
|
||||
.approval-print-wrap table.data-table { width: 100% !important; table-layout: fixed; font-size: 9px; }
|
||||
.approval-print-wrap .data-table th,
|
||||
.approval-print-wrap .data-table td {
|
||||
white-space: normal !important; /* nowrap 해제 → 줄바꿈 허용 */
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
padding: 2px 3px !important;
|
||||
vertical-align: top;
|
||||
}
|
||||
/* '관리'(승인/반려 버튼) 열은 인쇄에 불필요 → 제외해 가로 폭 확보 */
|
||||
.approval-print-wrap .col-actions { display: none !important; }
|
||||
}
|
||||
</style>
|
||||
<div class="approval-print-wrap border border-gray-300 rounded-lg p-4 mt-2">
|
||||
<form method="get" action="<?= base_url('admin/access/approvals') ?>" class="no-print mb-4 flex flex-wrap items-center gap-2 text-sm">
|
||||
<label for="status" class="font-bold text-gray-700 shrink-0">상태</label>
|
||||
<select id="status" name="status" class="border border-gray-300 rounded px-3 py-1.5 text-sm min-w-[12rem] w-48 max-w-full">
|
||||
<option value="pending" <?= ($status ?? 'pending') === 'pending' ? 'selected' : '' ?>>승인 대기</option>
|
||||
@@ -18,14 +36,14 @@
|
||||
<table class="w-full data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>요청일</th>
|
||||
<th>아이디</th>
|
||||
<th>이름</th>
|
||||
<th>지자체</th>
|
||||
<th>요청 역할</th>
|
||||
<th>상태</th>
|
||||
<th>처리일</th>
|
||||
<th>관리</th>
|
||||
<th class="text-center">요청일</th>
|
||||
<th class="text-center">아이디</th>
|
||||
<th class="text-center">이름</th>
|
||||
<th class="text-center">지자체</th>
|
||||
<th class="text-center">요청 역할</th>
|
||||
<th class="text-center">상태</th>
|
||||
<th class="text-center">처리일</th>
|
||||
<th class="text-center col-actions">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -45,7 +63,7 @@
|
||||
<?php if (($row->mar_status ?? '') === 'rejected'): ?>반려<?php endif; ?>
|
||||
</td>
|
||||
<td class="text-center"><?= esc($row->mar_processed_at ?? '-') ?></td>
|
||||
<td class="text-center">
|
||||
<td class="text-center col-actions">
|
||||
<?php if (($row->mar_status ?? '') === 'pending'): ?>
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<form action="<?= base_url('admin/access/approve/' . $row->mar_idx) ?>" method="post" class="inline">
|
||||
|
||||
@@ -13,15 +13,15 @@
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
<div class="border border-gray-300 overflow-auto mt-2">
|
||||
<div class="border border-gray-300 rounded-lg p-4 overflow-auto mt-2">
|
||||
<table class="w-full data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>일시</th>
|
||||
<th>아이디</th>
|
||||
<th>성공</th>
|
||||
<th>IP</th>
|
||||
<th>메시지</th>
|
||||
<th class="text-left">일시</th>
|
||||
<th class="text-left">아이디</th>
|
||||
<th class="text-center">성공</th>
|
||||
<th class="text-left">IP</th>
|
||||
<th class="text-left">메시지</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-right">
|
||||
|
||||
@@ -8,15 +8,15 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="border border-gray-300 overflow-auto mt-2">
|
||||
<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">번호</th>
|
||||
<th>봉투코드</th>
|
||||
<th>봉투명</th>
|
||||
<th>현재재고(낱장)</th>
|
||||
<th>최종갱신</th>
|
||||
<th class="w-16 text-center">번호</th>
|
||||
<th class="text-center">봉투코드</th>
|
||||
<th class="text-left">봉투명</th>
|
||||
<th class="text-right">현재재고(낱장)</th>
|
||||
<th class="text-center">최종갱신</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-right">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
||||
<span class="text-sm font-bold text-gray-700">무료용 불출 처리</span>
|
||||
</section>
|
||||
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
|
||||
<div class="border border-gray-300 rounded-lg p-4 mt-2 bg-white max-w-3xl">
|
||||
<form action="<?= mgmt_url('bag-issues/store') ?>" method="POST" class="space-y-4">
|
||||
<?= csrf_field() ?>
|
||||
|
||||
|
||||
@@ -18,20 +18,20 @@
|
||||
<a href="<?= mgmt_url('bag-issues') ?>" class="text-sm text-gray-500 hover:underline">초기화</a>
|
||||
</form>
|
||||
</section>
|
||||
<div class="border border-gray-300 overflow-auto mt-2">
|
||||
<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">번호</th>
|
||||
<th>연도</th>
|
||||
<th>분기</th>
|
||||
<th>구분</th>
|
||||
<th>불출일</th>
|
||||
<th class="w-16 text-center">번호</th>
|
||||
<th class="text-center">연도</th>
|
||||
<th class="text-center">분기</th>
|
||||
<th class="text-center">구분</th>
|
||||
<th class="text-center">불출일</th>
|
||||
<th>불출처</th>
|
||||
<th>봉투코드</th>
|
||||
<th>봉투명</th>
|
||||
<th>수량</th>
|
||||
<th class="w-24">작업</th>
|
||||
<th class="text-center">봉투코드</th>
|
||||
<th class="text-left">봉투명</th>
|
||||
<th class="text-right">수량</th>
|
||||
<th class="w-24 text-center">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-right">
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
<form action="<?= mgmt_url('bag-orders/store') ?>" method="POST" class="mt-2 space-y-2">
|
||||
<?= csrf_field() ?>
|
||||
|
||||
<div class="border border-gray-300 bg-white p-2">
|
||||
<div class="border border-gray-300 rounded-lg bg-white p-2">
|
||||
<div class="flex flex-wrap items-center gap-4 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="bo_order_month_ui" class="font-bold text-gray-700">발주월</label>
|
||||
@@ -63,16 +63,16 @@
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 xl:grid-cols-12 gap-2">
|
||||
<section class="xl:col-span-5 border border-gray-300 bg-white">
|
||||
<section class="xl:col-span-5 border border-gray-300 rounded-lg 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">발주일</th>
|
||||
<th>제작업체</th>
|
||||
<th>입고처</th>
|
||||
<th class="w-16">상태</th>
|
||||
<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>
|
||||
@@ -92,7 +92,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="xl:col-span-7 border border-gray-300 bg-white">
|
||||
<section class="xl:col-span-7 border border-gray-300 rounded-lg bg-white">
|
||||
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">발주 Form</div>
|
||||
<div class="p-2 space-y-2">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
@@ -136,17 +136,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border border-gray-300 overflow-auto">
|
||||
<div class="border border-gray-300 rounded-lg p-4 overflow-auto">
|
||||
<table class="w-full data-table text-sm order-input-table" id="order-item-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-12">번호</th>
|
||||
<th class="w-16">선택</th>
|
||||
<th>품명</th>
|
||||
<th class="w-28">수량(BOX)</th>
|
||||
<th class="w-24">단가</th>
|
||||
<th class="w-24">환산수량</th>
|
||||
<th class="w-28">금액</th>
|
||||
<th class="w-12 text-center">번호</th>
|
||||
<th class="w-16 text-center">선택</th>
|
||||
<th class="text-left">품명</th>
|
||||
<th class="w-28 text-right">수량(BOX)</th>
|
||||
<th class="w-24 text-right">단가</th>
|
||||
<th class="w-24 text-right">환산수량</th>
|
||||
<th class="w-28 text-right">금액</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="selected-order-items-body"></tbody>
|
||||
@@ -170,20 +170,20 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="border border-gray-300 bg-white">
|
||||
<section class="border border-gray-300 rounded-lg bg-white">
|
||||
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">발주 등록 종류</div>
|
||||
<p class="text-xs text-gray-600 px-2 py-1">아래 목록에서 봉투를 선택하면 발주 품목에 추가됩니다. (개수 제한 없음)</p>
|
||||
<div class="overflow-auto">
|
||||
<table class="w-full data-table text-sm order-reference-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-12">번호</th>
|
||||
<th class="w-20">선택</th>
|
||||
<th class="w-12 text-center">번호</th>
|
||||
<th class="w-20 text-center">선택</th>
|
||||
<th>봉투 종류</th>
|
||||
<th class="w-24">발주단가</th>
|
||||
<th class="w-24">Box당 팩</th>
|
||||
<th class="w-24">팩당 낱장</th>
|
||||
<th class="w-28">1박스 총 낱장</th>
|
||||
<th class="w-24 text-right">발주단가</th>
|
||||
<th class="w-24 text-right">Box당 팩</th>
|
||||
<th class="w-24 text-right">팩당 낱장</th>
|
||||
<th class="w-28 text-right">1박스 총 낱장</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<span class="text-sm font-bold text-gray-700">발주 상세 — <?= esc($order->bo_lot_no) ?></span>
|
||||
</div>
|
||||
</section>
|
||||
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-4xl">
|
||||
<div class="border border-gray-300 rounded-lg p-4 mt-2 bg-white max-w-4xl">
|
||||
<table class="w-full text-sm">
|
||||
<tbody>
|
||||
<tr class="border-b">
|
||||
@@ -53,16 +53,16 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="border border-gray-300 overflow-auto mt-4">
|
||||
<div class="border border-gray-300 rounded-lg p-4 overflow-auto mt-4">
|
||||
<table class="w-full data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>봉투코드</th>
|
||||
<th>봉투명</th>
|
||||
<th>단가</th>
|
||||
<th>박스수</th>
|
||||
<th>낱장수</th>
|
||||
<th>금액</th>
|
||||
<th class="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 class="text-right">
|
||||
|
||||
@@ -90,19 +90,19 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div class="bag-order-print-wrap border border-gray-300 overflow-auto mt-2">
|
||||
<div class="bag-order-print-wrap border border-gray-300 rounded-lg p-4 overflow-auto mt-2">
|
||||
<table class="bag-order-print-table w-full data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-32">발주일자</th>
|
||||
<th class="min-w-[10rem]">제작 업체</th>
|
||||
<th class="min-w-[12rem]">품 명</th>
|
||||
<th class="w-28">발주 수량</th>
|
||||
<th class="w-28">입고 수량</th>
|
||||
<th class="w-28">미입고수량</th>
|
||||
<th class="w-32">발주 금액</th>
|
||||
<th class="min-w-[9rem]">입고처</th>
|
||||
<th class="min-w-[8rem]">비 고</th>
|
||||
<th class="w-32 text-center">발주일자</th>
|
||||
<th class="min-w-[10rem] text-left">제작 업체</th>
|
||||
<th class="min-w-[12rem] text-left">품 명</th>
|
||||
<th class="w-28 text-right">발주 수량</th>
|
||||
<th class="w-28 text-right">입고 수량</th>
|
||||
<th class="w-28 text-right">미입고수량</th>
|
||||
<th class="w-32 text-right">발주 금액</th>
|
||||
<th class="min-w-[9rem] text-left">입고처</th>
|
||||
<th class="min-w-[8rem] text-left">비 고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-right">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
||||
<span class="text-sm font-bold text-gray-700">봉투 단가 등록</span>
|
||||
</section>
|
||||
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
|
||||
<div class="border border-gray-300 rounded-lg p-4 mt-2 bg-white max-w-3xl">
|
||||
<form action="<?= mgmt_url('bag-prices/store') ?>" method="POST" class="space-y-4">
|
||||
<?= csrf_field() ?>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
||||
<span class="text-sm font-bold text-gray-700">봉투 단가 수정</span>
|
||||
</section>
|
||||
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
|
||||
<div class="border border-gray-300 rounded-lg p-4 mt-2 bg-white max-w-3xl">
|
||||
<form action="<?= mgmt_url('bag-prices/update/' . (int) $item->bp_idx) ?>" method="POST" class="space-y-4">
|
||||
<?= csrf_field() ?>
|
||||
|
||||
|
||||
@@ -5,15 +5,15 @@
|
||||
<span class="text-sm font-bold text-gray-700">단가 변경 이력 — <?= esc($item->bp_bag_name) ?> (<?= esc($item->bp_bag_code) ?>)</span>
|
||||
</div>
|
||||
</section>
|
||||
<div class="border border-gray-300 overflow-auto mt-2">
|
||||
<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">번호</th>
|
||||
<th>변경 필드</th>
|
||||
<th>이전 값</th>
|
||||
<th>변경 값</th>
|
||||
<th>변경일시</th>
|
||||
<th class="w-16 text-center">번호</th>
|
||||
<th class="text-left">변경 필드</th>
|
||||
<th class="text-right">이전 값</th>
|
||||
<th class="text-right">변경 값</th>
|
||||
<th class="text-center">변경일시</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-right">
|
||||
|
||||
@@ -94,20 +94,20 @@
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
<div class="border border-gray-300 overflow-auto mt-2">
|
||||
<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">번호</th>
|
||||
<th>봉투코드</th>
|
||||
<th>봉투명</th>
|
||||
<th>발주단가</th>
|
||||
<th>도매가</th>
|
||||
<th>소비자가</th>
|
||||
<th>적용시작</th>
|
||||
<th>적용종료</th>
|
||||
<th class="w-20">상태</th>
|
||||
<th class="w-36 no-print">작업</th>
|
||||
<th class="w-16 text-center">번호</th>
|
||||
<th class="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-center">적용시작</th>
|
||||
<th class="text-center">적용종료</th>
|
||||
<th class="w-20 text-center">상태</th>
|
||||
<th class="w-36 no-print text-center">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-right">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
||||
<span class="text-sm font-bold text-gray-700">입고 처리</span>
|
||||
</section>
|
||||
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
|
||||
<div class="border border-gray-300 rounded-lg p-4 mt-2 bg-white max-w-3xl">
|
||||
<form action="<?= mgmt_url('bag-receivings/store') ?>" method="POST" class="space-y-4">
|
||||
<?= csrf_field() ?>
|
||||
|
||||
|
||||
@@ -18,18 +18,18 @@
|
||||
<a href="<?= mgmt_url('bag-receivings') ?>" class="text-sm text-gray-500 hover:underline">초기화</a>
|
||||
</form>
|
||||
</section>
|
||||
<div class="border border-gray-300 overflow-auto mt-2">
|
||||
<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">번호</th>
|
||||
<th>봉투코드</th>
|
||||
<th>봉투명</th>
|
||||
<th>박스수</th>
|
||||
<th>낱장수</th>
|
||||
<th>입고일</th>
|
||||
<th>구분</th>
|
||||
<th>등록일</th>
|
||||
<th class="w-16 text-center">번호</th>
|
||||
<th class="text-center">봉투코드</th>
|
||||
<th class="text-left">봉투명</th>
|
||||
<th class="text-right">박스수</th>
|
||||
<th class="text-right">낱장수</th>
|
||||
<th class="text-center">입고일</th>
|
||||
<th class="text-center">구분</th>
|
||||
<th class="text-center">등록일</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-right">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
||||
<span class="text-sm font-bold text-gray-700">판매 등록</span>
|
||||
</section>
|
||||
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
|
||||
<div class="border border-gray-300 rounded-lg p-4 mt-2 bg-white max-w-3xl">
|
||||
<form action="<?= mgmt_url('bag-sales/store') ?>" method="POST" class="space-y-4">
|
||||
<?= csrf_field() ?>
|
||||
|
||||
|
||||
@@ -26,19 +26,19 @@
|
||||
<a href="<?= mgmt_url('bag-sales') ?>" class="text-sm text-gray-500 hover:underline">초기화</a>
|
||||
</form>
|
||||
</section>
|
||||
<div class="border border-gray-300 overflow-auto mt-2">
|
||||
<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">번호</th>
|
||||
<th>판매소</th>
|
||||
<th>판매일</th>
|
||||
<th>봉투코드</th>
|
||||
<th>봉투명</th>
|
||||
<th>수량</th>
|
||||
<th>단가</th>
|
||||
<th>금액</th>
|
||||
<th class="w-20">구분</th>
|
||||
<th class="w-16 text-center">번호</th>
|
||||
<th class="text-left">판매소</th>
|
||||
<th class="text-center">판매일</th>
|
||||
<th class="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="w-20 text-center">구분</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-right">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<span class="text-sm font-bold text-gray-700">세부코드 등록</span>
|
||||
</div>
|
||||
</section>
|
||||
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
|
||||
<div class="border border-gray-300 rounded-lg p-4 mt-2 bg-white max-w-3xl">
|
||||
<form action="<?= base_url('admin/code-details/store') ?>" method="POST" class="space-y-4">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="cd_ck_idx" value="<?= (int) $kind->ck_idx ?>"/>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<span class="text-sm font-bold text-gray-700">세부코드 수정</span>
|
||||
</div>
|
||||
</section>
|
||||
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
|
||||
<div class="border border-gray-300 rounded-lg p-4 mt-2 bg-white max-w-3xl">
|
||||
<form action="<?= base_url('admin/code-details/update/' . (int) $item->cd_idx) ?>" method="POST" class="space-y-4">
|
||||
<?= csrf_field() ?>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
||||
<span class="text-sm font-bold text-gray-700">기본코드 종류 등록</span>
|
||||
</section>
|
||||
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
|
||||
<div class="border border-gray-300 rounded-lg p-4 mt-2 bg-white max-w-3xl">
|
||||
<form action="<?= base_url('admin/code-kinds/store') ?>" method="POST" class="space-y-4">
|
||||
<?= csrf_field() ?>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
||||
<span class="text-sm font-bold text-gray-700">기본코드 종류 수정</span>
|
||||
</section>
|
||||
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
|
||||
<div class="border border-gray-300 rounded-lg p-4 mt-2 bg-white max-w-3xl">
|
||||
<form action="<?= base_url('admin/code-kinds/update/' . (int) $item->ck_idx) ?>" method="POST" class="space-y-4">
|
||||
<?= csrf_field() ?>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
||||
<span class="text-sm font-bold text-gray-700">업체 등록</span>
|
||||
</section>
|
||||
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
|
||||
<div class="border border-gray-300 rounded-lg p-4 mt-2 bg-white max-w-3xl">
|
||||
<form action="<?= mgmt_url('companies/store') ?>" method="POST" class="space-y-4">
|
||||
<?= csrf_field() ?>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
||||
<span class="text-sm font-bold text-gray-700">업체 수정</span>
|
||||
</section>
|
||||
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
|
||||
<div class="border border-gray-300 rounded-lg p-4 mt-2 bg-white max-w-3xl">
|
||||
<form action="<?= mgmt_url('companies/update/' . (int) $item->cp_idx) ?>" method="POST" class="space-y-4">
|
||||
<?= csrf_field() ?>
|
||||
|
||||
|
||||
@@ -21,19 +21,19 @@
|
||||
<a href="<?= mgmt_url('companies') ?>" class="text-sm text-gray-500 hover:underline">초기화</a>
|
||||
</form>
|
||||
</section>
|
||||
<div class="border border-gray-300 overflow-auto mt-2">
|
||||
<table class="w-full data-table">
|
||||
<div class="overflow-auto mt-2">
|
||||
<table class="w-full text-[13px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-16">번호</th>
|
||||
<th>업체유형</th>
|
||||
<th>업체명</th>
|
||||
<th>사업자번호</th>
|
||||
<th>대표자</th>
|
||||
<th>전화</th>
|
||||
<th>주소</th>
|
||||
<th class="w-20">상태</th>
|
||||
<th class="w-36">작업</th>
|
||||
<tr class="text-left text-[11px] font-semibold text-gray-500 border-b border-gray-200">
|
||||
<th class="py-2.5 px-2 w-16 text-left">번호</th>
|
||||
<th class="py-2.5 px-2 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 w-20 text-left">상태</th>
|
||||
<th class="py-2.5 px-2 w-36 text-left">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -46,26 +46,34 @@
|
||||
}
|
||||
?>
|
||||
<?php foreach (($list ?? []) as $idx => $row): ?>
|
||||
<tr>
|
||||
<td class="text-center"><?= (int) $startNo + (int) $idx ?></td>
|
||||
<td class="text-center"><?= esc($row->cp_type) ?></td>
|
||||
<td class="text-left pl-2"><?= esc($row->cp_name) ?></td>
|
||||
<td class="text-center"><?= esc($row->cp_biz_no) ?></td>
|
||||
<td class="text-center"><?= esc($row->cp_rep_name) ?></td>
|
||||
<td class="text-center"><?= esc($row->cp_tel) ?></td>
|
||||
<td class="text-left pl-2"><?= esc($row->cp_addr) ?></td>
|
||||
<td class="text-center"><?= (int) $row->cp_state === 1 ? '사용' : '미사용' ?></td>
|
||||
<td class="text-center">
|
||||
<a href="<?= mgmt_url('companies/edit/' . (int) $row->cp_idx) ?>" class="text-blue-600 hover:underline text-sm mr-1">수정</a>
|
||||
<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"><?= (int) $startNo + (int) $idx ?></td>
|
||||
<td class="py-2.5 px-2 text-left">
|
||||
<span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium bg-gray-100 text-gray-600"><?= esc($row->cp_type) ?></span>
|
||||
</td>
|
||||
<td class="py-2.5 px-2 font-medium text-gray-900"><?= esc($row->cp_name) ?></td>
|
||||
<td class="py-2.5 px-2 font-mono text-gray-700"><?= esc($row->cp_biz_no) ?></td>
|
||||
<td class="py-2.5 px-2 text-gray-600"><?= esc($row->cp_rep_name) ?></td>
|
||||
<td class="py-2.5 px-2 font-mono text-gray-700"><?= esc($row->cp_tel) ?></td>
|
||||
<td class="py-2.5 px-2 text-gray-600"><?= esc($row->cp_addr) ?></td>
|
||||
<td class="py-2.5 px-2 text-left">
|
||||
<?php if ((int) $row->cp_state === 1): ?>
|
||||
<span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium bg-emerald-50 text-emerald-700">사용</span>
|
||||
<?php else: ?>
|
||||
<span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium bg-gray-100 text-gray-500">미사용</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="py-2.5 px-2 text-left text-xs">
|
||||
<a href="<?= mgmt_url('companies/edit/' . (int) $row->cp_idx) ?>" class="text-blue-600 hover:underline mr-1">수정</a>
|
||||
<form action="<?= mgmt_url('companies/delete/' . (int) $row->cp_idx) ?>" method="POST" class="inline" onsubmit="return confirm('삭제하시겠습니까?');">
|
||||
<?= csrf_field() ?>
|
||||
<button type="submit" class="text-red-600 hover:underline text-sm">삭제</button>
|
||||
<button type="submit" class="text-red-600 hover:underline">삭제</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($list)): ?>
|
||||
<tr><td colspan="9" class="text-center text-gray-400 py-4">등록된 데이터가 없습니다.</td></tr>
|
||||
<tr><td colspan="9" class="text-center text-gray-400 py-6">등록된 데이터가 없습니다.</td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -17,22 +17,22 @@
|
||||
|
||||
<!-- 통계 카드 -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
|
||||
<div class="border border-gray-300 p-4 bg-white">
|
||||
<div class="border border-gray-300 rounded-lg p-4 bg-white">
|
||||
<div class="text-xs text-gray-500">총 발주 건수</div>
|
||||
<div class="text-2xl font-bold text-blue-700"><?= number_format($s['order_count'] ?? 0) ?></div>
|
||||
<div class="text-xs text-gray-400 mt-1">금액: <?= number_format($s['order_amount'] ?? 0) ?>원</div>
|
||||
</div>
|
||||
<div class="border border-gray-300 p-4 bg-white">
|
||||
<div class="border border-gray-300 rounded-lg p-4 bg-white">
|
||||
<div class="text-xs text-gray-500">총 판매 건수</div>
|
||||
<div class="text-2xl font-bold text-green-700"><?= number_format($s['sale_count'] ?? 0) ?></div>
|
||||
<div class="text-xs text-gray-400 mt-1">금액: <?= number_format($s['sale_amount'] ?? 0) ?>원</div>
|
||||
</div>
|
||||
<div class="border border-gray-300 p-4 bg-white">
|
||||
<div class="border border-gray-300 rounded-lg p-4 bg-white">
|
||||
<div class="text-xs text-gray-500">재고 품목 수</div>
|
||||
<div class="text-2xl font-bold text-purple-700"><?= number_format($s['inventory_count'] ?? 0) ?></div>
|
||||
<div class="text-xs text-gray-400 mt-1">현재 재고가 있는 봉투 품목</div>
|
||||
</div>
|
||||
<div class="border border-gray-300 p-4 bg-white">
|
||||
<div class="border border-gray-300 rounded-lg p-4 bg-white">
|
||||
<div class="text-xs text-gray-500">이번 달 불출</div>
|
||||
<div class="text-2xl font-bold text-orange-700"><?= number_format($s['issue_count_month'] ?? 0) ?></div>
|
||||
<div class="text-xs text-gray-400 mt-1"><?= date('Y년 n월') ?> 무료용 불출</div>
|
||||
@@ -47,13 +47,13 @@
|
||||
<h3 class="text-sm font-bold text-gray-700">최근 발주 5건</h3>
|
||||
<a href="<?= base_url('bag/bag-orders') ?>" class="text-xs text-blue-600 hover:underline">전체보기</a>
|
||||
</div>
|
||||
<div class="border border-gray-300 overflow-auto">
|
||||
<div class="border border-gray-300 rounded-lg p-4 overflow-auto">
|
||||
<table class="w-full data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>LOT번호</th>
|
||||
<th>발주일</th>
|
||||
<th>상태</th>
|
||||
<th class="text-center">LOT번호</th>
|
||||
<th class="text-center">발주일</th>
|
||||
<th class="text-center">상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-center">
|
||||
@@ -92,15 +92,15 @@
|
||||
<h3 class="text-sm font-bold text-gray-700">최근 판매 5건</h3>
|
||||
<a href="<?= base_url('bag/bag-sales') ?>" class="text-xs text-blue-600 hover:underline">전체보기</a>
|
||||
</div>
|
||||
<div class="border border-gray-300 overflow-auto">
|
||||
<div class="border border-gray-300 rounded-lg p-4 overflow-auto">
|
||||
<table class="w-full data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>판매소</th>
|
||||
<th>봉투명</th>
|
||||
<th>수량</th>
|
||||
<th>금액</th>
|
||||
<th>구분</th>
|
||||
<th class="text-left pl-2">판매소</th>
|
||||
<th class="text-left pl-2">봉투명</th>
|
||||
<th class="text-right">수량</th>
|
||||
<th class="text-right">금액</th>
|
||||
<th class="text-center">구분</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-right">
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
<?= view('components/print_header', ['printTitle' => '지정판매소 바코드 출력']) ?>
|
||||
<style>
|
||||
.ds-bc-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.ds-bc-table th, .ds-bc-table td { border: 1px solid #ccc; padding: 4px 6px; }
|
||||
.ds-bc-table th { background: #e9ecef; color: #2d3748; }
|
||||
.ds-bc-table td { background: #fff; }
|
||||
.ds-bc-table td.name-cell { max-width: 14rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ds-bc-table td.addr-cell { max-width: 24rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
td.name-cell { max-width: 14rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
td.addr-cell { max-width: 24rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ds-bc-check { width: 14px; height: 14px; }
|
||||
</style>
|
||||
|
||||
@@ -55,17 +51,17 @@
|
||||
<label class="inline-flex items-center gap-1 cursor-pointer"><input type="checkbox" id="ds-bc-check-all" class="ds-bc-check"> 전체선택</label>
|
||||
<span class="ml-3">선택 건수: <strong id="ds-bc-selected-count">0</strong></span>
|
||||
</div>
|
||||
<div class="overflow-auto border border-gray-300 bg-white">
|
||||
<table class="ds-bc-table">
|
||||
<div class="overflow-auto">
|
||||
<table class="w-full text-[13px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-14">출력</th>
|
||||
<th class="w-36">판매소 코드</th>
|
||||
<th>판매소명</th>
|
||||
<th class="w-24">대표자명</th>
|
||||
<th class="w-32">사업자번호</th>
|
||||
<th>사업장 주소</th>
|
||||
<th class="w-16">상태</th>
|
||||
<tr class="text-left text-[11px] font-semibold text-gray-500 border-b border-gray-200">
|
||||
<th class="py-2.5 px-2 w-14 text-left">출력</th>
|
||||
<th class="py-2.5 px-2 w-36">판매소 코드</th>
|
||||
<th class="py-2.5 px-2">판매소명</th>
|
||||
<th class="py-2.5 px-2 w-24">대표자명</th>
|
||||
<th class="py-2.5 px-2 w-32">사업자번호</th>
|
||||
<th class="py-2.5 px-2">사업장 주소</th>
|
||||
<th class="py-2.5 px-2 w-16 text-left">상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -74,18 +70,18 @@
|
||||
$st = (int) ($row->ds_state ?? 1);
|
||||
$stLabel = $st === 1 ? '사용' : '정지';
|
||||
?>
|
||||
<tr>
|
||||
<td class="text-center"><input class="ds-bc-row-check ds-bc-check" type="checkbox" name="ds_idx[]" value="<?= (int) $row->ds_idx ?>"></td>
|
||||
<td class="text-center text-blue-700"><?= esc((string) ($row->ds_shop_no ?? '')) ?></td>
|
||||
<td class="name-cell text-blue-700" title="<?= esc((string) ($row->ds_name ?? '')) ?>"><?= esc((string) ($row->ds_name ?? '')) ?></td>
|
||||
<td><?= esc((string) ($row->ds_rep_name ?? '')) ?></td>
|
||||
<td><?= esc((string) ($row->ds_biz_no ?? '')) ?></td>
|
||||
<td class="addr-cell" title="<?= esc((string) ($row->ds_addr ?? '')) ?>"><?= esc((string) ($row->ds_addr ?? '')) ?></td>
|
||||
<td class="<?= $st === 1 ? 'text-blue-700' : 'text-red-600' ?>"><?= esc($stLabel) ?></td>
|
||||
<tr class="border-b border-gray-200 last:border-0 hover:bg-gray-50">
|
||||
<td class="py-2.5 px-2 text-left"><input class="ds-bc-row-check ds-bc-check" type="checkbox" name="ds_idx[]" value="<?= (int) $row->ds_idx ?>"></td>
|
||||
<td class="py-2.5 px-2 font-mono text-gray-700"><?= esc((string) ($row->ds_shop_no ?? '')) ?></td>
|
||||
<td class="py-2.5 px-2 name-cell font-medium text-gray-900" title="<?= esc((string) ($row->ds_name ?? '')) ?>"><?= esc((string) ($row->ds_name ?? '')) ?></td>
|
||||
<td class="py-2.5 px-2 text-gray-600"><?= esc((string) ($row->ds_rep_name ?? '')) ?></td>
|
||||
<td class="py-2.5 px-2 font-mono text-gray-700"><?= esc((string) ($row->ds_biz_no ?? '')) ?></td>
|
||||
<td class="py-2.5 px-2 addr-cell text-gray-500" title="<?= esc((string) ($row->ds_addr ?? '')) ?>"><?= esc((string) ($row->ds_addr ?? '')) ?></td>
|
||||
<td class="py-2.5 px-2 text-left"><span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium <?= $st === 1 ? 'bg-emerald-50 text-emerald-700' : 'bg-gray-100 text-gray-500' ?>"><?= esc($stLabel) ?></span></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($list)): ?>
|
||||
<tr><td colspan="7" class="text-center text-gray-400 py-8">조회된 지정판매소가 없습니다.</td></tr>
|
||||
<tr><td colspan="7" class="text-center text-gray-400 py-6">조회된 지정판매소가 없습니다.</td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
||||
<span class="text-sm font-bold text-gray-700">지정판매소 등록</span>
|
||||
</section>
|
||||
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
|
||||
<div class="border border-gray-300 rounded-lg p-4 mt-2 bg-white max-w-3xl">
|
||||
<form id="designated-shop-create-form" action="<?= mgmt_url('designated-shops/store') ?>" method="POST" class="space-y-4">
|
||||
<?= csrf_field() ?>
|
||||
|
||||
@@ -115,8 +115,9 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<label class="block text-sm font-bold text-gray-700 w-28">구코드</label>
|
||||
<div class="text-sm text-gray-600">해당 지자체(구·군) 코드로 등록 시 자동 설정</div>
|
||||
<label class="block text-sm font-bold text-gray-700 w-28">구·군(동코드)</label>
|
||||
<input type="text" id="ds_dong_display" class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60 bg-gray-100 text-gray-800 cursor-not-allowed" value="" readonly tabindex="-1" placeholder="주소 검색 시 자동 표시"/>
|
||||
<span id="ds_dong_hint" class="text-xs text-gray-500">주소를 검색하면 해당 동코드가 표시됩니다.</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@@ -165,3 +166,53 @@
|
||||
'detailFieldName' => 'ds_addr_detail',
|
||||
]) ?>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var form = document.getElementById('designated-shop-create-form');
|
||||
if (!form) return;
|
||||
var display = document.getElementById('ds_dong_display');
|
||||
var hint = document.getElementById('ds_dong_hint');
|
||||
var endpoint = new URL('<?= mgmt_url('designated-shops/resolve-address-codes') ?>', window.location.href).pathname;
|
||||
var csrfName = '<?= csrf_token() ?>';
|
||||
|
||||
function setHint(msg, isError) {
|
||||
if (!hint) return;
|
||||
hint.textContent = msg;
|
||||
hint.className = 'text-xs ' + (isError ? 'text-red-600' : 'text-gray-500');
|
||||
}
|
||||
|
||||
// 주소 검색 완료 시 서버에서 동코드 조회 → 표시
|
||||
form.addEventListener('kakao-address-selected', function () {
|
||||
if (display) display.value = '';
|
||||
setHint('동코드 조회 중…', false);
|
||||
|
||||
var body = new URLSearchParams();
|
||||
body.set('addr_search_sido', (form.querySelector('[name="addr_search_sido"]') || {}).value || '');
|
||||
body.set('addr_search_sigungu', (form.querySelector('[name="addr_search_sigungu"]') || {}).value || '');
|
||||
body.set('ds_addr', (form.querySelector('[name="ds_addr"]') || {}).value || '');
|
||||
body.set('ds_addr_jibun', (form.querySelector('[name="ds_addr_jibun"]') || {}).value || '');
|
||||
body.set('ds_zip', (form.querySelector('[name="ds_zip"]') || {}).value || '');
|
||||
var csrfEl = form.querySelector('[name="' + csrfName + '"]');
|
||||
if (csrfEl) body.set(csrfName, csrfEl.value);
|
||||
|
||||
fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
body: body
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
if (d && d.ok) {
|
||||
if (display) display.value = d.dong_code + (d.dong_name ? ' (' + d.dong_name + ')' : '');
|
||||
setHint('등록 시 판매소번호에 이 동코드가 반영됩니다.', false);
|
||||
} else {
|
||||
setHint((d && d.error) ? d.error : '동코드를 조회하지 못했습니다.', true);
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
setHint('동코드 조회 중 오류가 발생했습니다.', true);
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ $exportUrl = mgmt_url('designated-shops/district-new-cancel/export') . '?' . htt
|
||||
font-weight: 600;
|
||||
}
|
||||
.gbms-dnc-table tbody td.text-left { text-align: left; }
|
||||
.gbms-dnc-table thead th.gbms-region-head { text-align: left; }
|
||||
.gbms-dnc-table tbody tr.gbms-total td {
|
||||
font-weight: 700;
|
||||
border: 2px solid #c62828;
|
||||
@@ -144,11 +145,11 @@ $exportUrl = mgmt_url('designated-shops/district-new-cancel/export') . '?' . htt
|
||||
|
||||
<div class="mx-2 mt-3 mb-4 gbms-dnc-wrap">
|
||||
<div class="gbms-dnc-caption">지정 판매소 신규/취소 현황 조회 내역</div>
|
||||
<div class="overflow-x-auto border border-gray-400 bg-white">
|
||||
<div class="overflow-x-auto border border-gray-400 rounded-lg bg-white">
|
||||
<table class="gbms-dnc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th rowspan="2" class="min-w-[6rem]">군·구</th>
|
||||
<th rowspan="2" class="min-w-[6rem] gbms-region-head">군·구</th>
|
||||
<th rowspan="2">
|
||||
<span class="gbms-tip">
|
||||
종전
|
||||
|
||||
@@ -20,7 +20,7 @@ $dateField = static function (string $key) use ($shop, $v): string {
|
||||
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
||||
<span class="text-sm font-bold text-gray-700">지정판매소 수정</span>
|
||||
</section>
|
||||
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
|
||||
<div class="border border-gray-300 rounded-lg p-4 mt-2 bg-white max-w-3xl">
|
||||
<form id="designated-shop-edit-form" action="<?= mgmt_url('designated-shops/update/' . (int) $shop->ds_idx) ?>" method="POST" class="space-y-4">
|
||||
<?= csrf_field() ?>
|
||||
|
||||
|
||||
@@ -11,27 +11,37 @@ if ($currentPath === 'bag/designated-shops') {
|
||||
?>
|
||||
<?= view('components/print_header', ['printTitle' => $readOnly ? '지정판매소 조회 목록' : '지정판매소 목록']) ?>
|
||||
<style>
|
||||
/* 목록 위 → 지정판매소 정보 아래 (가로 2열 없음) */
|
||||
/* 목록 왼쪽 → 지정판매소 정보 오른쪽 (좌우 2열) */
|
||||
.ds-split {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-direction: row;
|
||||
gap: 0.5rem;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
align-items: stretch;
|
||||
}
|
||||
.ds-list-panel {
|
||||
flex: 0 1 auto;
|
||||
width: 100%;
|
||||
max-height: 42vh;
|
||||
flex: 1 1 55%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
border: 1px solid #ccc;
|
||||
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 {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
flex: 1 1 45%;
|
||||
min-width: 0;
|
||||
min-height: 12rem;
|
||||
border: 1px solid #ccc;
|
||||
@@ -39,6 +49,12 @@ if ($currentPath === 'bag/designated-shops') {
|
||||
display: flex;
|
||||
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 {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
@@ -175,10 +191,8 @@ $listBasePath = $readOnly ? 'designated-shops/browse' : 'designated-shops';
|
||||
<div class="flex flex-wrap items-center justify-between gap-y-2">
|
||||
<span class="text-sm font-bold text-gray-700"><?= $readOnly ? '지정판매소 조회' : '지정판매소 관리' ?></span>
|
||||
<div class="flex items-center gap-2">
|
||||
<?php if ($readOnly): ?>
|
||||
<a href="<?= mgmt_url('designated-shops/export') ?>" class="no-print border border-btn-excel-border text-btn-excel-text px-3 py-1 rounded-sm text-sm hover:bg-green-50 transition">엑셀저장</a>
|
||||
<button type="button" onclick="window.print()" class="no-print border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
|
||||
<?php endif; ?>
|
||||
<?php if (! $readOnly): ?>
|
||||
<a href="<?= mgmt_url('designated-shops/create') ?>" class="bg-btn-search text-white px-4 py-1.5 rounded-sm flex items-center gap-1 text-sm shadow hover:opacity-90 transition border border-transparent">지정판매소 등록</a>
|
||||
<?php endif; ?>
|
||||
@@ -222,24 +236,20 @@ $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-list-panel">
|
||||
<div class="ds-panel-title shrink-0">지정판매소 리스트</div>
|
||||
<div class="overflow-auto flex-1 min-h-0">
|
||||
<table class="w-full data-table">
|
||||
<div class="ds-list-scroll overflow-auto flex-1 min-h-0">
|
||||
<table class="w-full text-[13px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-14">번호</th>
|
||||
<th class="w-24">구·군</th>
|
||||
<th class="w-24">지정일</th>
|
||||
<th class="w-24">구역</th>
|
||||
<th class="ds-col-tight">대표자명</th>
|
||||
<th class="ds-col-tight">상호명</th>
|
||||
<th class="ds-col-zip">우편번호</th>
|
||||
<th class="text-left">주소</th>
|
||||
<th class="w-28">사업자번호</th>
|
||||
<th class="w-28">전화</th>
|
||||
<th class="w-16">상태</th>
|
||||
<tr class="text-left text-[11px] font-semibold text-gray-500 border-b border-gray-200">
|
||||
<th class="ds-sort-th py-2.5 px-2 w-14 text-left cursor-pointer select-none" data-sort-key="no" data-sort-type="num">번호<span class="ds-sort-ind"></span></th>
|
||||
<th class="ds-sort-th py-2.5 px-2 cursor-pointer select-none" data-sort-key="rep" data-sort-type="str">대표자명<span class="ds-sort-ind"></span></th>
|
||||
<th class="ds-sort-th py-2.5 px-2 cursor-pointer select-none" data-sort-key="name" data-sort-type="str">상호명<span class="ds-sort-ind"></span></th>
|
||||
<th class="ds-sort-th py-2.5 px-2 cursor-pointer select-none" data-sort-key="dong" data-sort-type="str">구·군(동코드)<span class="ds-sort-ind"></span></th>
|
||||
<th class="ds-sort-th py-2.5 px-2 w-24 text-left cursor-pointer select-none" data-sort-key="designated" data-sort-type="str">지정일<span class="ds-sort-ind"></span></th>
|
||||
<th class="ds-sort-th py-2.5 px-2 cursor-pointer select-none" data-sort-key="zone" data-sort-type="str">구역<span class="ds-sort-ind"></span></th>
|
||||
<th class="ds-sort-th py-2.5 px-2 w-16 text-left cursor-pointer select-none" data-sort-key="state" data-sort-type="num">상태<span class="ds-sort-ind"></span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="ds-list-body" class="text-right">
|
||||
<tbody id="ds-list-body">
|
||||
<?php foreach ($list as $i => $row): ?>
|
||||
<?php
|
||||
$sn = (string) ($row->ds_shop_no ?? '');
|
||||
@@ -266,19 +276,31 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
|
||||
if ($addrCombinedList === '') {
|
||||
$addrCombinedList = $addrMainList;
|
||||
}
|
||||
// 구·군(동코드) 표시: 동코드가 있으면 "동코드 (동명)", 없으면 구·군명
|
||||
$dongDisp = (string) (($dongDisplayMap[(int) ($row->ds_idx ?? 0)] ?? '') !== '' ? $dongDisplayMap[(int) ($row->ds_idx ?? 0)] : $ggLabel);
|
||||
?>
|
||||
<tr class="ds-list-row cursor-pointer" data-row-index="<?= (int) $i ?>" role="button" tabindex="0">
|
||||
<td class="text-center"><?= esc($shortNo) ?></td>
|
||||
<td class="text-left pl-1 text-xs"><?= esc($ggLabel) ?></td>
|
||||
<td class="text-center text-xs"><?= esc($daDisp) ?></td>
|
||||
<td class="text-left pl-1 text-xs"><?= esc($zone) ?></td>
|
||||
<td class="text-left pl-1 text-xs ds-col-tight" title="<?= esc($row->ds_rep_name ?? '') ?>"><?= esc($row->ds_rep_name ?? '') ?></td>
|
||||
<td class="text-left pl-1 text-xs ds-col-tight" title="<?= esc($row->ds_name ?? '') ?>"><?= esc($row->ds_name ?? '') ?></td>
|
||||
<td class="text-center text-xs ds-col-zip" title="<?= esc($zipList) ?>"><?= esc($zipList) ?></td>
|
||||
<td class="text-left pl-1 text-xs ds-col-addr-list" title="<?= esc($addrCombinedList) ?>"><?= esc($addrCombinedList) ?></td>
|
||||
<td class="text-left pl-1 text-xs"><?= esc($row->ds_biz_no ?? '') ?></td>
|
||||
<td class="text-left pl-1 text-xs"><?= esc($row->ds_tel ?? '') ?></td>
|
||||
<td class="text-center <?= $st === 2 ? 'text-pink-600 font-medium' : ($st === 3 ? 'text-orange-700' : '') ?>"><?= esc($stLabel) ?></td>
|
||||
<tr class="ds-list-row cursor-pointer border-b border-gray-200 last:border-0 hover:bg-blue-50/60"
|
||||
data-row-index="<?= (int) $i ?>" role="button" tabindex="0"
|
||||
data-sort-no="<?= esc((string) (preg_match('/\d+/', $shortNo, $mn) ? (int) $mn[0] : 0), 'attr') ?>"
|
||||
data-sort-rep="<?= esc($row->ds_rep_name ?? '', 'attr') ?>"
|
||||
data-sort-name="<?= esc($row->ds_name ?? '', 'attr') ?>"
|
||||
data-sort-dong="<?= esc($dongDisp, 'attr') ?>"
|
||||
data-sort-designated="<?= esc($daDisp, 'attr') ?>"
|
||||
data-sort-zone="<?= esc($zone, 'attr') ?>"
|
||||
data-sort-state="<?= (int) $st ?>">
|
||||
<td class="py-2.5 px-2 text-left font-mono text-gray-700"><?= esc($shortNo) ?></td>
|
||||
<td class="py-2.5 px-2 text-gray-600 ds-col-tight" title="<?= esc($row->ds_rep_name ?? '') ?>"><?= esc($row->ds_rep_name ?? '') ?></td>
|
||||
<td class="py-2.5 px-2 font-medium text-gray-900 ds-col-tight" title="<?= esc($row->ds_name ?? '') ?>"><?= esc($row->ds_name ?? '') ?></td>
|
||||
<td class="py-2.5 px-2 text-gray-600 font-mono" title="<?= esc($dongDisp) ?>"><?= esc($dongDisp) ?></td>
|
||||
<td class="py-2.5 px-2 text-left text-gray-500 text-[12px]"><?= esc($daDisp) ?></td>
|
||||
<td class="py-2.5 px-2 text-gray-600" title="<?= esc($zone) ?>"><?= esc($zone) ?></td>
|
||||
<td class="py-2.5 px-2 text-left">
|
||||
<?php if ($st === 1): ?>
|
||||
<span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium bg-emerald-50 text-emerald-700">정상</span>
|
||||
<?php elseif ($stLabel !== ''): ?>
|
||||
<span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium bg-gray-100 text-gray-500"><?= esc($stLabel) ?></span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
@@ -291,69 +313,89 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
|
||||
<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>
|
||||
<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="지정판매소 상세">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>판매소번호</th>
|
||||
<th class="ds-col-tight-head">상호명</th>
|
||||
<th>우편번호</th>
|
||||
<th>사업자번호</th>
|
||||
<th>일반전화</th>
|
||||
<th class="ds-col-tight-head">대표자명</th>
|
||||
<th>이메일</th>
|
||||
<th>업태</th>
|
||||
<th>업종</th>
|
||||
<th>지정일자</th>
|
||||
<th>지자체</th>
|
||||
<th>도로명주소</th>
|
||||
<th>지번주소</th>
|
||||
<th>상세주소</th>
|
||||
<th>개인전화</th>
|
||||
<th>구·군</th>
|
||||
<th>구역</th>
|
||||
<th>가상계좌(은행)</th>
|
||||
<th>계좌번호</th>
|
||||
<th>종사업장번호</th>
|
||||
<th>변경일자</th>
|
||||
<th>영업상태</th>
|
||||
<th>등록일시</th>
|
||||
<th>변경사유</th>
|
||||
<th class="no-print w-14">지도</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="text-left" data-ro="ds_shop_no">—</td>
|
||||
<td class="text-left ds-col-tight-cell" data-ro="ds_name">—</td>
|
||||
<td class="text-left" data-ro="ds_zip">—</td>
|
||||
<td class="text-left" data-ro="ds_biz_no">—</td>
|
||||
<td class="text-left" data-ro="ds_tel">—</td>
|
||||
<td class="text-left ds-col-tight-cell" data-ro="ds_rep_name">—</td>
|
||||
<td class="text-left" data-ro="ds_email">—</td>
|
||||
<td class="text-left" data-ro="ds_biz_type">—</td>
|
||||
<td class="text-left" data-ro="ds_biz_kind">—</td>
|
||||
<td class="text-left" data-ro="ds_designated_at">—</td>
|
||||
<td class="text-left" data-ro="lg_name">—</td>
|
||||
<td class="text-left min-w-[10rem]"><span data-ro="ds_addr">—</span></td>
|
||||
<td class="text-left" data-ro="ds_addr_jibun">—</td>
|
||||
<td class="text-left" data-ro="ds_addr_detail">—</td>
|
||||
<td class="text-left" data-ro="ds_rep_phone">—</td>
|
||||
<td class="text-left" data-ro="gugun_name">—</td>
|
||||
<td class="text-left" data-ro="ds_zone_code">—</td>
|
||||
<td class="text-left" data-ro="ds_va_bank">—</td>
|
||||
<td class="text-left" data-ro="ds_va_account">—</td>
|
||||
<td class="text-left" data-ro="ds_branch_no">—</td>
|
||||
<td class="text-left" data-ro="ds_state_changed_at">—</td>
|
||||
<td class="text-left" data-ro="state_label">—</td>
|
||||
<td class="text-left" data-ro="ds_regdate">—</td>
|
||||
<td class="text-left min-w-[8rem]" data-ro="ds_change_reason">—</td>
|
||||
<td class="text-center no-print">
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- 제목 왼쪽 · 내용 오른쪽 (라벨/값) 폼 형태 -->
|
||||
<div class="ds-detail-form" id="ds-detail-info-table" aria-label="지정판매소 상세">
|
||||
<div class="ds-row ds-row-wide">
|
||||
<div class="ds-field-label">판매소번호</div>
|
||||
<div class="ds-field-value ds-value-shop-wide" data-ro="ds_shop_no">—</div>
|
||||
</div>
|
||||
<div class="ds-row ds-row-4-even">
|
||||
<div class="ds-field-label">상호명</div>
|
||||
<div class="ds-field-value" data-ro="ds_name">—</div>
|
||||
<div class="ds-field-label">우편번호</div>
|
||||
<div class="ds-field-value" data-ro="ds_zip">—</div>
|
||||
</div>
|
||||
<div class="ds-row ds-row-4-even">
|
||||
<div class="ds-field-label">사업자번호</div>
|
||||
<div class="ds-field-value" data-ro="ds_biz_no">—</div>
|
||||
<div class="ds-field-label">일반전화</div>
|
||||
<div class="ds-field-value" data-ro="ds_tel">—</div>
|
||||
</div>
|
||||
<div class="ds-row ds-row-4-even">
|
||||
<div class="ds-field-label">대표자명</div>
|
||||
<div class="ds-field-value" data-ro="ds_rep_name">—</div>
|
||||
<div class="ds-field-label">이메일</div>
|
||||
<div class="ds-field-value" data-ro="ds_email">—</div>
|
||||
</div>
|
||||
<div class="ds-row ds-row-wide">
|
||||
<div class="ds-field-label">도로명주소</div>
|
||||
<div class="ds-field-value ds-field-value-with-map">
|
||||
<span class="ds-addr-text" data-ro="ds_addr">—</span>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ds-row ds-row-wide">
|
||||
<div class="ds-field-label">지번주소</div>
|
||||
<div class="ds-field-value" data-ro="ds_addr_jibun">—</div>
|
||||
</div>
|
||||
<div class="ds-row ds-row-wide">
|
||||
<div class="ds-field-label">상세주소</div>
|
||||
<div class="ds-field-value" data-ro="ds_addr_detail">—</div>
|
||||
</div>
|
||||
<div class="ds-row ds-row-wide">
|
||||
<div class="ds-field-label">개인전화</div>
|
||||
<div class="ds-field-value" data-ro="ds_rep_phone">—</div>
|
||||
</div>
|
||||
<div class="ds-row ds-row-4-even">
|
||||
<div class="ds-field-label">지정일자</div>
|
||||
<div class="ds-field-value" data-ro="ds_designated_at">—</div>
|
||||
<div class="ds-field-label">업태</div>
|
||||
<div class="ds-field-value" data-ro="ds_biz_type">—</div>
|
||||
</div>
|
||||
<div class="ds-row ds-row-4-even">
|
||||
<div class="ds-field-label">업종</div>
|
||||
<div class="ds-field-value" data-ro="ds_biz_kind">—</div>
|
||||
<div class="ds-field-label">구·군(동코드)</div>
|
||||
<div class="ds-field-value" data-ro="dong_display">—</div>
|
||||
</div>
|
||||
<div class="ds-row ds-row-4-even">
|
||||
<div class="ds-field-label">구역</div>
|
||||
<div class="ds-field-value" data-ro="ds_zone_code">—</div>
|
||||
<div class="ds-field-label">종사업장번호</div>
|
||||
<div class="ds-field-value" data-ro="ds_branch_no">—</div>
|
||||
</div>
|
||||
<div class="ds-row ds-row-4-even">
|
||||
<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>
|
||||
@@ -463,6 +505,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');
|
||||
if (mapBtnRo) {
|
||||
mapBtnRo.addEventListener('click', function (ev) {
|
||||
@@ -497,10 +577,10 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
|
||||
<table class="w-full data-table text-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>번호</th>
|
||||
<th class="text-center">번호</th>
|
||||
<th>지자체</th>
|
||||
<th>구·군</th>
|
||||
<th>지정일</th>
|
||||
<th>구·군(동코드)</th>
|
||||
<th class="text-center">지정일</th>
|
||||
<th>구역</th>
|
||||
<th>대표자명</th>
|
||||
<th>상호명</th>
|
||||
@@ -510,7 +590,7 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
|
||||
<th>전화</th>
|
||||
<th>판매소번호</th>
|
||||
<th>가상계좌</th>
|
||||
<th>상태</th>
|
||||
<th class="text-center">상태</th>
|
||||
<th>등록일</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -542,8 +622,12 @@ $sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
|
||||
<tr>
|
||||
<td class="text-center"><?= esc($shortNoP) ?></td>
|
||||
<td class="text-left"><?= esc($lgMap[$row->ds_lg_idx] ?? '') ?></td>
|
||||
<?php $gCodeP = (string) ($row->ds_gugun_code ?? ''); ?>
|
||||
<td class="text-left"><?= esc((string) (($gugunNameMap[$gCodeP] ?? '') !== '' ? $gugunNameMap[$gCodeP] : $gCodeP)) ?></td>
|
||||
<?php
|
||||
$gCodeP = (string) ($row->ds_gugun_code ?? '');
|
||||
$ggLabelP = (string) (($gugunNameMap[$gCodeP] ?? '') !== '' ? $gugunNameMap[$gCodeP] : $gCodeP);
|
||||
$dongDispP = (string) (($dongDisplayMap[(int) ($row->ds_idx ?? 0)] ?? '') !== '' ? $dongDisplayMap[(int) ($row->ds_idx ?? 0)] : $ggLabelP);
|
||||
?>
|
||||
<td class="text-left"><?= esc($dongDispP) ?></td>
|
||||
<td class="text-center"><?= esc($daDispP) ?></td>
|
||||
<td class="text-left"><?= esc($row->ds_zone_code ?? '') ?></td>
|
||||
<td class="text-left"><?= esc($row->ds_rep_name ?? '') ?></td>
|
||||
|
||||
@@ -40,42 +40,45 @@ $listBasePath = $readOnly ? 'designated-shops/browse' : 'designated-shops';
|
||||
<a href="<?= mgmt_url($listBasePath) ?>" class="border border-gray-300 text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">초기화</a>
|
||||
</form>
|
||||
</section>
|
||||
<div class="border border-gray-300 overflow-auto mt-2">
|
||||
<table class="w-full data-table">
|
||||
<div class="overflow-auto mt-2">
|
||||
<table class="w-full text-[13px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-16">번호</th>
|
||||
<th>지자체</th>
|
||||
<th>판매소번호</th>
|
||||
<th>상호명</th>
|
||||
<th>대표자</th>
|
||||
<th>사업자번호</th>
|
||||
<th>가상계좌</th>
|
||||
<th>상태</th>
|
||||
<th>등록일</th>
|
||||
<tr class="text-left text-[11px] font-semibold text-gray-500 border-b border-gray-200">
|
||||
<th class="py-2.5 px-2 w-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 text-left">상태</th>
|
||||
<th class="py-2.5 px-2">등록일</th>
|
||||
<?php if (! $readOnly): ?>
|
||||
<th class="w-28">작업</th>
|
||||
<th class="py-2.5 px-2 w-28 text-left">작업</th>
|
||||
<?php endif; ?>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-right">
|
||||
<tbody>
|
||||
<?php foreach ($list as $row): ?>
|
||||
<tr>
|
||||
<td class="text-center"><?= esc($row->ds_idx) ?></td>
|
||||
<td class="text-left pl-2"><?= esc($lgMap[$row->ds_lg_idx] ?? '') ?></td>
|
||||
<td class="text-left pl-2"><?= esc($row->ds_shop_no) ?></td>
|
||||
<td class="text-left pl-2"><?= esc($row->ds_name) ?></td>
|
||||
<td class="text-left pl-2"><?= esc($row->ds_rep_name) ?></td>
|
||||
<td class="text-left pl-2"><?= esc($row->ds_biz_no) ?></td>
|
||||
<td class="text-left pl-2"><?= esc($row->ds_va_number) ?></td>
|
||||
<td class="text-center"><?= (int) $row->ds_state === 1 ? '정상' : ((int) $row->ds_state === 2 ? '폐업' : '직권해지') ?></td>
|
||||
<td class="text-left pl-2"><?= esc($row->ds_regdate ?? '') ?></td>
|
||||
<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"><?= esc($row->ds_idx) ?></td>
|
||||
<td class="py-2.5 px-2 text-gray-600"><?= esc($lgMap[$row->ds_lg_idx] ?? '') ?></td>
|
||||
<td class="py-2.5 px-2 font-mono text-gray-700"><?= esc($row->ds_shop_no) ?></td>
|
||||
<td class="py-2.5 px-2 font-medium text-gray-900"><?= esc($row->ds_name) ?></td>
|
||||
<td class="py-2.5 px-2 text-gray-600"><?= esc($row->ds_rep_name) ?></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 font-mono text-gray-700"><?= esc($row->ds_va_number) ?></td>
|
||||
<td class="py-2.5 px-2 text-left">
|
||||
<?php $isActive = (int) $row->ds_state === 1; ?>
|
||||
<span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium <?= $isActive ? 'bg-emerald-50 text-emerald-700' : 'bg-gray-100 text-gray-500' ?>"><?= $isActive ? '정상' : ((int) $row->ds_state === 2 ? '폐업' : '직권해지') ?></span>
|
||||
</td>
|
||||
<td class="py-2.5 px-2 text-gray-500 text-[12px]"><?= esc($row->ds_regdate ?? '') ?></td>
|
||||
<?php if (! $readOnly): ?>
|
||||
<td class="text-center">
|
||||
<a href="<?= mgmt_url('designated-shops/edit/' . (int) $row->ds_idx) ?>" class="text-blue-600 hover:underline text-sm">수정</a>
|
||||
<td class="py-2.5 px-2 text-left text-xs">
|
||||
<a href="<?= mgmt_url('designated-shops/edit/' . (int) $row->ds_idx) ?>" class="text-blue-600 hover:underline">수정</a>
|
||||
<form action="<?= mgmt_url('designated-shops/delete/' . (int) $row->ds_idx) ?>" method="POST" class="inline ml-1" onsubmit="return confirm('이 지정판매소를 삭제하시겠습니까?');">
|
||||
<?= csrf_field() ?>
|
||||
<button type="submit" class="text-red-600 hover:underline text-sm">삭제</button>
|
||||
<button type="submit" class="text-red-600 hover:underline">삭제</button>
|
||||
</form>
|
||||
</td>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<a href="<?= mgmt_url('designated-shops') ?>" class="border border-gray-300 text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">목록으로</a>
|
||||
</div>
|
||||
</section>
|
||||
<div id="kakao-map" class="w-full border border-gray-300 mt-2" style="height:600px;"></div>
|
||||
<div id="kakao-map" class="w-full border border-gray-300 rounded-lg mt-2" style="height:600px;"></div>
|
||||
<div class="mt-2 text-sm text-gray-500">총 <?= count($shops) ?>개 판매소 표시</div>
|
||||
|
||||
<script src="//dapi.kakao.com/v2/maps/sdk.js?appkey=<?= esc($kakaoJavascriptKey ?? '', 'attr') ?>&libraries=services"></script>
|
||||
|
||||
@@ -13,13 +13,11 @@ $sumCurrForPct = (int) ($districtTotal->curr_end ?? 0);
|
||||
overflow-y: visible;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scroll-behavior: smooth;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
max-width: 100%;
|
||||
}
|
||||
@media print {
|
||||
.ds-status-x-scroll { overflow: visible !important; border: none; }
|
||||
.ds-status-x-scroll { overflow: visible !important; }
|
||||
}
|
||||
.ds-status-x-scroll .ds-status-table {
|
||||
width: max-content;
|
||||
@@ -29,15 +27,6 @@ $sumCurrForPct = (int) ($districtTotal->curr_end ?? 0);
|
||||
.ds-status-x-scroll .ds-status-table th,
|
||||
.ds-status-x-scroll .ds-status-table td {
|
||||
white-space: nowrap;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ds-status-x-scroll .ds-status-table thead th {
|
||||
background: #e9ecef;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
.ds-status-x-scroll .ds-status-table tbody td {
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
.ds-status-x-scroll th.sticky-num,
|
||||
.ds-status-x-scroll td.sticky-num {
|
||||
@@ -48,25 +37,21 @@ $sumCurrForPct = (int) ($districtTotal->curr_end ?? 0);
|
||||
max-width: 3rem;
|
||||
width: 3rem;
|
||||
box-sizing: border-box;
|
||||
background: #e9ecef;
|
||||
border-right: 1px solid #bbb;
|
||||
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.06);
|
||||
background: #fff;
|
||||
}
|
||||
.ds-status-x-scroll td.sticky-num {
|
||||
background: #fff;
|
||||
text-align: center;
|
||||
}
|
||||
.ds-status-x-scroll tr.sum-row td.sticky-num {
|
||||
background: #f3f4f6;
|
||||
background: #f9fafb;
|
||||
}
|
||||
.ds-status-x-scroll th.sticky-region,
|
||||
.ds-status-x-scroll td.sticky-region {
|
||||
position: sticky;
|
||||
left: 3rem;
|
||||
z-index: 2;
|
||||
background: #e9ecef;
|
||||
border-right: 1px solid #bbb;
|
||||
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.06);
|
||||
background: #fff;
|
||||
max-width: 16rem;
|
||||
text-align: left;
|
||||
}
|
||||
@@ -76,7 +61,7 @@ $sumCurrForPct = (int) ($districtTotal->curr_end ?? 0);
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.ds-status-x-scroll tr.sum-row td.sticky-region {
|
||||
background: #f3f4f6;
|
||||
background: #f9fafb;
|
||||
}
|
||||
.ds-help {
|
||||
position: relative;
|
||||
@@ -159,39 +144,39 @@ $sumCurrForPct = (int) ($districtTotal->curr_end ?? 0);
|
||||
|
||||
<!-- 인쇄 시에도 보이는 본표 -->
|
||||
<div class="mx-2 mt-2 mb-2 ds-status-x-scroll">
|
||||
<table class="ds-status-table data-table">
|
||||
<table class="ds-status-table w-full text-[13px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sticky-num text-center w-12">순번</th>
|
||||
<th class="sticky-region"><?= esc($regionColLabel) ?></th>
|
||||
<th class="text-left">
|
||||
<tr class="text-left text-[11px] font-semibold text-gray-500 border-b border-gray-200">
|
||||
<th class="sticky-num py-2.5 px-2 text-center w-12">순번</th>
|
||||
<th class="sticky-region py-2.5 px-2"><?= esc($regionColLabel) ?></th>
|
||||
<th class="py-2.5 px-2 text-left">
|
||||
<span class="ds-help">구코드 <span class="ds-help-badge" tabindex="0" data-tip="지정판매소에 저장된 구·군 코드 값">?</span></span>
|
||||
</th>
|
||||
<th class="text-right">
|
||||
<th class="py-2.5 px-2 text-left">
|
||||
<span class="ds-help">종전 <span class="ds-help-badge" tabindex="0" data-tip="전년도 12월 31일 기준 정상 상태 판매소 수">?</span></span>(전년도말)
|
||||
</th>
|
||||
<th class="text-right">
|
||||
<th class="py-2.5 px-2 text-left">
|
||||
<span class="ds-help">지정 <span class="ds-help-badge" tabindex="0" data-tip="<?= esc($ry) ?>년 내 지정일이 속한 신규 지정 건수">?</span></span>(<?= $ry ?>년)
|
||||
</th>
|
||||
<th class="text-right">
|
||||
<th class="py-2.5 px-2 text-left">
|
||||
<span class="ds-help">취소 <span class="ds-help-badge" tabindex="0" data-tip="<?= esc($ry) ?>년 내 폐업/해지 전환일이 속한 건수">?</span></span>(<?= $ry ?>년)
|
||||
</th>
|
||||
<th class="text-right">
|
||||
<th class="py-2.5 px-2 text-left">
|
||||
<span class="ds-help">현행 <span class="ds-help-badge" tabindex="0" data-tip="조회년도 12월 31일 기준 정상 상태 판매소 수">?</span></span>(금년도말)
|
||||
</th>
|
||||
<th class="text-right">
|
||||
<th class="py-2.5 px-2 text-left">
|
||||
<span class="ds-help">증감 <span class="ds-help-badge" tabindex="0" data-tip="현행에서 종전을 뺀 값 (현행−종전)">?</span></span>
|
||||
<br/><span class="font-normal text-xs">(현행−종전)</span>
|
||||
</th>
|
||||
<th class="text-right">
|
||||
<th class="py-2.5 px-2 text-left">
|
||||
<span class="ds-help">지정−취소 <span class="ds-help-badge" tabindex="0" data-tip="<?= esc($ry) ?>년 지정 건수에서 취소 건수를 뺀 값">?</span></span>
|
||||
<br/><span class="font-normal text-xs">(<?= $ry ?>년)</span>
|
||||
</th>
|
||||
<th class="text-right">
|
||||
<th class="py-2.5 px-2 text-left">
|
||||
<span class="ds-help">현행비중 <span class="ds-help-badge" tabindex="0" data-tip="전체 현행 합계 대비 해당 행 현행 건수의 비율(%)">?</span></span>
|
||||
<br/><span class="font-normal text-xs">(%)</span>
|
||||
</th>
|
||||
<th class="text-right">
|
||||
<th class="py-2.5 px-2 text-left">
|
||||
<span class="ds-help">전년대비 <span class="ds-help-badge ds-help-right" tabindex="0" data-tip="((현행−종전) / 종전) × 100, 종전이 0이면 표시 안함">?</span></span>
|
||||
<br/><span class="font-normal text-xs">증감률(%)</span>
|
||||
</th>
|
||||
@@ -207,36 +192,36 @@ $sumCurrForPct = (int) ($districtTotal->curr_end ?? 0);
|
||||
$pctShare = $sumCurrForPct > 0 ? round(($curr / $sumCurrForPct) * 100, 1) : 0.0;
|
||||
$yoyPct = $prev > 0 ? round((($curr - $prev) / $prev) * 100, 1) : null;
|
||||
?>
|
||||
<tr>
|
||||
<td class="sticky-num"><?= $rowNo ?></td>
|
||||
<td class="sticky-region" title="<?= esc($row->region_label) ?>"><?= esc($row->region_label) ?></td>
|
||||
<td class="text-left text-xs"><?= esc((string) ($row->gugun_code ?? '')) ?></td>
|
||||
<td><?= number_format($prev) ?></td>
|
||||
<td><?= number_format((int) $row->designated_y) ?></td>
|
||||
<td><?= number_format((int) $row->cancelled_y) ?></td>
|
||||
<td><?= number_format($curr) ?></td>
|
||||
<td><?= number_format((int) ($row->delta_curr_prev ?? 0)) ?></td>
|
||||
<td><?= number_format((int) ($row->delta_des_cancel ?? 0)) ?></td>
|
||||
<td><?= $pctShare ?></td>
|
||||
<td><?= $yoyPct !== null ? $yoyPct : '—' ?></td>
|
||||
<tr class="border-b border-gray-200 last:border-0 hover:bg-gray-50">
|
||||
<td class="sticky-num py-2.5 px-2 text-center text-gray-500"><?= $rowNo ?></td>
|
||||
<td class="sticky-region py-2.5 px-2 font-medium text-gray-900" title="<?= esc($row->region_label) ?>"><?= esc($row->region_label) ?></td>
|
||||
<td class="py-2.5 px-2 text-left font-mono text-[12px] text-gray-700"><?= esc((string) ($row->gugun_code ?? '')) ?></td>
|
||||
<td class="py-2.5 px-2 text-gray-600"><?= number_format($prev) ?></td>
|
||||
<td class="py-2.5 px-2 text-gray-600"><?= number_format((int) $row->designated_y) ?></td>
|
||||
<td class="py-2.5 px-2 text-gray-600"><?= number_format((int) $row->cancelled_y) ?></td>
|
||||
<td class="py-2.5 px-2 font-medium text-gray-900"><?= number_format($curr) ?></td>
|
||||
<td class="py-2.5 px-2 text-gray-600"><?= number_format((int) ($row->delta_curr_prev ?? 0)) ?></td>
|
||||
<td class="py-2.5 px-2 text-gray-600"><?= number_format((int) ($row->delta_des_cancel ?? 0)) ?></td>
|
||||
<td class="py-2.5 px-2 text-gray-600"><?= $pctShare ?></td>
|
||||
<td class="py-2.5 px-2 text-gray-600"><?= $yoyPct !== null ? $yoyPct : '—' ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($districtRows)): ?>
|
||||
<tr><td colspan="11" class="text-center text-gray-400 py-6">조건에 맞는 데이터가 없습니다.</td></tr>
|
||||
<?php endif; ?>
|
||||
<?php if (! empty($districtRows) && isset($districtTotal)): ?>
|
||||
<tr class="font-bold bg-gray-50 sum-row">
|
||||
<td class="sticky-num">—</td>
|
||||
<td class="sticky-region"><?= esc($districtTotal->region_label) ?></td>
|
||||
<td class="text-left">—</td>
|
||||
<td><?= number_format((int) $districtTotal->prev_end) ?></td>
|
||||
<td><?= number_format((int) $districtTotal->designated_y) ?></td>
|
||||
<td><?= number_format((int) $districtTotal->cancelled_y) ?></td>
|
||||
<td><?= number_format((int) $districtTotal->curr_end) ?></td>
|
||||
<td><?= number_format((int) ($districtTotal->delta_curr_prev ?? 0)) ?></td>
|
||||
<td><?= number_format((int) ($districtTotal->delta_des_cancel ?? 0)) ?></td>
|
||||
<td>100</td>
|
||||
<td>
|
||||
<tr class="font-bold bg-gray-50 sum-row border-b border-gray-200 last:border-0">
|
||||
<td class="sticky-num py-2.5 px-2 text-center text-gray-500">—</td>
|
||||
<td class="sticky-region py-2.5 px-2 text-gray-900"><?= esc($districtTotal->region_label) ?></td>
|
||||
<td class="py-2.5 px-2 text-left text-gray-500">—</td>
|
||||
<td class="py-2.5 px-2 text-gray-700"><?= number_format((int) $districtTotal->prev_end) ?></td>
|
||||
<td class="py-2.5 px-2 text-gray-700"><?= number_format((int) $districtTotal->designated_y) ?></td>
|
||||
<td class="py-2.5 px-2 text-gray-700"><?= number_format((int) $districtTotal->cancelled_y) ?></td>
|
||||
<td class="py-2.5 px-2 text-gray-900"><?= number_format((int) $districtTotal->curr_end) ?></td>
|
||||
<td class="py-2.5 px-2 text-gray-700"><?= number_format((int) ($districtTotal->delta_curr_prev ?? 0)) ?></td>
|
||||
<td class="py-2.5 px-2 text-gray-700"><?= number_format((int) ($districtTotal->delta_des_cancel ?? 0)) ?></td>
|
||||
<td class="py-2.5 px-2 text-gray-700">100</td>
|
||||
<td class="py-2.5 px-2 text-gray-700">
|
||||
<?php
|
||||
$tPrev = (int) $districtTotal->prev_end;
|
||||
$tCurr = (int) $districtTotal->curr_end;
|
||||
@@ -260,27 +245,27 @@ $sumCurrForPct = (int) ($districtTotal->curr_end ?? 0);
|
||||
</span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<div class="border border-gray-300 bg-white overflow-auto max-h-56">
|
||||
<table class="w-full data-table text-xs">
|
||||
<div class="overflow-auto max-h-56">
|
||||
<table class="w-full text-[13px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<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>
|
||||
<th class="text-right">증감</th>
|
||||
<tr class="text-left text-[11px] font-semibold text-gray-500 border-b border-gray-200">
|
||||
<th class="py-2.5 px-2 text-left">동</th>
|
||||
<th class="py-2.5 px-2 text-left">종전</th>
|
||||
<th class="py-2.5 px-2 text-left">지정</th>
|
||||
<th class="py-2.5 px-2 text-left">취소</th>
|
||||
<th class="py-2.5 px-2 text-left">현행</th>
|
||||
<th class="py-2.5 px-2 text-left">증감</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-right">
|
||||
<?php foreach ($zoneRows as $z): ?>
|
||||
<tr>
|
||||
<td class="text-left"><?= esc((string) $z->zone_label) ?></td>
|
||||
<td><?= number_format((int) $z->prev_end) ?></td>
|
||||
<td><?= number_format((int) $z->designated_y) ?></td>
|
||||
<td><?= number_format((int) $z->cancelled_y) ?></td>
|
||||
<td><?= number_format((int) $z->curr_end) ?></td>
|
||||
<td><?= number_format((int) $z->delta_curr_prev) ?></td>
|
||||
<tr class="border-b border-gray-200 last:border-0 hover:bg-gray-50">
|
||||
<td class="py-2.5 px-2 text-left font-medium text-gray-900"><?= esc((string) $z->zone_label) ?></td>
|
||||
<td class="py-2.5 px-2 text-gray-600"><?= number_format((int) $z->prev_end) ?></td>
|
||||
<td class="py-2.5 px-2 text-gray-600"><?= number_format((int) $z->designated_y) ?></td>
|
||||
<td class="py-2.5 px-2 text-gray-600"><?= number_format((int) $z->cancelled_y) ?></td>
|
||||
<td class="py-2.5 px-2 text-gray-700"><?= number_format((int) $z->curr_end) ?></td>
|
||||
<td class="py-2.5 px-2 text-gray-600"><?= number_format((int) $z->delta_curr_prev) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
@@ -345,7 +330,7 @@ $sumCurrForPct = (int) ($districtTotal->curr_end ?? 0);
|
||||
<details class="mx-2 mb-4 no-print text-sm">
|
||||
<summary class="cursor-pointer text-gray-600 hover:text-gray-800">연도별 요약 (참고)</summary>
|
||||
<div class="flex gap-4 mt-2">
|
||||
<div class="border border-gray-300 p-2 flex-1">
|
||||
<div class="border border-gray-300 rounded-lg p-2 flex-1">
|
||||
<div class="text-xs font-bold text-gray-700 mb-1">활성 / 비활성 / 전체</div>
|
||||
<div class="text-sm">활성 <?= number_format((int) ($totalActive ?? 0)) ?> · 비활성 <?= number_format((int) ($totalInactive ?? 0)) ?> · 합 <?= number_format((int) ($totalActive ?? 0) + (int) ($totalInactive ?? 0)) ?></div>
|
||||
</div>
|
||||
@@ -353,15 +338,23 @@ $sumCurrForPct = (int) ($districtTotal->curr_end ?? 0);
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-2">
|
||||
<div>
|
||||
<h3 class="text-xs font-bold text-gray-700 mb-1">연도별 신규등록 (지정일)</h3>
|
||||
<div class="border border-gray-300 overflow-auto max-h-48">
|
||||
<table class="w-full data-table text-xs">
|
||||
<thead><tr><th>연도</th><th>건수</th></tr></thead>
|
||||
<div class="overflow-auto max-h-48">
|
||||
<table class="w-full text-[13px]">
|
||||
<thead>
|
||||
<tr class="text-left text-[11px] font-semibold text-gray-500 border-b border-gray-200">
|
||||
<th class="py-2.5 px-2 text-left">연도</th>
|
||||
<th class="py-2.5 px-2 text-left">건수</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-right">
|
||||
<?php foreach (($newByYear ?? []) as $row): ?>
|
||||
<tr><td class="text-center"><?= esc($row->yr) ?>년</td><td><?= number_format((int) $row->cnt) ?></td></tr>
|
||||
<tr class="border-b border-gray-200 last:border-0 hover:bg-gray-50">
|
||||
<td class="py-2.5 px-2 text-left font-medium text-gray-900"><?= esc($row->yr) ?>년</td>
|
||||
<td class="py-2.5 px-2 text-gray-700"><?= number_format((int) $row->cnt) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($newByYear)): ?>
|
||||
<tr><td colspan="2" class="text-center text-gray-400 py-2">없음</td></tr>
|
||||
<tr><td colspan="2" class="text-center text-gray-400 py-6">없음</td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -369,15 +362,23 @@ $sumCurrForPct = (int) ($districtTotal->curr_end ?? 0);
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xs font-bold text-gray-700 mb-1">연도별 취소/비활성 (등록일 기준)</h3>
|
||||
<div class="border border-gray-300 overflow-auto max-h-48">
|
||||
<table class="w-full data-table text-xs">
|
||||
<thead><tr><th>연도</th><th>건수</th></tr></thead>
|
||||
<div class="overflow-auto max-h-48">
|
||||
<table class="w-full text-[13px]">
|
||||
<thead>
|
||||
<tr class="text-left text-[11px] font-semibold text-gray-500 border-b border-gray-200">
|
||||
<th class="py-2.5 px-2 text-left">연도</th>
|
||||
<th class="py-2.5 px-2 text-left">건수</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-right">
|
||||
<?php foreach (($cancelByYear ?? []) as $row): ?>
|
||||
<tr><td class="text-center"><?= esc($row->yr) ?>년</td><td><?= number_format((int) $row->cnt) ?></td></tr>
|
||||
<tr class="border-b border-gray-200 last:border-0 hover:bg-gray-50">
|
||||
<td class="py-2.5 px-2 text-left font-medium text-gray-900"><?= esc($row->yr) ?>년</td>
|
||||
<td class="py-2.5 px-2 text-gray-700"><?= number_format((int) $row->cnt) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($cancelByYear)): ?>
|
||||
<tr><td colspan="2" class="text-center text-gray-400 py-2">없음</td></tr>
|
||||
<tr><td colspan="2" class="text-center text-gray-400 py-6">없음</td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
106
app/Views/admin/feedback/all.php
Normal file
106
app/Views/admin/feedback/all.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?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-teal-50 text-teal-700',
|
||||
'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>';
|
||||
};
|
||||
?>
|
||||
<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>
|
||||
<div class="text-[12px] text-gray-500">
|
||||
<?= esc((string) ($row->fb_page_title ?: '-')) ?>
|
||||
<span class="text-gray-400"> · <?= esc((string) ($row->fb_page_url ?: '')) ?></span>
|
||||
</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; ?>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user