Compare commits

..

12 Commits

Author SHA1 Message Date
javamon1174
ac88ebdedb 신규 기능 E2E 테스트 29개 추가 — 도메인 전체 통과
CT-01 페이지네이션, CT-02 엑셀, CT-03 인쇄, CT-06 대시보드
P2-15 다조건조회, P2-17 지도, P2-18 현황
P5-04 년판매, P5-05 판매소별, P5-06 홈택스, P5-08 반품파기
P5-10 LOT수불, P5-11 기타입출고
사이트 CRUD 레이아웃 검증 6개, 엑셀 다운로드 2개

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 18:16:12 +09:00
javamon1174
6fdd040d4d jobs.md 최종 업데이트 — 웹 미구현 기능 전체 완료 반영
P2-15/17/18, P5-04~06/08/10/11, CT-01~03/05~07 완료 표시
남은 Phase 6+ 항목: P2-16(바코드출력), P3-09/10(스캐너/PDF417),
P4-07(스캔판매), P5-09/12(수급계획/스캔현황), CT-04(스캐너연동)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:52:44 +09:00
javamon1174
1e8bf1eeeb P2-15~18, P5-04~11, CT-05~06 웹 미구현 기능 전체 구현
P2-15: 지정판매소 다조건 조회 (이름/구군/상태 필터)
P2-17: 지정판매소 지도 표시 (Kakao Maps)
P2-18: 지정판매소 현황 (연도별 신규/취소 통계)
P5-04: 년 판매 현황 (월별 피벗 테이블)
P5-05: 지정판매소별 판매현황 (판매소별 수량/금액)
P5-06: 홈택스 세금계산서 엑셀 내보내기
P5-08: 반품/파기 현황 (기간별 조회)
P5-10: LOT 수불 조회 (LOT 번호 검색)
P5-11: 기타 입출고 (등록 + 재고 연동)
CT-05: CRUD 로깅 (activity_log 테이블 + audit_helper)
CT-06: 대시보드 실 데이터 (발주/판매/재고/불출 통계)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:50:28 +09:00
javamon1174
704141a1f0 CT-01/02/03 공통 컴포넌트 구현 — 페이지네이션/엑셀/인쇄
CT-01: 페이지네이션
- 커스텀 Tailwind 페이저 뷰 (components/pager.php)
- 18개 admin 컨트롤러 findAll() → paginate(20) 전환
- Bag 컨트롤러 7개 리스트도 paginate 적용
- 19개 admin index 뷰에 페이저 링크 추가

CT-02: 엑셀 저장
- export_helper.php (UTF-8 BOM CSV)
- 발주/판매/지정판매소/재고 4개 엑셀 내보내기 라우트+메서드
- 해당 뷰에 "엑셀저장" 버튼 추가

CT-03: 인쇄
- print_header.php (지자체명/제목/결재란 컴포넌트)
- admin/bag 레이아웃에 @media print CSS 추가
- 23개 뷰에 인쇄 버튼 + print_header 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:40:49 +09:00
javamon1174
35561b414b 재고 조정(실사) 기능 추가 + 수불 관리 바로가기 버튼
- /bag/inventory/adjust: 재고 수량 조정 (실사 설정/증가/감소)
- 재고 관리 페이지에 "재고 조정" 버튼 추가
- 봉투 수불 관리에 입고/판매/불출 바로가기 버튼 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:20:35 +09:00
javamon1174
39ee71cc80 사이트 메뉴 CRUD를 /bag/* 경로로 통합 — 관리자 레이아웃 혼용 해결
- Bag 컨트롤러에 create/store 메서드 추가 (불출/발주/입고/판매/주문)
- bag용 create 뷰 5개 생성 (form action을 /bag/*로 변경)
- 모든 등록/취소 버튼을 /admin/* → /bag/*로 변경
- 사이트 레이아웃이 CRUD 전체에서 유지됨
- playwright.production.config.js 추가 (도메인 테스트용)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:13:07 +09:00
javamon1174
f6a64e07b8 서버 인프라 문서 정리 (docs/server.md) + README 운영 환경 추가
- docs/SERVER_DEPLOY.md → docs/server.md 통합 재작성
- 접속 정보 (웹/Gitea/SSH), 서비스 구조, 자동 배포, SSL, 트러블슈팅
- 테스트 계정 4개 정보 포함
- README에 운영 환경 URL 요약 + 서버 가이드 링크

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:58:44 +09:00
javamon1174
56661ed5dc 서버 배포 가이드 업데이트 — HTTPS + Gitea 도메인 연동
- trash.wxn.co.kr HTTPS (Let's Encrypt) 적용
- gitea.wxn.co.kr HTTPS 도메인 연동
- SSH 터널 방식 → HTTPS 도메인 직접 push 방식으로 변경
- DNS/SSL 설정 정보 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:55:47 +09:00
javamon1174
a0c75a4a31 서버 배포 가이드 문서 추가 (docs/SERVER_DEPLOY.md)
- Gitea 서버 (Docker, 116.122.157.166:3001, wixon/wixon1234!)
- nginx + PHP-FPM 8.2 → trash.wxn.co.kr 도메인 연동
- 자동 배포 (git push → webhook → deploy.sh)
- SSH 터널 설정, push 자동화, 서비스 관리 명령 문서화

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:45:12 +09:00
javamon1174
9193fc587e chore: remove deploy test file 2026-03-26 15:45:12 +09:00
javamon1174
c3c731cda0 test: deploy webhook trigger 2026-03-26 15:45:11 +09:00
taekyoungc
a3f92cd322 feat: TOTP 2차 인증, 관리자 메뉴/대시보드 및 의존성 반영
- robthree/twofactorauth, Auth 설정·TotpService·2FA 뷰·라우트
- member TOTP 컬럼 DDL(login_tables, member_add_totp.sql)
- 관리자 메뉴·레이아웃·필터·대시보드 등 연관 변경
- env 샘플에 auth.requireTotp 주석

Made-with: Cursor
2026-03-26 15:30:32 +09:00
106 changed files with 4392 additions and 385 deletions

View File

@@ -6,7 +6,15 @@
백엔드는 **[CodeIgniter 4](https://codeigniter.com/)** 기반입니다.
**저장소:** [wixon-associates/jongryangje](https://github.com/wixon-associates/jongryangje)
| **[구현 화면 스크린샷](./docs/SCREENSHOTS.md)** | **[Notion 진행상황](https://www.notion.so/31b42b987c3780baba32ded04a1d41bb)** |
| **[구현 화면 스크린샷](./docs/SCREENSHOTS.md)** | **[Notion 진행상황](https://www.notion.so/31b42b987c3780baba32ded04a1d41bb)** | **[서버/배포 가이드](./docs/server.md)** |
### 운영 환경
| 서비스 | URL |
|--------|-----|
| 웹 서비스 | https://trash.wxn.co.kr |
| Gitea (Git) | https://gitea.wxn.co.kr |
| GitHub | https://github.com/wixon-associates/jongryangje |
---

52
app/Config/Auth.php Normal file
View File

@@ -0,0 +1,52 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
/**
* 로그인·2차 인증(TOTP) 관련 설정
*
* .env 예:
* auth.requireTotp = true
* auth.totpIssuer = "쓰레기봉투 물류시스템"
*/
class Auth extends BaseConfig
{
/** 운영·스테이징 true 권장. 로컬 개발 시 false 로 1단계만 로그인 가능 */
public bool $requireTotp = true;
/** 인증 앱에 표시되는 발급자(issuer) */
public string $totpIssuer = '쓰레기봉투 물류시스템';
/** TOTP 연속 실패 시 세션 종료 전 허용 횟수 */
public int $totpMaxAttempts = 5;
/** 비밀번호 통과 후 2단계 완료까지 허용 시간(초) */
public int $pending2faTtlSeconds = 600;
public function __construct()
{
parent::__construct();
$require = env('auth.requireTotp');
if ($require !== null && $require !== '') {
$this->requireTotp = filter_var($require, FILTER_VALIDATE_BOOLEAN);
}
$issuer = env('auth.totpIssuer');
if (is_string($issuer) && $issuer !== '') {
$this->totpIssuer = $issuer;
}
$max = env('auth.totpMaxAttempts');
if ($max !== null && $max !== '' && is_numeric($max)) {
$this->totpMaxAttempts = max(1, (int) $max);
}
$ttl = env('auth.pending2faTtlSeconds');
if ($ttl !== null && $ttl !== '' && is_numeric($ttl)) {
$this->pending2faTtlSeconds = max(60, (int) $ttl);
}
}
}

View File

@@ -21,8 +21,8 @@ class Pager extends BaseConfig
* @var array<string, string>
*/
public array $templates = [
'default_full' => 'CodeIgniter\Pager\Views\default_full',
'default_simple' => 'CodeIgniter\Pager\Views\default_simple',
'default_full' => 'App\Views\components\pager',
'default_simple' => 'App\Views\components\pager',
'default_head' => 'CodeIgniter\Pager\Views\default_head',
];

View File

@@ -15,6 +15,8 @@ class Roles extends BaseConfig
* mb_level 상수 (member.mb_level)
*/
public const LEVEL_SUPER_ADMIN = 4;
/** 본부 관리자 — 현재는 super admin과 동일한 관리자 권한(지자체 선택 후 작업). 추후 super 전용 기능 분리 시 여기만 조정 */
public const LEVEL_HEADQUARTERS_ADMIN = 5;
public const LEVEL_LOCAL_ADMIN = 3; // 지자체관리자
public const LEVEL_SHOP = 2; // 지정판매소
public const LEVEL_CITIZEN = 1; // 일반 사용자(시민)
@@ -29,8 +31,27 @@ class Roles extends BaseConfig
self::LEVEL_SHOP => '지정판매소',
self::LEVEL_LOCAL_ADMIN => '지자체관리자',
self::LEVEL_SUPER_ADMIN => 'super admin',
self::LEVEL_HEADQUARTERS_ADMIN => '본부 관리자',
];
/**
* super admin(4) 또는 본부 관리자(5) — 동일 관리자 UX(지자체 선택 등)에 사용
*/
public static function isSuperAdminEquivalent(int $level): bool
{
return $level === self::LEVEL_SUPER_ADMIN || $level === self::LEVEL_HEADQUARTERS_ADMIN;
}
/**
* TOTP 2차 인증 적용 대상 (지자체·super·본부 관리자)
*/
public static function requiresTotp(int $level): bool
{
return $level === self::LEVEL_LOCAL_ADMIN
|| $level === self::LEVEL_SUPER_ADMIN
|| $level === self::LEVEL_HEADQUARTERS_ADMIN;
}
/**
* 자체 회원가입 시 기본 역할 (mb_level)
*/

View File

@@ -11,6 +11,7 @@ $routes->get('dashboard/classic-mock', 'Home::dashboardClassicMock');
$routes->get('dashboard/modern', 'Home::dashboardModern');
$routes->get('dashboard/dense', 'Home::dashboardDense');
$routes->get('dashboard/charts', 'Home::dashboardCharts');
$routes->get('dashboard/blend', 'Home::dashboardBlend');
$routes->get('bag/inventory-inquiry', 'Home::inventoryInquiry');
$routes->get('bag/waste-suibal-enterprise', 'Home::wasteSuibalEnterprise');
@@ -26,9 +27,28 @@ $routes->get('bag/analytics', 'Bag::analytics');
$routes->get('bag/window', 'Bag::window');
$routes->get('bag/help', 'Bag::help');
// 사이트 메뉴 CRUD (사이트 레이아웃)
$routes->get('bag/inventory/adjust', 'Bag::inventoryAdjust');
$routes->post('bag/inventory/adjust', 'Bag::inventoryAdjustStore');
$routes->get('bag/issue/create', 'Bag::issueCreate');
$routes->post('bag/issue/store', 'Bag::issueStore');
$routes->post('bag/issue/cancel/(:num)', 'Bag::issueCancel/$1');
$routes->get('bag/order/create', 'Bag::orderCreate');
$routes->post('bag/order/store', 'Bag::orderStore');
$routes->get('bag/receiving/create', 'Bag::receivingCreate');
$routes->post('bag/receiving/store', 'Bag::receivingStore');
$routes->get('bag/sale/create', 'Bag::saleCreate');
$routes->post('bag/sale/store', 'Bag::saleStore');
$routes->get('bag/shop-order/create', 'Bag::shopOrderCreate');
$routes->post('bag/shop-order/store', 'Bag::shopOrderStore');
// Auth
$routes->get('login', 'Auth::showLoginForm');
$routes->post('login', 'Auth::login');
$routes->get('login/two-factor', 'Auth::showTwoFactor');
$routes->post('login/two-factor', 'Auth::verifyTwoFactor');
$routes->get('login/totp-setup', 'Auth::showTotpSetup');
$routes->post('login/totp-setup', 'Auth::completeTotpSetup');
$routes->get('logout', 'Auth::logout');
$routes->get('register', 'Auth::showRegisterForm');
$routes->post('register', 'Auth::register');
@@ -94,6 +114,7 @@ $routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): vo
$routes->get('bag-prices/history/(:num)', 'Admin\BagPrice::history/$1');
// 발주 관리 (P3-01~05)
$routes->get('bag-orders/export', 'Admin\BagOrder::export');
$routes->get('bag-orders', 'Admin\BagOrder::index');
$routes->get('bag-orders/create', 'Admin\BagOrder::create');
$routes->post('bag-orders/store', 'Admin\BagOrder::store');
@@ -107,6 +128,7 @@ $routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): vo
$routes->post('bag-receivings/store', 'Admin\BagReceiving::store');
// 재고 현황 (P3-10)
$routes->get('bag-inventory/export', 'Admin\BagInventory::export');
$routes->get('bag-inventory', 'Admin\BagInventory::index');
// 주문 접수 관리 (P4-01~03)
@@ -116,6 +138,7 @@ $routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): vo
$routes->post('shop-orders/cancel/(:num)', 'Admin\ShopOrder::cancel/$1');
// 판매/반품 관리 (P4-04~07)
$routes->get('bag-sales/export', 'Admin\BagSale::export');
$routes->get('bag-sales', 'Admin\BagSale::index');
$routes->get('bag-sales/create', 'Admin\BagSale::create');
$routes->post('bag-sales/store', 'Admin\BagSale::store');
@@ -140,6 +163,13 @@ $routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): vo
$routes->get('reports/daily-summary', 'Admin\SalesReport::dailySummary');
$routes->get('reports/period-sales', 'Admin\SalesReport::periodSales');
$routes->get('reports/supply-demand', 'Admin\SalesReport::supplyDemand');
$routes->get('reports/yearly-sales', 'Admin\SalesReport::yearlySales');
$routes->get('reports/shop-sales', 'Admin\SalesReport::shopSales');
$routes->get('reports/hometax-export', 'Admin\SalesReport::hometaxExport');
$routes->get('reports/returns', 'Admin\SalesReport::returns');
$routes->get('reports/lot-flow', 'Admin\SalesReport::lotFlow');
$routes->get('reports/misc-flow', 'Admin\SalesReport::miscFlow');
$routes->post('reports/misc-flow', 'Admin\SalesReport::miscFlowStore');
// 판매 대행소 관리 (P2-07/08)
$routes->get('sales-agencies', 'Admin\SalesAgency::index');
@@ -173,6 +203,9 @@ $routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): vo
$routes->post('free-recipients/update/(:num)', 'Admin\FreeRecipient::update/$1');
$routes->post('free-recipients/delete/(:num)', 'Admin\FreeRecipient::delete/$1');
$routes->get('designated-shops/export', 'Admin\DesignatedShop::export');
$routes->get('designated-shops/map', 'Admin\DesignatedShop::map');
$routes->get('designated-shops/status', 'Admin\DesignatedShop::status');
$routes->get('designated-shops', 'Admin\DesignatedShop::index');
$routes->get('designated-shops/create', 'Admin\DesignatedShop::create');
$routes->post('designated-shops/store', 'Admin\DesignatedShop::store');

View File

@@ -31,8 +31,7 @@ class Access extends BaseController
{
$start = $this->request->getGet('start');
$end = $this->request->getGet('end');
$builder = $this->memberLogModel->builder();
$builder->select('member_log.*');
$builder = $this->memberLogModel;
$builder->orderBy('mll_regdate', 'DESC');
if ($start !== null && $start !== '') {
$builder->where('mll_regdate >=', $start . ' 00:00:00');
@@ -40,10 +39,11 @@ class Access extends BaseController
if ($end !== null && $end !== '') {
$builder->where('mll_regdate <=', $end . ' 23:59:59');
}
$list = $builder->get()->getResult();
$list = $builder->paginate(20);
$pager = $this->memberLogModel->pager;
return view('admin/layout', [
'title' => '로그인 이력',
'content' => view('admin/access/login_history', ['list' => $list, 'start' => $start, 'end' => $end]),
'content' => view('admin/access/login_history', ['list' => $list, 'start' => $start, 'end' => $end, 'pager' => $pager]),
]);
}
@@ -59,15 +59,14 @@ class Access extends BaseController
$status = MemberApprovalRequestModel::STATUS_PENDING;
}
$builder = $this->approvalModel->builder();
$builder->select(
'member_approval_request.*, member.mb_id, member.mb_name, member.mb_lg_idx, local_government.lg_name'
);
$builder->join('member', 'member.mb_idx = member_approval_request.mb_idx', 'left');
$builder->join('local_government', 'local_government.lg_idx = member.mb_lg_idx', 'left');
$builder->where('member_approval_request.mar_status', $status);
$builder->orderBy('member_approval_request.mar_requested_at', 'DESC');
$list = $builder->get()->getResult();
$list = $this->approvalModel
->select('member_approval_request.*, member.mb_id, member.mb_name, member.mb_lg_idx, local_government.lg_name')
->join('member', 'member.mb_idx = member_approval_request.mb_idx', 'left')
->join('local_government', 'local_government.lg_idx = member.mb_lg_idx', 'left')
->where('member_approval_request.mar_status', $status)
->orderBy('member_approval_request.mar_requested_at', 'DESC')
->paginate(20);
$pager = $this->approvalModel->pager;
return view('admin/layout', [
'title' => '승인 대기',
@@ -75,6 +74,7 @@ class Access extends BaseController
'list' => $list,
'status' => $status,
'roles' => $this->roles,
'pager' => $pager,
]),
]);
}
@@ -90,8 +90,8 @@ class Access extends BaseController
}
$requestedLevel = (int) $requestRow->mar_requested_level;
if ($requestedLevel === Roles::LEVEL_SUPER_ADMIN) {
return redirect()->to(site_url('admin/access/approvals'))->with('error', 'super admin 역할 요청은 승인할 수 없습니다.');
if ($requestedLevel === Roles::LEVEL_SUPER_ADMIN || $requestedLevel === Roles::LEVEL_HEADQUARTERS_ADMIN) {
return redirect()->to(site_url('admin/access/approvals'))->with('error', '상위 관리자 역할 요청은 승인할 수 없습니다.');
}
$db = db_connect();

View File

@@ -13,11 +13,41 @@ class BagInventory extends BaseController
$lgIdx = admin_effective_lg_idx();
if (!$lgIdx) return redirect()->to(site_url('admin'))->with('error', '지자체를 선택해 주세요.');
$list = model(BagInventoryModel::class)->where('bi_lg_idx', $lgIdx)->orderBy('bi_bag_code', 'ASC')->findAll();
$invModel = model(BagInventoryModel::class);
$list = $invModel->where('bi_lg_idx', $lgIdx)->orderBy('bi_bag_code', 'ASC')->paginate(20);
$pager = $invModel->pager;
return view('admin/layout', [
'title' => '재고 현황',
'content' => view('admin/bag_inventory/index', ['list' => $list]),
'content' => view('admin/bag_inventory/index', ['list' => $list, 'pager' => $pager]),
]);
}
public function export()
{
helper(['admin', 'export']);
$lgIdx = admin_effective_lg_idx();
if (!$lgIdx) {
return redirect()->to(site_url('admin/bag-inventory'))->with('error', '지자체를 선택해 주세요.');
}
$list = model(BagInventoryModel::class)->where('bi_lg_idx', $lgIdx)->orderBy('bi_bag_code', 'ASC')->findAll();
$rows = [];
foreach ($list as $row) {
$rows[] = [
$row->bi_idx,
$row->bi_bag_code,
$row->bi_bag_name,
(int) $row->bi_qty,
$row->bi_updated_at,
];
}
export_csv(
'재고현황_' . date('Ymd') . '.csv',
['번호', '봉투코드', '봉투명', '현재재고(낱장)', '최종갱신'],
$rows
);
}
}

View File

@@ -29,11 +29,12 @@ class BagIssue extends BaseController
if ($startDate) $builder->where('bi2_issue_date >=', $startDate);
if ($endDate) $builder->where('bi2_issue_date <=', $endDate);
$list = $builder->orderBy('bi2_issue_date', 'DESC')->orderBy('bi2_idx', 'DESC')->findAll();
$list = $builder->orderBy('bi2_issue_date', 'DESC')->orderBy('bi2_idx', 'DESC')->paginate(20);
$pager = $this->issueModel->pager;
return view('admin/layout', [
'title' => '무료용 불출 관리',
'content' => view('admin/bag_issue/index', compact('list', 'startDate', 'endDate')),
'content' => view('admin/bag_issue/index', compact('list', 'startDate', 'endDate', 'pager')),
]);
}
@@ -77,7 +78,7 @@ class BagIssue extends BaseController
$db = \Config\Database::connect();
$db->transStart();
$this->issueModel->insert([
$issueData = [
'bi2_lg_idx' => $lgIdx,
'bi2_year' => (int) $this->request->getPost('bi2_year'),
'bi2_quarter' => (int) $this->request->getPost('bi2_quarter'),
@@ -90,7 +91,13 @@ class BagIssue extends BaseController
'bi2_qty' => $qty,
'bi2_status' => 'normal',
'bi2_regdate' => date('Y-m-d H:i:s'),
]);
];
$this->issueModel->insert($issueData);
$bi2Idx = (int) $this->issueModel->getInsertID();
// CT-05: 감사 로그
helper('audit');
audit_log('create', 'bag_issue', $bi2Idx, null, array_merge($issueData, ['bi2_idx' => $bi2Idx]));
// 재고 감산
model(BagInventoryModel::class)->adjustQty($lgIdx, $bagCode, $bagName, -$qty);
@@ -111,7 +118,11 @@ class BagIssue extends BaseController
$db = \Config\Database::connect();
$db->transStart();
$before = (array) $item;
$this->issueModel->update($id, ['bi2_status' => 'cancelled']);
// CT-05: 감사 로그
helper('audit');
audit_log('update', 'bag_issue', $id, $before, ['bi2_status' => 'cancelled']);
// 재고 복원
model(BagInventoryModel::class)->adjustQty((int) $item->bi2_lg_idx, $item->bi2_bag_code, $item->bi2_bag_name, (int) $item->bi2_qty);

View File

@@ -42,7 +42,8 @@ class BagOrder extends BaseController
if ($endDate) $builder->where('bo_order_date <=', $endDate);
if ($status) $builder->where('bo_status', $status);
$list = $builder->orderBy('bo_order_date', 'DESC')->orderBy('bo_idx', 'DESC')->findAll();
$list = $builder->orderBy('bo_order_date', 'DESC')->orderBy('bo_idx', 'DESC')->paginate(20);
$pager = $this->orderModel->pager;
// 발주별 품목 합계
$itemSummary = [];
@@ -60,10 +61,56 @@ class BagOrder extends BaseController
return view('admin/layout', [
'title' => '발주 현황',
'content' => view('admin/bag_order/index', compact('list', 'itemSummary', 'companyMap', 'agencyMap', 'startDate', 'endDate', 'status')),
'content' => view('admin/bag_order/index', compact('list', 'itemSummary', 'companyMap', 'agencyMap', 'startDate', 'endDate', 'status', 'pager')),
]);
}
public function export()
{
helper(['admin', 'export']);
$lgIdx = admin_effective_lg_idx();
if (!$lgIdx) {
return redirect()->to(site_url('admin/bag-orders'))->with('error', '지자체를 선택해 주세요.');
}
$builder = $this->orderModel->where('bo_lg_idx', $lgIdx);
$startDate = $this->request->getGet('start_date');
$endDate = $this->request->getGet('end_date');
$status = $this->request->getGet('status');
if ($startDate) $builder->where('bo_order_date >=', $startDate);
if ($endDate) $builder->where('bo_order_date <=', $endDate);
if ($status) $builder->where('bo_status', $status);
$list = $builder->orderBy('bo_order_date', 'DESC')->orderBy('bo_idx', 'DESC')->findAll();
$rows = [];
$statusMap = ['normal' => '정상', 'cancelled' => '취소', 'deleted' => '삭제'];
foreach ($list as $row) {
$items = $this->itemModel->where('boi_bo_idx', $row->bo_idx)->findAll();
$totalQty = 0;
$totalAmt = 0;
foreach ($items as $it) {
$totalQty += (int) $it->boi_qty_sheet;
$totalAmt += (float) $it->boi_amount;
}
$rows[] = [
$row->bo_idx,
$row->bo_lot_no,
$row->bo_order_date,
count($items),
$totalQty,
$totalAmt,
$statusMap[$row->bo_status] ?? $row->bo_status,
];
}
export_csv(
'발주현황_' . date('Ymd') . '.csv',
['번호', 'LOT번호', '발주일', '품목수', '총수량', '총금액', '상태'],
$rows
);
}
public function create()
{
helper('admin');
@@ -132,6 +179,10 @@ class BagOrder extends BaseController
$this->orderModel->insert($orderData);
$boIdx = (int) $this->orderModel->getInsertID();
// CT-05: 감사 로그
helper('audit');
audit_log('create', 'bag_order', $boIdx, null, array_merge($orderData, ['bo_idx' => $boIdx]));
// 품목 저장
$bagCodes = $this->request->getPost('item_bag_code') ?? [];
$qtyBoxes = $this->request->getPost('item_qty_box') ?? [];
@@ -203,7 +254,10 @@ class BagOrder extends BaseController
return redirect()->to(site_url('admin/bag-orders'))->with('error', '발주를 찾을 수 없습니다.');
}
$before = (array) $order;
$this->orderModel->update($id, ['bo_status' => 'cancelled', 'bo_moddate' => date('Y-m-d H:i:s')]);
helper('audit');
audit_log('update', 'bag_order', $id, $before, ['bo_status' => 'cancelled']);
return redirect()->to(site_url('admin/bag-orders'))->with('success', '발주가 취소되었습니다.');
}
@@ -215,7 +269,10 @@ class BagOrder extends BaseController
return redirect()->to(site_url('admin/bag-orders'))->with('error', '발주를 찾을 수 없습니다.');
}
$before = (array) $order;
$this->orderModel->update($id, ['bo_status' => 'deleted', 'bo_moddate' => date('Y-m-d H:i:s')]);
helper('audit');
audit_log('delete', 'bag_order', $id, $before, ['bo_status' => 'deleted']);
return redirect()->to(site_url('admin/bag-orders'))->with('success', '발주가 삭제 처리되었습니다.');
}
}

View File

@@ -42,7 +42,8 @@ class BagPrice extends BaseController
->groupEnd();
}
$list = $builder->orderBy('bp_bag_code', 'ASC')->orderBy('bp_start_date', 'DESC')->findAll();
$list = $builder->orderBy('bp_bag_code', 'ASC')->orderBy('bp_start_date', 'DESC')->paginate(20);
$pager = $this->priceModel->pager;
return view('admin/layout', [
'title' => '봉투 단가 관리',
@@ -50,6 +51,7 @@ class BagPrice extends BaseController
'list' => $list,
'startDate' => $startDate,
'endDate' => $endDate,
'pager' => $pager,
]),
]);
}

View File

@@ -30,11 +30,12 @@ class BagReceiving extends BaseController
if ($startDate) $builder->where('br_receive_date >=', $startDate);
if ($endDate) $builder->where('br_receive_date <=', $endDate);
$list = $builder->orderBy('br_receive_date', 'DESC')->orderBy('br_idx', 'DESC')->findAll();
$list = $builder->orderBy('br_receive_date', 'DESC')->orderBy('br_idx', 'DESC')->paginate(20);
$pager = $this->recvModel->pager;
return view('admin/layout', [
'title' => '입고 현황',
'content' => view('admin/bag_receiving/index', compact('list', 'startDate', 'endDate')),
'content' => view('admin/bag_receiving/index', compact('list', 'startDate', 'endDate', 'pager')),
]);
}

View File

@@ -33,14 +33,56 @@ class BagSale extends BaseController
if ($endDate) $builder->where('bs_sale_date <=', $endDate);
if ($type) $builder->where('bs_type', $type);
$list = $builder->orderBy('bs_sale_date', 'DESC')->orderBy('bs_idx', 'DESC')->findAll();
$list = $builder->orderBy('bs_sale_date', 'DESC')->orderBy('bs_idx', 'DESC')->paginate(20);
$pager = $this->saleModel->pager;
return view('admin/layout', [
'title' => '판매/반품 관리',
'content' => view('admin/bag_sale/index', compact('list', 'startDate', 'endDate', 'type')),
'content' => view('admin/bag_sale/index', compact('list', 'startDate', 'endDate', 'type', 'pager')),
]);
}
public function export()
{
helper(['admin', 'export']);
$lgIdx = admin_effective_lg_idx();
if (!$lgIdx) {
return redirect()->to(site_url('admin/bag-sales'))->with('error', '지자체를 선택해 주세요.');
}
$builder = $this->saleModel->where('bs_lg_idx', $lgIdx);
$startDate = $this->request->getGet('start_date');
$endDate = $this->request->getGet('end_date');
$type = $this->request->getGet('type');
if ($startDate) $builder->where('bs_sale_date >=', $startDate);
if ($endDate) $builder->where('bs_sale_date <=', $endDate);
if ($type) $builder->where('bs_type', $type);
$list = $builder->orderBy('bs_sale_date', 'DESC')->orderBy('bs_idx', 'DESC')->findAll();
$typeMap = ['sale' => '판매', 'return' => '반품', 'cancel' => '취소'];
$rows = [];
foreach ($list as $row) {
$rows[] = [
$row->bs_idx,
$row->bs_ds_name,
$row->bs_sale_date,
$row->bs_bag_code,
$row->bs_bag_name,
(int) $row->bs_qty,
(int) $row->bs_unit_price,
(int) $row->bs_amount,
$typeMap[$row->bs_type] ?? $row->bs_type,
];
}
export_csv(
'판매반품_' . date('Ymd') . '.csv',
['번호', '판매소', '판매일', '봉투코드', '봉투명', '수량', '단가', '금액', '구분'],
$rows
);
}
public function create()
{
helper('admin');
@@ -89,7 +131,7 @@ class BagSale extends BaseController
$db = \Config\Database::connect();
$db->transStart();
$this->saleModel->insert([
$saleData = [
'bs_lg_idx' => $lgIdx,
'bs_ds_idx' => $dsIdx,
'bs_ds_name' => $shop ? $shop->ds_name : '',
@@ -101,7 +143,13 @@ class BagSale extends BaseController
'bs_amount' => $unitPrice * abs($actualQty),
'bs_type' => $type,
'bs_regdate' => date('Y-m-d H:i:s'),
]);
];
$this->saleModel->insert($saleData);
$bsIdx = (int) $this->saleModel->getInsertID();
// CT-05: 감사 로그
helper('audit');
audit_log('create', 'bag_sale', $bsIdx, null, array_merge($saleData, ['bs_idx' => $bsIdx]));
// 재고 감산(판매) / 가산(반품)
model(BagInventoryModel::class)->adjustQty($lgIdx, $bagCode, $detail ? $detail->cd_name : '', -$actualQty);

View File

@@ -24,13 +24,15 @@ class CodeDetail extends BaseController
return redirect()->to(site_url('admin/code-kinds'))->with('error', '코드 종류를 찾을 수 없습니다.');
}
$list = $this->detailModel->getByKind($ckIdx);
$list = $this->detailModel->where('cd_ck_idx', $ckIdx)->orderBy('cd_sort', 'ASC')->paginate(20);
$pager = $this->detailModel->pager;
return view('admin/layout', [
'title' => '세부코드 관리 — ' . $kind->ck_name . ' (' . $kind->ck_code . ')',
'content' => view('admin/code_detail/index', [
'kind' => $kind,
'list' => $list,
'pager' => $pager,
]),
]);
}

View File

@@ -18,7 +18,8 @@ class CodeKind extends BaseController
public function index()
{
$list = $this->kindModel->orderBy('ck_code', 'ASC')->findAll();
$list = $this->kindModel->orderBy('ck_code', 'ASC')->paginate(20);
$pager = $this->kindModel->pager;
// 세부코드 수 매핑
$detailModel = model(CodeDetailModel::class);
@@ -32,6 +33,7 @@ class CodeKind extends BaseController
'content' => view('admin/code_kind/index', [
'list' => $list,
'countMap' => $countMap,
'pager' => $pager,
]),
]);
}

View File

@@ -22,11 +22,12 @@ class Company extends BaseController
return redirect()->to(site_url('admin'))->with('error', '지자체를 선택해 주세요.');
}
$list = $this->model->where('cp_lg_idx', $lgIdx)->orderBy('cp_idx', 'DESC')->findAll();
$list = $this->model->where('cp_lg_idx', $lgIdx)->orderBy('cp_idx', 'DESC')->paginate(20);
$pager = $this->model->pager;
return view('admin/layout', [
'title' => '업체 관리',
'content' => view('admin/company/index', ['list' => $list]),
'content' => view('admin/company/index', ['list' => $list, 'pager' => $pager]),
]);
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Controllers\Admin;
use App\Controllers\BaseController;
@@ -8,9 +10,82 @@ class Dashboard extends BaseController
{
public function index(): string
{
helper('admin');
$lgIdx = admin_effective_lg_idx();
$stats = [
'order_count' => 0,
'order_amount' => 0,
'sale_count' => 0,
'sale_amount' => 0,
'inventory_count' => 0,
'issue_count_month'=> 0,
'recent_orders' => [],
'recent_sales' => [],
];
if ($lgIdx) {
$db = \Config\Database::connect();
// 총 발주 건수/금액
$orderStats = $db->query("
SELECT COUNT(*) as cnt,
COALESCE(SUM(sub.total_amt), 0) as total_amount
FROM bag_order bo
LEFT JOIN (
SELECT boi_bo_idx, SUM(boi_amount) as total_amt
FROM bag_order_item GROUP BY boi_bo_idx
) sub ON sub.boi_bo_idx = bo.bo_idx
WHERE bo.bo_lg_idx = ? AND bo.bo_status = 'normal'
", [$lgIdx])->getRow();
$stats['order_count'] = (int) ($orderStats->cnt ?? 0);
$stats['order_amount'] = (int) ($orderStats->total_amount ?? 0);
// 총 판매 건수/금액
$saleStats = $db->query("
SELECT COUNT(*) as cnt, COALESCE(SUM(bs_amount), 0) as total_amount
FROM bag_sale
WHERE bs_lg_idx = ? AND bs_type = 'sale'
", [$lgIdx])->getRow();
$stats['sale_count'] = (int) ($saleStats->cnt ?? 0);
$stats['sale_amount'] = (int) ($saleStats->total_amount ?? 0);
// 현재 재고 품목 수
$invCount = $db->query("
SELECT COUNT(*) as cnt FROM bag_inventory WHERE bi_lg_idx = ? AND bi_qty > 0
", [$lgIdx])->getRow();
$stats['inventory_count'] = (int) ($invCount->cnt ?? 0);
// 이번 달 불출 건수
$monthStart = date('Y-m-01');
$issueCount = $db->query("
SELECT COUNT(*) as cnt FROM bag_issue
WHERE bi2_lg_idx = ? AND bi2_status = 'normal' AND bi2_issue_date >= ?
", [$lgIdx, $monthStart])->getRow();
$stats['issue_count_month'] = (int) ($issueCount->cnt ?? 0);
// 최근 발주 5건
$stats['recent_orders'] = $db->query("
SELECT bo_idx, bo_lot_no, bo_order_date, bo_status
FROM bag_order
WHERE bo_lg_idx = ?
ORDER BY bo_order_date DESC, bo_idx DESC
LIMIT 5
", [$lgIdx])->getResult();
// 최근 판매 5건
$stats['recent_sales'] = $db->query("
SELECT bs_idx, bs_ds_name, bs_bag_name, bs_qty, bs_amount, bs_sale_date, bs_type
FROM bag_sale
WHERE bs_lg_idx = ?
ORDER BY bs_sale_date DESC, bs_idx DESC
LIMIT 5
", [$lgIdx])->getResult();
}
return view('admin/layout', [
'title' => '대시보드',
'content' => view('admin/dashboard/index'),
'content' => view('admin/dashboard/index', ['stats' => $stats, 'lgIdx' => $lgIdx]),
]);
}
}

View File

@@ -22,7 +22,7 @@ class DesignatedShop extends BaseController
private function isSuperAdmin(): bool
{
return (int) session()->get('mb_level') === Roles::LEVEL_SUPER_ADMIN;
return Roles::isSuperAdminEquivalent((int) session()->get('mb_level'));
}
private function isLocalAdmin(): bool
@@ -43,10 +43,25 @@ class DesignatedShop extends BaseController
->with('error', '작업할 지자체가 선택되지 않았습니다. 지자체를 선택해 주세요.');
}
$list = $this->shopModel
->where('ds_lg_idx', $lgIdx)
->orderBy('ds_idx', 'DESC')
->findAll();
$builder = $this->shopModel->where('ds_lg_idx', $lgIdx);
// 다조건 검색 (P2-15)
$dsName = $this->request->getGet('ds_name');
$dsGugunCode = $this->request->getGet('ds_gugun_code');
$dsState = $this->request->getGet('ds_state');
if ($dsName !== null && $dsName !== '') {
$builder->like('ds_name', $dsName);
}
if ($dsGugunCode !== null && $dsGugunCode !== '') {
$builder->where('ds_gugun_code', $dsGugunCode);
}
if ($dsState !== null && $dsState !== '') {
$builder->where('ds_state', (int) $dsState);
}
$list = $builder->orderBy('ds_idx', 'DESC')->paginate(20);
$pager = $this->shopModel->pager;
// 지자체 이름 매핑용
$lgMap = [];
@@ -54,15 +69,58 @@ class DesignatedShop extends BaseController
$lgMap[$lg->lg_idx] = $lg->lg_name;
}
// 구군코드 목록 (검색 필터용)
$db = \Config\Database::connect();
$gugunCodes = $db->query("SELECT DISTINCT ds_gugun_code FROM designated_shop WHERE ds_lg_idx = ? AND ds_gugun_code != '' ORDER BY ds_gugun_code", [$lgIdx])->getResult();
return view('admin/layout', [
'title' => '지정판매소 관리',
'content' => view('admin/designated_shop/index', [
'list' => $list,
'lgMap' => $lgMap,
'pager' => $pager,
'dsName' => $dsName ?? '',
'dsGugunCode' => $dsGugunCode ?? '',
'dsState' => $dsState ?? '',
'gugunCodes' => $gugunCodes,
]),
]);
}
public function export()
{
helper(['admin', 'export']);
$lgIdx = admin_effective_lg_idx();
if (!$lgIdx) {
return redirect()->to(site_url('admin/designated-shops'))->with('error', '지자체를 선택해 주세요.');
}
$list = $this->shopModel->where('ds_lg_idx', $lgIdx)->orderBy('ds_idx', 'DESC')->findAll();
$rows = [];
foreach ($list as $row) {
$stateMap = [1 => '정상', 2 => '폐업', 3 => '직권해지'];
$rows[] = [
$row->ds_idx,
$row->ds_shop_no,
$row->ds_name,
$row->ds_rep_name,
$row->ds_biz_no,
$row->ds_va_number,
$row->ds_tel ?? '',
$row->ds_addr ?? '',
$stateMap[(int) $row->ds_state] ?? '',
$row->ds_regdate ?? '',
];
}
export_csv(
'지정판매소_' . date('Ymd') . '.csv',
['번호', '판매소번호', '상호명', '대표자', '사업자번호', '가상계좌', '전화번호', '주소', '상태', '등록일'],
$rows
);
}
/**
* 지정판매소 등록 폼 (효과 지자체 기준)
*/
@@ -281,6 +339,78 @@ class DesignatedShop extends BaseController
->with('success', '지정판매소가 삭제되었습니다.');
}
/**
* P2-17: 지정판매소 지도 표시
*/
public function map()
{
helper('admin');
$lgIdx = admin_effective_lg_idx();
if ($lgIdx === null || $lgIdx <= 0) {
return redirect()->to(site_url('admin'))
->with('error', '작업할 지자체가 선택되지 않았습니다.');
}
$shops = $this->shopModel
->where('ds_lg_idx', $lgIdx)
->where('ds_state', 1)
->findAll();
return view('admin/layout', [
'title' => '지정판매소 지도',
'content' => view('admin/designated_shop/map', [
'shops' => $shops,
]),
]);
}
/**
* P2-18: 지정판매소 현황 (연도별 신규/취소)
*/
public function status()
{
helper('admin');
$lgIdx = admin_effective_lg_idx();
if ($lgIdx === null || $lgIdx <= 0) {
return redirect()->to(site_url('admin'))
->with('error', '작업할 지자체가 선택되지 않았습니다.');
}
$db = \Config\Database::connect();
// 연도별 신규등록 건수 (ds_designated_at 기준)
$newByYear = $db->query("
SELECT YEAR(ds_designated_at) as yr, COUNT(*) as cnt
FROM designated_shop
WHERE ds_lg_idx = ? AND ds_designated_at IS NOT NULL
GROUP BY YEAR(ds_designated_at)
ORDER BY yr DESC
", [$lgIdx])->getResult();
// 연도별 취소/비활성 건수 (ds_state != 1, ds_regdate 기준)
$cancelByYear = $db->query("
SELECT YEAR(ds_regdate) as yr, COUNT(*) as cnt
FROM designated_shop
WHERE ds_lg_idx = ? AND ds_state != 1
GROUP BY YEAR(ds_regdate)
ORDER BY yr DESC
", [$lgIdx])->getResult();
// 전체 현황 합계
$totalActive = $this->shopModel->where('ds_lg_idx', $lgIdx)->where('ds_state', 1)->countAllResults(false);
$totalInactive = $this->shopModel->where('ds_lg_idx', $lgIdx)->where('ds_state !=', 1)->countAllResults(false);
return view('admin/layout', [
'title' => '지정판매소 현황',
'content' => view('admin/designated_shop/status', [
'newByYear' => $newByYear,
'cancelByYear' => $cancelByYear,
'totalActive' => $totalActive,
'totalInactive' => $totalInactive,
]),
]);
}
/**
* 지자체별 다음 판매소번호 생성 (lg_code + 3자리 일련번호)
* 문서: docs/기본 개발계획/22-판매소번호_일련번호_결정.md §3

View File

@@ -30,11 +30,12 @@ class FreeRecipient extends BaseController
return redirect()->to(site_url('admin'))->with('error', '지자체를 선택해 주세요.');
}
$list = $this->model->where('fr_lg_idx', $lgIdx)->orderBy('fr_idx', 'DESC')->findAll();
$list = $this->model->where('fr_lg_idx', $lgIdx)->orderBy('fr_idx', 'DESC')->paginate(20);
$pager = $this->model->pager;
return view('admin/layout', [
'title' => '무료용 대상자 관리',
'content' => view('admin/free_recipient/index', ['list' => $list]),
'content' => view('admin/free_recipient/index', ['list' => $list, 'pager' => $pager]),
]);
}

View File

@@ -19,7 +19,7 @@ class LocalGovernment extends BaseController
private function isSuperAdmin(): bool
{
return (int) session()->get('mb_level') === Roles::LEVEL_SUPER_ADMIN;
return Roles::isSuperAdminEquivalent((int) session()->get('mb_level'));
}
/**
@@ -29,14 +29,15 @@ class LocalGovernment extends BaseController
{
if (! $this->isSuperAdmin()) {
return redirect()->to(site_url('admin'))
->with('error', '지자체 관리는 super admin만 접근할 수 있습니다.');
->with('error', '지자체 관리는 상위 관리자만 접근할 수 있습니다.');
}
$list = $this->lgModel->orderBy('lg_idx', 'DESC')->findAll();
$list = $this->lgModel->orderBy('lg_idx', 'DESC')->paginate(20);
$pager = $this->lgModel->pager;
return view('admin/layout', [
'title' => '지자체 관리',
'content' => view('admin/local_government/index', ['list' => $list]),
'content' => view('admin/local_government/index', ['list' => $list, 'pager' => $pager]),
]);
}
@@ -47,7 +48,7 @@ class LocalGovernment extends BaseController
{
if (! $this->isSuperAdmin()) {
return redirect()->to(site_url('admin/local-governments'))
->with('error', '지자체 등록은 super admin만 가능합니다.');
->with('error', '지자체 등록은 상위 관리자만 가능합니다.');
}
return view('admin/layout', [
@@ -63,7 +64,7 @@ class LocalGovernment extends BaseController
{
if (! $this->isSuperAdmin()) {
return redirect()->to(site_url('admin/local-governments'))
->with('error', '지자체 등록은 super admin만 가능합니다.');
->with('error', '지자체 등록은 상위 관리자만 가능합니다.');
}
$rules = [

View File

@@ -30,11 +30,12 @@ class Manager extends BaseController
return redirect()->to(site_url('admin'))->with('error', '지자체를 선택해 주세요.');
}
$list = $this->model->where('mg_lg_idx', $lgIdx)->orderBy('mg_idx', 'DESC')->findAll();
$list = $this->model->where('mg_lg_idx', $lgIdx)->orderBy('mg_idx', 'DESC')->paginate(20);
$pager = $this->model->pager;
return view('admin/layout', [
'title' => '담당자 관리',
'content' => view('admin/manager/index', ['list' => $list]),
'content' => view('admin/manager/index', ['list' => $list, 'pager' => $pager]),
]);
}

View File

@@ -205,8 +205,8 @@ class Menu extends BaseController
return '';
}
$levels = array_map('intval', $levels);
// super admin(4)은 DB 저장 대상 아님. 1,2,3은 그대로 저장
$levels = array_filter($levels, static fn ($v) => $v > 0 && $v !== \Config\Roles::LEVEL_SUPER_ADMIN);
// super/본부(4·5)는 mm_level 저장 대상 아님. 1,2,3은 그대로 저장
$levels = array_filter($levels, static fn ($v) => $v > 0 && ! \Config\Roles::isSuperAdminEquivalent($v));
return implode(',', array_values($levels));
}

View File

@@ -38,12 +38,13 @@ class PackagingUnit extends BaseController
$builder->groupStart()->where('pu_end_date IS NULL')->orWhere('pu_end_date <=', $endDate)->groupEnd();
}
$list = $builder->orderBy('pu_bag_code', 'ASC')->orderBy('pu_start_date', 'DESC')->findAll();
$list = $builder->orderBy('pu_bag_code', 'ASC')->orderBy('pu_start_date', 'DESC')->paginate(20);
$pager = $this->unitModel->pager;
return view('admin/layout', [
'title' => '포장 단위 관리',
'content' => view('admin/packaging_unit/index', [
'list' => $list, 'startDate' => $startDate, 'endDate' => $endDate,
'list' => $list, 'startDate' => $startDate, 'endDate' => $endDate, 'pager' => $pager,
]),
]);
}

View File

@@ -22,11 +22,12 @@ class SalesAgency extends BaseController
return redirect()->to(site_url('admin'))->with('error', '지자체를 선택해 주세요.');
}
$list = $this->model->where('sa_lg_idx', $lgIdx)->orderBy('sa_idx', 'DESC')->findAll();
$list = $this->model->where('sa_lg_idx', $lgIdx)->orderBy('sa_idx', 'DESC')->paginate(20);
$pager = $this->model->pager;
return view('admin/layout', [
'title' => '판매 대행소 관리',
'content' => view('admin/sales_agency/index', ['list' => $list]),
'content' => view('admin/sales_agency/index', ['list' => $list, 'pager' => $pager]),
]);
}

View File

@@ -128,6 +128,264 @@ class SalesReport extends BaseController
]);
}
/**
* P5-04: 년 판매 현황 (월별)
*/
public function yearlySales()
{
helper('admin');
$lgIdx = admin_effective_lg_idx();
if (!$lgIdx) return redirect()->to(site_url('admin'))->with('error', '지자체를 선택해 주세요.');
$year = $this->request->getGet('year') ?? date('Y');
$db = \Config\Database::connect();
$result = $db->query("
SELECT bs_bag_code, bs_bag_name,
SUM(CASE WHEN MONTH(bs_sale_date)=1 THEN ABS(bs_qty) ELSE 0 END) as m01,
SUM(CASE WHEN MONTH(bs_sale_date)=2 THEN ABS(bs_qty) ELSE 0 END) as m02,
SUM(CASE WHEN MONTH(bs_sale_date)=3 THEN ABS(bs_qty) ELSE 0 END) as m03,
SUM(CASE WHEN MONTH(bs_sale_date)=4 THEN ABS(bs_qty) ELSE 0 END) as m04,
SUM(CASE WHEN MONTH(bs_sale_date)=5 THEN ABS(bs_qty) ELSE 0 END) as m05,
SUM(CASE WHEN MONTH(bs_sale_date)=6 THEN ABS(bs_qty) ELSE 0 END) as m06,
SUM(CASE WHEN MONTH(bs_sale_date)=7 THEN ABS(bs_qty) ELSE 0 END) as m07,
SUM(CASE WHEN MONTH(bs_sale_date)=8 THEN ABS(bs_qty) ELSE 0 END) as m08,
SUM(CASE WHEN MONTH(bs_sale_date)=9 THEN ABS(bs_qty) ELSE 0 END) as m09,
SUM(CASE WHEN MONTH(bs_sale_date)=10 THEN ABS(bs_qty) ELSE 0 END) as m10,
SUM(CASE WHEN MONTH(bs_sale_date)=11 THEN ABS(bs_qty) ELSE 0 END) as m11,
SUM(CASE WHEN MONTH(bs_sale_date)=12 THEN ABS(bs_qty) ELSE 0 END) as m12,
SUM(ABS(bs_qty)) as total
FROM bag_sale
WHERE bs_lg_idx = ? AND YEAR(bs_sale_date) = ? AND bs_type = 'sale'
GROUP BY bs_bag_code, bs_bag_name
ORDER BY bs_bag_code
", [$lgIdx, $year])->getResult();
return view('admin/layout', [
'title' => '년 판매 현황',
'content' => view('admin/sales_report/yearly_sales', compact('result', 'year')),
]);
}
/**
* P5-05: 지정판매소별 판매현황
*/
public function shopSales()
{
helper('admin');
$lgIdx = admin_effective_lg_idx();
if (!$lgIdx) return redirect()->to(site_url('admin'))->with('error', '지자체를 선택해 주세요.');
$startDate = $this->request->getGet('start_date') ?? date('Y-m-01');
$endDate = $this->request->getGet('end_date') ?? date('Y-m-d');
$db = \Config\Database::connect();
$result = $db->query("
SELECT bs_ds_name,
SUM(CASE WHEN bs_type='sale' THEN ABS(bs_qty) ELSE 0 END) as sale_qty,
SUM(CASE WHEN bs_type='sale' THEN bs_amount ELSE 0 END) as sale_amount,
SUM(CASE WHEN bs_type='return' THEN ABS(bs_qty) ELSE 0 END) as return_qty,
SUM(CASE WHEN bs_type='return' THEN ABS(bs_amount) ELSE 0 END) as return_amount
FROM bag_sale
WHERE bs_lg_idx = ? AND bs_sale_date BETWEEN ? AND ?
GROUP BY bs_ds_name
ORDER BY bs_ds_name
", [$lgIdx, $startDate, $endDate])->getResult();
return view('admin/layout', [
'title' => '지정판매소별 판매현황',
'content' => view('admin/sales_report/shop_sales', compact('result', 'startDate', 'endDate')),
]);
}
/**
* P5-06: 홈택스 세금계산서 엑셀 내보내기
*/
public function hometaxExport()
{
helper(['admin', 'export']);
$lgIdx = admin_effective_lg_idx();
if (!$lgIdx) return redirect()->to(site_url('admin'))->with('error', '지자체를 선택해 주세요.');
$startDate = $this->request->getGet('start_date') ?? date('Y-m-01');
$endDate = $this->request->getGet('end_date') ?? date('Y-m-d');
$db = \Config\Database::connect();
$rows = $db->query("
SELECT bs.bs_sale_date, ds.ds_biz_no as buyer_biz_no, ds.ds_name as buyer_name,
bs.bs_bag_name, ABS(bs.bs_qty) as qty, bs.bs_unit_price, bs.bs_amount
FROM bag_sale bs
LEFT JOIN designated_shop ds ON bs.bs_ds_idx = ds.ds_idx
WHERE bs.bs_lg_idx = ? AND bs.bs_sale_date BETWEEN ? AND ? AND bs.bs_type = 'sale'
ORDER BY bs.bs_sale_date, ds.ds_name
", [$lgIdx, $startDate, $endDate])->getResult();
// 지자체 정보 (공급자)
$lg = model(\App\Models\LocalGovernmentModel::class)->find($lgIdx);
$supplierBizNo = $lg->lg_biz_no ?? '';
$supplierName = $lg->lg_name ?? '';
$csvRows = [];
foreach ($rows as $row) {
$amount = (int) $row->bs_amount;
$tax = (int) round($amount * 0.1);
$csvRows[] = [
str_replace('-', '', $row->bs_sale_date), // 작성일자 (YYYYMMDD)
$supplierBizNo, // 공급자사업자번호
$supplierName, // 공급자상호
$row->buyer_biz_no ?? '', // 공급받는자사업자번호
$row->buyer_name ?? '', // 공급받는자상호
$row->bs_bag_name, // 품목
(int) $row->qty, // 수량
(int) $row->bs_unit_price, // 단가
$amount, // 공급가액
$tax, // 세액
];
}
export_csv(
'홈택스_세금계산서_' . date('Ymd') . '.csv',
['작성일자', '공급자사업자번호', '공급자상호', '공급받는자사업자번호', '공급받는자상호', '품목', '수량', '단가', '공급가액', '세액'],
$csvRows
);
}
/**
* P5-08: 반품/파기 현황
*/
public function returns()
{
helper('admin');
$lgIdx = admin_effective_lg_idx();
if (!$lgIdx) return redirect()->to(site_url('admin'))->with('error', '지자체를 선택해 주세요.');
$startDate = $this->request->getGet('start_date') ?? date('Y-m-01');
$endDate = $this->request->getGet('end_date') ?? date('Y-m-d');
$db = \Config\Database::connect();
$result = $db->query("
SELECT bs_sale_date, bs_ds_name, bs_bag_code, bs_bag_name, bs_type,
ABS(bs_qty) as qty, ABS(bs_amount) as amount
FROM bag_sale
WHERE bs_lg_idx = ? AND bs_sale_date BETWEEN ? AND ? AND bs_type IN('return','cancel')
ORDER BY bs_sale_date DESC, bs_ds_name
", [$lgIdx, $startDate, $endDate])->getResult();
return view('admin/layout', [
'title' => '반품/파기 현황',
'content' => view('admin/sales_report/returns', compact('result', 'startDate', 'endDate')),
]);
}
/**
* P5-10: LOT 수불 조회
*/
public function lotFlow()
{
helper('admin');
$lgIdx = admin_effective_lg_idx();
if (!$lgIdx) return redirect()->to(site_url('admin'))->with('error', '지자체를 선택해 주세요.');
$lotNo = $this->request->getGet('lot_no') ?? '';
$order = null;
$items = [];
$receivings = [];
if ($lotNo !== '') {
$db = \Config\Database::connect();
$order = $db->query("SELECT * FROM bag_order WHERE bo_lg_idx = ? AND bo_lot_no = ?", [$lgIdx, $lotNo])->getRow();
if ($order) {
$items = $db->query("SELECT * FROM bag_order_item WHERE boi_bo_idx = ? ORDER BY boi_bag_code", [(int) $order->bo_idx])->getResult();
$receivings = $db->query("SELECT * FROM bag_receiving WHERE br_bo_idx = ? ORDER BY br_receive_date", [(int) $order->bo_idx])->getResult();
}
}
return view('admin/layout', [
'title' => 'LOT 수불 조회',
'content' => view('admin/sales_report/lot_flow', compact('lotNo', 'order', 'items', 'receivings')),
]);
}
/**
* P5-11: 기타 입출고 목록
*/
public function miscFlow()
{
helper('admin');
$lgIdx = admin_effective_lg_idx();
if (!$lgIdx) return redirect()->to(site_url('admin'))->with('error', '지자체를 선택해 주세요.');
$startDate = $this->request->getGet('start_date') ?? date('Y-m-01');
$endDate = $this->request->getGet('end_date') ?? date('Y-m-d');
$db = \Config\Database::connect();
// bag_misc_flow 테이블이 존재하는지 확인
$tableExists = $db->query("SHOW TABLES LIKE 'bag_misc_flow'")->getNumRows() > 0;
$result = [];
if ($tableExists) {
$result = $db->query("
SELECT * FROM bag_misc_flow
WHERE bmf_lg_idx = ? AND bmf_date BETWEEN ? AND ?
ORDER BY bmf_date DESC, bmf_idx DESC
", [$lgIdx, $startDate, $endDate])->getResult();
}
// 봉투 코드 목록
$kindO = model(\App\Models\CodeKindModel::class)->where('ck_code', 'O')->first();
$bagCodes = $kindO ? model(\App\Models\CodeDetailModel::class)->getByKind((int) $kindO->ck_idx, true) : [];
return view('admin/layout', [
'title' => '기타 입출고',
'content' => view('admin/sales_report/misc_flow', compact('result', 'startDate', 'endDate', 'bagCodes', 'tableExists')),
]);
}
/**
* P5-11: 기타 입출고 등록 처리
*/
public function miscFlowStore()
{
helper('admin');
$lgIdx = admin_effective_lg_idx();
if (!$lgIdx) return redirect()->to(site_url('admin/reports/misc-flow'))->with('error', '지자체를 선택해 주세요.');
$rules = [
'bmf_type' => 'required|in_list[in,out]',
'bmf_bag_code' => 'required|max_length[50]',
'bmf_qty' => 'required|is_natural_no_zero',
'bmf_date' => 'required|valid_date[Y-m-d]',
'bmf_reason' => 'required|max_length[200]',
];
if (! $this->validate($rules)) {
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
}
$bagCode = $this->request->getPost('bmf_bag_code');
$qty = (int) $this->request->getPost('bmf_qty');
$type = $this->request->getPost('bmf_type');
// 봉투명 조회
$kindO = model(\App\Models\CodeKindModel::class)->where('ck_code', 'O')->first();
$detail = $kindO ? model(\App\Models\CodeDetailModel::class)->where('cd_ck_idx', $kindO->ck_idx)->where('cd_code', $bagCode)->first() : null;
$bagName = $detail ? $detail->cd_name : '';
$db = \Config\Database::connect();
$db->transStart();
$db->query("
INSERT INTO bag_misc_flow (bmf_lg_idx, bmf_type, bmf_bag_code, bmf_bag_name, bmf_qty, bmf_date, bmf_reason, bmf_regdate)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
", [$lgIdx, $type, $bagCode, $bagName, $qty, $this->request->getPost('bmf_date'), $this->request->getPost('bmf_reason'), date('Y-m-d H:i:s')]);
// 재고 조정
$delta = ($type === 'in') ? $qty : -$qty;
model(BagInventoryModel::class)->adjustQty($lgIdx, $bagCode, $bagName, $delta);
$db->transComplete();
return redirect()->to(site_url('admin/reports/misc-flow'))->with('success', '기타 입출고가 등록되었습니다.');
}
/**
* P5-07: 봉투 수불 현황
*/

View File

@@ -9,12 +9,12 @@ use Config\Roles;
class SelectLocalGovernment extends BaseController
{
/**
* 지자체 선택 화면 (super admin 전용)
* 지자체 선택 화면 (super·본부 관리자)
*/
public function index()
{
if ((int) session()->get('mb_level') !== Roles::LEVEL_SUPER_ADMIN) {
return redirect()->to(site_url('admin'))->with('error', '지자체 선택은 super admin만 사용할 수 있습니다.');
if (! Roles::isSuperAdminEquivalent((int) session()->get('mb_level'))) {
return redirect()->to(site_url('admin'))->with('error', '지자체 선택은 상위 관리자만 사용할 수 있습니다.');
}
$list = model(LocalGovernmentModel::class)
@@ -35,8 +35,8 @@ class SelectLocalGovernment extends BaseController
*/
public function store()
{
if ((int) session()->get('mb_level') !== Roles::LEVEL_SUPER_ADMIN) {
return redirect()->to(site_url('admin'))->with('error', '지자체 선택은 super admin만 사용할 수 있습니다.');
if (! Roles::isSuperAdminEquivalent((int) session()->get('mb_level'))) {
return redirect()->to(site_url('admin'))->with('error', '지자체 선택은 상위 관리자만 사용할 수 있습니다.');
}
$lgIdx = (int) $this->request->getPost('lg_idx');

View File

@@ -34,11 +34,12 @@ class ShopOrder extends BaseController
if ($startDate) $builder->where('so_delivery_date >=', $startDate);
if ($endDate) $builder->where('so_delivery_date <=', $endDate);
$list = $builder->orderBy('so_idx', 'DESC')->findAll();
$list = $builder->orderBy('so_idx', 'DESC')->paginate(20);
$pager = $this->orderModel->pager;
return view('admin/layout', [
'title' => '주문 접수 관리',
'content' => view('admin/shop_order/index', compact('list', 'startDate', 'endDate')),
'content' => view('admin/shop_order/index', compact('list', 'startDate', 'endDate', 'pager')),
]);
}

View File

@@ -26,7 +26,8 @@ class User extends BaseController
*/
public function index(): string
{
$list = $this->memberModel->orderBy('mb_idx', 'DESC')->findAll();
$list = $this->memberModel->orderBy('mb_idx', 'DESC')->paginate(20);
$pager = $this->memberModel->pager;
$approvalMap = [];
try {
$memberIds = array_map(static fn ($row) => (int) $row->mb_idx, $list);
@@ -56,6 +57,7 @@ class User extends BaseController
'list' => $list,
'roles' => $this->roles,
'approvalMap' => $approvalMap,
'pager' => $pager,
]),
]);
}
@@ -177,7 +179,7 @@ class User extends BaseController
/**
* 현재 로그인한 관리자가 부여 가능한 역할 목록.
* super admin만 super admin(4) 부여 가능, 그 외는 1~3만 허용.
* super/본부만 4·5 부여 가능, 지자체 관리자는 1~3만.
*
* @return array<int,string>
*/
@@ -185,10 +187,11 @@ class User extends BaseController
{
$levelNames = $this->roles->levelNames;
$myLevel = (int) session()->get('mb_level');
if ($myLevel === Roles::LEVEL_SUPER_ADMIN) {
if (Roles::isSuperAdminEquivalent($myLevel)) {
return $levelNames;
}
unset($levelNames[Roles::LEVEL_SUPER_ADMIN]);
unset($levelNames[Roles::LEVEL_SUPER_ADMIN], $levelNames[Roles::LEVEL_HEADQUARTERS_ADMIN]);
return $levelNames;
}

View File

@@ -2,10 +2,12 @@
namespace App\Controllers;
use App\Libraries\TotpService;
use App\Models\LocalGovernmentModel;
use App\Models\MemberApprovalRequestModel;
use App\Models\MemberLogModel;
use App\Models\MemberModel;
use CodeIgniter\HTTP\RedirectResponse;
class Auth extends BaseController
{
@@ -123,35 +125,177 @@ class Auth extends BaseController
}
}
// 로그인 성공
$sessionData = [
'mb_idx' => $member->mb_idx,
'mb_id' => $member->mb_id,
'mb_name' => $member->mb_name,
'mb_level' => $member->mb_level,
'mb_lg_idx' => $member->mb_lg_idx ?? null,
'logged_in' => true,
];
session()->set($sessionData);
if ($this->needsTotpStep($member)) {
$this->beginPending2faSession((int) $member->mb_idx);
$enabled = (int) ($member->mb_totp_enabled ?? 0) === 1;
if ($enabled) {
return redirect()->to(site_url('login/two-factor'));
}
session()->set('pending_totp_setup', true);
$memberModel->update($member->mb_idx, [
'mb_latestdate' => date('Y-m-d H:i:s'),
'mb_login_fail_count' => 0,
'mb_locked_until' => null,
return redirect()->to(site_url('login/totp-setup'));
}
return $this->completeLogin($member, $logData);
}
public function showTwoFactor()
{
if (session()->get('logged_in')) {
return redirect()->to('/');
}
$member = $this->ensurePending2faContext();
if ($member === null) {
return redirect()->to(site_url('login'))->with('error', '로그인 세션이 만료되었습니다. 다시 로그인해 주세요.');
}
if (session()->get('pending_totp_setup')) {
return redirect()->to(site_url('login/totp-setup'));
}
if ((int) ($member->mb_totp_enabled ?? 0) !== 1) {
return redirect()->to(site_url('login/totp-setup'));
}
return view('auth/login_two_factor', [
'memberId' => $member->mb_id,
]);
$this->insertMemberLog($logData, true, '로그인 성공', $member->mb_idx);
// 지자체 관리자 → 관리자 대시보드로 이동
if ((int) $member->mb_level === \Config\Roles::LEVEL_LOCAL_ADMIN) {
return redirect()->to(site_url('admin'))->with('success', '로그인되었습니다.');
}
// super admin → 지자체 선택 페이지로 이동 (선택 후 관리자 페이지 사용)
if ((int) $member->mb_level === \Config\Roles::LEVEL_SUPER_ADMIN) {
return redirect()->to(site_url('admin/select-local-government'))->with('success', '로그인되었습니다.');
}
return redirect()->to(site_url('/'))->with('success', '로그인되었습니다.');
public function verifyTwoFactor()
{
if (session()->get('logged_in')) {
return redirect()->to('/');
}
$member = $this->ensurePending2faContext();
if ($member === null) {
return redirect()->to(site_url('login'))->with('error', '로그인 세션이 만료되었습니다. 다시 로그인해 주세요.');
}
if (session()->get('pending_totp_setup') || (int) ($member->mb_totp_enabled ?? 0) !== 1) {
return redirect()->to(site_url('login/totp-setup'));
}
$rules = [
'totp_code' => 'required|exact_length[6]|numeric',
];
$messages = [
'totp_code' => [
'required' => '인증 코드 6자리를 입력해 주세요.',
'exact_length' => '인증 코드는 6자리 숫자입니다.',
'numeric' => '인증 코드는 숫자만 입력해 주세요.',
],
];
if (! $this->validate($rules, $messages)) {
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
}
$code = (string) $this->request->getPost('totp_code');
helper('pii_encryption');
$secret = pii_decrypt((string) ($member->mb_totp_secret ?? ''));
if ($secret === '') {
$this->clearPending2faSession();
return redirect()->to(site_url('login'))->with('error', '2차 인증 설정이 올바르지 않습니다. 관리자에게 문의해 주세요.');
}
$totp = new TotpService();
if (! $totp->verify($secret, $code)) {
return $this->handleTotpFailure($member, $this->buildLogData($member->mb_id, (int) $member->mb_idx));
}
return $this->completeLogin($member, $this->buildLogData($member->mb_id, (int) $member->mb_idx));
}
public function showTotpSetup()
{
if (session()->get('logged_in')) {
return redirect()->to('/');
}
$member = $this->ensurePending2faContext();
if ($member === null) {
return redirect()->to(site_url('login'))->with('error', '로그인 세션이 만료되었습니다. 다시 로그인해 주세요.');
}
if (! session()->get('pending_totp_setup')) {
if ((int) ($member->mb_totp_enabled ?? 0) === 1) {
return redirect()->to(site_url('login/two-factor'));
}
return redirect()->to(site_url('login'));
}
$totp = new TotpService();
$secret = session()->get('pending_totp_secret');
if (! is_string($secret) || $secret === '') {
$secret = $totp->createSecret();
session()->set('pending_totp_secret', $secret);
}
$qrDataUri = null;
try {
$qrDataUri = $totp->getQrDataUri((string) $member->mb_id, $secret);
} catch (\Throwable) {
$qrDataUri = null;
}
return view('auth/totp_setup', [
'memberId' => $member->mb_id,
'qrDataUri' => $qrDataUri,
'secret' => $secret,
]);
}
public function completeTotpSetup()
{
if (session()->get('logged_in')) {
return redirect()->to('/');
}
$member = $this->ensurePending2faContext();
if ($member === null) {
return redirect()->to(site_url('login'))->with('error', '로그인 세션이 만료되었습니다. 다시 로그인해 주세요.');
}
if (! session()->get('pending_totp_setup')) {
return redirect()->to(site_url('login/two-factor'));
}
$rules = [
'totp_code' => 'required|exact_length[6]|numeric',
];
$messages = [
'totp_code' => [
'required' => '인증 코드 6자리를 입력해 주세요.',
'exact_length' => '인증 코드는 6자리 숫자입니다.',
'numeric' => '인증 코드는 숫자만 입력해 주세요.',
],
];
if (! $this->validate($rules, $messages)) {
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
}
$secret = session()->get('pending_totp_secret');
if (! is_string($secret) || $secret === '') {
return redirect()->to(site_url('login/totp-setup'))->with('error', '설정 정보가 없습니다. 페이지를 새로고침해 주세요.');
}
$code = (string) $this->request->getPost('totp_code');
$totp = new TotpService();
if (! $totp->verify($secret, $code)) {
return $this->handleTotpFailure($member, $this->buildLogData($member->mb_id, (int) $member->mb_idx));
}
helper('pii_encryption');
model(MemberModel::class)->update((int) $member->mb_idx, [
'mb_totp_secret' => pii_encrypt($secret),
'mb_totp_enabled' => 1,
]);
session()->remove('pending_totp_setup');
session()->remove('pending_totp_secret');
$fresh = model(MemberModel::class)->find((int) $member->mb_idx);
if ($fresh === null) {
$this->clearPending2faSession();
return redirect()->to(site_url('login'))->with('error', '회원 정보를 다시 확인할 수 없습니다.');
}
return $this->completeLogin($fresh, $this->buildLogData($fresh->mb_id, (int) $fresh->mb_idx));
}
public function logout()
@@ -182,6 +326,7 @@ class Auth extends BaseController
}
}
$this->clearPending2faSession();
session()->destroy();
return redirect()->to('login')->with('success', '로그아웃되었습니다.');
@@ -298,6 +443,130 @@ class Auth extends BaseController
return redirect()->to('login')->with('success', '회원가입이 완료되었습니다. 관리자 승인 후 로그인 가능합니다.');
}
private function needsTotpStep(object $member): bool
{
if (! config('Auth')->requireTotp) {
return false;
}
return \Config\Roles::requiresTotp((int) $member->mb_level);
}
private function beginPending2faSession(int $mbIdx): void
{
session()->set([
'pending_2fa' => true,
'pending_mb_idx' => $mbIdx,
'pending_2fa_started' => time(),
'totp_attempts' => 0,
]);
session()->remove('pending_totp_setup');
session()->remove('pending_totp_secret');
}
private function clearPending2faSession(): void
{
session()->remove([
'pending_2fa',
'pending_mb_idx',
'pending_2fa_started',
'pending_totp_setup',
'pending_totp_secret',
'totp_attempts',
]);
}
private function pending2faExpired(): bool
{
$started = (int) session()->get('pending_2fa_started');
if ($started <= 0) {
return true;
}
$ttl = config('Auth')->pending2faTtlSeconds;
return (time() - $started) > $ttl;
}
private function ensurePending2faContext(): ?object
{
if (! session()->get('pending_2fa')) {
return null;
}
if ($this->pending2faExpired()) {
$this->clearPending2faSession();
return null;
}
$mbIdx = (int) session()->get('pending_mb_idx');
if ($mbIdx <= 0) {
$this->clearPending2faSession();
return null;
}
$member = model(MemberModel::class)->find($mbIdx);
if ($member === null) {
$this->clearPending2faSession();
return null;
}
return $member;
}
/**
* @param array<string, mixed> $logData
*/
private function handleTotpFailure(object $member, array $logData): RedirectResponse
{
$this->insertMemberLog($logData, false, '2차 인증 실패', (int) $member->mb_idx);
$attempts = (int) session()->get('totp_attempts') + 1;
session()->set('totp_attempts', $attempts);
$max = config('Auth')->totpMaxAttempts;
if ($attempts >= $max) {
$this->clearPending2faSession();
return redirect()->to(site_url('login'))->with('error', "인증 코드가 {$max}회 틀려 세션이 종료되었습니다. 처음부터 로그인해 주세요.");
}
return redirect()->back()
->withInput()
->with('error', '인증 코드가 올바르지 않습니다.');
}
/**
* @param array<string, mixed> $logData
*/
private function completeLogin(object $member, array $logData): RedirectResponse
{
$this->clearPending2faSession();
$sessionData = [
'mb_idx' => $member->mb_idx,
'mb_id' => $member->mb_id,
'mb_name' => $member->mb_name,
'mb_level' => $member->mb_level,
'mb_lg_idx' => $member->mb_lg_idx ?? null,
'logged_in' => true,
];
session()->set($sessionData);
model(MemberModel::class)->update($member->mb_idx, [
'mb_latestdate' => date('Y-m-d H:i:s'),
'mb_login_fail_count' => 0,
'mb_locked_until' => null,
]);
$this->insertMemberLog($logData, true, '로그인 성공', (int) $member->mb_idx);
if ((int) $member->mb_level === \Config\Roles::LEVEL_LOCAL_ADMIN) {
return redirect()->to(site_url('admin'))->with('success', '로그인되었습니다.');
}
if (\Config\Roles::isSuperAdminEquivalent((int) $member->mb_level)) {
return redirect()->to(site_url('admin/select-local-government'))->with('success', '로그인되었습니다.');
}
return redirect()->to(site_url('/'))->with('success', '로그인되었습니다.');
}
private function buildLogData(string $mbId, ?int $mbIdx): array
{
return [

View File

@@ -17,6 +17,7 @@ use App\Models\CompanyModel;
use App\Models\PackagingUnitModel;
use App\Models\SalesAgencyModel;
use App\Models\ShopOrderModel;
use App\Models\DesignatedShopModel;
class Bag extends BaseController
{
@@ -72,7 +73,8 @@ class Bag extends BaseController
$orderBuilder = model(BagOrderModel::class)->where('bo_lg_idx', $lgIdx);
if ($startDate) $orderBuilder->where('bo_order_date >=', $startDate);
if ($endDate) $orderBuilder->where('bo_order_date <=', $endDate);
$data['orders'] = $orderBuilder->orderBy('bo_order_date', 'DESC')->findAll();
$data['orders'] = $orderBuilder->orderBy('bo_order_date', 'DESC')->paginate(20, 'orders');
$data['orderPager'] = model(BagOrderModel::class)->pager;
// 발주별 품목 합계
$itemSummary = [];
@@ -92,7 +94,8 @@ class Bag extends BaseController
$recvBuilder = model(BagReceivingModel::class)->where('br_lg_idx', $lgIdx);
if ($startDate) $recvBuilder->where('br_receive_date >=', $startDate);
if ($endDate) $recvBuilder->where('br_receive_date <=', $endDate);
$data['receivings'] = $recvBuilder->orderBy('br_receive_date', 'DESC')->findAll();
$data['receivings'] = $recvBuilder->orderBy('br_receive_date', 'DESC')->paginate(20, 'receivings');
$data['recvPager'] = model(BagReceivingModel::class)->pager;
}
return $this->render('발주 입고 관리', 'bag/purchase_inbound', $data);
@@ -115,7 +118,8 @@ class Bag extends BaseController
$builder = model(BagIssueModel::class)->where('bi2_lg_idx', $lgIdx);
if ($startDate) $builder->where('bi2_issue_date >=', $startDate);
if ($endDate) $builder->where('bi2_issue_date <=', $endDate);
$data['list'] = $builder->orderBy('bi2_issue_date', 'DESC')->findAll();
$data['list'] = $builder->orderBy('bi2_issue_date', 'DESC')->paginate(20);
$data['pager'] = model(BagIssueModel::class)->pager;
}
return $this->render('불출 관리', 'bag/issue', $data);
@@ -130,7 +134,9 @@ class Bag extends BaseController
$data = ['list' => []];
if ($lgIdx) {
$data['list'] = model(BagInventoryModel::class)->where('bi_lg_idx', $lgIdx)->orderBy('bi_bag_code', 'ASC')->findAll();
$invModel = model(BagInventoryModel::class);
$data['list'] = $invModel->where('bi_lg_idx', $lgIdx)->orderBy('bi_bag_code', 'ASC')->paginate(20);
$data['pager'] = $invModel->pager;
}
return $this->render('재고 관리', 'bag/inventory', $data);
@@ -154,13 +160,15 @@ class Bag extends BaseController
$saleBuilder = model(BagSaleModel::class)->where('bs_lg_idx', $lgIdx);
if ($startDate) $saleBuilder->where('bs_sale_date >=', $startDate);
if ($endDate) $saleBuilder->where('bs_sale_date <=', $endDate);
$data['salesList'] = $saleBuilder->orderBy('bs_sale_date', 'DESC')->findAll();
$data['salesList'] = $saleBuilder->orderBy('bs_sale_date', 'DESC')->paginate(20, 'sales');
$data['salesPager'] = model(BagSaleModel::class)->pager;
// 주문 접수
$orderBuilder = model(ShopOrderModel::class)->where('so_lg_idx', $lgIdx);
if ($startDate) $orderBuilder->where('so_delivery_date >=', $startDate);
if ($endDate) $orderBuilder->where('so_delivery_date <=', $endDate);
$data['orderList'] = $orderBuilder->orderBy('so_idx', 'DESC')->findAll();
$data['orderList'] = $orderBuilder->orderBy('so_idx', 'DESC')->paginate(20, 'shoporders');
$data['orderPager'] = model(ShopOrderModel::class)->pager;
}
return $this->render('판매 관리', 'bag/sales', $data);
@@ -183,7 +191,8 @@ class Bag extends BaseController
$builder = model(BagSaleModel::class)->where('bs_lg_idx', $lgIdx)->where('bs_type', 'sale');
if ($startDate) $builder->where('bs_sale_date >=', $startDate);
if ($endDate) $builder->where('bs_sale_date <=', $endDate);
$data['result'] = $builder->orderBy('bs_sale_date', 'DESC')->findAll();
$data['result'] = $builder->orderBy('bs_sale_date', 'DESC')->paginate(20);
$data['pager'] = model(BagSaleModel::class)->pager;
}
return $this->render('판매 현황', 'bag/sales_stats', $data);
@@ -247,4 +256,173 @@ class Bag extends BaseController
{
return $this->render('도움말', 'bag/help', []);
}
// ──────────────────────────────────────────────
// 재고 조정 (실사)
// ──────────────────────────────────────────────
public function inventoryAdjust(): string
{
$lgIdx = $this->lgIdx();
$inventory = $lgIdx ? model(BagInventoryModel::class)->where('bi_lg_idx', $lgIdx)->orderBy('bi_bag_code')->findAll() : [];
return $this->render('재고 조정', 'bag/inventory_adjust', compact('inventory'));
}
public function inventoryAdjustStore()
{
helper('admin');
$lgIdx = $this->lgIdx();
if (! $lgIdx) {
return redirect()->to(site_url('bag/inventory'))->with('error', '지자체를 선택해 주세요.');
}
$rules = [
'bag_code' => 'required|max_length[50]',
'adjust_type' => 'required|in_list[set,add,sub]',
'qty' => 'required|is_natural',
];
if (! $this->validate($rules)) {
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
}
$bagCode = $this->request->getPost('bag_code');
$type = $this->request->getPost('adjust_type');
$qty = (int) $this->request->getPost('qty');
$invModel = model(BagInventoryModel::class);
$existing = $invModel->where('bi_lg_idx', $lgIdx)->where('bi_bag_code', $bagCode)->first();
if ($type === 'set') {
if ($existing) {
$invModel->update($existing->bi_idx, ['bi_qty' => $qty, 'bi_updated_at' => date('Y-m-d H:i:s')]);
}
} elseif ($type === 'add') {
$bagName = $existing ? $existing->bi_bag_name : '';
$invModel->adjustQty($lgIdx, $bagCode, $bagName, $qty);
} elseif ($type === 'sub') {
$bagName = $existing ? $existing->bi_bag_name : '';
$invModel->adjustQty($lgIdx, $bagCode, $bagName, -$qty);
}
return redirect()->to(site_url('bag/inventory'))->with('success', '재고가 조정되었습니다.');
}
// ══════════════════════════════════════════════
// CRUD — 사이트 레이아웃으로 등록/처리 폼 제공
// ══════════════════════════════════════════════
// --- 불출 등록 ---
public function issueCreate(): string
{
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$bagCodes = $kind ? model(CodeDetailModel::class)->where('cd_ck_idx', $kind->ck_idx)->where('cd_state', 1)->orderBy('cd_sort')->findAll() : [];
return $this->render('불출 처리', 'bag/create_bag_issue', compact('bagCodes'));
}
public function issueStore()
{
$admin = new \App\Controllers\Admin\BagIssue();
$admin->initController($this->request, $this->response, service('logger'));
$result = $admin->store();
if ($result instanceof \CodeIgniter\HTTP\RedirectResponse) {
$to = (string) $result->getHeaderLine('Location');
$to = str_replace('/admin/bag-issues', '/bag/issue', $to);
return redirect()->to($to)->with('success', session()->getFlashdata('success'))->with('errors', session()->getFlashdata('errors'));
}
return redirect()->to(site_url('bag/issue'))->with('success', '불출 처리되었습니다.');
}
public function issueCancel(int $id)
{
$admin = new \App\Controllers\Admin\BagIssue();
$admin->initController($this->request, $this->response, service('logger'));
$admin->cancel($id);
return redirect()->to(site_url('bag/issue'))->with('success', session()->getFlashdata('success') ?? '취소되었습니다.');
}
// --- 발주 등록 ---
public function orderCreate(): string
{
helper('admin');
$lgIdx = $this->lgIdx();
$companies = $lgIdx ? model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', 'manufacturer')->where('cp_state', 1)->findAll() : [];
$agencies = $lgIdx ? model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->where('sa_state', 1)->findAll() : [];
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$bagCodes = $kind ? model(CodeDetailModel::class)->where('cd_ck_idx', $kind->ck_idx)->where('cd_state', 1)->orderBy('cd_sort')->findAll() : [];
return $this->render('발주 등록', 'bag/create_bag_order', compact('companies', 'agencies', 'bagCodes'));
}
public function orderStore()
{
$admin = new \App\Controllers\Admin\BagOrder();
$admin->initController($this->request, $this->response, service('logger'));
$result = $admin->store();
if ($result instanceof \CodeIgniter\HTTP\RedirectResponse) {
return redirect()->to(site_url('bag/purchase-inbound'))->with('success', session()->getFlashdata('success'))->with('errors', session()->getFlashdata('errors'));
}
return redirect()->to(site_url('bag/purchase-inbound'))->with('success', '발주 등록되었습니다.');
}
// --- 입고 처리 ---
public function receivingCreate(): string
{
helper('admin');
$lgIdx = $this->lgIdx();
$orders = $lgIdx ? model(BagOrderModel::class)->where('bo_lg_idx', $lgIdx)->where('bo_status', 'normal')->orderBy('bo_order_date', 'DESC')->findAll() : [];
return $this->render('입고 처리', 'bag/create_bag_receiving', compact('orders'));
}
public function receivingStore()
{
$admin = new \App\Controllers\Admin\BagReceiving();
$admin->initController($this->request, $this->response, service('logger'));
$result = $admin->store();
if ($result instanceof \CodeIgniter\HTTP\RedirectResponse) {
return redirect()->to(site_url('bag/purchase-inbound'))->with('success', session()->getFlashdata('success'))->with('errors', session()->getFlashdata('errors'));
}
return redirect()->to(site_url('bag/purchase-inbound'))->with('success', '입고 처리되었습니다.');
}
// --- 판매 등록 ---
public function saleCreate(): string
{
helper('admin');
$lgIdx = $this->lgIdx();
$shops = $lgIdx ? model(DesignatedShopModel::class)->where('ds_lg_idx', $lgIdx)->where('ds_state', 1)->findAll() : [];
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$bagCodes = $kind ? model(CodeDetailModel::class)->where('cd_ck_idx', $kind->ck_idx)->where('cd_state', 1)->orderBy('cd_sort')->findAll() : [];
return $this->render('판매 등록', 'bag/create_bag_sale', compact('shops', 'bagCodes'));
}
public function saleStore()
{
$admin = new \App\Controllers\Admin\BagSale();
$admin->initController($this->request, $this->response, service('logger'));
$result = $admin->store();
if ($result instanceof \CodeIgniter\HTTP\RedirectResponse) {
return redirect()->to(site_url('bag/sales'))->with('success', session()->getFlashdata('success'))->with('errors', session()->getFlashdata('errors'));
}
return redirect()->to(site_url('bag/sales'))->with('success', '판매 등록되었습니다.');
}
// --- 주문 접수 ---
public function shopOrderCreate(): string
{
helper('admin');
$lgIdx = $this->lgIdx();
$shops = $lgIdx ? model(DesignatedShopModel::class)->where('ds_lg_idx', $lgIdx)->where('ds_state', 1)->findAll() : [];
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$bagCodes = $kind ? model(CodeDetailModel::class)->where('cd_ck_idx', $kind->ck_idx)->where('cd_state', 1)->orderBy('cd_sort')->findAll() : [];
return $this->render('주문 접수', 'bag/create_shop_order', compact('shops', 'bagCodes'));
}
public function shopOrderStore()
{
$admin = new \App\Controllers\Admin\ShopOrder();
$admin->initController($this->request, $this->response, service('logger'));
$result = $admin->store();
if ($result instanceof \CodeIgniter\HTTP\RedirectResponse) {
return redirect()->to(site_url('bag/sales'))->with('success', session()->getFlashdata('success'))->with('errors', session()->getFlashdata('errors'));
}
return redirect()->to(site_url('bag/sales'))->with('success', '주문 접수되었습니다.');
}
}

View File

@@ -61,6 +61,16 @@ class Home extends BaseController
]);
}
/**
* dense(표·KPI) + charts(Chart.js) 혼합. URL: /dashboard/blend
*/
public function dashboardBlend()
{
return view('bag/lg_dashboard_blend', [
'lgLabel' => $this->resolveLgLabel(),
]);
}
/**
* 재고 조회(수불) 화면 (목업)
*/

View File

@@ -11,7 +11,7 @@ use Config\Roles;
/**
* 관리자 전용 접근 필터.
* logged_in 이고 mb_level 이 SUPER_ADMIN(4) 또는 LOCAL_ADMIN(3) 일 때만 통과.
* logged_in 이고 mb_level 이 SUPER_ADMIN(4)·HEADQUARTERS_ADMIN(5)·LOCAL_ADMIN(3) 일 때만 통과.
*/
class AdminAuthFilter implements FilterInterface
{
@@ -22,15 +22,16 @@ class AdminAuthFilter implements FilterInterface
}
$level = (int) session()->get('mb_level');
if ($level !== Roles::LEVEL_SUPER_ADMIN && $level !== Roles::LEVEL_LOCAL_ADMIN) {
$isAdminLevel = Roles::isSuperAdminEquivalent($level) || $level === Roles::LEVEL_LOCAL_ADMIN;
if (! $isAdminLevel) {
return redirect()->to(site_url('/'))->with('error', '관리자만 접근할 수 있습니다.');
}
// Super admin: 지자체 미선택 시 지자체 선택 페이지로 유도 (지자체 선택·지자체 CRUD는 미선택도 허용)
// Super/본부: 지자체 미선택 시 지자체 선택 페이지로 유도 (지자체 선택·지자체 CRUD는 미선택도 허용)
$uri = $request->getUri();
$seg2 = $uri->getSegment(2);
$allowedWithoutSelection = ['select-local-government', 'local-governments'];
if ($level === Roles::LEVEL_SUPER_ADMIN && ! in_array($seg2, $allowedWithoutSelection, true)) {
if (Roles::isSuperAdminEquivalent($level) && ! in_array($seg2, $allowedWithoutSelection, true)) {
$selected = session()->get('admin_selected_lg_idx');
if ($selected === null || $selected === '') {
return redirect()->to(site_url('admin/select-local-government'))->with('error', '작업할 지자체를 먼저 선택해 주세요.');

View File

@@ -7,12 +7,12 @@ use Config\Roles;
if (! function_exists('admin_effective_lg_idx')) {
/**
* 현재 로그인한 관리자가 작업 대상으로 사용하는 지자체 PK.
* Super admin → admin_selected_lg_idx, 지자체 관리자 → mb_lg_idx, 그 외 null.
* Super/본부 관리자 → admin_selected_lg_idx, 지자체 관리자 → mb_lg_idx, 그 외 null.
*/
function admin_effective_lg_idx(): ?int
{
$level = (int) session()->get('mb_level');
if ($level === Roles::LEVEL_SUPER_ADMIN) {
if (Roles::isSuperAdminEquivalent($level)) {
$idx = session()->get('admin_selected_lg_idx');
return $idx !== null && $idx !== '' ? (int) $idx : null;
}
@@ -27,7 +27,7 @@ if (! function_exists('admin_effective_lg_idx')) {
if (! function_exists('get_admin_nav_items')) {
/**
* 관리자 상단 메뉴 항목 (DB menu 테이블, admin 타입, 현재 지자체·mb_level 기준, 평면 배열).
* 지자체 미선택(super admin)이면 빈 배열. 테이블/조회 실패 시에도 빈 배열.
* 지자체 미선택(super/본부)이면 빈 배열. 테이블/조회 실패 시에도 빈 배열.
*
* 하위 메뉴 포함 트리 구조가 필요하면 get_admin_nav_tree() 사용.
*/

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
if (! function_exists('audit_log')) {
/**
* CRUD 활동 로그 기록
*
* @param string $action 'create', 'update', 'delete'
* @param string $table 대상 테이블명
* @param int $recordId 대상 레코드 PK
* @param array|null $before 변경 전 데이터 (update/delete 시)
* @param array|null $after 변경 후 데이터 (create/update 시)
*/
function audit_log(string $action, string $table, int $recordId, ?array $before = null, ?array $after = null): void
{
try {
$db = \Config\Database::connect();
// 테이블 존재 여부 확인 (없으면 skip)
if ($db->query("SHOW TABLES LIKE 'activity_log'")->getNumRows() === 0) {
return;
}
$mbIdx = session()->get('mb_idx');
$ip = service('request')->getIPAddress();
model(\App\Models\ActivityLogModel::class)->insert([
'al_mb_idx' => $mbIdx ? (int) $mbIdx : null,
'al_action' => $action,
'al_table' => $table,
'al_record_id' => $recordId,
'al_data_before' => $before !== null ? json_encode($before, JSON_UNESCAPED_UNICODE) : null,
'al_data_after' => $after !== null ? json_encode($after, JSON_UNESCAPED_UNICODE) : null,
'al_ip' => $ip,
'al_regdate' => date('Y-m-d H:i:s'),
]);
} catch (\Throwable $e) {
// 로깅 실패 시 본 로직 방해하지 않음
log_message('error', 'audit_log failed: ' . $e->getMessage());
}
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
/**
* CSV 엑셀 내보내기 헬퍼
*
* UTF-8 BOM 포함으로 한글 엑셀 호환성 보장
*/
if (! function_exists('export_csv')) {
/**
* CSV 파일을 브라우저로 다운로드 전송
*
* @param string $filename 파일명 (확장자 포함, 예: 'export.csv')
* @param string[] $headers 컬럼 헤더 배열
* @param array $rows 데이터 행 배열 (각 행은 배열)
*/
function export_csv(string $filename, array $headers, array $rows): void
{
// 파일명에 .csv 확장자 보장
if (! str_ends_with($filename, '.csv')) {
$filename .= '.csv';
}
$response = service('response');
$response->setHeader('Content-Type', 'text/csv; charset=UTF-8');
$response->setHeader('Content-Disposition', 'attachment; filename="' . $filename . '"');
$response->setHeader('Pragma', 'no-cache');
$response->setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
// UTF-8 BOM (한글 엑셀 호환)
$output = "\xEF\xBB\xBF";
// 헤더 행
$output .= csv_encode_row($headers);
// 데이터 행
foreach ($rows as $row) {
$output .= csv_encode_row(array_values((array) $row));
}
$response->setBody($output);
$response->send();
exit;
}
}
if (! function_exists('csv_encode_row')) {
/**
* 배열 한 행을 CSV 문자열로 변환
*
* @param array $fields
* @return string
*/
function csv_encode_row(array $fields): string
{
$escaped = [];
foreach ($fields as $field) {
$val = (string) ($field ?? '');
// 쌍따옴표 이스케이프 및 감싸기
if (str_contains($val, '"') || str_contains($val, ',') || str_contains($val, "\n") || str_contains($val, "\r")) {
$val = '"' . str_replace('"', '""', $val) . '"';
}
$escaped[] = $val;
}
return implode(',', $escaped) . "\r\n";
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Libraries;
use Config\Auth as AuthConfig;
use RobThree\Auth\Providers\Qr\QRServerProvider;
use RobThree\Auth\TwoFactorAuth;
/**
* TOTP 생성·검증·QR (robthree/twofactorauth)
*/
class TotpService
{
private TwoFactorAuth $tfa;
public function __construct(?AuthConfig $authConfig = null)
{
$authConfig ??= config('Auth');
$this->tfa = new TwoFactorAuth(
new QRServerProvider(),
$authConfig->totpIssuer,
);
}
public function createSecret(): string
{
return $this->tfa->createSecret();
}
public function verify(string $plainSecret, string $code): bool
{
$code = preg_replace('/\s+/', '', $code) ?? '';
if (strlen($code) !== 6 || ! ctype_digit($code)) {
return false;
}
return $this->tfa->verifyCode($plainSecret, $code);
}
/**
* PNG data URI (외부 QR API 호출 — 네트워크 필요)
*/
public function getQrDataUri(string $accountLabel, string $secret): string
{
return $this->tfa->getQRCodeImageAsDataUri($accountLabel, $secret);
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Models;
use CodeIgniter\Model;
class ActivityLogModel extends Model
{
protected $table = 'activity_log';
protected $primaryKey = 'al_idx';
protected $returnType = 'object';
protected $useTimestamps = false;
protected $allowedFields = [
'al_mb_idx',
'al_action',
'al_table',
'al_record_id',
'al_data_before',
'al_data_after',
'al_ip',
'al_regdate',
];
}

View File

@@ -13,6 +13,8 @@ class MemberModel extends Model
protected $allowedFields = [
'mb_id',
'mb_passwd',
'mb_totp_secret',
'mb_totp_enabled',
'mb_name',
'mb_email',
'mb_phone',

View File

@@ -28,12 +28,12 @@ class MenuModel extends Model
/**
* 특정 mb_level에 노출할 메뉴만 필터링 (mm_is_view=Y, mm_level에 해당 레벨 포함 또는 빈값).
* lg_idx 기준 해당 지자체 메뉴만 대상. super admin(4)은 mm_level 무관하게 해당 지자체 메뉴 전체 노출.
* lg_idx 기준 해당 지자체 메뉴만 대상. super/본부(4·5)는 mm_level 무관하게 해당 지자체 메뉴 전체 노출.
*/
public function getVisibleByLevel(int $mtIdx, int $mbLevel, int $lgIdx): array
{
$all = $this->getAllByType($mtIdx, $lgIdx);
if ($mbLevel === \Config\Roles::LEVEL_SUPER_ADMIN) {
if (\Config\Roles::isSuperAdminEquivalent($mbLevel)) {
return array_values(array_filter($all, static fn ($row) => (string) $row->mm_is_view === 'Y'));
}
$levelStr = (string) $mbLevel;

View File

@@ -1,5 +1,9 @@
<?= view('components/print_header', ['printTitle' => '권한 승인 대기']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">권한 승인 대기</span>
<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">
@@ -64,3 +68,4 @@
</tbody>
</table>
</div>
<?php if (isset($pager)): ?><div class="mt-3"><?= $pager->links() ?></div><?php endif; ?>

View File

@@ -1,3 +1,4 @@
<?= view('components/print_header', ['printTitle' => '로그인 이력']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<div class="flex items-center gap-4 text-sm">
@@ -9,6 +10,7 @@
<button type="submit" 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">조회</button>
</form>
</div>
<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">
@@ -35,3 +37,4 @@
</tbody>
</table>
</div>
<?php if (isset($pager)): ?><div class="mt-3"><?= $pager->links() ?></div><?php endif; ?>

View File

@@ -1,5 +1,12 @@
<?= view('components/print_header', ['printTitle' => '재고 현황']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">재고 현황</span>
<div class="flex items-center gap-2">
<a href="<?= base_url('admin/bag-inventory/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 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>
</div>
</section>
<div class="border border-gray-300 overflow-auto mt-2">
<table class="w-full data-table">
@@ -28,3 +35,4 @@
</tbody>
</table>
</div>
<?php if (isset($pager)): ?><div class="mt-3"><?= $pager->links() ?></div><?php endif; ?>

View File

@@ -1,8 +1,12 @@
<?= view('components/print_header', ['printTitle' => '무료용 불출 관리']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">무료용 불출 관리</span>
<div class="flex items-center gap-2">
<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>
<a href="<?= base_url('admin/bag-issues/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>
</div>
</div>
</section>
<section class="p-2 bg-white border-b border-gray-200">
<form method="GET" action="<?= base_url('admin/bag-issues') ?>" class="flex flex-wrap items-center gap-2">
@@ -58,3 +62,4 @@
</tbody>
</table>
</div>
<?php if (isset($pager)): ?><div class="mt-3"><?= $pager->links() ?></div><?php endif; ?>

View File

@@ -1,8 +1,13 @@
<?= view('components/print_header', ['printTitle' => '발주 현황']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">발주 현황</span>
<div class="flex items-center gap-2">
<a href="<?= base_url('admin/bag-orders/export') . '?' . http_build_query(array_filter(['start_date' => $startDate ?? '', 'end_date' => $endDate ?? '', 'status' => $status ?? ''])) ?>" 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 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>
<a href="<?= base_url('admin/bag-orders/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>
</div>
</div>
</section>
<section class="p-2 bg-white border-b border-gray-200">
<form method="GET" action="<?= base_url('admin/bag-orders') ?>" class="flex flex-wrap items-center gap-2">
@@ -73,3 +78,4 @@
</tbody>
</table>
</div>
<?php if (isset($pager)): ?><div class="mt-3"><?= $pager->links() ?></div><?php endif; ?>

View File

@@ -1,8 +1,12 @@
<?= view('components/print_header', ['printTitle' => '봉투 단가 관리']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">봉투 단가 관리</span>
<div class="flex items-center gap-2">
<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>
<a href="<?= base_url('admin/bag-prices/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>
</div>
</div>
</section>
<section class="p-2 bg-white border-b border-gray-200">
<form method="GET" action="<?= base_url('admin/bag-prices') ?>" class="flex flex-wrap items-center gap-2">
@@ -58,3 +62,4 @@
</tbody>
</table>
</div>
<?php if (isset($pager)): ?><div class="mt-3"><?= $pager->links() ?></div><?php endif; ?>

View File

@@ -1,8 +1,12 @@
<?= view('components/print_header', ['printTitle' => '입고 현황']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">입고 현황</span>
<div class="flex items-center gap-2">
<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>
<a href="<?= base_url('admin/bag-receivings/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>
</div>
</div>
</section>
<section class="p-2 bg-white border-b border-gray-200">
<form method="GET" action="<?= base_url('admin/bag-receivings') ?>" class="flex flex-wrap items-center gap-2">
@@ -47,3 +51,4 @@
</tbody>
</table>
</div>
<?php if (isset($pager)): ?><div class="mt-3"><?= $pager->links() ?></div><?php endif; ?>

View File

@@ -1,8 +1,13 @@
<?= view('components/print_header', ['printTitle' => '판매/반품 관리']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">판매/반품 관리</span>
<div class="flex items-center gap-2">
<a href="<?= base_url('admin/bag-sales/export') . '?' . http_build_query(array_filter(['start_date' => $startDate ?? '', 'end_date' => $endDate ?? '', 'type' => $type ?? ''])) ?>" 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 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>
<a href="<?= base_url('admin/bag-sales/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>
</div>
</div>
</section>
<section class="p-2 bg-white border-b border-gray-200">
<form method="GET" action="<?= base_url('admin/bag-sales') ?>" class="flex flex-wrap items-center gap-2">
@@ -61,3 +66,4 @@
</tbody>
</table>
</div>
<?php if (isset($pager)): ?><div class="mt-3"><?= $pager->links() ?></div><?php endif; ?>

View File

@@ -1,3 +1,4 @@
<?= view('components/print_header', ['printTitle' => '세부코드 관리 - ' . esc($kind->ck_name)]) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<div class="flex items-center gap-2">
@@ -5,8 +6,11 @@
<span class="text-gray-400">|</span>
<span class="text-sm font-bold text-gray-700">세부코드 — <?= esc($kind->ck_name) ?> (<?= esc($kind->ck_code) ?>)</span>
</div>
<div class="flex items-center gap-2">
<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>
<a href="<?= base_url('admin/code-details/' . (int) $kind->ck_idx . '/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>
</div>
</div>
</section>
<div class="border border-gray-300 overflow-auto mt-2">
<table class="w-full data-table">
@@ -42,3 +46,4 @@
</tbody>
</table>
</div>
<?php if (isset($pager)): ?><div class="mt-3"><?= $pager->links() ?></div><?php endif; ?>

View File

@@ -1,8 +1,12 @@
<?= view('components/print_header', ['printTitle' => '기본코드 종류 관리']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">기본코드 종류 관리</span>
<div class="flex items-center gap-2">
<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>
<a href="<?= base_url('admin/code-kinds/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>
</div>
</div>
</section>
<div class="border border-gray-300 overflow-auto mt-2">
<table class="w-full data-table">
@@ -41,3 +45,4 @@
</tbody>
</table>
</div>
<?php if (isset($pager)): ?><div class="mt-3"><?= $pager->links() ?></div><?php endif; ?>

View File

@@ -1,8 +1,12 @@
<?= view('components/print_header', ['printTitle' => '업체 관리']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">업체 관리</span>
<div class="flex items-center gap-2">
<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>
<a href="<?= base_url('admin/companies/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>
</div>
</div>
</section>
<div class="border border-gray-300 overflow-auto mt-2">
<table class="w-full data-table">
@@ -45,3 +49,4 @@
</tbody>
</table>
</div>
<?php if (isset($pager)): ?><div class="mt-3"><?= $pager->links() ?></div><?php endif; ?>

View File

@@ -1,3 +1,118 @@
<div class="border border-gray-300 p-4">
<p class="text-sm text-gray-600">관리자 메인 화면입니다. 상단 메뉴에서 기능을 선택하세요.</p>
<?php $s = $stats ?? []; ?>
<?php if (!($lgIdx ?? null)): ?>
<div class="border border-orange-300 bg-orange-50 p-4 text-sm text-orange-700">
작업할 지자체가 선택되지 않았습니다. 상단에서 지자체를 선택해 주세요.
</div>
<?php else: ?>
<!-- 통계 카드 -->
<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="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="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="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="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>
</div>
</div>
<!-- 최근 내역 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- 최근 발주 -->
<div>
<div class="flex items-center justify-between mb-1">
<h3 class="text-sm font-bold text-gray-700">최근 발주 5건</h3>
<a href="<?= base_url('admin/bag-orders') ?>" class="text-xs text-blue-600 hover:underline">전체보기</a>
</div>
<div class="border border-gray-300 overflow-auto">
<table class="w-full data-table">
<thead>
<tr>
<th>LOT번호</th>
<th>발주일</th>
<th>상태</th>
</tr>
</thead>
<tbody class="text-center">
<?php
$statusMap = ['normal' => '정상', 'cancelled' => '취소', 'deleted' => '삭제'];
foreach (($s['recent_orders'] ?? []) as $order):
?>
<tr>
<td class="font-mono text-sm">
<a href="<?= base_url('admin/bag-orders/detail/' . (int) $order->bo_idx) ?>" class="text-blue-600 hover:underline"><?= esc($order->bo_lot_no) ?></a>
</td>
<td><?= esc($order->bo_order_date) ?></td>
<td>
<?php
$stClass = match($order->bo_status) {
'cancelled' => 'text-red-600',
'deleted' => 'text-gray-400',
default => 'text-green-600',
};
?>
<span class="<?= $stClass ?>"><?= esc($statusMap[$order->bo_status] ?? $order->bo_status) ?></span>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($s['recent_orders'])): ?>
<tr><td colspan="3" class="text-gray-400 py-3">발주 내역이 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<!-- 최근 판매 -->
<div>
<div class="flex items-center justify-between mb-1">
<h3 class="text-sm font-bold text-gray-700">최근 판매 5건</h3>
<a href="<?= base_url('admin/bag-sales') ?>" class="text-xs text-blue-600 hover:underline">전체보기</a>
</div>
<div class="border border-gray-300 overflow-auto">
<table class="w-full data-table">
<thead>
<tr>
<th>판매소</th>
<th>봉투명</th>
<th>수량</th>
<th>금액</th>
<th>구분</th>
</tr>
</thead>
<tbody class="text-right">
<?php
$typeMap = ['sale' => '판매', 'return' => '반품', 'cancel' => '취소'];
foreach (($s['recent_sales'] ?? []) as $sale):
?>
<tr>
<td class="text-left pl-2"><?= esc($sale->bs_ds_name) ?></td>
<td class="text-left pl-2"><?= esc($sale->bs_bag_name) ?></td>
<td><?= number_format(abs((int) $sale->bs_qty)) ?></td>
<td><?= number_format((int) $sale->bs_amount) ?></td>
<td class="text-center"><?= esc($typeMap[$sale->bs_type] ?? $sale->bs_type) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($s['recent_sales'])): ?>
<tr><td colspan="5" class="text-center text-gray-400 py-3">판매 내역이 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
<?php endif; ?>

View File

@@ -1,8 +1,36 @@
<?= view('components/print_header', ['printTitle' => '지정판매소 목록']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">지정판매소 목록</span>
<div class="flex items-center gap-2">
<a href="<?= base_url('admin/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 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>
<a href="<?= base_url('admin/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>
</div>
</div>
</section>
<!-- P2-15: 다조건 검색 -->
<section class="p-2 bg-white border-b border-gray-200 no-print">
<form method="GET" action="<?= base_url('admin/designated-shops') ?>" class="flex flex-wrap items-center gap-2">
<label class="text-sm text-gray-600">상호명</label>
<input type="text" name="ds_name" value="<?= esc($dsName ?? '') ?>" placeholder="상호명 검색" class="border border-gray-300 rounded px-2 py-1 text-sm w-40"/>
<label class="text-sm text-gray-600">구군코드</label>
<select name="ds_gugun_code" class="border border-gray-300 rounded px-2 py-1 text-sm">
<option value="">전체</option>
<?php foreach (($gugunCodes ?? []) as $gc): ?>
<option value="<?= esc($gc->ds_gugun_code) ?>" <?= ($dsGugunCode ?? '') === $gc->ds_gugun_code ? 'selected' : '' ?>><?= esc($gc->ds_gugun_code) ?></option>
<?php endforeach; ?>
</select>
<label class="text-sm text-gray-600">상태</label>
<select name="ds_state" class="border border-gray-300 rounded px-2 py-1 text-sm">
<option value="">전체</option>
<option value="1" <?= ($dsState ?? '') === '1' ? 'selected' : '' ?>>정상</option>
<option value="2" <?= ($dsState ?? '') === '2' ? 'selected' : '' ?>>폐업</option>
<option value="3" <?= ($dsState ?? '') === '3' ? 'selected' : '' ?>>직권해지</option>
</select>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
<a href="<?= base_url('admin/designated-shops') ?>" 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">
@@ -44,4 +72,5 @@
</tbody>
</table>
</div>
<?php if (isset($pager)): ?><div class="mt-3"><?= $pager->links() ?></div><?php endif; ?>

View File

@@ -0,0 +1,56 @@
<?= view('components/print_header', ['printTitle' => '지정판매소 지도']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">지정판매소 지도</span>
<a href="<?= base_url('admin/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 class="mt-2 text-sm text-gray-500">총 <?= count($shops) ?>개 판매소 표시</div>
<script src="//dapi.kakao.com/v2/maps/sdk.js?appkey=KAKAO_APP_KEY&libraries=services"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
var mapContainer = document.getElementById('kakao-map');
if (typeof kakao === 'undefined' || typeof kakao.maps === 'undefined') {
mapContainer.innerHTML = '<div class="flex items-center justify-center h-full text-gray-400">카카오맵 API 키를 설정해 주세요.</div>';
return;
}
var mapOption = {
center: new kakao.maps.LatLng(35.8714, 128.6014), // 대구 기본 좌표
level: 8
};
var map = new kakao.maps.Map(mapContainer, mapOption);
var geocoder = new kakao.maps.services.Geocoder();
var shops = <?= json_encode(array_map(function($s) {
return ['name' => $s->ds_name, 'addr' => $s->ds_addr ?? '', 'rep' => $s->ds_rep_name ?? '', 'tel' => $s->ds_tel ?? ''];
}, $shops), JSON_UNESCAPED_UNICODE) ?>;
var bounds = new kakao.maps.LatLngBounds();
var markerCount = 0;
shops.forEach(function(shop) {
if (!shop.addr) return;
geocoder.addressSearch(shop.addr, function(result, status) {
if (status === kakao.maps.services.Status.OK) {
var coords = new kakao.maps.LatLng(result[0].y, result[0].x);
var marker = new kakao.maps.Marker({ map: map, position: coords });
var infoContent = '<div style="padding:5px;font-size:12px;min-width:150px;">' +
'<strong>' + shop.name + '</strong><br/>' +
(shop.rep ? '대표: ' + shop.rep + '<br/>' : '') +
(shop.tel ? 'TEL: ' + shop.tel + '<br/>' : '') +
'<span style="color:#888;">' + shop.addr + '</span></div>';
var infowindow = new kakao.maps.InfoWindow({ content: infoContent });
kakao.maps.event.addListener(marker, 'click', function() {
infowindow.open(map, marker);
});
bounds.extend(coords);
markerCount++;
if (markerCount > 0) map.setBounds(bounds);
}
});
});
});
</script>

View File

@@ -0,0 +1,80 @@
<?= view('components/print_header', ['printTitle' => '지정판매소 현황']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">지정판매소 현황 (신규/취소)</span>
<div class="flex items-center gap-2">
<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>
<a href="<?= base_url('admin/designated-shops') ?>" class="border border-gray-300 text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">목록으로</a>
</div>
</div>
</section>
<!-- 전체 현황 요약 -->
<div class="flex gap-4 mt-2 mb-2">
<div class="border border-gray-300 p-3 flex-1 text-center">
<div class="text-sm text-gray-500">활성 판매소</div>
<div class="text-2xl font-bold text-green-600"><?= number_format($totalActive) ?></div>
</div>
<div class="border border-gray-300 p-3 flex-1 text-center">
<div class="text-sm text-gray-500">비활성/취소 판매소</div>
<div class="text-2xl font-bold text-red-600"><?= number_format($totalInactive) ?></div>
</div>
<div class="border border-gray-300 p-3 flex-1 text-center">
<div class="text-sm text-gray-500">전체</div>
<div class="text-2xl font-bold text-gray-700"><?= number_format($totalActive + $totalInactive) ?></div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-2">
<!-- 연도별 신규등록 -->
<div>
<h3 class="text-sm font-bold text-gray-700 mb-1">연도별 신규등록 건수</h3>
<div class="border border-gray-300 overflow-auto">
<table class="w-full data-table">
<thead>
<tr>
<th>연도</th>
<th>신규등록 건수</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>
<?php endforeach; ?>
<?php if (empty($newByYear)): ?>
<tr><td colspan="2" class="text-center text-gray-400 py-4">데이터가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<!-- 연도별 취소/비활성 -->
<div>
<h3 class="text-sm font-bold text-gray-700 mb-1">연도별 취소/비활성 건수</h3>
<div class="border border-gray-300 overflow-auto">
<table class="w-full data-table">
<thead>
<tr>
<th>연도</th>
<th>취소/비활성 건수</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>
<?php endforeach; ?>
<?php if (empty($cancelByYear)): ?>
<tr><td colspan="2" class="text-center text-gray-400 py-4">데이터가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>

View File

@@ -1,8 +1,12 @@
<?= view('components/print_header', ['printTitle' => '무료용 대상자 관리']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">무료용 대상자 관리</span>
<div class="flex items-center gap-2">
<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>
<a href="<?= base_url('admin/free-recipients/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>
</div>
</div>
</section>
<div class="border border-gray-300 overflow-auto mt-2">
<table class="w-full data-table">
@@ -47,3 +51,4 @@
</tbody>
</table>
</div>
<?php if (isset($pager)): ?><div class="mt-3"><?= $pager->links() ?></div><?php endif; ?>

View File

@@ -5,7 +5,7 @@ $n = $uriObj->getTotalSegments();
$uri = $n >= 2 ? $uriObj->getSegment(2) : '';
$seg3 = $n >= 3 ? $uriObj->getSegment(3) : '';
$mbLevel = (int) session()->get('mb_level');
$isSuperAdmin = ($mbLevel === \Config\Roles::LEVEL_SUPER_ADMIN);
$isSuperAdmin = \Config\Roles::isSuperAdminEquivalent($mbLevel);
$effectiveLgIdx = admin_effective_lg_idx();
$effectiveLgName = null;
if ($effectiveLgIdx) {
@@ -70,6 +70,14 @@ tailwind.config = {
.data-table tbody tr:hover td { background-color: #e6f7ff !important; }
.main-content-area { height: calc(100vh - 170px); overflow: auto; }
body { overflow: hidden; }
@media print {
header, footer, .no-print, nav { display: none !important; }
.main-content-area { height: auto !important; overflow: visible !important; }
body { overflow: visible !important; }
.bg-title-bar { display: none !important; }
.bg-control-panel { break-inside: avoid; }
.print-header { display: block !important; }
}
</style>
</head>
<body class="bg-gray-100 text-gray-800 flex flex-col h-screen font-sans antialiased select-none">

View File

@@ -1,8 +1,12 @@
<?= view('components/print_header', ['printTitle' => '지자체 목록']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">지자체 목록</span>
<div class="flex items-center gap-2">
<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>
<a href="<?= base_url('admin/local-governments/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>
</div>
</div>
</section>
<div class="border border-gray-300 overflow-auto mt-2">
<table class="w-full data-table">
@@ -40,4 +44,5 @@
</tbody>
</table>
</div>
<?php if (isset($pager)): ?><div class="mt-3"><?= $pager->links() ?></div><?php endif; ?>

View File

@@ -1,8 +1,12 @@
<?= view('components/print_header', ['printTitle' => '담당자 관리']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">담당자 관리</span>
<div class="flex items-center gap-2">
<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>
<a href="<?= base_url('admin/managers/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>
</div>
</div>
</section>
<div class="border border-gray-300 overflow-auto mt-2">
<table class="w-full data-table">
@@ -45,3 +49,4 @@
</tbody>
</table>
</div>
<?php if (isset($pager)): ?><div class="mt-3"><?= $pager->links() ?></div><?php endif; ?>

View File

@@ -4,7 +4,6 @@ $list = $list ?? [];
$mtIdx = (int) ($mtIdx ?? 0);
$mtCode = (string) ($mtCode ?? '');
$levelNames = $levelNames ?? [];
$superAdminLevel = \Config\Roles::LEVEL_SUPER_ADMIN;
?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<div class="flex flex-wrap items-center justify-between gap-y-2">
@@ -76,7 +75,7 @@ $superAdminLevel = \Config\Roles::LEVEL_SUPER_ADMIN;
if ((string) $row->mm_level === '') {
echo '전체';
} else {
$levels = array_filter(explode(',', $row->mm_level), fn ($lv) => (int) trim($lv) !== $superAdminLevel);
$levels = array_filter(explode(',', $row->mm_level), fn ($lv) => ! \Config\Roles::isSuperAdminEquivalent((int) trim($lv)));
$labels = array_map(fn ($lv) => $levelNames[trim($lv)] ?? trim($lv), $levels);
echo esc(implode(', ', $labels) ?: '전체');
}
@@ -146,7 +145,7 @@ $superAdminLevel = \Config\Roles::LEVEL_SUPER_ADMIN;
<span class="text-sm">전체</span>
</label>
<?php foreach ($levelNames as $lv => $name): ?>
<?php if ((int) $lv === $superAdminLevel) { continue; } ?>
<?php if (\Config\Roles::isSuperAdminEquivalent((int) $lv)) { continue; } ?>
<label class="inline-flex items-center gap-1 mm-level-label">
<input type="checkbox" name="mm_level[]" value="<?= (int) $lv ?>" class="mm-level-cb"/>
<span class="text-sm"><?= esc($name) ?></span>

View File

@@ -1,8 +1,12 @@
<?= view('components/print_header', ['printTitle' => '포장 단위 관리']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">포장 단위 관리</span>
<div class="flex items-center gap-2">
<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>
<a href="<?= base_url('admin/packaging-units/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>
</div>
</div>
</section>
<section class="p-2 bg-white border-b border-gray-200">
<form method="GET" action="<?= base_url('admin/packaging-units') ?>" class="flex flex-wrap items-center gap-2">
@@ -58,3 +62,4 @@
</tbody>
</table>
</div>
<?php if (isset($pager)): ?><div class="mt-3"><?= $pager->links() ?></div><?php endif; ?>

View File

@@ -1,8 +1,12 @@
<?= view('components/print_header', ['printTitle' => '판매 대행소 관리']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">판매 대행소 관리</span>
<div class="flex items-center gap-2">
<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>
<a href="<?= base_url('admin/sales-agencies/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>
</div>
</div>
</section>
<div class="border border-gray-300 overflow-auto mt-2">
<table class="w-full data-table">
@@ -43,3 +47,4 @@
</tbody>
</table>
</div>
<?php if (isset($pager)): ?><div class="mt-3"><?= $pager->links() ?></div><?php endif; ?>

View File

@@ -1,5 +1,9 @@
<?= view('components/print_header', ['printTitle' => '일계표']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">일계표</span>
<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>
<section class="p-2 bg-white border-b border-gray-200">
<form method="GET" action="<?= base_url('admin/reports/daily-summary') ?>" class="flex flex-wrap items-center gap-2">

View File

@@ -0,0 +1,99 @@
<?= view('components/print_header', ['printTitle' => 'LOT 수불 조회']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">LOT 수불 조회</span>
<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>
<section class="p-2 bg-white border-b border-gray-200">
<form method="GET" action="<?= base_url('admin/reports/lot-flow') ?>" class="flex flex-wrap items-center gap-2">
<label class="text-sm text-gray-600">LOT 번호</label>
<input type="text" name="lot_no" value="<?= esc($lotNo ?? '') ?>" placeholder="LOT-YYYYMMDD-XXXXXX" class="border border-gray-300 rounded px-2 py-1 text-sm w-64"/>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
</form>
</section>
<?php if ($lotNo !== '' && $order): ?>
<!-- 발주 정보 -->
<div class="border border-gray-300 p-3 mt-2 bg-gray-50">
<h3 class="text-sm font-bold text-gray-700 mb-2">발주 정보</h3>
<div class="grid grid-cols-2 md:grid-cols-4 gap-2 text-sm">
<div><span class="text-gray-500">LOT번호:</span> <span class="font-mono"><?= esc($order->bo_lot_no) ?></span></div>
<div><span class="text-gray-500">발주일:</span> <?= esc($order->bo_order_date) ?></div>
<div><span class="text-gray-500">상태:</span>
<?php $statusMap = ['normal' => '정상', 'cancelled' => '취소', 'deleted' => '삭제']; ?>
<?= esc($statusMap[$order->bo_status] ?? $order->bo_status) ?>
</div>
<div><span class="text-gray-500">등록일:</span> <?= esc($order->bo_regdate) ?></div>
</div>
</div>
<!-- 발주 품목 -->
<h3 class="text-sm font-bold text-gray-700 mt-3 mb-1">발주 품목</h3>
<div class="border border-gray-300 overflow-auto">
<table class="w-full data-table">
<thead>
<tr>
<th>봉투코드</th>
<th>봉투명</th>
<th>발주수량(박스)</th>
<th>발주수량(매)</th>
<th>단가</th>
<th>금액</th>
</tr>
</thead>
<tbody class="text-right">
<?php foreach ($items as $item): ?>
<tr>
<td class="text-center font-mono"><?= esc($item->boi_bag_code) ?></td>
<td class="text-left pl-2"><?= esc($item->boi_bag_name) ?></td>
<td><?= number_format((int) $item->boi_qty_box) ?></td>
<td><?= number_format((int) $item->boi_qty_sheet) ?></td>
<td><?= number_format((int) $item->boi_unit_price) ?></td>
<td><?= number_format((int) $item->boi_amount) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($items)): ?>
<tr><td colspan="6" class="text-center text-gray-400 py-4">품목이 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<!-- 입고 내역 -->
<h3 class="text-sm font-bold text-gray-700 mt-3 mb-1">입고 내역</h3>
<div class="border border-gray-300 overflow-auto">
<table class="w-full data-table">
<thead>
<tr>
<th>입고일</th>
<th>봉투코드</th>
<th>봉투명</th>
<th>입고수량(박스)</th>
<th>입고수량(매)</th>
<th>납품자</th>
</tr>
</thead>
<tbody class="text-right">
<?php foreach ($receivings as $recv): ?>
<tr>
<td class="text-center"><?= esc($recv->br_receive_date) ?></td>
<td class="text-center font-mono"><?= esc($recv->br_bag_code) ?></td>
<td class="text-left pl-2"><?= esc($recv->br_bag_name) ?></td>
<td><?= number_format((int) $recv->br_qty_box) ?></td>
<td><?= number_format((int) $recv->br_qty_sheet) ?></td>
<td class="text-left pl-2"><?= esc($recv->br_sender_name ?? '') ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($receivings)): ?>
<tr><td colspan="6" class="text-center text-gray-400 py-4">입고 내역이 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<?php elseif ($lotNo !== '' && !$order): ?>
<div class="border border-gray-300 p-4 mt-2 text-center text-gray-400">해당 LOT 번호의 발주를 찾을 수 없습니다.</div>
<?php else: ?>
<div class="border border-gray-300 p-4 mt-2 text-center text-gray-400">LOT 번호를 입력하고 조회해 주세요.</div>
<?php endif; ?>

View File

@@ -0,0 +1,84 @@
<?= view('components/print_header', ['printTitle' => '기타 입출고']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">기타 입출고</span>
<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>
<?php if (!($tableExists ?? false)): ?>
<div class="border border-orange-300 bg-orange-50 p-3 mt-2 text-sm text-orange-700">
bag_misc_flow 테이블이 생성되지 않았습니다. <code>writable/database/bag_misc_flow_tables.sql</code>을 실행해 주세요.
</div>
<?php endif; ?>
<!-- 등록 폼 -->
<section class="p-2 bg-white border-b border-gray-200 no-print">
<form method="POST" action="<?= base_url('admin/reports/misc-flow') ?>" class="flex flex-wrap items-center gap-2">
<?= csrf_field() ?>
<label class="text-sm text-gray-600">구분</label>
<select name="bmf_type" class="border border-gray-300 rounded px-2 py-1 text-sm" required>
<option value="in">입고</option>
<option value="out">출고</option>
</select>
<label class="text-sm text-gray-600">봉투</label>
<select name="bmf_bag_code" class="border border-gray-300 rounded px-2 py-1 text-sm" required>
<option value="">선택</option>
<?php foreach ($bagCodes as $bc): ?>
<option value="<?= esc($bc->cd_code) ?>"><?= esc($bc->cd_code . ' - ' . $bc->cd_name) ?></option>
<?php endforeach; ?>
</select>
<label class="text-sm text-gray-600">수량</label>
<input type="number" name="bmf_qty" min="1" class="border border-gray-300 rounded px-2 py-1 text-sm w-24" required/>
<label class="text-sm text-gray-600">일자</label>
<input type="date" name="bmf_date" value="<?= date('Y-m-d') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm" required/>
<label class="text-sm text-gray-600">사유</label>
<input type="text" name="bmf_reason" placeholder="입출고 사유" class="border border-gray-300 rounded px-2 py-1 text-sm w-48" required/>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">등록</button>
</form>
</section>
<!-- 조회 필터 -->
<section class="p-2 bg-white border-b border-gray-200 no-print">
<form method="GET" action="<?= base_url('admin/reports/misc-flow') ?>" class="flex flex-wrap items-center gap-2">
<label class="text-sm text-gray-600">시작일</label>
<input type="date" name="start_date" value="<?= esc($startDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
<label class="text-sm text-gray-600">~</label>
<input type="date" name="end_date" value="<?= esc($endDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
</form>
</section>
<div class="border border-gray-300 overflow-auto mt-2">
<table class="w-full data-table">
<thead>
<tr>
<th>번호</th>
<th>구분</th>
<th>일자</th>
<th>봉투코드</th>
<th>봉투명</th>
<th>수량</th>
<th>사유</th>
<th>등록일</th>
</tr>
</thead>
<tbody class="text-right">
<?php foreach ($result as $row): ?>
<tr>
<td class="text-center"><?= (int) $row->bmf_idx ?></td>
<td class="text-center"><?= $row->bmf_type === 'in' ? '<span class="text-blue-600">입고</span>' : '<span class="text-red-600">출고</span>' ?></td>
<td class="text-center"><?= esc($row->bmf_date) ?></td>
<td class="text-center font-mono"><?= esc($row->bmf_bag_code) ?></td>
<td class="text-left pl-2"><?= esc($row->bmf_bag_name) ?></td>
<td><?= number_format((int) $row->bmf_qty) ?></td>
<td class="text-left pl-2"><?= esc($row->bmf_reason) ?></td>
<td class="text-center"><?= esc($row->bmf_regdate) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($result)): ?>
<tr><td colspan="8" class="text-center text-gray-400 py-4">조회된 데이터가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>

View File

@@ -1,5 +1,9 @@
<?= view('components/print_header', ['printTitle' => '기간별 판매현황']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">기간별 판매현황</span>
<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>
<section class="p-2 bg-white border-b border-gray-200">
<form method="GET" action="<?= base_url('admin/reports/period-sales') ?>" class="flex flex-wrap items-center gap-2">

View File

@@ -0,0 +1,59 @@
<?= view('components/print_header', ['printTitle' => '반품/파기 현황']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">반품/파기 현황</span>
<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>
<section class="p-2 bg-white border-b border-gray-200">
<form method="GET" action="<?= base_url('admin/reports/returns') ?>" class="flex flex-wrap items-center gap-2">
<label class="text-sm text-gray-600">시작일</label>
<input type="date" name="start_date" value="<?= esc($startDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
<label class="text-sm text-gray-600">~</label>
<input type="date" name="end_date" value="<?= esc($endDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
</form>
</section>
<div class="border border-gray-300 overflow-auto mt-2">
<table class="w-full data-table">
<thead>
<tr>
<th>일자</th>
<th>판매소</th>
<th>봉투코드</th>
<th>봉투명</th>
<th>구분</th>
<th>수량</th>
<th>금액</th>
</tr>
</thead>
<tbody class="text-right">
<?php
$totalQty = 0; $totalAmt = 0;
$typeMap = ['return' => '반품', 'cancel' => '취소/파기'];
foreach ($result as $row):
$totalQty += (int) $row->qty;
$totalAmt += (int) $row->amount;
?>
<tr>
<td class="text-center"><?= esc($row->bs_sale_date) ?></td>
<td class="text-left pl-2"><?= esc($row->bs_ds_name) ?></td>
<td class="text-center font-mono"><?= esc($row->bs_bag_code) ?></td>
<td class="text-left pl-2"><?= esc($row->bs_bag_name) ?></td>
<td class="text-center"><?= esc($typeMap[$row->bs_type] ?? $row->bs_type) ?></td>
<td><?= number_format((int) $row->qty) ?></td>
<td><?= number_format((int) $row->amount) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($result)): ?>
<tr><td colspan="7" class="text-center text-gray-400 py-4">조회된 데이터가 없습니다.</td></tr>
<?php else: ?>
<tr class="font-bold bg-gray-100">
<td colspan="5" class="text-center">합계</td>
<td><?= number_format($totalQty) ?></td>
<td><?= number_format($totalAmt) ?></td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>

View File

@@ -1,5 +1,9 @@
<?= view('components/print_header', ['printTitle' => '판매 대장']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">판매 대장</span>
<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>
<section class="p-2 bg-white border-b border-gray-200">
<form method="GET" action="<?= base_url('admin/reports/sales-ledger') ?>" class="flex flex-wrap items-center gap-2">

View File

@@ -0,0 +1,64 @@
<?= view('components/print_header', ['printTitle' => '지정판매소별 판매현황']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">지정판매소별 판매현황</span>
<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>
<section class="p-2 bg-white border-b border-gray-200">
<form method="GET" action="<?= base_url('admin/reports/shop-sales') ?>" class="flex flex-wrap items-center gap-2">
<label class="text-sm text-gray-600">시작일</label>
<input type="date" name="start_date" value="<?= esc($startDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
<label class="text-sm text-gray-600">~</label>
<input type="date" name="end_date" value="<?= esc($endDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
</form>
</section>
<div class="border border-gray-300 overflow-auto mt-2">
<table class="w-full data-table">
<thead>
<tr>
<th>판매소명</th>
<th>판매수량</th>
<th>판매금액</th>
<th>반품수량</th>
<th>반품금액</th>
<th>순판매수량</th>
<th>순판매금액</th>
</tr>
</thead>
<tbody class="text-right">
<?php
$totSaleQty = 0; $totSaleAmt = 0; $totRetQty = 0; $totRetAmt = 0;
foreach ($result as $row):
$totSaleQty += (int) $row->sale_qty;
$totSaleAmt += (int) $row->sale_amount;
$totRetQty += (int) $row->return_qty;
$totRetAmt += (int) $row->return_amount;
?>
<tr>
<td class="text-left pl-2"><?= esc($row->bs_ds_name) ?></td>
<td><?= number_format((int) $row->sale_qty) ?></td>
<td><?= number_format((int) $row->sale_amount) ?></td>
<td><?= number_format((int) $row->return_qty) ?></td>
<td><?= number_format((int) $row->return_amount) ?></td>
<td class="font-bold"><?= number_format((int) $row->sale_qty - (int) $row->return_qty) ?></td>
<td class="font-bold"><?= number_format((int) $row->sale_amount - (int) $row->return_amount) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($result)): ?>
<tr><td colspan="7" class="text-center text-gray-400 py-4">조회된 데이터가 없습니다.</td></tr>
<?php else: ?>
<tr class="font-bold bg-gray-100">
<td class="text-center">합계</td>
<td><?= number_format($totSaleQty) ?></td>
<td><?= number_format($totSaleAmt) ?></td>
<td><?= number_format($totRetQty) ?></td>
<td><?= number_format($totRetAmt) ?></td>
<td><?= number_format($totSaleQty - $totRetQty) ?></td>
<td><?= number_format($totSaleAmt - $totRetAmt) ?></td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>

View File

@@ -1,5 +1,9 @@
<?= view('components/print_header', ['printTitle' => '봉투 수불 현황']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">봉투 수불 현황</span>
<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>
<section class="p-2 bg-white border-b border-gray-200">
<form method="GET" action="<?= base_url('admin/reports/supply-demand') ?>" class="flex flex-wrap items-center gap-2">

View File

@@ -0,0 +1,62 @@
<?= view('components/print_header', ['printTitle' => '년 판매 현황']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">년 판매 현황 (월별)</span>
<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>
<section class="p-2 bg-white border-b border-gray-200">
<form method="GET" action="<?= base_url('admin/reports/yearly-sales') ?>" class="flex flex-wrap items-center gap-2">
<label class="text-sm text-gray-600">연도</label>
<select name="year" class="border border-gray-300 rounded px-2 py-1 text-sm">
<?php for ($y = (int) date('Y'); $y >= 2020; $y--): ?>
<option value="<?= $y ?>" <?= (int)($year ?? date('Y')) === $y ? 'selected' : '' ?>><?= $y ?>년</option>
<?php endfor; ?>
</select>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
</form>
</section>
<div class="border border-gray-300 overflow-auto mt-2">
<table class="w-full data-table">
<thead>
<tr>
<th>봉투코드</th>
<th>봉투명</th>
<th>1월</th><th>2월</th><th>3월</th><th>4월</th><th>5월</th><th>6월</th>
<th>7월</th><th>8월</th><th>9월</th><th>10월</th><th>11월</th><th>12월</th>
<th class="bg-gray-100">합계</th>
</tr>
</thead>
<tbody class="text-right">
<?php
$grandTotal = array_fill(1, 13, 0); // 1~12 + 13=total
foreach ($result as $row):
?>
<tr>
<td class="text-center font-mono"><?= esc($row->bs_bag_code) ?></td>
<td class="text-left pl-2"><?= esc($row->bs_bag_name) ?></td>
<?php for ($m = 1; $m <= 12; $m++):
$key = 'm' . sprintf('%02d', $m);
$val = (int) $row->$key;
$grandTotal[$m] += $val;
?>
<td><?= $val > 0 ? number_format($val) : '-' ?></td>
<?php endfor; ?>
<?php $grandTotal[13] += (int) $row->total; ?>
<td class="font-bold bg-gray-50"><?= number_format((int) $row->total) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($result)): ?>
<tr><td colspan="15" class="text-center text-gray-400 py-4">조회된 데이터가 없습니다.</td></tr>
<?php else: ?>
<tr class="font-bold bg-gray-100">
<td colspan="2" class="text-center">합계</td>
<?php for ($m = 1; $m <= 12; $m++): ?>
<td><?= $grandTotal[$m] > 0 ? number_format($grandTotal[$m]) : '-' ?></td>
<?php endfor; ?>
<td class="bg-gray-200"><?= number_format($grandTotal[13]) ?></td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>

View File

@@ -1,8 +1,12 @@
<?= view('components/print_header', ['printTitle' => '주문 접수 관리']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">주문 접수 관리</span>
<div class="flex items-center gap-2">
<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>
<a href="<?= base_url('admin/shop-orders/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>
</div>
</div>
</section>
<section class="p-2 bg-white border-b border-gray-200">
<form method="GET" action="<?= base_url('admin/shop-orders') ?>" class="flex flex-wrap items-center gap-2">
@@ -73,3 +77,4 @@
</tbody>
</table>
</div>
<?php if (isset($pager)): ?><div class="mt-3"><?= $pager->links() ?></div><?php endif; ?>

View File

@@ -1,8 +1,12 @@
<?= view('components/print_header', ['printTitle' => '회원 목록']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">회원 목록</span>
<div class="flex items-center gap-2">
<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>
<a href="<?= base_url('admin/users/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>
</div>
</div>
</section>
<div class="border border-gray-300 overflow-auto mt-2">
<table class="w-full data-table">
@@ -53,3 +57,4 @@
</tbody>
</table>
</div>
<?php if (isset($pager)): ?><div class="mt-3"><?= $pager->links() ?></div><?php endif; ?>

View File

@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>2 인증 - 쓰레기봉투 물류시스템</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&amp;display=swap" rel="stylesheet"/>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: { sans: ['"Malgun Gothic"', '"Noto Sans KR"', 'sans-serif'] },
colors: {
'system-header': '#ffffff',
'title-bar': '#2c3e50',
'control-panel': '#f8f9fa',
'btn-search': '#1c4e80',
}
}
}
}
</script>
<style>body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }</style>
</head>
<body class="bg-gray-100 text-gray-800 flex flex-col min-h-screen font-sans antialiased">
<header class="bg-white border-b border-gray-300 h-12 flex items-center justify-between px-4 shrink-0">
<div class="flex items-center gap-2">
<a href="<?= base_url() ?>" class="text-base font-semibold text-gray-800 tracking-tight hover:text-blue-600">쓰레기봉투 물류시스템</a>
</div>
</header>
<div class="bg-title-bar text-white px-4 py-2 text-sm font-medium shrink-0">
2 인증 (TOTP)
</div>
<?php if (session()->getFlashdata('error')): ?>
<div class="mx-4 mt-2 p-3 rounded-lg bg-red-50 text-red-700 text-sm" role="alert"><?= esc(session()->getFlashdata('error')) ?></div>
<?php endif; ?>
<?php if (session()->getFlashdata('errors')): ?>
<div class="mx-4 mt-2 p-3 rounded-lg bg-red-50 text-red-700 text-sm space-y-1" role="alert">
<?php foreach (session()->getFlashdata('errors') as $error): ?><p><?= esc($error) ?></p><?php endforeach; ?>
</div>
<?php endif; ?>
<main class="flex-grow bg-control-panel border-b border-gray-300 p-6 flex items-center justify-center">
<section class="w-full max-w-md bg-white border border-gray-300 rounded shadow-sm p-6 space-y-4">
<p class="text-sm text-gray-600">계정 <strong class="text-gray-800"><?= esc($memberId) ?></strong> 에 대해 인증 앱의 6자리 코드를 입력해 주세요.</p>
<form action="<?= base_url('login/two-factor') ?>" method="POST" class="space-y-4">
<?= csrf_field() ?>
<div>
<label class="block text-sm font-bold text-gray-700 mb-1" for="totp_code">인증 코드</label>
<input class="block w-full border border-gray-300 rounded px-3 py-2 text-sm tracking-widest focus:ring-blue-500 focus:border-blue-500" id="totp_code" name="totp_code" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="6" autocomplete="one-time-code" autofocus placeholder="000000" value="<?= esc(old('totp_code')) ?>"/>
</div>
<div class="flex gap-2 pt-2">
<button type="submit" class="bg-btn-search text-white px-4 py-2 rounded-sm text-sm font-medium shadow hover:opacity-90 transition border border-transparent">확인</button>
<a href="<?= base_url('login') ?>" class="bg-white text-gray-700 border border-gray-300 px-4 py-2 rounded-sm text-sm shadow hover:bg-gray-50 transition">처음으로</a>
</div>
</form>
</section>
</main>
<footer class="bg-gray-200 border-t border-gray-300 px-4 py-1 text-xs text-gray-600 shrink-0">쓰레기봉투 물류시스템</footer>
</body>
</html>

View File

@@ -89,7 +89,7 @@ tailwind.config = {
<label class="block text-sm font-bold text-gray-700 mb-1" for="mb_level">사용자 역할 <span class="text-red-500">*</span></label>
<select class="block w-full border border-gray-300 rounded px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500" id="mb_level" name="mb_level">
<?php foreach (config('Roles')->levelNames as $level => $name): ?>
<?php if ((int) $level === \Config\Roles::LEVEL_SUPER_ADMIN) continue; ?>
<?php if (\Config\Roles::isSuperAdminEquivalent((int) $level)) continue; ?>
<option value="<?= $level ?>" <?= old('mb_level', config('Roles')->defaultLevelForSelfRegister) == $level ? 'selected' : '' ?>><?= esc($name) ?></option>
<?php endforeach; ?>
</select>

View File

@@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>2 인증 등록 - 쓰레기봉투 물류시스템</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&amp;display=swap" rel="stylesheet"/>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: { sans: ['"Malgun Gothic"', '"Noto Sans KR"', 'sans-serif'] },
colors: {
'title-bar': '#2c3e50',
'control-panel': '#f8f9fa',
'btn-search': '#1c4e80',
}
}
}
}
</script>
<style>body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }</style>
</head>
<body class="bg-gray-100 text-gray-800 flex flex-col min-h-screen font-sans antialiased">
<header class="bg-white border-b border-gray-300 h-12 flex items-center px-4 shrink-0">
<a href="<?= base_url() ?>" class="text-base font-semibold text-gray-800 tracking-tight hover:text-blue-600">쓰레기봉투 물류시스템</a>
</header>
<div class="bg-title-bar text-white px-4 py-2 text-sm font-medium shrink-0">
2 인증 등록
</div>
<?php if (session()->getFlashdata('error')): ?>
<div class="mx-4 mt-2 p-3 rounded-lg bg-red-50 text-red-700 text-sm" role="alert"><?= esc(session()->getFlashdata('error')) ?></div>
<?php endif; ?>
<?php if (session()->getFlashdata('errors')): ?>
<div class="mx-4 mt-2 p-3 rounded-lg bg-red-50 text-red-700 text-sm space-y-1" role="alert">
<?php foreach (session()->getFlashdata('errors') as $error): ?><p><?= esc($error) ?></p><?php endforeach; ?>
</div>
<?php endif; ?>
<main class="flex-grow bg-control-panel border-b border-gray-300 p-6 flex items-center justify-center">
<section class="w-full max-w-lg bg-white border border-gray-300 rounded shadow-sm p-6 space-y-4">
<p class="text-sm text-gray-600">관리자 계정 <strong class="text-gray-800"><?= esc($memberId) ?></strong> 에 Google Authenticator, Microsoft Authenticator 등으로 아래 시크릿 또는 QR을 등록한 뒤, 표시되는 6자리 코드를 입력해 주세요.</p>
<?php if (! empty($qrDataUri)): ?>
<div class="flex justify-center">
<img src="<?= esc($qrDataUri, 'attr') ?>" alt="TOTP QR 코드" class="border border-gray-200 rounded max-w-[200px] h-auto"/>
</div>
<?php else: ?>
<p class="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded p-2">QR 이미지를 불러올 수 없습니다. 아래 시크릿을 앱에 직접 입력해 주세요.</p>
<?php endif; ?>
<div>
<span class="block text-xs font-semibold text-gray-500 mb-1">수동 입력용 시크릿</span>
<code class="block text-sm bg-gray-100 border border-gray-200 rounded px-3 py-2 break-all select-all"><?= esc($secret) ?></code>
</div>
<form action="<?= base_url('login/totp-setup') ?>" method="POST" class="space-y-4 pt-2 border-t border-gray-200">
<?= csrf_field() ?>
<div>
<label class="block text-sm font-bold text-gray-700 mb-1" for="totp_code">확인용 인증 코드</label>
<input class="block w-full border border-gray-300 rounded px-3 py-2 text-sm tracking-widest focus:ring-blue-500 focus:border-blue-500" id="totp_code" name="totp_code" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="6" autocomplete="one-time-code" autofocus placeholder="000000" value="<?= esc(old('totp_code')) ?>"/>
</div>
<div class="flex gap-2">
<button type="submit" class="bg-btn-search text-white px-4 py-2 rounded-sm text-sm font-medium shadow hover:opacity-90 transition border border-transparent">등록 완료</button>
<a href="<?= base_url('login') ?>" class="bg-white text-gray-700 border border-gray-300 px-4 py-2 rounded-sm text-sm shadow hover:bg-gray-50 transition">취소</a>
</div>
</form>
</section>
</main>
<footer class="bg-gray-200 border-t border-gray-300 px-4 py-1 text-xs text-gray-600 shrink-0">쓰레기봉투 물류시스템</footer>
</body>
</html>

View File

@@ -0,0 +1,70 @@
<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">
<form action="<?= base_url('bag/issue/store') ?>" method="POST" class="space-y-4">
<?= csrf_field() ?>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">연도 <span class="text-red-500">*</span></label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-32 text-right" name="bi2_year" type="number" min="2000" max="2099" value="<?= esc(old('bi2_year', date('Y'))) ?>" required/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">분기 <span class="text-red-500">*</span></label>
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-32" name="bi2_quarter" required>
<option value="">선택</option>
<option value="1" <?= old('bi2_quarter') === '1' ? 'selected' : '' ?>>1</option>
<option value="2" <?= old('bi2_quarter') === '2' ? 'selected' : '' ?>>2</option>
<option value="3" <?= old('bi2_quarter') === '3' ? 'selected' : '' ?>>3</option>
<option value="4" <?= old('bi2_quarter') === '4' ? 'selected' : '' ?>>4</option>
</select>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">구분 <span class="text-red-500">*</span></label>
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-44" name="bi2_issue_type" required>
<option value="">선택</option>
<option value="무료용" <?= old('bi2_issue_type') === '무료용' ? 'selected' : '' ?>>무료용</option>
<option value="공공용" <?= old('bi2_issue_type') === '공공용' ? 'selected' : '' ?>>공공용</option>
</select>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">불출일 <span class="text-red-500">*</span></label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-44" name="bi2_issue_date" type="date" value="<?= esc(old('bi2_issue_date', date('Y-m-d'))) ?>" required/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">불출처 유형</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-44" name="bi2_dest_type" type="text" placeholder="동사무소" value="<?= esc(old('bi2_dest_type')) ?>"/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">불출처명 <span class="text-red-500">*</span></label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="bi2_dest_name" type="text" value="<?= esc(old('bi2_dest_name')) ?>" required/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">봉투코드 <span class="text-red-500">*</span></label>
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="bi2_bag_code" required>
<option value="">선택</option>
<?php foreach ($bagCodes as $cd): ?>
<option value="<?= esc($cd->cd_code) ?>" <?= old('bi2_bag_code') === $cd->cd_code ? 'selected' : '' ?>>
<?= esc($cd->cd_code) ?> — <?= esc($cd->cd_name) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">수량 <span class="text-red-500">*</span></label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-32 text-right" name="bi2_qty" type="number" min="0" value="<?= esc(old('bi2_qty', '0')) ?>" required/>
</div>
<div class="flex gap-2 pt-2">
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">등록</button>
<a href="<?= base_url('bag/issue') ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm hover:bg-gray-300 transition">취소</a>
</div>
</form>
</div>

View File

@@ -0,0 +1,83 @@
<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-4xl">
<form action="<?= base_url('bag/order/store') ?>" method="POST" class="space-y-4">
<?= csrf_field() ?>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">발주일 <span class="text-red-500">*</span></label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-44" name="bo_order_date" type="date" value="<?= esc(old('bo_order_date', date('Y-m-d'))) ?>" required/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">수수료율</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-32 text-right" name="bo_fee_rate" type="number" step="0.01" value="<?= esc(old('bo_fee_rate', '0')) ?>"/>
<span class="text-sm text-gray-500">%</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">제작업체</label>
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="bo_company_idx">
<option value="">선택</option>
<?php foreach ($companies as $cp): ?>
<option value="<?= esc($cp->cp_idx) ?>" <?= (int) old('bo_company_idx') === (int) $cp->cp_idx ? 'selected' : '' ?>>
<?= esc($cp->cp_name) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">입고처</label>
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="bo_agency_idx">
<option value="">선택</option>
<?php foreach ($agencies as $ag): ?>
<option value="<?= esc($ag->sa_idx) ?>" <?= (int) old('bo_agency_idx') === (int) $ag->sa_idx ? 'selected' : '' ?>>
<?= esc($ag->sa_name) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="mt-4">
<label class="block text-sm font-bold text-gray-700 mb-2">발주 품목</label>
<div class="border border-gray-300 overflow-auto">
<table class="w-full data-table">
<thead>
<tr>
<th class="w-16">순번</th>
<th>봉투</th>
<th class="w-32">박스수</th>
</tr>
</thead>
<tbody>
<?php for ($i = 0; $i < 3; $i++): ?>
<tr>
<td class="text-center"><?= $i + 1 ?></td>
<td>
<select class="border border-gray-300 rounded px-2 py-1 text-sm w-full" name="item_bag_code[]">
<option value="">선택</option>
<?php foreach ($bagCodes as $cd): ?>
<option value="<?= esc($cd->cd_code) ?>">
<?= esc($cd->cd_code) ?> — <?= esc($cd->cd_name) ?>
</option>
<?php endforeach; ?>
</select>
</td>
<td>
<input class="border border-gray-300 rounded px-2 py-1 text-sm w-full text-right" name="item_qty_box[]" type="number" min="0" value="0"/>
</td>
</tr>
<?php endfor; ?>
</tbody>
</table>
</div>
</div>
<div class="flex gap-2 pt-2">
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">등록</button>
<a href="<?= base_url('bag/purchase-inbound') ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm hover:bg-gray-300 transition">취소</a>
</div>
</form>
</div>

View File

@@ -0,0 +1,53 @@
<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">
<form action="<?= base_url('bag/receiving/store') ?>" method="POST" class="space-y-4">
<?= csrf_field() ?>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">발주건</label>
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-72" name="br_bo_idx">
<option value="">선택</option>
<?php foreach ($orders as $od): ?>
<option value="<?= esc($od->bo_idx) ?>" <?= (int) old('br_bo_idx') === (int) $od->bo_idx ? 'selected' : '' ?>>
<?= esc($od->bo_lot_no) ?> (<?= esc($od->bo_order_date) ?>)
</option>
<?php endforeach; ?>
</select>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">봉투코드</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-44" name="br_bag_code" type="text" value="<?= esc(old('br_bag_code')) ?>"/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">박스수 <span class="text-red-500">*</span></label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-32 text-right" name="br_qty_box" type="number" min="0" value="<?= esc(old('br_qty_box', '0')) ?>" required/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">입고일 <span class="text-red-500">*</span></label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-44" name="br_receive_date" type="date" value="<?= esc(old('br_receive_date', date('Y-m-d'))) ?>" required/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">보내는분</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-44" name="br_sender_name" type="text" value="<?= esc(old('br_sender_name')) ?>"/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">구분</label>
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-44" name="br_type">
<option value="batch" <?= old('br_type') === 'batch' ? 'selected' : '' ?>>batch</option>
<option value="scanner" <?= old('br_type') === 'scanner' ? 'selected' : '' ?>>scanner</option>
</select>
</div>
<div class="flex gap-2 pt-2">
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">등록</button>
<a href="<?= base_url('bag/purchase-inbound') ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm hover:bg-gray-300 transition">취소</a>
</div>
</form>
</div>

View File

@@ -0,0 +1,56 @@
<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">
<form action="<?= base_url('bag/sale/store') ?>" method="POST" class="space-y-4">
<?= csrf_field() ?>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">판매소 <span class="text-red-500">*</span></label>
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="bs_ds_idx" required>
<option value="">선택</option>
<?php foreach ($shops as $shop): ?>
<option value="<?= esc($shop->ds_idx) ?>" <?= (int) old('bs_ds_idx') === (int) $shop->ds_idx ? 'selected' : '' ?>>
<?= esc($shop->ds_name) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">봉투코드 <span class="text-red-500">*</span></label>
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="bs_bag_code" required>
<option value="">선택</option>
<?php foreach ($bagCodes as $cd): ?>
<option value="<?= esc($cd->cd_code) ?>" <?= old('bs_bag_code') === $cd->cd_code ? 'selected' : '' ?>>
<?= esc($cd->cd_code) ?> — <?= esc($cd->cd_name) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">수량 <span class="text-red-500">*</span></label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-32 text-right" name="bs_qty" type="number" min="0" value="<?= esc(old('bs_qty', '0')) ?>" required/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">판매일 <span class="text-red-500">*</span></label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-44" name="bs_sale_date" type="date" value="<?= esc(old('bs_sale_date', date('Y-m-d'))) ?>" required/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">구분 <span class="text-red-500">*</span></label>
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-44" name="bs_type" required>
<option value="">선택</option>
<option value="sale" <?= old('bs_type') === 'sale' ? 'selected' : '' ?>>판매</option>
<option value="return" <?= old('bs_type') === 'return' ? 'selected' : '' ?>>반품</option>
</select>
</div>
<div class="flex gap-2 pt-2">
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">등록</button>
<a href="<?= base_url('bag/sales') ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm hover:bg-gray-300 transition">취소</a>
</div>
</form>
</div>

View File

@@ -0,0 +1,74 @@
<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-4xl">
<form action="<?= base_url('bag/shop-order/store') ?>" method="POST" class="space-y-4">
<?= csrf_field() ?>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">판매소 <span class="text-red-500">*</span></label>
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="so_ds_idx" required>
<option value="">선택</option>
<?php foreach ($shops as $shop): ?>
<option value="<?= esc($shop->ds_idx) ?>" <?= (int) old('so_ds_idx') === (int) $shop->ds_idx ? 'selected' : '' ?>>
<?= esc($shop->ds_name) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">배달일 <span class="text-red-500">*</span></label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-44" name="so_delivery_date" type="date" value="<?= esc(old('so_delivery_date', date('Y-m-d', strtotime('+1 day')))) ?>" required/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">결제방법 <span class="text-red-500">*</span></label>
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-44" name="so_payment_type" required>
<option value="">선택</option>
<option value="이체" <?= old('so_payment_type') === '이체' ? 'selected' : '' ?>>이체</option>
<option value="가상계좌" <?= old('so_payment_type') === '가상계좌' ? 'selected' : '' ?>>가상계좌</option>
</select>
</div>
<div class="mt-4">
<label class="block text-sm font-bold text-gray-700 mb-2">주문 품목</label>
<div class="border border-gray-300 overflow-auto">
<table class="w-full data-table">
<thead>
<tr>
<th class="w-16">순번</th>
<th>봉투</th>
<th class="w-32">수량</th>
</tr>
</thead>
<tbody>
<?php for ($i = 0; $i < 3; $i++): ?>
<tr>
<td class="text-center"><?= $i + 1 ?></td>
<td>
<select class="border border-gray-300 rounded px-2 py-1 text-sm w-full" name="item_bag_code[]">
<option value="">선택</option>
<?php foreach ($bagCodes as $cd): ?>
<option value="<?= esc($cd->cd_code) ?>">
<?= esc($cd->cd_code) ?> — <?= esc($cd->cd_name) ?>
</option>
<?php endforeach; ?>
</select>
</td>
<td>
<input class="border border-gray-300 rounded px-2 py-1 text-sm w-full text-right" name="item_qty[]" type="number" min="0" value="0"/>
</td>
</tr>
<?php endfor; ?>
</tbody>
</table>
</div>
</div>
<div class="flex gap-2 pt-2">
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">등록</button>
<a href="<?= base_url('bag/sales') ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm hover:bg-gray-300 transition">취소</a>
</div>
</form>
</div>

View File

@@ -147,7 +147,7 @@ $siteNavTree = get_site_nav_tree();
</nav>
<?php
$mbLevel = (int) session()->get('mb_level');
$isAdmin = ($mbLevel === \Config\Roles::LEVEL_SUPER_ADMIN || $mbLevel === \Config\Roles::LEVEL_LOCAL_ADMIN);
$isAdmin = (\Config\Roles::isSuperAdminEquivalent($mbLevel) || $mbLevel === \Config\Roles::LEVEL_LOCAL_ADMIN);
?>
<!-- 관리자 이동 버튼(관리자만) · 종료 -->
<div class="flex items-center gap-2">

View File

@@ -7,6 +7,11 @@
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm">조회</button>
<a href="<?= base_url('bag/flow') ?>" class="text-sm text-gray-500 hover:text-gray-700">초기화</a>
</form>
<div class="flex gap-2 mb-2">
<a href="<?= base_url('bag/receiving/create') ?>" class="bg-btn-search text-white px-3 py-1.5 rounded-sm text-sm">입고 처리</a>
<a href="<?= base_url('bag/sale/create') ?>" class="bg-white text-blue-600 border border-blue-300 px-3 py-1.5 rounded-sm text-sm">판매 등록</a>
<a href="<?= base_url('bag/issue/create') ?>" class="bg-white text-blue-600 border border-blue-300 px-3 py-1.5 rounded-sm text-sm">불출 처리</a>
</div>
<!-- 수불 요약 -->
<table class="data-table">

View File

@@ -1,4 +1,10 @@
<table class="data-table">
<div class="space-y-1">
<div class="flex items-center justify-between mb-2">
<span></span>
<a href="<?= base_url('bag/inventory/adjust') ?>" class="bg-btn-search text-white px-3 py-1.5 rounded-sm text-sm">재고 조정</a>
</div>
<table class="data-table">
<thead><tr>
<th class="w-16">번호</th><th>봉투코드</th><th>봉투명</th><th>현재재고(낱장)</th><th>최종갱신</th>
</tr></thead>
@@ -9,12 +15,13 @@
<td class="text-center"><?= $i + 1 ?></td>
<td class="text-center"><?= esc($row->bi_bag_code ?? '') ?></td>
<td><?= esc($row->bi_bag_name ?? '') ?></td>
<td class="text-right"><?= number_format((int)($row->bi_qty_sheet ?? 0)) ?></td>
<td class="text-center"><?= esc($row->bi_updated_at ?? $row->updated_at ?? '') ?></td>
<td class="text-right"><?= number_format((int)($row->bi_qty ?? 0)) ?></td>
<td class="text-center"><?= esc($row->bi_updated_at ?? '') ?></td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr><td colspan="5" class="text-center text-gray-400 py-4">재고 데이터가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</table>
</div>

View File

@@ -0,0 +1,43 @@
<div class="max-w-lg">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-bold text-gray-700">재고 수량 조정 (실사)</h3>
</div>
<form action="<?= base_url('bag/inventory/adjust') ?>" method="POST" class="space-y-4">
<?= csrf_field() ?>
<div>
<label class="block text-sm font-bold text-gray-700 mb-1">봉투코드 <span class="text-red-500">*</span></label>
<select name="bag_code" required class="w-full border border-gray-300 rounded px-3 py-2 text-sm">
<option value="">선택</option>
<?php foreach ($inventory as $item): ?>
<option value="<?= esc($item->bi_bag_code) ?>"><?= esc($item->bi_bag_code) ?> — <?= esc($item->bi_bag_name) ?> (현재: <?= number_format((int)$item->bi_qty) ?>)</option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="block text-sm font-bold text-gray-700 mb-1">조정 유형 <span class="text-red-500">*</span></label>
<select name="adjust_type" required class="w-full border border-gray-300 rounded px-3 py-2 text-sm">
<option value="set">실사 수량으로 설정</option>
<option value="add">증가 (+)</option>
<option value="sub">감소 (-)</option>
</select>
</div>
<div>
<label class="block text-sm font-bold text-gray-700 mb-1">수량 <span class="text-red-500">*</span></label>
<input type="number" name="qty" required min="0" value="0" class="w-full border border-gray-300 rounded px-3 py-2 text-sm"/>
</div>
<div>
<label class="block text-sm font-bold text-gray-700 mb-1">사유</label>
<input type="text" name="reason" placeholder="실사 조정, 오류 수정 등" class="w-full border border-gray-300 rounded px-3 py-2 text-sm"/>
</div>
<div class="flex gap-2 pt-2">
<button type="submit" class="bg-btn-search text-white px-6 py-2 rounded-sm text-sm">조정</button>
<a href="<?= base_url('bag/inventory') ?>" class="bg-gray-200 text-gray-700 px-6 py-2 rounded-sm text-sm">취소</a>
</div>
</form>
</div>

View File

@@ -8,7 +8,7 @@
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm">조회</button>
<a href="<?= base_url('bag/issue') ?>" class="text-sm text-gray-500 hover:text-gray-700">초기화</a>
</form>
<a href="<?= base_url('admin/bag-issues/create') ?>" class="bg-btn-search text-white px-3 py-1.5 rounded-sm text-sm">불출 처리</a>
<a href="<?= base_url('bag/issue/create') ?>" class="bg-btn-search text-white px-3 py-1.5 rounded-sm text-sm">불출 처리</a>
</div>
<table class="data-table">
@@ -36,7 +36,7 @@
</td>
<td class="text-center">
<?php if (($row->bi2_status ?? '') === 'normal'): ?>
<form method="post" action="<?= base_url('admin/bag-issues/cancel/' . $row->bi2_idx) ?>" class="inline" onsubmit="return confirm('취소하시겠습니까?')">
<form method="post" action="<?= base_url('bag/issue/cancel/' . $row->bi2_idx) ?>" class="inline" onsubmit="return confirm('취소하시겠습니까?')">
<?= csrf_field() ?>
<button class="text-orange-600 hover:underline text-xs">취소</button>
</form>

View File

@@ -52,6 +52,14 @@ tailwind.config = {
.data-table tbody tr:hover td { background-color: #e6f7ff !important; }
.main-content-area { height: calc(100vh - 130px); overflow: auto; }
body { overflow: hidden; }
@media print {
header, footer, .no-print, nav { display: none !important; }
.main-content-area { height: auto !important; overflow: visible !important; }
body { overflow: visible !important; }
.bg-title-bar { display: none !important; }
.bg-control-panel { break-inside: avoid; }
.print-header { display: block !important; }
}
</style>
</head>
<body class="bg-gray-100 text-gray-800 flex flex-col h-screen font-sans antialiased select-none">

View File

@@ -70,6 +70,8 @@ $mbName = session()->get('mb_name') ?? '담당자';
<span class="text-gray-300 hidden sm:inline">|</span>
<a href="<?= base_url('dashboard/dense') ?>" class="text-xs text-[#2b4c8c] hover:underline whitespace-nowrap hidden sm:inline" title="정보 집약 종합">종합</a>
<span class="text-gray-300 hidden sm:inline">|</span>
<a href="<?= base_url('dashboard/blend') ?>" class="text-xs text-[#2b4c8c] hover:underline whitespace-nowrap hidden sm:inline" title="표+차트 혼합">혼합</a>
<span class="text-gray-300 hidden sm:inline">|</span>
<a href="<?= base_url('dashboard/charts') ?>" class="text-xs text-[#2b4c8c] hover:underline whitespace-nowrap hidden sm:inline" title="그래프 대시보드">차트</a>
<a href="<?= base_url('logout') ?>" class="text-gray-500 hover:text-gray-800 p-1" title="로그아웃">
<i class="fa-solid fa-arrow-right-from-bracket text-lg"></i>
@@ -259,11 +261,6 @@ $mbName = session()->get('mb_name') ?? '담당자';
</div>
</div>
<p class="text-[11px] text-gray-400 border-t border-gray-200 pt-3">
차장님 요청 반영: <strong>봉투별 재고</strong>·<strong>구매신청 리스트</strong>·그래프 /
추가 시안: <strong>발주·입고</strong>, <strong>승인 대기</strong>, <strong>수불 추이</strong>.
레이아웃은 <strong>수불 엔터프라이즈 화면</strong>과 동일한 상단 메뉴·제목바 스타일입니다.
</p>
</div>
</main>

View File

@@ -0,0 +1,700 @@
<?php
/**
* dense(정보 집약 표·KPI) + charts(Chart.js) 혼합 대시보드
*
* @var string $lgLabel
*/
$lgLabel = $lgLabel ?? '북구';
$mbName = session()->get('mb_name') ?? '담당자';
$dashClassic = base_url('dashboard/classic-mock');
$dashModern = base_url('dashboard/modern');
$dashDense = base_url('dashboard/dense');
$dashCharts = base_url('dashboard/charts');
$dashBlend = base_url('dashboard/blend');
$kpiTop = [
['icon' => 'fa-triangle-exclamation', 'c' => 'text-amber-700', 'bg' => 'bg-amber-50', 'v' => '3', 'l' => '재고부족', 'sub' => '품목'],
['icon' => 'fa-cart-shopping', 'c' => 'text-sky-700', 'bg' => 'bg-sky-50', 'v' => '12', 'l' => '구매신청', 'sub' => '미처리'],
['icon' => 'fa-truck', 'c' => 'text-emerald-700', 'bg' => 'bg-emerald-50', 'v' => '8', 'l' => '발주·입고', 'sub' => '금주'],
['icon' => 'fa-user-clock', 'c' => 'text-violet-700', 'bg' => 'bg-violet-50', 'v' => '4', 'l' => '회원승인', 'sub' => '대기'],
['icon' => 'fa-store', 'c' => 'text-rose-700', 'bg' => 'bg-rose-50', 'v' => '127', 'l' => '지정판매소', 'sub' => '등록'],
['icon' => 'fa-boxes-stacked', 'c' => 'text-slate-700', 'bg' => 'bg-slate-100', 'v' => '48.2k', 'l' => '봉투재고', 'sub' => '장 합계'],
['icon' => 'fa-file-invoice', 'c' => 'text-orange-700', 'bg' => 'bg-orange-50', 'v' => '6', 'l' => '세금계산서', 'sub' => '발행대기'],
['icon' => 'fa-headset', 'c' => 'text-cyan-700', 'bg' => 'bg-cyan-50', 'v' => '2', 'l' => '민원·문의', 'sub' => '오늘'],
];
$stockRows = [
['일반 5L', '12,400', '안전', '3.2주'],
['일반 10L', '8,200', '주의', '1.8주'],
['일반 20L', '2,100', '부족', '0.6주'],
['음식물 스티커', '15,000', '안전', '5.1주'],
['재사용봉투', '4,300', '안전', '2.4주'],
['특수규격 A', '890', '부족', '0.3주'],
];
$orderRows = [
['PO-2025-0218', '○○상사', '일반 5L×2박스', '발주확인', '02-26 10:20'],
['PO-2025-0217', '△△유통', '스티커 500매', '납품중', '02-26 09:05'],
['PO-2025-0216', '□□종량제', '20L 혼합', '입고완료', '02-25 16:40'],
['REQ-8841', '행복마트 북구점', '5L 2,000장', '접수', '02-26 09:12'],
['REQ-8839', '○○슈퍼', '스티커 500', '처리중', '02-26 08:45'],
];
$logRows = [
['10:42', 'system', '일일 재고 스냅샷 생성 완료'],
['10:18', 'user', esc($mbName) . ' 로그인 (IP 마스킹)'],
['09:55', 'batch', '구매신청 자동 분배 3건'],
['09:30', 'admin', '판매소 코드 2건 갱신'],
['08:12', 'api', '세금계산서 연동 응답 정상'],
];
$storeSummary = [
['행복마트 북구점', '42', '정상', '02-26'],
['○○슈퍼', '38', '정상', '02-25'],
['△△상회', '15', '연체1건', '02-20'],
['□□마트', '29', '정상', '02-26'],
['◇◇할인점', '51', '정상', '02-26'],
];
$approvals = [
['김○○', '판매소', '02-26', '서류검토'],
['이○○', '일반', '02-25', '본인확인'],
['박○○', '판매소', '02-25', '주소불일치'],
];
$notices = [
'2월 말 정기 재고 실사 안내 — 2/28 17:00 마감',
'봉투 단가 조정 예고 — 3/1 적용 예정 (안내문 배포 완료)',
];
?>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>종량제 시스템 — 종합·그래프 혼합</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"/>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<style>
body {
font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', 'Noto Sans KR', sans-serif;
font-size: 12px;
color: #333;
}
.nav-top a.nav-active {
color: #2b4c8c;
font-weight: 600;
border-bottom: 2px solid #2b4c8c;
padding-bottom: 2px;
margin-bottom: -2px;
}
.dense-table th, .dense-table td { padding: 0.25rem 0.4rem; line-height: 1.25; }
.dense-table thead th { font-size: 11px; font-weight: 600; color: #555; background: #f3f4f6; border-bottom: 1px solid #d1d5db; }
.dense-table tbody td { border-bottom: 1px solid #eee; font-size: 11px; }
.spark { display: flex; align-items: flex-end; gap: 2px; height: 36px; }
.spark span { flex: 1; background: linear-gradient(180deg, #3b82f6, #93c5fd); border-radius: 1px; min-width: 4px; }
.chart-card {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 0.25rem;
box-shadow: 0 1px 2px rgba(0,0,0,.04);
}
.chart-card h2 {
font-size: 11px;
font-weight: 700;
color: #1f2937;
padding: 0.4rem 0.5rem;
border-bottom: 1px solid #f3f4f6;
background: #fafafa;
}
.chart-wrap { position: relative; height: 180px; padding: 0.4rem 0.5rem 0.5rem; }
.chart-wrap.tall { height: 260px; }
.chart-wrap.wide { height: 220px; }
</style>
</head>
<body class="bg-[#f0f2f5] flex flex-col min-h-screen">
<header class="border-b border-gray-300 bg-white shadow-sm shrink-0" data-purpose="top-navigation">
<div class="flex items-center justify-between px-3 py-1.5 gap-3 flex-wrap">
<div class="flex items-center gap-2 shrink-0">
<div class="flex items-center gap-2 text-green-700 font-bold text-base">
<i class="fa-solid fa-recycle text-lg"></i>
<span>종량제 시스템</span>
</div>
<span class="hidden sm:inline text-[11px] text-gray-500 border-l border-gray-300 pl-2">
<?= esc($lgLabel) ?> · <strong class="text-gray-700"><?= esc($mbName) ?></strong>님
</span>
</div>
<nav class="nav-top hidden lg:flex flex-wrap items-center gap-3 xl:gap-4 text-[13px] font-medium text-gray-700">
<a class="nav-active flex items-center gap-1 whitespace-nowrap" href="<?= esc($dashBlend) ?>">
<i class="fa-solid fa-gauge-high"></i> 업무 현황
</a>
<a class="flex items-center gap-1 hover:text-blue-600 whitespace-nowrap" href="#"><i class="fa-regular fa-file-lines"></i> 문서 관리</a>
<a class="flex items-center gap-1 hover:text-blue-600 whitespace-nowrap" href="#"><i class="fa-solid fa-box-open"></i> 규격</a>
<a class="flex items-center gap-1 hover:text-blue-600 whitespace-nowrap" href="#"><i class="fa-solid fa-bag-shopping"></i> 봉투 양식</a>
<a class="flex items-center gap-1 hover:text-blue-600 whitespace-nowrap" href="#"><i class="fa-solid fa-table"></i> 데이터 양식</a>
<a class="flex items-center gap-1 hover:text-blue-600 whitespace-nowrap" href="#"><i class="fa-solid fa-clock-rotate-left"></i> 사용 내역</a>
<a class="flex items-center gap-1 hover:text-blue-600 whitespace-nowrap" href="<?= base_url('bag/inventory-inquiry') ?>"><i class="fa-solid fa-boxes-stacked"></i> 재고 현황</a>
<a class="flex items-center gap-1 hover:text-blue-600 whitespace-nowrap" href="<?= base_url('bag/waste-suibal-enterprise') ?>"><i class="fa-solid fa-table-list"></i> 수불 현황</a>
<a class="flex items-center gap-1 hover:text-blue-600 whitespace-nowrap" href="#"><i class="fa-solid fa-chart-line"></i> 통계 분석</a>
<a class="flex items-center gap-1 hover:text-blue-600 whitespace-nowrap" href="#"><i class="fa-solid fa-gear"></i> 설정</a>
</nav>
<div class="flex items-center gap-1.5 shrink-0 text-[11px]">
<a href="<?= esc($dashClassic) ?>" class="text-[#2b4c8c] hover:underline whitespace-nowrap hidden md:inline">클래식</a>
<span class="text-gray-300 hidden md:inline">|</span>
<a href="<?= esc($dashModern) ?>" class="text-[#2b4c8c] hover:underline whitespace-nowrap hidden md:inline">모던</a>
<span class="text-gray-300 hidden md:inline">|</span>
<a href="<?= esc($dashDense) ?>" class="text-[#2b4c8c] hover:underline whitespace-nowrap hidden md:inline" title="정보 집약">종합</a>
<span class="text-gray-300 hidden md:inline">|</span>
<a href="<?= esc($dashCharts) ?>" class="text-[#2b4c8c] hover:underline whitespace-nowrap hidden md:inline" title="그래프만">차트</a>
<a href="<?= base_url('logout') ?>" class="text-gray-500 hover:text-gray-800 p-1 ml-1" title="로그아웃">
<i class="fa-solid fa-arrow-right-from-bracket"></i>
</a>
</div>
</div>
</header>
<div class="bg-gradient-to-r from-[#eff5fb] to-[#e8eef8] border-b border-gray-300 px-3 py-1 flex flex-wrap items-center justify-between gap-2 text-[11px] shrink-0">
<span class="font-semibold text-gray-800">
<i class="fa-solid fa-layer-group text-[#2b4c8c] mr-1"></i>종합·그래프 혼합 현황
<span class="font-normal text-gray-500 ml-1">· dense 표/KPI + Chart.js</span>
</span>
<div class="flex flex-wrap items-center gap-2 text-gray-600">
<span><i class="fa-regular fa-calendar mr-0.5"></i><?= date('Y-m-d (D) H:i') ?></span>
<span class="text-gray-300">|</span>
<span>기준지자체 <strong class="text-gray-800"><?= esc($lgLabel) ?></strong></span>
<button type="button" class="bg-[#2b4c8c] text-white px-2 py-0.5 rounded text-[11px]"><i class="fa-solid fa-rotate mr-0.5"></i>새로고침</button>
</div>
</div>
<main class="flex-1 overflow-y-auto p-2 sm:p-3">
<?php if (session()->getFlashdata('success')): ?>
<div class="mb-2 p-2 rounded border border-green-200 bg-green-50 text-green-800 text-[11px]"><?= esc(session()->getFlashdata('success')) ?></div>
<?php endif; ?>
<div class="mb-2 flex flex-wrap gap-2">
<?php foreach ($notices as $n): ?>
<div class="flex-1 min-w-[200px] flex items-center gap-2 bg-amber-50 border border-amber-200 text-amber-900 px-2 py-1 rounded text-[11px]">
<i class="fa-solid fa-bullhorn shrink-0"></i>
<span class="truncate" title="<?= esc($n) ?>"><?= esc($n) ?></span>
</div>
<?php endforeach; ?>
</div>
<div class="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-8 gap-1.5 mb-2">
<?php foreach ($kpiTop as $k): ?>
<div class="bg-white border border-gray-200 rounded px-2 py-1.5 flex items-center gap-2 shadow-sm">
<div class="w-8 h-8 rounded <?= $k['bg'] ?> <?= $k['c'] ?> flex items-center justify-center shrink-0 text-sm">
<i class="fa-solid <?= esc($k['icon'], 'attr') ?>"></i>
</div>
<div class="min-w-0">
<div class="text-base font-bold text-gray-900 leading-tight"><?= esc($k['v']) ?></div>
<div class="text-[10px] text-gray-500 leading-tight"><?= esc($k['l']) ?></div>
<div class="text-[9px] text-gray-400"><?= esc($k['sub']) ?></div>
</div>
</div>
<?php endforeach; ?>
</div>
<!-- dense: 재고 / 발주·신청 / 로그+스파크 -->
<div class="grid grid-cols-1 xl:grid-cols-12 gap-2 mb-2">
<section class="xl:col-span-4 bg-white border border-gray-200 rounded shadow-sm overflow-hidden">
<div class="px-2 py-1 border-b border-gray-200 bg-gray-50 flex justify-between items-center">
<h2 class="text-[11px] font-bold text-gray-800"><i class="fa-solid fa-warehouse text-[#2b4c8c] mr-1"></i>품목별 재고·소진예상</h2>
<a href="<?= base_url('bag/inventory-inquiry') ?>" class="text-[10px] text-blue-600 hover:underline">상세</a>
</div>
<div class="overflow-x-auto max-h-[200px] overflow-y-auto">
<table class="w-full dense-table text-left">
<thead>
<tr>
<th>품목</th>
<th class="text-right">재고(장)</th>
<th>상태</th>
<th class="text-right">소진</th>
</tr>
</thead>
<tbody>
<?php foreach ($stockRows as $r): ?>
<tr>
<td class="font-medium text-gray-800"><?= esc($r[0]) ?></td>
<td class="text-right tabular-nums"><?= esc($r[1]) ?></td>
<td>
<?php
$badge = match ($r[2]) {
'안전' => 'bg-emerald-100 text-emerald-800',
'주의' => 'bg-amber-100 text-amber-800',
'부족' => 'bg-red-100 text-red-800',
default => 'bg-gray-100 text-gray-700',
};
?>
<span class="text-[10px] px-1 py-0 rounded <?= $badge ?>"><?= esc($r[2]) ?></span>
</td>
<td class="text-right text-gray-600"><?= esc($r[3]) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</section>
<section class="xl:col-span-4 bg-white border border-gray-200 rounded shadow-sm overflow-hidden">
<div class="px-2 py-1 border-b border-gray-200 bg-gray-50 flex justify-between items-center">
<h2 class="text-[11px] font-bold text-gray-800"><i class="fa-solid fa-list-check text-[#2b4c8c] mr-1"></i>발주 / 구매신청 진행</h2>
<span class="text-[10px] text-gray-500">최근 5건</span>
</div>
<div class="overflow-x-auto max-h-[200px] overflow-y-auto">
<table class="w-full dense-table text-left">
<thead>
<tr>
<th>문서</th>
<th>상대</th>
<th>내용</th>
<th>단계</th>
<th class="text-right">시각</th>
</tr>
</thead>
<tbody>
<?php foreach ($orderRows as $r): ?>
<tr>
<td class="text-blue-700 font-mono text-[10px]"><?= esc($r[0]) ?></td>
<td class="truncate max-w-[4.5rem]" title="<?= esc($r[1]) ?>"><?= esc($r[1]) ?></td>
<td class="truncate max-w-[5rem]" title="<?= esc($r[2]) ?>"><?= esc($r[2]) ?></td>
<td><span class="text-[10px] bg-slate-100 px-1 rounded"><?= esc($r[3]) ?></span></td>
<td class="text-right text-gray-500 text-[10px] whitespace-nowrap"><?= esc($r[4]) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</section>
<section class="xl:col-span-4 bg-white border border-gray-200 rounded shadow-sm overflow-hidden flex flex-col">
<div class="px-2 py-1 border-b border-gray-200 bg-gray-50">
<h2 class="text-[11px] font-bold text-gray-800"><i class="fa-solid fa-clock-rotate-left text-[#2b4c8c] mr-1"></i>최근 이벤트 로그</h2>
</div>
<ul class="flex-1 max-h-[120px] overflow-y-auto divide-y divide-gray-100 px-2 py-0.5">
<?php foreach ($logRows as $L): ?>
<li class="py-1 flex gap-2 text-[10px]">
<span class="text-gray-400 font-mono shrink-0 w-8"><?= esc($L[0]) ?></span>
<span class="shrink-0 w-12 text-center rounded bg-gray-100 text-gray-600"><?= esc($L[1]) ?></span>
<span class="text-gray-700"><?= $L[2] ?></span>
</li>
<?php endforeach; ?>
</ul>
<div class="border-t border-gray-100 px-2 py-1.5 bg-gray-50/80">
<div class="text-[10px] font-semibold text-gray-600 mb-1">주간 봉투 출고(천 장, 목업)</div>
<div class="spark" title="주간 추이">
<?php foreach ([40, 55, 48, 62, 58, 71, 65] as $h): ?>
<span style="height: <?= (int) $h ?>%"></span>
<?php endforeach; ?>
</div>
<div class="flex justify-between text-[9px] text-gray-400 mt-0.5">
<span>월</span><span>화</span><span>수</span><span>목</span><span>금</span><span>토</span><span>일</span>
</div>
</div>
</section>
</div>
<!-- charts: 요약 차트 4종 (dense 행 아래) -->
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-2 mb-2">
<section class="chart-card">
<h2><i class="fa-solid fa-chart-pie text-[#2b4c8c] mr-1"></i>규격 출고 비중</h2>
<div class="chart-wrap"><canvas id="chDoughnutSpec"></canvas></div>
</section>
<section class="chart-card">
<h2><i class="fa-solid fa-circle-notch text-[#2b4c8c] mr-1"></i>구매신청 처리 단계</h2>
<div class="chart-wrap"><canvas id="chDoughnutFlow"></canvas></div>
</section>
<section class="chart-card">
<h2><i class="fa-solid fa-calendar-week text-[#2b4c8c] mr-1"></i>금주 일별 출고(천장)</h2>
<div class="chart-wrap"><canvas id="chBarWeek"></canvas></div>
</section>
<section class="chart-card">
<h2><i class="fa-solid fa-bullseye text-[#2b4c8c] mr-1"></i>운영 지표 (목업)</h2>
<div class="chart-wrap"><canvas id="chRadar"></canvas></div>
</section>
</div>
<section class="chart-card mb-2">
<h2><i class="fa-solid fa-chart-line text-[#2b4c8c] mr-1"></i>월별 출고 vs 구매신청 건수 (최근 12개월)</h2>
<div class="chart-wrap tall"><canvas id="chLineYear"></canvas></div>
</section>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-2 mb-2">
<section class="chart-card">
<h2><i class="fa-solid fa-boxes-stacked text-[#2b4c8c] mr-1"></i>품목별 재고 (천 장)</h2>
<div class="chart-wrap wide"><canvas id="chBarSku"></canvas></div>
</section>
<section class="chart-card">
<h2><i class="fa-solid fa-store text-[#2b4c8c] mr-1"></i>판매소별 월 출고 TOP</h2>
<div class="chart-wrap wide"><canvas id="chBarHStore"></canvas></div>
</section>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-2 mb-2">
<section class="bg-white border border-gray-200 rounded shadow-sm overflow-hidden">
<div class="px-2 py-1 border-b border-gray-200 bg-gray-50 flex justify-between items-center">
<h2 class="text-[11px] font-bold text-gray-800"><i class="fa-solid fa-store text-[#2b4c8c] mr-1"></i>지정판매소 요약</h2>
<span class="text-[10px] text-gray-500">상위 5곳</span>
</div>
<table class="w-full dense-table">
<thead>
<tr>
<th>판매소명</th>
<th class="text-right">월 봉투(백장)</th>
<th>상태</th>
<th class="text-right">최종거래</th>
</tr>
</thead>
<tbody>
<?php foreach ($storeSummary as $s): ?>
<tr>
<td class="font-medium"><?= esc($s[0]) ?></td>
<td class="text-right tabular-nums"><?= esc($s[1]) ?></td>
<td>
<?php if ($s[2] === '정상'): ?>
<span class="text-[10px] text-emerald-700"><?= esc($s[2]) ?></span>
<?php else: ?>
<span class="text-[10px] text-red-600"><?= esc($s[2]) ?></span>
<?php endif; ?>
</td>
<td class="text-right text-gray-500"><?= esc($s[3]) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</section>
<section class="bg-white border border-gray-200 rounded shadow-sm overflow-hidden">
<div class="px-2 py-1 border-b border-gray-200 bg-gray-50">
<h2 class="text-[11px] font-bold text-gray-800"><i class="fa-solid fa-user-check text-[#2b4c8c] mr-1"></i>회원·판매소 승인 대기</h2>
</div>
<div class="grid grid-cols-3 gap-1 p-1.5 border-b border-gray-100 bg-gray-50/50 text-center">
<div>
<div class="text-lg font-bold text-[#2b4c8c]">4</div>
<div class="text-[9px] text-gray-500">전체 대기</div>
</div>
<div>
<div class="text-lg font-bold text-amber-600">2</div>
<div class="text-[9px] text-gray-500">오늘 접수</div>
</div>
<div>
<div class="text-lg font-bold text-gray-600">1.2일</div>
<div class="text-[9px] text-gray-500">평균 처리</div>
</div>
</div>
<table class="w-full dense-table">
<thead>
<tr>
<th>신청자</th>
<th>유형</th>
<th>접수일</th>
<th>메모</th>
</tr>
</thead>
<tbody>
<?php foreach ($approvals as $a): ?>
<tr>
<td><?= esc($a[0]) ?></td>
<td><?= esc($a[1]) ?></td>
<td class="text-gray-600"><?= esc($a[2]) ?></td>
<td class="text-gray-500 truncate max-w-[6rem]"><?= esc($a[3]) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</section>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-2 mb-2">
<section class="chart-card">
<h2><i class="fa-solid fa-layer-group text-[#2b4c8c] mr-1"></i>분기별 입고 / 출고 / 조정</h2>
<div class="chart-wrap wide"><canvas id="chStackedQ"></canvas></div>
</section>
<section class="chart-card">
<h2><i class="fa-solid fa-compass text-[#2b4c8c] mr-1"></i>요일·시간대 신청 분포</h2>
<div class="chart-wrap wide"><canvas id="chPolarTime"></canvas></div>
</section>
</div>
<div class="bg-white border border-gray-200 rounded shadow-sm p-2 mb-2">
<h3 class="text-[11px] font-bold text-gray-800 mb-1"><i class="fa-solid fa-clipboard-list text-[#2b4c8c] mr-1"></i>운영 브리핑 · 추가 그래프</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<ul class="text-[11px] text-gray-600 space-y-0.5 list-disc list-inside">
<li>다음 주 예상 구매신청 <strong class="text-gray-900">약 28건</strong> (전주 대비 +12%)</li>
<li><strong class="text-red-700">일반 20L</strong>·<strong class="text-red-700">특수규격 A</strong> 발주 권고</li>
<li>세금계산서 <strong>6건</strong> 미발행 — 회계 알림 발송됨</li>
</ul>
<div class="chart-card border-0 shadow-none">
<h2 class="rounded-t"><i class="fa-solid fa-chart-area text-[#2b4c8c] mr-1"></i>누적 출고 추이 (올해)</h2>
<div class="chart-wrap"><canvas id="chAreaCum"></canvas></div>
</div>
</div>
</div>
<p class="text-center text-[10px] text-gray-400">
<strong class="text-gray-600">/dashboard/blend</strong> (표 + 차트 혼합)
· <a href="<?= esc($dashDense) ?>" class="text-[#2b4c8c] hover:underline">/dashboard/dense</a>
· <a href="<?= esc($dashCharts) ?>" class="text-[#2b4c8c] hover:underline">/dashboard/charts</a>
· <a href="<?= esc($dashClassic) ?>" class="text-[#2b4c8c] hover:underline">클래식</a>
</p>
</main>
<script>
(function () {
const C = {
primary: '#2b4c8c',
blue: '#3b82f6',
teal: '#0d9488',
emerald: '#059669',
amber: '#d97706',
rose: '#e11d48',
violet: '#7c3aed',
slate: '#64748b',
grid: 'rgba(0,0,0,.06)',
};
Chart.defaults.font.family = "'Malgun Gothic','Apple SD Gothic Neo','Noto Sans KR',sans-serif";
Chart.defaults.font.size = 11;
Chart.defaults.color = '#4b5563';
const commonOpts = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom', labels: { boxWidth: 10, padding: 8, font: { size: 10 } } },
},
};
const axisOpts = {
scales: {
x: { grid: { color: C.grid }, ticks: { maxRotation: 45, minRotation: 0, font: { size: 10 } } },
y: { grid: { color: C.grid }, ticks: { font: { size: 10 } }, beginAtZero: true },
},
};
new Chart(document.getElementById('chDoughnutSpec'), {
type: 'doughnut',
data: {
labels: ['일반 5L', '일반 10·20L', '음식물 스티커', '재사용', '기타'],
datasets: [{
data: [38, 22, 28, 9, 3],
backgroundColor: [C.primary, C.blue, C.teal, C.emerald, C.slate],
borderWidth: 0,
}],
},
options: { ...commonOpts, cutout: '58%' },
});
new Chart(document.getElementById('chDoughnutFlow'), {
type: 'doughnut',
data: {
labels: ['접수', '검토', '발주', '납품', '완료'],
datasets: [{
data: [12, 8, 6, 5, 42],
backgroundColor: [C.amber, C.blue, C.violet, C.teal, C.emerald],
borderWidth: 0,
}],
},
options: { ...commonOpts, cutout: '55%' },
});
new Chart(document.getElementById('chBarWeek'), {
type: 'bar',
data: {
labels: ['월', '화', '수', '목', '금', '토', '일'],
datasets: [{
label: '출고',
data: [42, 55, 48, 61, 58, 22, 8],
backgroundColor: C.primary,
borderRadius: 4,
}],
},
options: { ...commonOpts, ...axisOpts, plugins: { ...commonOpts.plugins, legend: { display: false } } },
});
new Chart(document.getElementById('chRadar'), {
type: 'radar',
data: {
labels: ['재고안정', '신청처리', '납기준수', '민원응대', '데이터품질'],
datasets: [
{
label: '이번 달',
data: [82, 76, 88, 71, 85],
borderColor: C.primary,
backgroundColor: 'rgba(43, 76, 140, 0.2)',
pointBackgroundColor: C.primary,
},
{
label: '전월',
data: [78, 80, 84, 75, 80],
borderColor: C.blue,
backgroundColor: 'rgba(59, 130, 246, 0.12)',
pointBackgroundColor: C.blue,
},
],
},
options: {
...commonOpts,
scales: {
r: {
beginAtZero: true,
max: 100,
ticks: { stepSize: 20, font: { size: 9 } },
grid: { color: C.grid },
pointLabels: { font: { size: 10 } },
},
},
},
});
new Chart(document.getElementById('chLineYear'), {
type: 'line',
data: {
labels: ['3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월', '1월', '2월'],
datasets: [
{
label: '출고(천 장)',
data: [320, 340, 310, 355, 380, 360, 370, 390, 400, 385, 410, 395],
borderColor: C.primary,
backgroundColor: 'rgba(43, 76, 140, 0.08)',
fill: true,
tension: 0.35,
pointRadius: 3,
},
{
label: '구매신청(건)',
data: [118, 125, 112, 130, 142, 128, 135, 140, 155, 148, 160, 152],
borderColor: C.teal,
backgroundColor: 'transparent',
tension: 0.35,
yAxisID: 'y1',
pointRadius: 3,
},
],
},
options: {
...commonOpts,
...axisOpts,
scales: {
x: axisOpts.scales.x,
y: { type: 'linear', position: 'left', grid: { color: C.grid }, title: { display: true, text: '출고', font: { size: 10 } } },
y1: {
type: 'linear',
position: 'right',
grid: { drawOnChartArea: false },
title: { display: true, text: '건수', font: { size: 10 } },
beginAtZero: true,
},
},
},
});
new Chart(document.getElementById('chBarSku'), {
type: 'bar',
data: {
labels: ['5L', '10L', '20L', '스티커', '재사용', '특수'],
datasets: [{
label: '재고',
data: [12.4, 8.2, 2.1, 15.0, 4.3, 0.9],
backgroundColor: [C.primary, C.blue, C.amber, C.teal, C.emerald, C.rose],
borderRadius: 4,
}],
},
options: {
...commonOpts,
...axisOpts,
indexAxis: 'x',
plugins: { ...commonOpts.plugins, legend: { display: false } },
},
});
new Chart(document.getElementById('chBarHStore'), {
type: 'bar',
data: {
labels: ['행복마트 북구', '◇◇할인점', '□□마트', '○○슈퍼', '△△상회'],
datasets: [{
label: '천 장',
data: [5.2, 4.8, 3.9, 3.5, 2.1],
backgroundColor: C.primary,
borderRadius: 4,
}],
},
options: {
...commonOpts,
indexAxis: 'y',
plugins: { ...commonOpts.plugins, legend: { display: false } },
scales: {
x: { grid: { color: C.grid }, beginAtZero: true, ticks: { font: { size: 10 } } },
y: { grid: { display: false }, ticks: { font: { size: 10 } } },
},
},
});
new Chart(document.getElementById('chStackedQ'), {
type: 'bar',
data: {
labels: ['1분기', '2분기', '3분기', '4분기(예)'],
datasets: [
{ label: '입고', data: [420, 450, 480, 460], backgroundColor: C.emerald, stack: 's' },
{ label: '출고', data: [380, 410, 440, 430], backgroundColor: C.primary, stack: 's' },
{ label: '조정', data: [12, 8, 15, 10], backgroundColor: C.amber, stack: 's' },
],
},
options: {
...commonOpts,
scales: {
x: { stacked: true, grid: { display: false }, ticks: { font: { size: 10 } } },
y: { stacked: true, grid: { color: C.grid }, ticks: { font: { size: 10 } }, beginAtZero: true },
},
},
});
new Chart(document.getElementById('chAreaCum'), {
type: 'line',
data: {
labels: ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'],
datasets: [{
label: '누적 출고(만 장)',
data: [3.2, 6.8, 10.5, 14.2, 18.0, 21.5, 25.1, 28.9, 32.4, 36.0, 39.8, 43.5],
borderColor: C.blue,
backgroundColor: 'rgba(59, 130, 246, 0.25)',
fill: true,
tension: 0.4,
pointRadius: 0,
}],
},
options: { ...commonOpts, ...axisOpts, plugins: { ...commonOpts.plugins, legend: { display: true } } },
});
new Chart(document.getElementById('chPolarTime'), {
type: 'polarArea',
data: {
labels: ['평일 오전', '평일 오후', '평일 야간', '주말'],
datasets: [{
data: [28, 45, 8, 19],
backgroundColor: [
'rgba(43, 76, 140, 0.75)',
'rgba(13, 148, 136, 0.7)',
'rgba(217, 119, 6, 0.65)',
'rgba(124, 58, 237, 0.65)',
],
borderWidth: 1,
borderColor: '#fff',
}],
},
options: {
...commonOpts,
scales: {
r: {
ticks: { backdropColor: 'transparent', font: { size: 9 } },
grid: { color: C.grid },
pointLabels: { font: { size: 10 } },
},
},
},
});
})();
</script>
</body>
</html>

View File

@@ -11,6 +11,7 @@ $dashClassic = base_url('dashboard/classic-mock');
$dashModern = base_url('dashboard/modern');
$dashDense = base_url('dashboard/dense');
$dashCharts = base_url('dashboard/charts');
$dashBlend = base_url('dashboard/blend');
?>
<!DOCTYPE html>
<html lang="ko">
@@ -85,6 +86,8 @@ $dashCharts = base_url('dashboard/charts');
<a href="<?= esc($dashModern) ?>" class="text-[#2b4c8c] hover:underline whitespace-nowrap hidden md:inline">모던</a>
<span class="text-gray-300 hidden md:inline">|</span>
<a href="<?= esc($dashDense) ?>" class="text-[#2b4c8c] hover:underline whitespace-nowrap hidden md:inline">종합</a>
<span class="text-gray-300 hidden md:inline">|</span>
<a href="<?= esc($dashBlend) ?>" class="text-[#2b4c8c] hover:underline whitespace-nowrap hidden md:inline" title="표+차트">혼합</a>
<a href="<?= base_url('logout') ?>" class="text-gray-500 hover:text-gray-800 p-1 ml-1" title="로그아웃">
<i class="fa-solid fa-arrow-right-from-bracket"></i>
</a>
@@ -165,6 +168,7 @@ $dashCharts = base_url('dashboard/charts');
<a href="<?= esc($dashClassic) ?>" class="text-[#2b4c8c] hover:underline">/dashboard</a>
· <a href="<?= esc($dashModern) ?>" class="text-[#2b4c8c] hover:underline">/dashboard/modern</a>
· <a href="<?= esc($dashDense) ?>" class="text-[#2b4c8c] hover:underline">/dashboard/dense</a>
· <a href="<?= esc($dashBlend) ?>" class="text-[#2b4c8c] hover:underline">/dashboard/blend</a>
· <strong class="text-gray-600">/dashboard/charts</strong>
</p>
</main>

View File

@@ -11,6 +11,7 @@ $dashClassic = base_url('dashboard/classic-mock');
$dashModern = base_url('dashboard/modern');
$dashDense = base_url('dashboard/dense');
$dashCharts = base_url('dashboard/charts');
$dashBlend = base_url('dashboard/blend');
$kpiTop = [
['icon' => 'fa-triangle-exclamation', 'c' => 'text-amber-700', 'bg' => 'bg-amber-50', 'v' => '3', 'l' => '재고부족', 'sub' => '품목'],
@@ -126,6 +127,8 @@ $notices = [
<span class="text-gray-300 hidden md:inline">|</span>
<a href="<?= esc($dashModern) ?>" class="text-[#2b4c8c] hover:underline whitespace-nowrap hidden md:inline">모던</a>
<span class="text-gray-300 hidden md:inline">|</span>
<a href="<?= esc($dashBlend) ?>" class="text-[#2b4c8c] hover:underline whitespace-nowrap hidden md:inline" title="표+차트 혼합">혼합</a>
<span class="text-gray-300 hidden md:inline">|</span>
<a href="<?= esc($dashCharts) ?>" class="text-[#2b4c8c] hover:underline whitespace-nowrap hidden md:inline" title="그래프 대시보드">차트</a>
<a href="<?= base_url('logout') ?>" class="text-gray-500 hover:text-gray-800 p-1 ml-1" title="로그아웃">
<i class="fa-solid fa-arrow-right-from-bracket"></i>
@@ -384,6 +387,7 @@ $notices = [
레이아웃: <a href="<?= esc($dashClassic) ?>" class="text-[#2b4c8c] hover:underline">/dashboard</a>
· <a href="<?= esc($dashModern) ?>" class="text-[#2b4c8c] hover:underline">/dashboard/modern</a>
· <strong class="text-gray-600">/dashboard/dense</strong> (이 화면)
· <a href="<?= esc($dashBlend) ?>" class="text-[#2b4c8c] hover:underline">/dashboard/blend</a>
· <a href="<?= esc($dashCharts) ?>" class="text-[#2b4c8c] hover:underline">/dashboard/charts</a>
</p>
</main>

View File

@@ -11,6 +11,7 @@ $dashClassic = base_url('dashboard/classic-mock');
$dashModern = base_url('dashboard/modern');
$dashDense = base_url('dashboard/dense');
$dashCharts = base_url('dashboard/charts');
$dashBlend = base_url('dashboard/blend');
?>
<!DOCTYPE html>
<html lang="ko">
@@ -70,6 +71,8 @@ $dashCharts = base_url('dashboard/charts');
<span class="text-gray-300 hidden sm:inline">|</span>
<a href="<?= esc($dashDense) ?>" class="text-xs text-[#2b4c8c] hover:underline whitespace-nowrap hidden sm:inline" title="정보 집약 종합">종합</a>
<span class="text-gray-300 hidden sm:inline">|</span>
<a href="<?= esc($dashBlend) ?>" class="text-xs text-[#2b4c8c] hover:underline whitespace-nowrap hidden sm:inline" title="표+차트">혼합</a>
<span class="text-gray-300 hidden sm:inline">|</span>
<a href="<?= esc($dashCharts) ?>" class="text-xs text-[#2b4c8c] hover:underline whitespace-nowrap hidden sm:inline" title="그래프 대시보드">차트</a>
<a href="<?= base_url('logout') ?>" class="text-gray-500 hover:text-gray-800 p-1" title="로그아웃">
<i class="fa-solid fa-arrow-right-from-bracket text-lg"></i>
@@ -207,6 +210,7 @@ $dashCharts = base_url('dashboard/charts');
<p class="text-center text-[11px] text-slate-400">
URL 비교 — <strong class="text-slate-600">클래식 레이아웃</strong> <code class="bg-slate-100 px-1 rounded">/dashboard</code>
· <strong class="text-slate-600">모던 콘텐츠(이 화면)</strong> <code class="bg-slate-100 px-1 rounded">/dashboard/modern</code>
· <a href="<?= esc($dashBlend) ?>" class="text-[#2b4c8c] hover:underline">/dashboard/blend</a>
· <a href="<?= esc($dashCharts) ?>" class="text-[#2b4c8c] hover:underline">/dashboard/charts</a>
<span class="block sm:inline mt-1 sm:mt-0">· 상단 메뉴는 동일</span>
</p>

View File

@@ -13,7 +13,7 @@
<section>
<div class="flex items-center justify-between mb-2 border-b pb-1">
<h3 class="text-base font-bold text-gray-700">발주 현황</h3>
<a href="<?= base_url('admin/bag-orders/create') ?>" class="bg-btn-search text-white px-3 py-1.5 rounded-sm text-sm">발주 등록</a>
<a href="<?= base_url('bag/order/create') ?>" class="bg-btn-search text-white px-3 py-1.5 rounded-sm text-sm">발주 등록</a>
</div>
<table class="data-table">
<thead><tr>
@@ -37,9 +37,9 @@
?>
</td>
<td class="text-center">
<a href="<?= base_url('admin/bag-orders/detail/' . $row->bo_idx) ?>" class="text-blue-600 hover:underline text-xs">상세</a>
<a href="<?= base_url('bag/purchase-inbound?detail=' . $row->bo_idx) ?>" class="text-blue-600 hover:underline text-xs">상세</a>
<?php if (($row->bo_status ?? '') === 'normal'): ?>
<form method="post" action="<?= base_url('admin/bag-orders/cancel/' . $row->bo_idx) ?>" class="inline" onsubmit="return confirm('취소하시겠습니까?')">
<form method="post" action="<?= base_url('bag/order/cancel/' . $row->bo_idx) ?>" class="inline" onsubmit="return confirm('취소하시겠습니까?')">
<?= csrf_field() ?>
<button class="text-orange-600 hover:underline text-xs ml-1">취소</button>
</form>
@@ -58,7 +58,7 @@
<section class="mt-4">
<div class="flex items-center justify-between mb-2 border-b pb-1">
<h3 class="text-base font-bold text-gray-700">입고 현황</h3>
<a href="<?= base_url('admin/bag-receivings/create') ?>" class="bg-btn-search text-white px-3 py-1.5 rounded-sm text-sm">입고 처리</a>
<a href="<?= base_url('bag/receiving/create') ?>" class="bg-btn-search text-white px-3 py-1.5 rounded-sm text-sm">입고 처리</a>
</div>
<table class="data-table">
<thead><tr>

View File

@@ -12,7 +12,7 @@
<section>
<div class="flex items-center justify-between mb-2 border-b pb-1">
<h3 class="text-base font-bold text-gray-700">주문 접수</h3>
<a href="<?= base_url('admin/shop-orders/create') ?>" class="bg-btn-search text-white px-3 py-1.5 rounded-sm text-sm">주문 등록</a>
<a href="<?= base_url('bag/shop-order/create') ?>" class="bg-btn-search text-white px-3 py-1.5 rounded-sm text-sm">주문 등록</a>
</div>
<table class="data-table">
<thead><tr>
@@ -47,7 +47,7 @@
<section class="mt-4">
<div class="flex items-center justify-between mb-2 border-b pb-1">
<h3 class="text-base font-bold text-gray-700">판매/반품</h3>
<a href="<?= base_url('admin/bag-sales/create') ?>" class="bg-btn-search text-white px-3 py-1.5 rounded-sm text-sm">판매 등록</a>
<a href="<?= base_url('bag/sale/create') ?>" class="bg-btn-search text-white px-3 py-1.5 rounded-sm text-sm">판매 등록</a>
</div>
<table class="data-table">
<thead><tr>

View File

@@ -0,0 +1,38 @@
<?php
/**
* Custom Tailwind CSS Pagination View for CodeIgniter 4
*
* @var \CodeIgniter\Pager\PagerRenderer $pager
*/
$pager->setSurroundCount(2);
?>
<?php if ($pager->hasPreviousPage() || $pager->hasNextPage()): ?>
<nav aria-label="Page navigation" class="flex items-center justify-center gap-1 mt-3 mb-2 no-print">
<?php if ($pager->hasPreviousPage()): ?>
<a href="<?= $pager->getFirst() ?>" class="px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-100 text-gray-600" title="처음">&laquo;</a>
<a href="<?= $pager->getPreviousPage() ?>" class="px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-100 text-gray-600" title="이전">&lsaquo;</a>
<?php else: ?>
<span class="px-2 py-1 text-xs border border-gray-200 rounded text-gray-300">&laquo;</span>
<span class="px-2 py-1 text-xs border border-gray-200 rounded text-gray-300">&lsaquo;</span>
<?php endif; ?>
<?php foreach ($pager->links() as $link): ?>
<?php if ($link['active']): ?>
<span class="px-3 py-1 text-xs border border-blue-600 rounded bg-blue-600 text-white font-bold"><?= $link['title'] ?></span>
<?php else: ?>
<a href="<?= $link['uri'] ?>" class="px-3 py-1 text-xs border border-gray-300 rounded hover:bg-gray-100 text-gray-700"><?= $link['title'] ?></a>
<?php endif; ?>
<?php endforeach; ?>
<?php if ($pager->hasNextPage()): ?>
<a href="<?= $pager->getNextPage() ?>" class="px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-100 text-gray-600" title="다음">&rsaquo;</a>
<a href="<?= $pager->getLast() ?>" class="px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-100 text-gray-600" title="마지막">&raquo;</a>
<?php else: ?>
<span class="px-2 py-1 text-xs border border-gray-200 rounded text-gray-300">&rsaquo;</span>
<span class="px-2 py-1 text-xs border border-gray-200 rounded text-gray-300">&raquo;</span>
<?php endif; ?>
</nav>
<?php endif; ?>

View File

@@ -0,0 +1,55 @@
<?php
/**
* 인쇄용 문서 헤더 컴포넌트
*
* 사용 변수:
* $printTitle - 문서 제목 (필수)
* $printLgName - 지자체명 (선택, 미지정 시 세션에서 조회)
* $printDate - 날짜 (선택, 기본 오늘)
*/
if (! isset($printLgName)) {
helper('admin');
$lgIdx = admin_effective_lg_idx();
$printLgName = '';
if ($lgIdx) {
$lgRow = model(\App\Models\LocalGovernmentModel::class)->find($lgIdx);
$printLgName = $lgRow ? $lgRow->lg_name : '';
}
}
$printDate = $printDate ?? date('Y-m-d');
$printTitle = $printTitle ?? '';
?>
<div class="print-header" style="display:none;">
<table style="width:100%; border-collapse:collapse; margin-bottom:10px;">
<tr>
<td style="width:60%; vertical-align:bottom;">
<div style="font-size:12px; color:#666; margin-bottom:4px;"><?= esc($printLgName) ?></div>
<div style="font-size:20px; font-weight:bold; letter-spacing:2px;"><?= esc($printTitle) ?></div>
<div style="font-size:11px; color:#888; margin-top:4px;">출력일: <?= esc($printDate) ?></div>
</td>
<td style="width:40%; vertical-align:top;">
<table style="border-collapse:collapse; float:right; font-size:11px;">
<tr>
<th style="border:1px solid #333; padding:4px 12px; background:#f0f0f0; text-align:center;">담당</th>
<th style="border:1px solid #333; padding:4px 12px; background:#f0f0f0; text-align:center;">팀장</th>
<th style="border:1px solid #333; padding:4px 12px; background:#f0f0f0; text-align:center;">과장</th>
</tr>
<tr>
<td style="border:1px solid #333; padding:8px 12px; height:40px; min-width:60px;">&nbsp;</td>
<td style="border:1px solid #333; padding:8px 12px; height:40px; min-width:60px;">&nbsp;</td>
<td style="border:1px solid #333; padding:8px 12px; height:40px; min-width:60px;">&nbsp;</td>
</tr>
</table>
</td>
</tr>
</table>
<hr style="border:1px solid #333; margin-bottom:10px;"/>
</div>
<style>
@media print {
.print-header { display: block !important; }
}
</style>

View File

@@ -11,7 +11,8 @@
},
"require": {
"php": "^8.2",
"codeigniter4/framework": "^4.7"
"codeigniter4/framework": "^4.7",
"robthree/twofactorauth": "^3.0"
},
"require-dev": {
"fakerphp/faker": "^1.9",

83
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "f5cce40800fa5dae1504b9364f585e6a",
"content-hash": "62775ba19440bda6e4bb1fbe91908932",
"packages": [
{
"name": "codeigniter4/framework",
@@ -193,6 +193,87 @@
"source": "https://github.com/php-fig/log/tree/3.0.2"
},
"time": "2024-09-11T13:17:53+00:00"
},
{
"name": "robthree/twofactorauth",
"version": "v3.0.3",
"source": {
"type": "git",
"url": "https://github.com/RobThree/TwoFactorAuth.git",
"reference": "85408c4e775dba7c0802f2d928efd921d530bc5b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/RobThree/TwoFactorAuth/zipball/85408c4e775dba7c0802f2d928efd921d530bc5b",
"reference": "85408c4e775dba7c0802f2d928efd921d530bc5b",
"shasum": ""
},
"require": {
"php": ">=8.2.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.13",
"phpstan/phpstan": "^1.9",
"phpunit/phpunit": "^9"
},
"suggest": {
"bacon/bacon-qr-code": "Needed for BaconQrCodeProvider provider",
"endroid/qr-code": "Needed for EndroidQrCodeProvider"
},
"type": "library",
"autoload": {
"psr-4": {
"RobThree\\Auth\\": "lib"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Rob Janssen",
"homepage": "http://robiii.me",
"role": "Developer"
},
{
"name": "Nicolas CARPi",
"homepage": "https://github.com/NicolasCARPi",
"role": "Developer"
},
{
"name": "Will Power",
"homepage": "https://github.com/willpower232",
"role": "Developer"
}
],
"description": "Two Factor Authentication",
"homepage": "https://github.com/RobThree/TwoFactorAuth",
"keywords": [
"Authentication",
"MFA",
"Multi Factor Authentication",
"Two Factor Authentication",
"authenticator",
"authy",
"php",
"tfa"
],
"support": {
"issues": "https://github.com/RobThree/TwoFactorAuth/issues",
"source": "https://github.com/RobThree/TwoFactorAuth"
},
"funding": [
{
"url": "https://paypal.me/robiii",
"type": "custom"
},
{
"url": "https://github.com/RobThree",
"type": "github"
}
],
"time": "2026-01-05T13:17:41+00:00"
}
],
"packages-dev": [

View File

@@ -1,186 +0,0 @@
# 서버 배포 가이드
## 서버 정보
| 항목 | 값 |
|------|------|
| 서버 IP | `116.122.157.166` |
| OS | Ubuntu 22.04.5 LTS |
| SSH 접속 | `ssh wixon@116.122.157.166` (비밀번호: `1111`) |
| 도메인 | `trash.wxn.co.kr` |
| 호스팅 | 카페24 (qm391-0435.cafe24.com) |
## Gitea (Git 서버)
| 항목 | 값 |
|------|------|
| 내부 URL | `http://localhost:3001` |
| 컨테이너 | `gitea` (Docker) |
| 관리자 계정 | `wixon` / `wixon1234!` |
| 관리자 이메일 | `admin@wxn.co.kr` |
| 레포 URL (내부) | `http://localhost:3001/wixon/jongryangje` |
| SSH 포트 (내부) | `2222` |
| DB | SQLite (`/srv/gitea/data/gitea/gitea.db`) |
| 데이터 경로 | `/srv/gitea/data` |
| docker-compose | `/srv/gitea/docker-compose.yml` |
### Gitea 관리 명령
```bash
# 컨테이너 상태 확인
sudo docker ps | grep gitea
# 재시작
cd /srv/gitea && sudo docker compose restart
# 로그 확인
sudo docker logs gitea --tail 50
# 사용자 추가
sudo docker exec -u git gitea gitea admin user create \
--username <name> --password <pass> --email <email> --admin
```
## 웹 서비스 구조
```
[클라이언트] → [NPM (80/443)] → [nginx (8045)] → [PHP-FPM 8.2] → [CodeIgniter 4]
[MySQL 3.36.27.239]
```
| 컴포넌트 | 포트 | 설명 |
|----------|------|------|
| Nginx Proxy Manager | 80/443 | 도메인 프록시 (Docker) |
| nginx | 8045 | PHP-FPM 연동 vhost |
| PHP-FPM 8.2 | unix socket | `/run/php/php8.2-fpm.sock` |
| Gitea | 3001 | Git 서버 (Docker) |
| MySQL | 3306 (원격 3.36.27.239) | 데이터베이스 |
### 프로젝트 배포 경로
```
/srv/jongryangje/ # 프로젝트 루트 (git clone)
/srv/jongryangje/public/ # nginx document root
/srv/jongryangje/.env # 환경 설정 (gitignore)
/srv/gitea/ # Gitea Docker 설정
```
### nginx 설정
```
/etc/nginx/sites-available/jongryangje # vhost 설정
/etc/nginx/sites-enabled/jongryangje # symlink
```
### NPM 프록시 설정
NPM 컨테이너 내부에서 `trash.wxn.co.kr → 116.122.157.166:8045` 프록시:
```
/data/nginx/proxy_host/7.conf
```
## 자동 배포 (CI/CD)
### 플로우
```
[로컬 git push] → [Gitea] → [Webhook (port 9000)] → [deploy.sh] → [git pull + composer install]
```
### 관련 파일
| 파일 | 용도 |
|------|------|
| `/srv/jongryangje/deploy.sh` | 배포 스크립트 (git pull + composer) |
| `/srv/jongryangje/webhook-server.sh` | Webhook 수신 서버 |
| `/etc/systemd/system/jongryangje-webhook.service` | Webhook systemd 서비스 |
| `/srv/jongryangje/writable/logs/deploy.log` | 배포 로그 |
### 배포 명령
```bash
# 수동 배포
ssh wixon@116.122.157.166 "/srv/jongryangje/deploy.sh"
# 자동 배포 (로컬에서 push만 하면 됨)
git push gitea main
# 배포 로그 확인
ssh wixon@116.122.157.166 "tail -20 /srv/jongryangje/writable/logs/deploy.log"
# webhook 서비스 상태
ssh wixon@116.122.157.166 "sudo systemctl status jongryangje-webhook"
```
## 로컬 Git 설정
### Remote 목록
| Remote | URL | 용도 |
|--------|-----|------|
| `origin` | `github.com/wixon-associates/jongryangje` | GitHub (메인) |
| `gitea` | `localhost:13001/wixon/jongryangje` (SSH 터널) | 서버 배포용 |
### SSH 터널 (Gitea push용)
외부에서 Gitea 3001 포트에 직접 접근할 수 없어 SSH 터널을 사용합니다.
```bash
# 터널 열기
sshpass -p '1111' ssh -f -N -L 13001:localhost:3001 wixon@116.122.157.166
# push (터널 열린 상태에서)
git push gitea main
# 또는 origin(GitHub)과 gitea 모두 push
git push origin main && git push gitea main
```
### push 자동화 스크립트
```bash
#!/bin/bash
# push-all.sh — GitHub + Gitea 동시 push
git push origin main
# SSH 터널 확인/열기
if ! nc -z localhost 13001 2>/dev/null; then
sshpass -p '1111' ssh -f -N -L 13001:localhost:3001 wixon@116.122.157.166
sleep 2
fi
git push gitea main
echo "Push complete to GitHub + Gitea (auto-deploy triggered)"
```
## DNS 설정
`trash.wxn.co.kr` A 레코드를 `116.122.157.166`으로 설정 필요.
```
trash.wxn.co.kr → A → 116.122.157.166
```
DNS가 적용되면 `http://trash.wxn.co.kr`으로 접속 가능합니다.
## 서비스 관리
```bash
# nginx 재시작
sudo systemctl restart nginx
# PHP-FPM 재시작
sudo systemctl restart php8.2-fpm
# Gitea 재시작
cd /srv/gitea && sudo docker compose restart
# NPM 재시작
sudo docker restart nginx-proxy-manager
# Webhook 재시작
sudo systemctl restart jongryangje-webhook
# 전체 상태 확인
sudo systemctl status nginx php8.2-fpm jongryangje-webhook
sudo docker ps
```

261
docs/server.md Normal file
View File

@@ -0,0 +1,261 @@
# 서버 인프라 / 배포 가이드
## 접속 정보 요약
| 서비스 | URL | 계정 |
|--------|-----|------|
| 웹 서비스 | https://trash.wxn.co.kr | (아래 테스트 계정 참조) |
| Gitea | https://gitea.wxn.co.kr | `wixon` / `wixon1234!` |
| SSH | `ssh wixon@116.122.157.166` | `wixon` / `1111` |
### 웹 테스트 계정 (비밀번호: `test1234!`)
| ID | 역할 | Level | 소속 |
|----|------|-------|------|
| `tester_admin` | Super Admin | 4 | 전체 (지자체 선택 필요) |
| `tester_local` | 지자체관리자 | 3 | 중구청 |
| `tester_shop` | 지정판매소 | 2 | - |
| `tester_user` | 일반 사용자 | 1 | - |
---
## 1. 서버 환경
| 항목 | 값 |
|------|------|
| IP | `116.122.157.166` |
| OS | Ubuntu 22.04.5 LTS |
| 호스팅 | 카페24 (`qm391-0435.cafe24.com`) |
| PHP | 8.2.30 (PHP-FPM) |
| nginx | 호스트 설치 (80/443/8045) |
| Docker | 28.5.1 |
| DB | MySQL (원격 `3.36.27.239`) |
---
## 2. 서비스 구조
```
┌─── https://trash.wxn.co.kr
[클라이언트] ─── nginx ───┤
(80/443) └─── https://gitea.wxn.co.kr
│ │
▼ ▼
nginx (8045) Gitea Docker (3001)
PHP-FPM 8.2
CodeIgniter 4
MySQL 3.36.27.239
```
### 포트 구성
| 포트 | 서비스 | 설명 |
|------|--------|------|
| 80 | nginx | HTTP → HTTPS 리다이렉트 |
| 443 | nginx | HTTPS (Let's Encrypt) |
| 8045 | nginx | PHP-FPM vhost (내부) |
| 3001 | Gitea | Git 서버 (Docker, 내부) |
| 9000 | webhook | 배포 트리거 수신 (내부) |
| 3306 | MySQL | 원격 DB (`3.36.27.239`) |
---
## 3. Gitea (Git 서버)
| 항목 | 값 |
|------|------|
| 외부 URL | https://gitea.wxn.co.kr |
| 관리자 | `wixon` / `wixon1234!` (`admin@wxn.co.kr`) |
| 레포 | https://gitea.wxn.co.kr/wixon/jongryangje |
| 컨테이너 | `gitea` (Docker) |
| DB | SQLite (`/srv/gitea/data/gitea/gitea.db`) |
| docker-compose | `/srv/gitea/docker-compose.yml` |
| 데이터 | `/srv/gitea/data` |
### 관리 명령
```bash
# 상태 확인
sudo docker ps | grep gitea
# 재시작
cd /srv/gitea && sudo docker compose restart
# 로그
sudo docker logs gitea --tail 50
# 사용자 추가
sudo docker exec -u git gitea gitea admin user create \
--username <name> --password <pass> --email <email> --admin
```
---
## 4. 자동 배포 (CI/CD)
### 플로우
```
로컬 git push → Gitea → Webhook (9000) → deploy.sh → 서버 반영
```
`git push gitea main` 한 번이면 서버에 자동 반영됩니다.
### 배포 스크립트 (`/srv/jongryangje/deploy.sh`)
```bash
#!/bin/bash
cd /srv/jongryangje
git fetch origin main && git reset --hard origin/main
composer install --no-dev --no-interaction | tail -3
chmod -R 777 writable/
echo "Deployed at $(date)"
```
### 관련 파일
| 파일 | 용도 |
|------|------|
| `/srv/jongryangje/deploy.sh` | 배포 실행 (git fetch + reset + composer) |
| `/srv/jongryangje/webhook-server.sh` | Webhook 수신 (nc 기반) |
| `/etc/systemd/system/jongryangje-webhook.service` | Webhook systemd 서비스 |
| `/srv/jongryangje/writable/logs/deploy.log` | 배포 로그 |
### 수동 배포
```bash
ssh wixon@116.122.157.166 "/srv/jongryangje/deploy.sh"
```
### 배포 로그 확인
```bash
ssh wixon@116.122.157.166 "tail -20 /srv/jongryangje/writable/logs/deploy.log"
```
---
## 5. 로컬 Git 설정
### Remote
| Remote | URL | 용도 |
|--------|-----|------|
| `origin` | `github.com/wixon-associates/jongryangje` | GitHub (소스 관리) |
| `gitea` | `gitea.wxn.co.kr/wixon/jongryangje` | 서버 배포 (auto-deploy) |
### Push 방법
```bash
# GitHub + 서버 동시 배포
git push origin main && git push gitea main
```
---
## 6. 서버 파일 구조
```
/srv/
├── jongryangje/ # 프로젝트 (git clone)
│ ├── public/ # nginx document root
│ ├── .env # 환경 설정 (gitignore)
│ ├── deploy.sh # 배포 스크립트
│ └── webhook-server.sh # Webhook 수신
└── gitea/ # Gitea Docker
├── docker-compose.yml
└── data/ # Gitea 데이터
/etc/nginx/sites-available/
├── jongryangje # PHP-FPM vhost (:8045)
├── jongryangje-ssl # trash.wxn.co.kr (80→443, SSL)
└── gitea # gitea.wxn.co.kr (80→443, SSL → :3001)
/etc/letsencrypt/live/
├── trash.wxn.co.kr/ # 웹 SSL 인증서
└── gitea.wxn.co.kr/ # Gitea SSL 인증서
```
---
## 7. SSL 인증서 (Let's Encrypt)
| 도메인 | 인증서 경로 | 만료 |
|--------|-------------|------|
| `trash.wxn.co.kr` | `/etc/letsencrypt/live/trash.wxn.co.kr/` | 자동 갱신 (certbot) |
| `gitea.wxn.co.kr` | `/etc/letsencrypt/live/gitea.wxn.co.kr/` | 자동 갱신 (certbot) |
```bash
# 인증서 상태 확인
sudo certbot certificates
# 수동 갱신
sudo certbot renew
```
---
## 8. 서비스 관리
```bash
# nginx
sudo systemctl restart nginx
sudo nginx -t # 설정 테스트
# PHP-FPM
sudo systemctl restart php8.2-fpm
# Gitea
cd /srv/gitea && sudo docker compose restart
# Webhook
sudo systemctl restart jongryangje-webhook
# 전체 상태
sudo systemctl status nginx php8.2-fpm jongryangje-webhook
sudo docker ps
```
---
## 9. DNS 설정
| 도메인 | 타입 | 값 |
|--------|------|------|
| `trash.wxn.co.kr` | A | `116.122.157.166` |
| `gitea.wxn.co.kr` | A | `116.122.157.166` |
---
## 10. 트러블슈팅
### 사이트 접속 안 됨
```bash
# nginx 상태 확인
sudo systemctl status nginx
sudo tail -20 /var/log/nginx/error.log
# PHP-FPM 확인
sudo systemctl status php8.2-fpm
```
### 배포 안 됨
```bash
# webhook 서비스 확인
sudo systemctl status jongryangje-webhook
# 수동 배포로 테스트
/srv/jongryangje/deploy.sh
# 배포 로그
tail -30 /srv/jongryangje/writable/logs/deploy.log
```
### Gitea 접속 안 됨
```bash
sudo docker ps | grep gitea
sudo docker logs gitea --tail 30
```

297
e2e/new-features.spec.js Normal file
View File

@@ -0,0 +1,297 @@
// @ts-check
const { test, expect } = require('@playwright/test');
const { login } = require('./helpers/auth');
async function loginAsAdmin(page) {
await login(page, 'admin');
await page.locator('input[name="lg_idx"]').first().check();
await page.click('button[type="submit"]');
await page.waitForURL(url => !url.pathname.includes('select-local-government'), { timeout: 30000 });
}
async function loginAsLocal(page) {
await login(page, 'local');
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// CT-01: 페이지네이션
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
test.describe('CT-01: 페이지네이션', () => {
test('발주 목록에 데이터 테이블 존재', async ({ page }) => {
await loginAsLocal(page);
await page.goto('/admin/bag-orders');
await expect(page.locator('table.data-table')).toBeVisible();
});
test('판매 목록에 데이터 테이블 존재', async ({ page }) => {
await loginAsLocal(page);
await page.goto('/admin/bag-sales');
await expect(page.locator('table.data-table')).toBeVisible();
});
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// CT-02: 엑셀 저장
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
test.describe('CT-02: 엑셀 저장', () => {
test('발주 엑셀 다운로드', async ({ page }) => {
await loginAsLocal(page);
await page.goto('/admin/bag-orders');
const downloadPromise = page.waitForEvent('download', { timeout: 10000 });
await page.locator('a[href*="export"]').first().click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toContain('.csv');
});
test('재고 엑셀 다운로드', async ({ page }) => {
await loginAsLocal(page);
await page.goto('/admin/bag-inventory');
const downloadPromise = page.waitForEvent('download', { timeout: 10000 });
await page.locator('a[href*="export"]').first().click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toContain('.csv');
});
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// CT-03: 인쇄 버튼
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
test.describe('CT-03: 인쇄 버튼', () => {
test('발주 목록에 인쇄 버튼 존재', async ({ page }) => {
await loginAsLocal(page);
await page.goto('/admin/bag-orders');
await expect(page.locator('button:has-text("인쇄"), a:has-text("인쇄")')).toBeVisible();
});
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// CT-06: 대시보드 실 데이터
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
test.describe('CT-06: 대시보드 실 데이터', () => {
test('대시보드에 통계 표시', async ({ page }) => {
await loginAsLocal(page);
await page.goto('/admin');
const content = await page.textContent('main');
expect(content).toBeTruthy();
expect(content.length).toBeGreaterThan(50);
});
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// P2-15: 지정판매소 다조건 조회
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
test.describe('P2-15: 지정판매소 다조건 조회', () => {
test('이름 검색 필터', async ({ page }) => {
await loginAsLocal(page);
await page.goto('/admin/designated-shops?ds_name=CU');
await expect(page).toHaveURL(/ds_name=CU/);
await expect(page.locator('table.data-table')).toBeVisible();
});
test('상태 필터', async ({ page }) => {
await loginAsLocal(page);
await page.goto('/admin/designated-shops?ds_state=1');
await expect(page.locator('table.data-table')).toBeVisible();
});
test('검색 폼에서 이름 입력 후 조회', async ({ page }) => {
await loginAsLocal(page);
await page.goto('/admin/designated-shops');
const nameInput = page.locator('input[name="ds_name"]');
if (await nameInput.count() > 0) {
await nameInput.fill('GS');
await page.click('button:has-text("조회")');
await expect(page).toHaveURL(/ds_name=GS/);
}
});
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// P2-17: 지정판매소 지도
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
test.describe('P2-17: 지정판매소 지도', () => {
test('지도 페이지 접근', async ({ page }) => {
await loginAsLocal(page);
await page.goto('/admin/designated-shops/map');
await expect(page).toHaveURL(/\/map/);
});
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// P2-18: 지정판매소 현황
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
test.describe('P2-18: 지정판매소 현황', () => {
test('현황 페이지 접근', async ({ page }) => {
await loginAsLocal(page);
await page.goto('/admin/designated-shops/status');
await expect(page).toHaveURL(/\/status/);
await expect(page.locator('table.data-table').first()).toBeVisible();
});
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// P5-04: 년 판매 현황
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
test.describe('P5-04: 년 판매 현황', () => {
test('년 판매 현황 접근', async ({ page }) => {
await loginAsLocal(page);
await page.goto('/admin/reports/yearly-sales');
await expect(page).toHaveURL(/yearly-sales/);
await expect(page.locator('table.data-table')).toBeVisible();
});
test('연도 변경 조회', async ({ page }) => {
await loginAsLocal(page);
await page.goto('/admin/reports/yearly-sales?year=2025');
await expect(page).toHaveURL(/year=2025/);
});
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// P5-05: 지정판매소별 판매현황
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
test.describe('P5-05: 판매소별 판매현황', () => {
test('판매소별 현황 접근', async ({ page }) => {
await loginAsLocal(page);
await page.goto('/admin/reports/shop-sales');
await expect(page).toHaveURL(/shop-sales/);
await expect(page.locator('table.data-table')).toBeVisible();
});
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// P5-06: 홈택스 엑셀
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
test.describe('P5-06: 홈택스 세금계산서 엑셀', () => {
test('홈택스 엑셀 내보내기', async ({ page }) => {
await loginAsLocal(page);
// 다운로드를 트리거하는 URL이므로 evaluate로 fetch 테스트
const status = await page.evaluate(async () => {
const res = await fetch('/admin/reports/hometax-export');
return res.status;
});
expect(status).toBe(200);
});
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// P5-08: 반품/파기 현황
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
test.describe('P5-08: 반품/파기 현황', () => {
test('반품/파기 목록 접근', async ({ page }) => {
await loginAsLocal(page);
await page.goto('/admin/reports/returns');
await expect(page).toHaveURL(/returns/);
await expect(page.locator('table.data-table')).toBeVisible();
});
test('기간 필터 조회', async ({ page }) => {
await loginAsLocal(page);
await page.goto('/admin/reports/returns?start_date=2026-01-01&end_date=2026-12-31');
await expect(page.locator('table.data-table')).toBeVisible();
});
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// P5-10: LOT 수불 조회
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
test.describe('P5-10: LOT 수불 조회', () => {
test('LOT 수불 페이지 접근', async ({ page }) => {
await loginAsLocal(page);
await page.goto('/admin/reports/lot-flow');
await expect(page).toHaveURL(/lot-flow/);
});
test('LOT 번호 검색', async ({ page }) => {
await loginAsLocal(page);
await page.goto('/admin/reports/lot-flow?lot=LOT-2025');
await expect(page).toHaveURL(/lot=LOT/);
});
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// P5-11: 기타 입출고
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
test.describe('P5-11: 기타 입출고', () => {
test('기타 입출고 페이지 접근', async ({ page }) => {
await loginAsLocal(page);
await page.goto('/admin/reports/misc-flow');
await expect(page).toHaveURL(/misc-flow/);
});
test('기타 입출고 등록 폼 표시', async ({ page }) => {
await loginAsLocal(page);
await page.goto('/admin/reports/misc-flow');
await expect(page.locator('select[name="bmf_type"]')).toBeVisible();
await expect(page.locator('input[name="bmf_qty"]')).toBeVisible();
});
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 사이트 메뉴 CRUD (DOM 조작)
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
test.describe('사이트 메뉴 CRUD 동작', () => {
test.beforeEach(async ({ page }) => {
await loginAsLocal(page);
});
test('불출 처리 폼 → 사이트 레이아웃', async ({ page }) => {
await page.goto('/bag/issue/create');
await expect(page.locator('a:has-text("발주 입고 관리")')).toBeVisible();
expect(await page.locator('a:has-text("회원 관리")').count()).toBe(0);
await expect(page.locator('select[name="bi2_bag_code"]')).toBeVisible();
});
test('발주 등록 폼 → 사이트 레이아웃', async ({ page }) => {
await page.goto('/bag/order/create');
await expect(page.locator('a:has-text("불출 관리")')).toBeVisible();
await expect(page.locator('input[name="bo_order_date"]')).toBeVisible();
});
test('입고 처리 폼 → 사이트 레이아웃', async ({ page }) => {
await page.goto('/bag/receiving/create');
await expect(page.locator('a:has-text("재고 관리")')).toBeVisible();
});
test('판매 등록 폼 → 사이트 레이아웃', async ({ page }) => {
await page.goto('/bag/sale/create');
await expect(page.locator('a:has-text("판매 현황")')).toBeVisible();
await expect(page.locator('select[name="bs_ds_idx"]')).toBeVisible();
});
test('주문 접수 폼 → 사이트 레이아웃', async ({ page }) => {
await page.goto('/bag/shop-order/create');
await expect(page.locator('a:has-text("봉투 수불 관리")')).toBeVisible();
await expect(page.locator('select[name="so_ds_idx"]')).toBeVisible();
});
test('재고 조정 폼', async ({ page }) => {
await page.goto('/bag/inventory/adjust');
await expect(page.locator('select[name="bag_code"]')).toBeVisible();
await expect(page.locator('select[name="adjust_type"]')).toBeVisible();
await expect(page.locator('input[name="qty"]')).toBeVisible();
});
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 엑셀 내보내기 다운로드
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
test.describe('엑셀 내보내기 다운로드', () => {
test('지정판매소 엑셀', async ({ page }) => {
await loginAsLocal(page);
await page.goto('/admin/designated-shops');
const downloadPromise = page.waitForEvent('download', { timeout: 10000 });
await page.locator('a[href*="export"]').first().click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toContain('.csv');
});
test('판매 엑셀', async ({ page }) => {
await loginAsLocal(page);
await page.goto('/admin/bag-sales');
const downloadPromise = page.waitForEvent('download', { timeout: 10000 });
await page.locator('a[href*="export"]').first().click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toContain('.csv');
});
});

9
env
View File

@@ -55,6 +55,15 @@
# encryption.key =
#--------------------------------------------------------------------
# AUTH (TOTP 2차 인증) — 관리자(mb_level 3·4·5)만 적용, 로컬은 false 권장
#--------------------------------------------------------------------
# auth.requireTotp = true
# auth.totpIssuer = "쓰레기봉투 물류시스템"
# auth.totpMaxAttempts = 5
# auth.pending2faTtlSeconds = 600
#--------------------------------------------------------------------
# SESSION
#--------------------------------------------------------------------

Some files were not shown because too many files have changed in this diff Show More