Compare commits

...

4 Commits

Author SHA1 Message Date
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
33 changed files with 1602 additions and 66 deletions

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

@@ -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');
@@ -29,6 +30,10 @@ $routes->get('bag/help', 'Bag::help');
// 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');

View File

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

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

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,7 +29,7 @@ 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();
@@ -47,7 +47,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 +63,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

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

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

@@ -177,7 +177,7 @@ class User extends BaseController
/**
* 현재 로그인한 관리자가 부여 가능한 역할 목록.
* super admin만 super admin(4) 부여 가능, 그 외는 1~3만 허용.
* super/본부만 4·5 부여 가능, 지자체 관리자는 1~3만.
*
* @return array<int,string>
*/
@@ -185,10 +185,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
{
@@ -50,8 +52,8 @@ class Auth extends BaseController
$loginId = trim($this->request->getPost('login_id'));
$password = $this->request->getPost('password');
$memberModel = model(MemberModel::class);
$member = $memberModel->findByLoginId($loginId);
$memberModel = model(MemberModel::class);
$member = $memberModel->findByLoginId($loginId);
$approvalModel = model(MemberApprovalRequestModel::class);
$logData = $this->buildLogData($loginId, $member?->mb_idx);
@@ -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', '로그인되었습니다.');
public function verifyTwoFactor()
{
if (session()->get('logged_in')) {
return redirect()->to('/');
}
// super admin → 지자체 선택 페이지로 이동 (선택 후 관리자 페이지 사용)
if ((int) $member->mb_level === \Config\Roles::LEVEL_SUPER_ADMIN) {
return redirect()->to(site_url('admin/select-local-government'))->with('success', '로그인되었습니다.');
$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'));
}
return redirect()->to(site_url('/'))->with('success', '로그인되었습니다.');
$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

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

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

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

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

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

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

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

@@ -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": [

186
docs/SERVER_DEPLOY.md Normal file
View File

@@ -0,0 +1,186 @@
# 서버 배포 가이드
## 서버 정보
| 항목 | 값 |
|------|------|
| 서버 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
```

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

View File

@@ -8,17 +8,19 @@ SET FOREIGN_KEY_CHECKS = 0;
-- member: 로그인·권한용 회원 테이블
-- mb_state: 1=정상, 2=정지, 0=탈퇴
-- mb_level: app/Config/Roles.php 참고
-- 1=일반 사용자, 2=지정판매소, 3=지자체관리자, 4=super admin
-- 1=일반 사용자, 2=지정판매소, 3=지자체관리자, 4=super admin, 5=본부 관리자
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `member` (
`mb_idx` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '회원 PK',
`mb_id` VARCHAR(50) NOT NULL COMMENT '로그인 아이디',
`mb_passwd` VARCHAR(255) NOT NULL COMMENT '비밀번호 해시(password_hash)',
`mb_totp_secret` TEXT NULL DEFAULT NULL COMMENT 'TOTP 시크릿(암호화 저장, pii_encrypt)',
`mb_totp_enabled` TINYINT(1) UNSIGNED NOT NULL DEFAULT 0 COMMENT '1=등록 완료·검증 대상',
`mb_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '이름',
`mb_email` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '이메일',
`mb_phone` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '연락처',
`mb_lang` VARCHAR(10) NOT NULL DEFAULT 'ko' COMMENT '언어',
`mb_level` TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '1=일반, 2=지정판매소, 3=지자체관리자, 4=super admin (Roles.php)',
`mb_level` TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '1=일반, 2=지정판매소, 3=지자체관리자, 4=super admin, 5=본부 관리자 (Roles.php)',
`mb_group` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '권한 그룹 코드(Phase 2)',
`mb_lg_idx` INT UNSIGNED NULL DEFAULT NULL COMMENT '소속 지자체 PK(지자체관리자만 사용)',
`mb_state` TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '1=정상, 2=정지, 0=탈퇴',

View File

@@ -0,0 +1,9 @@
-- TOTP 2차 인증 컬럼 추가 (기존 DB 마이그레이션용)
-- docs/2차인증-TOTP-개발계획.md
-- 실행 예: mysql ... < writable/database/member_add_totp.sql
SET NAMES utf8mb4;
ALTER TABLE `member`
ADD COLUMN `mb_totp_secret` TEXT NULL DEFAULT NULL COMMENT 'TOTP 시크릿(Base32), 저장 시 pii_encrypt 권장' AFTER `mb_passwd`,
ADD COLUMN `mb_totp_enabled` TINYINT(1) UNSIGNED NOT NULL DEFAULT 0 COMMENT '1=등록·로그인 시 TOTP 검증' AFTER `mb_totp_secret`;