Compare commits
4 Commits
7af4f082c9
...
a0c75a4a31
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0c75a4a31 | ||
|
|
9193fc587e | ||
|
|
c3c731cda0 | ||
|
|
a3f92cd322 |
52
app/Config/Auth.php
Normal file
52
app/Config/Auth.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@ class Roles extends BaseConfig
|
|||||||
* mb_level 상수 (member.mb_level)
|
* mb_level 상수 (member.mb_level)
|
||||||
*/
|
*/
|
||||||
public const LEVEL_SUPER_ADMIN = 4;
|
public const LEVEL_SUPER_ADMIN = 4;
|
||||||
|
/** 본부 관리자 — 현재는 super admin과 동일한 관리자 권한(지자체 선택 후 작업). 추후 super 전용 기능 분리 시 여기만 조정 */
|
||||||
|
public const LEVEL_HEADQUARTERS_ADMIN = 5;
|
||||||
public const LEVEL_LOCAL_ADMIN = 3; // 지자체관리자
|
public const LEVEL_LOCAL_ADMIN = 3; // 지자체관리자
|
||||||
public const LEVEL_SHOP = 2; // 지정판매소
|
public const LEVEL_SHOP = 2; // 지정판매소
|
||||||
public const LEVEL_CITIZEN = 1; // 일반 사용자(시민)
|
public const LEVEL_CITIZEN = 1; // 일반 사용자(시민)
|
||||||
@@ -29,8 +31,27 @@ class Roles extends BaseConfig
|
|||||||
self::LEVEL_SHOP => '지정판매소',
|
self::LEVEL_SHOP => '지정판매소',
|
||||||
self::LEVEL_LOCAL_ADMIN => '지자체관리자',
|
self::LEVEL_LOCAL_ADMIN => '지자체관리자',
|
||||||
self::LEVEL_SUPER_ADMIN => 'super 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)
|
* 자체 회원가입 시 기본 역할 (mb_level)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ $routes->get('dashboard/classic-mock', 'Home::dashboardClassicMock');
|
|||||||
$routes->get('dashboard/modern', 'Home::dashboardModern');
|
$routes->get('dashboard/modern', 'Home::dashboardModern');
|
||||||
$routes->get('dashboard/dense', 'Home::dashboardDense');
|
$routes->get('dashboard/dense', 'Home::dashboardDense');
|
||||||
$routes->get('dashboard/charts', 'Home::dashboardCharts');
|
$routes->get('dashboard/charts', 'Home::dashboardCharts');
|
||||||
|
$routes->get('dashboard/blend', 'Home::dashboardBlend');
|
||||||
$routes->get('bag/inventory-inquiry', 'Home::inventoryInquiry');
|
$routes->get('bag/inventory-inquiry', 'Home::inventoryInquiry');
|
||||||
$routes->get('bag/waste-suibal-enterprise', 'Home::wasteSuibalEnterprise');
|
$routes->get('bag/waste-suibal-enterprise', 'Home::wasteSuibalEnterprise');
|
||||||
|
|
||||||
@@ -29,6 +30,10 @@ $routes->get('bag/help', 'Bag::help');
|
|||||||
// Auth
|
// Auth
|
||||||
$routes->get('login', 'Auth::showLoginForm');
|
$routes->get('login', 'Auth::showLoginForm');
|
||||||
$routes->post('login', 'Auth::login');
|
$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('logout', 'Auth::logout');
|
||||||
$routes->get('register', 'Auth::showRegisterForm');
|
$routes->get('register', 'Auth::showRegisterForm');
|
||||||
$routes->post('register', 'Auth::register');
|
$routes->post('register', 'Auth::register');
|
||||||
|
|||||||
@@ -90,8 +90,8 @@ class Access extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$requestedLevel = (int) $requestRow->mar_requested_level;
|
$requestedLevel = (int) $requestRow->mar_requested_level;
|
||||||
if ($requestedLevel === Roles::LEVEL_SUPER_ADMIN) {
|
if ($requestedLevel === Roles::LEVEL_SUPER_ADMIN || $requestedLevel === Roles::LEVEL_HEADQUARTERS_ADMIN) {
|
||||||
return redirect()->to(site_url('admin/access/approvals'))->with('error', 'super admin 역할 요청은 승인할 수 없습니다.');
|
return redirect()->to(site_url('admin/access/approvals'))->with('error', '상위 관리자 역할 요청은 승인할 수 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$db = db_connect();
|
$db = db_connect();
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class DesignatedShop extends BaseController
|
|||||||
|
|
||||||
private function isSuperAdmin(): bool
|
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
|
private function isLocalAdmin(): bool
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class LocalGovernment extends BaseController
|
|||||||
|
|
||||||
private function isSuperAdmin(): bool
|
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()) {
|
if (! $this->isSuperAdmin()) {
|
||||||
return redirect()->to(site_url('admin'))
|
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')->findAll();
|
||||||
@@ -47,7 +47,7 @@ class LocalGovernment extends BaseController
|
|||||||
{
|
{
|
||||||
if (! $this->isSuperAdmin()) {
|
if (! $this->isSuperAdmin()) {
|
||||||
return redirect()->to(site_url('admin/local-governments'))
|
return redirect()->to(site_url('admin/local-governments'))
|
||||||
->with('error', '지자체 등록은 super admin만 가능합니다.');
|
->with('error', '지자체 등록은 상위 관리자만 가능합니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('admin/layout', [
|
return view('admin/layout', [
|
||||||
@@ -63,7 +63,7 @@ class LocalGovernment extends BaseController
|
|||||||
{
|
{
|
||||||
if (! $this->isSuperAdmin()) {
|
if (! $this->isSuperAdmin()) {
|
||||||
return redirect()->to(site_url('admin/local-governments'))
|
return redirect()->to(site_url('admin/local-governments'))
|
||||||
->with('error', '지자체 등록은 super admin만 가능합니다.');
|
->with('error', '지자체 등록은 상위 관리자만 가능합니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$rules = [
|
$rules = [
|
||||||
|
|||||||
@@ -205,8 +205,8 @@ class Menu extends BaseController
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
$levels = array_map('intval', $levels);
|
$levels = array_map('intval', $levels);
|
||||||
// super admin(4)은 DB 저장 대상 아님. 1,2,3은 그대로 저장
|
// super/본부(4·5)는 mm_level 저장 대상 아님. 1,2,3은 그대로 저장
|
||||||
$levels = array_filter($levels, static fn ($v) => $v > 0 && $v !== \Config\Roles::LEVEL_SUPER_ADMIN);
|
$levels = array_filter($levels, static fn ($v) => $v > 0 && ! \Config\Roles::isSuperAdminEquivalent($v));
|
||||||
|
|
||||||
return implode(',', array_values($levels));
|
return implode(',', array_values($levels));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ use Config\Roles;
|
|||||||
class SelectLocalGovernment extends BaseController
|
class SelectLocalGovernment extends BaseController
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* 지자체 선택 화면 (super admin 전용)
|
* 지자체 선택 화면 (super·본부 관리자)
|
||||||
*/
|
*/
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
if ((int) session()->get('mb_level') !== Roles::LEVEL_SUPER_ADMIN) {
|
if (! Roles::isSuperAdminEquivalent((int) session()->get('mb_level'))) {
|
||||||
return redirect()->to(site_url('admin'))->with('error', '지자체 선택은 super admin만 사용할 수 있습니다.');
|
return redirect()->to(site_url('admin'))->with('error', '지자체 선택은 상위 관리자만 사용할 수 있습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$list = model(LocalGovernmentModel::class)
|
$list = model(LocalGovernmentModel::class)
|
||||||
@@ -35,8 +35,8 @@ class SelectLocalGovernment extends BaseController
|
|||||||
*/
|
*/
|
||||||
public function store()
|
public function store()
|
||||||
{
|
{
|
||||||
if ((int) session()->get('mb_level') !== Roles::LEVEL_SUPER_ADMIN) {
|
if (! Roles::isSuperAdminEquivalent((int) session()->get('mb_level'))) {
|
||||||
return redirect()->to(site_url('admin'))->with('error', '지자체 선택은 super admin만 사용할 수 있습니다.');
|
return redirect()->to(site_url('admin'))->with('error', '지자체 선택은 상위 관리자만 사용할 수 있습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$lgIdx = (int) $this->request->getPost('lg_idx');
|
$lgIdx = (int) $this->request->getPost('lg_idx');
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ class User extends BaseController
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 현재 로그인한 관리자가 부여 가능한 역할 목록.
|
* 현재 로그인한 관리자가 부여 가능한 역할 목록.
|
||||||
* super admin만 super admin(4) 부여 가능, 그 외는 1~3만 허용.
|
* super/본부만 4·5 부여 가능, 지자체 관리자는 1~3만.
|
||||||
*
|
*
|
||||||
* @return array<int,string>
|
* @return array<int,string>
|
||||||
*/
|
*/
|
||||||
@@ -185,10 +185,11 @@ class User extends BaseController
|
|||||||
{
|
{
|
||||||
$levelNames = $this->roles->levelNames;
|
$levelNames = $this->roles->levelNames;
|
||||||
$myLevel = (int) session()->get('mb_level');
|
$myLevel = (int) session()->get('mb_level');
|
||||||
if ($myLevel === Roles::LEVEL_SUPER_ADMIN) {
|
if (Roles::isSuperAdminEquivalent($myLevel)) {
|
||||||
return $levelNames;
|
return $levelNames;
|
||||||
}
|
}
|
||||||
unset($levelNames[Roles::LEVEL_SUPER_ADMIN]);
|
unset($levelNames[Roles::LEVEL_SUPER_ADMIN], $levelNames[Roles::LEVEL_HEADQUARTERS_ADMIN]);
|
||||||
|
|
||||||
return $levelNames;
|
return $levelNames;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
namespace App\Controllers;
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use App\Libraries\TotpService;
|
||||||
use App\Models\LocalGovernmentModel;
|
use App\Models\LocalGovernmentModel;
|
||||||
use App\Models\MemberApprovalRequestModel;
|
use App\Models\MemberApprovalRequestModel;
|
||||||
use App\Models\MemberLogModel;
|
use App\Models\MemberLogModel;
|
||||||
use App\Models\MemberModel;
|
use App\Models\MemberModel;
|
||||||
|
use CodeIgniter\HTTP\RedirectResponse;
|
||||||
|
|
||||||
class Auth extends BaseController
|
class Auth extends BaseController
|
||||||
{
|
{
|
||||||
@@ -123,35 +125,177 @@ class Auth extends BaseController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 로그인 성공
|
if ($this->needsTotpStep($member)) {
|
||||||
$sessionData = [
|
$this->beginPending2faSession((int) $member->mb_idx);
|
||||||
'mb_idx' => $member->mb_idx,
|
$enabled = (int) ($member->mb_totp_enabled ?? 0) === 1;
|
||||||
'mb_id' => $member->mb_id,
|
if ($enabled) {
|
||||||
'mb_name' => $member->mb_name,
|
return redirect()->to(site_url('login/two-factor'));
|
||||||
'mb_level' => $member->mb_level,
|
}
|
||||||
'mb_lg_idx' => $member->mb_lg_idx ?? null,
|
session()->set('pending_totp_setup', true);
|
||||||
'logged_in' => true,
|
|
||||||
];
|
|
||||||
session()->set($sessionData);
|
|
||||||
|
|
||||||
$memberModel->update($member->mb_idx, [
|
return redirect()->to(site_url('login/totp-setup'));
|
||||||
'mb_latestdate' => date('Y-m-d H:i:s'),
|
}
|
||||||
'mb_login_fail_count' => 0,
|
|
||||||
'mb_locked_until' => null,
|
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()
|
public function logout()
|
||||||
@@ -182,6 +326,7 @@ class Auth extends BaseController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->clearPending2faSession();
|
||||||
session()->destroy();
|
session()->destroy();
|
||||||
|
|
||||||
return redirect()->to('login')->with('success', '로그아웃되었습니다.');
|
return redirect()->to('login')->with('success', '로그아웃되었습니다.');
|
||||||
@@ -298,6 +443,130 @@ class Auth extends BaseController
|
|||||||
return redirect()->to('login')->with('success', '회원가입이 완료되었습니다. 관리자 승인 후 로그인 가능합니다.');
|
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
|
private function buildLogData(string $mbId, ?int $mbIdx): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 재고 조회(수불) 화면 (목업)
|
* 재고 조회(수불) 화면 (목업)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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
|
class AdminAuthFilter implements FilterInterface
|
||||||
{
|
{
|
||||||
@@ -22,15 +22,16 @@ class AdminAuthFilter implements FilterInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
$level = (int) session()->get('mb_level');
|
$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', '관리자만 접근할 수 있습니다.');
|
return redirect()->to(site_url('/'))->with('error', '관리자만 접근할 수 있습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Super admin: 지자체 미선택 시 지자체 선택 페이지로 유도 (지자체 선택·지자체 CRUD는 미선택도 허용)
|
// Super/본부: 지자체 미선택 시 지자체 선택 페이지로 유도 (지자체 선택·지자체 CRUD는 미선택도 허용)
|
||||||
$uri = $request->getUri();
|
$uri = $request->getUri();
|
||||||
$seg2 = $uri->getSegment(2);
|
$seg2 = $uri->getSegment(2);
|
||||||
$allowedWithoutSelection = ['select-local-government', 'local-governments'];
|
$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');
|
$selected = session()->get('admin_selected_lg_idx');
|
||||||
if ($selected === null || $selected === '') {
|
if ($selected === null || $selected === '') {
|
||||||
return redirect()->to(site_url('admin/select-local-government'))->with('error', '작업할 지자체를 먼저 선택해 주세요.');
|
return redirect()->to(site_url('admin/select-local-government'))->with('error', '작업할 지자체를 먼저 선택해 주세요.');
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ use Config\Roles;
|
|||||||
if (! function_exists('admin_effective_lg_idx')) {
|
if (! function_exists('admin_effective_lg_idx')) {
|
||||||
/**
|
/**
|
||||||
* 현재 로그인한 관리자가 작업 대상으로 사용하는 지자체 PK.
|
* 현재 로그인한 관리자가 작업 대상으로 사용하는 지자체 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
|
function admin_effective_lg_idx(): ?int
|
||||||
{
|
{
|
||||||
$level = (int) session()->get('mb_level');
|
$level = (int) session()->get('mb_level');
|
||||||
if ($level === Roles::LEVEL_SUPER_ADMIN) {
|
if (Roles::isSuperAdminEquivalent($level)) {
|
||||||
$idx = session()->get('admin_selected_lg_idx');
|
$idx = session()->get('admin_selected_lg_idx');
|
||||||
return $idx !== null && $idx !== '' ? (int) $idx : null;
|
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')) {
|
if (! function_exists('get_admin_nav_items')) {
|
||||||
/**
|
/**
|
||||||
* 관리자 상단 메뉴 항목 (DB menu 테이블, admin 타입, 현재 지자체·mb_level 기준, 평면 배열).
|
* 관리자 상단 메뉴 항목 (DB menu 테이블, admin 타입, 현재 지자체·mb_level 기준, 평면 배열).
|
||||||
* 지자체 미선택(super admin)이면 빈 배열. 테이블/조회 실패 시에도 빈 배열.
|
* 지자체 미선택(super/본부)이면 빈 배열. 테이블/조회 실패 시에도 빈 배열.
|
||||||
*
|
*
|
||||||
* 하위 메뉴 포함 트리 구조가 필요하면 get_admin_nav_tree() 사용.
|
* 하위 메뉴 포함 트리 구조가 필요하면 get_admin_nav_tree() 사용.
|
||||||
*/
|
*/
|
||||||
|
|||||||
49
app/Libraries/TotpService.php
Normal file
49
app/Libraries/TotpService.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ class MemberModel extends Model
|
|||||||
protected $allowedFields = [
|
protected $allowedFields = [
|
||||||
'mb_id',
|
'mb_id',
|
||||||
'mb_passwd',
|
'mb_passwd',
|
||||||
|
'mb_totp_secret',
|
||||||
|
'mb_totp_enabled',
|
||||||
'mb_name',
|
'mb_name',
|
||||||
'mb_email',
|
'mb_email',
|
||||||
'mb_phone',
|
'mb_phone',
|
||||||
|
|||||||
@@ -28,12 +28,12 @@ class MenuModel extends Model
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 특정 mb_level에 노출할 메뉴만 필터링 (mm_is_view=Y, mm_level에 해당 레벨 포함 또는 빈값).
|
* 특정 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
|
public function getVisibleByLevel(int $mtIdx, int $mbLevel, int $lgIdx): array
|
||||||
{
|
{
|
||||||
$all = $this->getAllByType($mtIdx, $lgIdx);
|
$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'));
|
return array_values(array_filter($all, static fn ($row) => (string) $row->mm_is_view === 'Y'));
|
||||||
}
|
}
|
||||||
$levelStr = (string) $mbLevel;
|
$levelStr = (string) $mbLevel;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ $n = $uriObj->getTotalSegments();
|
|||||||
$uri = $n >= 2 ? $uriObj->getSegment(2) : '';
|
$uri = $n >= 2 ? $uriObj->getSegment(2) : '';
|
||||||
$seg3 = $n >= 3 ? $uriObj->getSegment(3) : '';
|
$seg3 = $n >= 3 ? $uriObj->getSegment(3) : '';
|
||||||
$mbLevel = (int) session()->get('mb_level');
|
$mbLevel = (int) session()->get('mb_level');
|
||||||
$isSuperAdmin = ($mbLevel === \Config\Roles::LEVEL_SUPER_ADMIN);
|
$isSuperAdmin = \Config\Roles::isSuperAdminEquivalent($mbLevel);
|
||||||
$effectiveLgIdx = admin_effective_lg_idx();
|
$effectiveLgIdx = admin_effective_lg_idx();
|
||||||
$effectiveLgName = null;
|
$effectiveLgName = null;
|
||||||
if ($effectiveLgIdx) {
|
if ($effectiveLgIdx) {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ $list = $list ?? [];
|
|||||||
$mtIdx = (int) ($mtIdx ?? 0);
|
$mtIdx = (int) ($mtIdx ?? 0);
|
||||||
$mtCode = (string) ($mtCode ?? '');
|
$mtCode = (string) ($mtCode ?? '');
|
||||||
$levelNames = $levelNames ?? [];
|
$levelNames = $levelNames ?? [];
|
||||||
$superAdminLevel = \Config\Roles::LEVEL_SUPER_ADMIN;
|
|
||||||
?>
|
?>
|
||||||
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-y-2">
|
<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 === '') {
|
if ((string) $row->mm_level === '') {
|
||||||
echo '전체';
|
echo '전체';
|
||||||
} else {
|
} 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);
|
$labels = array_map(fn ($lv) => $levelNames[trim($lv)] ?? trim($lv), $levels);
|
||||||
echo esc(implode(', ', $labels) ?: '전체');
|
echo esc(implode(', ', $labels) ?: '전체');
|
||||||
}
|
}
|
||||||
@@ -146,7 +145,7 @@ $superAdminLevel = \Config\Roles::LEVEL_SUPER_ADMIN;
|
|||||||
<span class="text-sm">전체</span>
|
<span class="text-sm">전체</span>
|
||||||
</label>
|
</label>
|
||||||
<?php foreach ($levelNames as $lv => $name): ?>
|
<?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">
|
<label class="inline-flex items-center gap-1 mm-level-label">
|
||||||
<input type="checkbox" name="mm_level[]" value="<?= (int) $lv ?>" class="mm-level-cb"/>
|
<input type="checkbox" name="mm_level[]" value="<?= (int) $lv ?>" class="mm-level-cb"/>
|
||||||
<span class="text-sm"><?= esc($name) ?></span>
|
<span class="text-sm"><?= esc($name) ?></span>
|
||||||
|
|||||||
61
app/Views/auth/login_two_factor.php
Normal file
61
app/Views/auth/login_two_factor.php
Normal 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&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>
|
||||||
@@ -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>
|
<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">
|
<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 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>
|
<option value="<?= $level ?>" <?= old('mb_level', config('Roles')->defaultLevelForSelfRegister) == $level ? 'selected' : '' ?>><?= esc($name) ?></option>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
69
app/Views/auth/totp_setup.php
Normal file
69
app/Views/auth/totp_setup.php
Normal 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&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>
|
||||||
@@ -147,7 +147,7 @@ $siteNavTree = get_site_nav_tree();
|
|||||||
</nav>
|
</nav>
|
||||||
<?php
|
<?php
|
||||||
$mbLevel = (int) session()->get('mb_level');
|
$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">
|
<div class="flex items-center gap-2">
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ $mbName = session()->get('mb_name') ?? '담당자';
|
|||||||
<span class="text-gray-300 hidden sm:inline">|</span>
|
<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>
|
<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>
|
<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('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="로그아웃">
|
<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>
|
<i class="fa-solid fa-arrow-right-from-bracket text-lg"></i>
|
||||||
@@ -259,11 +261,6 @@ $mbName = session()->get('mb_name') ?? '담당자';
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
700
app/Views/bag/lg_dashboard_blend.php
Normal file
700
app/Views/bag/lg_dashboard_blend.php
Normal 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>
|
||||||
@@ -11,6 +11,7 @@ $dashClassic = base_url('dashboard/classic-mock');
|
|||||||
$dashModern = base_url('dashboard/modern');
|
$dashModern = base_url('dashboard/modern');
|
||||||
$dashDense = base_url('dashboard/dense');
|
$dashDense = base_url('dashboard/dense');
|
||||||
$dashCharts = base_url('dashboard/charts');
|
$dashCharts = base_url('dashboard/charts');
|
||||||
|
$dashBlend = base_url('dashboard/blend');
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="ko">
|
<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>
|
<a href="<?= esc($dashModern) ?>" class="text-[#2b4c8c] hover:underline whitespace-nowrap hidden md:inline">모던</a>
|
||||||
<span class="text-gray-300 hidden md:inline">|</span>
|
<span class="text-gray-300 hidden md:inline">|</span>
|
||||||
<a href="<?= esc($dashDense) ?>" class="text-[#2b4c8c] hover:underline whitespace-nowrap hidden md:inline">종합</a>
|
<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="로그아웃">
|
<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>
|
<i class="fa-solid fa-arrow-right-from-bracket"></i>
|
||||||
</a>
|
</a>
|
||||||
@@ -165,6 +168,7 @@ $dashCharts = base_url('dashboard/charts');
|
|||||||
<a href="<?= esc($dashClassic) ?>" class="text-[#2b4c8c] hover:underline">/dashboard</a>
|
<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($dashModern) ?>" class="text-[#2b4c8c] hover:underline">/dashboard/modern</a>
|
||||||
· <a href="<?= esc($dashDense) ?>" class="text-[#2b4c8c] hover:underline">/dashboard/dense</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>
|
· <strong class="text-gray-600">/dashboard/charts</strong>
|
||||||
</p>
|
</p>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ $dashClassic = base_url('dashboard/classic-mock');
|
|||||||
$dashModern = base_url('dashboard/modern');
|
$dashModern = base_url('dashboard/modern');
|
||||||
$dashDense = base_url('dashboard/dense');
|
$dashDense = base_url('dashboard/dense');
|
||||||
$dashCharts = base_url('dashboard/charts');
|
$dashCharts = base_url('dashboard/charts');
|
||||||
|
$dashBlend = base_url('dashboard/blend');
|
||||||
|
|
||||||
$kpiTop = [
|
$kpiTop = [
|
||||||
['icon' => 'fa-triangle-exclamation', 'c' => 'text-amber-700', 'bg' => 'bg-amber-50', 'v' => '3', 'l' => '재고부족', 'sub' => '품목'],
|
['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>
|
<span class="text-gray-300 hidden md:inline">|</span>
|
||||||
<a href="<?= esc($dashModern) ?>" class="text-[#2b4c8c] hover:underline whitespace-nowrap hidden md:inline">모던</a>
|
<a href="<?= esc($dashModern) ?>" class="text-[#2b4c8c] hover:underline whitespace-nowrap hidden md:inline">모던</a>
|
||||||
<span class="text-gray-300 hidden md:inline">|</span>
|
<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="<?= 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="로그아웃">
|
<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>
|
<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($dashClassic) ?>" class="text-[#2b4c8c] hover:underline">/dashboard</a>
|
||||||
· <a href="<?= esc($dashModern) ?>" class="text-[#2b4c8c] hover:underline">/dashboard/modern</a>
|
· <a href="<?= esc($dashModern) ?>" class="text-[#2b4c8c] hover:underline">/dashboard/modern</a>
|
||||||
· <strong class="text-gray-600">/dashboard/dense</strong> (이 화면)
|
· <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>
|
· <a href="<?= esc($dashCharts) ?>" class="text-[#2b4c8c] hover:underline">/dashboard/charts</a>
|
||||||
</p>
|
</p>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ $dashClassic = base_url('dashboard/classic-mock');
|
|||||||
$dashModern = base_url('dashboard/modern');
|
$dashModern = base_url('dashboard/modern');
|
||||||
$dashDense = base_url('dashboard/dense');
|
$dashDense = base_url('dashboard/dense');
|
||||||
$dashCharts = base_url('dashboard/charts');
|
$dashCharts = base_url('dashboard/charts');
|
||||||
|
$dashBlend = base_url('dashboard/blend');
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="ko">
|
<html lang="ko">
|
||||||
@@ -70,6 +71,8 @@ $dashCharts = base_url('dashboard/charts');
|
|||||||
<span class="text-gray-300 hidden sm:inline">|</span>
|
<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>
|
<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>
|
<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="<?= 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="로그아웃">
|
<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>
|
<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">
|
<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>
|
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>
|
· <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>
|
· <a href="<?= esc($dashCharts) ?>" class="text-[#2b4c8c] hover:underline">/dashboard/charts</a>
|
||||||
<span class="block sm:inline mt-1 sm:mt-0">· 상단 메뉴는 동일</span>
|
<span class="block sm:inline mt-1 sm:mt-0">· 상단 메뉴는 동일</span>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
"codeigniter4/framework": "^4.7"
|
"codeigniter4/framework": "^4.7",
|
||||||
|
"robthree/twofactorauth": "^3.0"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.9",
|
"fakerphp/faker": "^1.9",
|
||||||
|
|||||||
83
composer.lock
generated
83
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "f5cce40800fa5dae1504b9364f585e6a",
|
"content-hash": "62775ba19440bda6e4bb1fbe91908932",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "codeigniter4/framework",
|
"name": "codeigniter4/framework",
|
||||||
@@ -193,6 +193,87 @@
|
|||||||
"source": "https://github.com/php-fig/log/tree/3.0.2"
|
"source": "https://github.com/php-fig/log/tree/3.0.2"
|
||||||
},
|
},
|
||||||
"time": "2024-09-11T13:17:53+00:00"
|
"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": [
|
"packages-dev": [
|
||||||
|
|||||||
9
env
9
env
@@ -55,6 +55,15 @@
|
|||||||
|
|
||||||
# encryption.key =
|
# encryption.key =
|
||||||
|
|
||||||
|
#--------------------------------------------------------------------
|
||||||
|
# AUTH (TOTP 2차 인증) — 관리자(mb_level 3·4·5)만 적용, 로컬은 false 권장
|
||||||
|
#--------------------------------------------------------------------
|
||||||
|
|
||||||
|
# auth.requireTotp = true
|
||||||
|
# auth.totpIssuer = "쓰레기봉투 물류시스템"
|
||||||
|
# auth.totpMaxAttempts = 5
|
||||||
|
# auth.pending2faTtlSeconds = 600
|
||||||
|
|
||||||
#--------------------------------------------------------------------
|
#--------------------------------------------------------------------
|
||||||
# SESSION
|
# SESSION
|
||||||
#--------------------------------------------------------------------
|
#--------------------------------------------------------------------
|
||||||
|
|||||||
@@ -8,17 +8,19 @@ SET FOREIGN_KEY_CHECKS = 0;
|
|||||||
-- member: 로그인·권한용 회원 테이블
|
-- member: 로그인·권한용 회원 테이블
|
||||||
-- mb_state: 1=정상, 2=정지, 0=탈퇴
|
-- mb_state: 1=정상, 2=정지, 0=탈퇴
|
||||||
-- mb_level: app/Config/Roles.php 참고
|
-- 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` (
|
CREATE TABLE IF NOT EXISTS `member` (
|
||||||
`mb_idx` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '회원 PK',
|
`mb_idx` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '회원 PK',
|
||||||
`mb_id` VARCHAR(50) NOT NULL COMMENT '로그인 아이디',
|
`mb_id` VARCHAR(50) NOT NULL COMMENT '로그인 아이디',
|
||||||
`mb_passwd` VARCHAR(255) NOT NULL COMMENT '비밀번호 해시(password_hash)',
|
`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_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '이름',
|
||||||
`mb_email` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '이메일',
|
`mb_email` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '이메일',
|
||||||
`mb_phone` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '연락처',
|
`mb_phone` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '연락처',
|
||||||
`mb_lang` VARCHAR(10) NOT NULL DEFAULT 'ko' 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_group` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '권한 그룹 코드(Phase 2)',
|
||||||
`mb_lg_idx` INT UNSIGNED NULL DEFAULT NULL COMMENT '소속 지자체 PK(지자체관리자만 사용)',
|
`mb_lg_idx` INT UNSIGNED NULL DEFAULT NULL COMMENT '소속 지자체 PK(지자체관리자만 사용)',
|
||||||
`mb_state` TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '1=정상, 2=정지, 0=탈퇴',
|
`mb_state` TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '1=정상, 2=정지, 0=탈퇴',
|
||||||
|
|||||||
9
writable/database/member_add_totp.sql
Normal file
9
writable/database/member_add_totp.sql
Normal 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`;
|
||||||
Reference in New Issue
Block a user