daemon-idc init...1

This commit is contained in:
최준흠 2026-02-09 18:38:26 +09:00
parent 4916b7c5bf
commit d6e1db1155
30 changed files with 1256 additions and 137 deletions

View File

@ -93,7 +93,7 @@ class App extends BaseConfig
* strings (like currency markers, numbers, etc), that your program
* should run under for this request.
*/
public string $defaultLocale = 'en';
public string $defaultLocale = 'ko';
/**
* --------------------------------------------------------------------------

View File

@ -287,4 +287,11 @@ define("BOARD", [
'NOTICE' => 'notice',
'REQUESTTASK' => 'requesttask'
],
]);
//사이트 선택관련
define("SITES", [
"primeidc" => "PRIME",
"itsolution" => "ITSOLUTION",
"gdidc" => "GDIDC",
]);

107
app/Config/Layout.php Normal file
View File

@ -0,0 +1,107 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
class Layout extends BaseConfig
{
public const KEYWORD = '일본IDC 일본서버 일본 서버 일본호스팅 서버호스팅 디도스 공격 해외 호스팅 DDOS 방어 ddos 의뢰 디도스 보안 일본 단독서버 가상서버';
public array $layouts = [
'auth' => [
'title' => self::KEYWORD,
'path' => 'auth',
'layout' => 'layouts' . DIRECTORY_SEPARATOR . 'auth',
'template' => 'templates' . DIRECTORY_SEPARATOR . 'auth',
'metas' => [
'<meta charset="UTF-8">',
'<meta name="viewport" content="width=device-width, initial-scale=1.0">',
'<meta http-equiv="X-UA-Compatible" content="IE=Edge">',
'<meta name="subject" content="Daemon IDC">',
'<meta name="description" content="' . self::KEYWORD . '">',
'<meta name="keywords" content="' . self::KEYWORD . '">',
'<meta property="og:type" content="website">',
'<meta property="og:title" content="Daemon IDC">',
'<meta property="og:description" content="' . self::KEYWORD . '">',
],
'stylesheets' => [
'<link rel="icon" href="/favicon.ico">',
'<link href="//cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous">',
'<link rel="stylesheet" href="/css/common/style.css" />',
],
'javascripts' => [
'<script src="//cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz" crossorigin="anonymous"></script>',
],
'footerScripts' => []
],
'front' => [
'title' => self::KEYWORD,
'path' => 'front',
'layout' => 'layouts' . DIRECTORY_SEPARATOR . 'front',
'template' => 'templates' . DIRECTORY_SEPARATOR . 'front',
'topmenus' => ['aboutus', 'hosting', 'service', 'support'],
'metas' => [
'<meta charset="UTF-8">',
'<meta name="viewport" content="width=device-width, initial-scale=1.0">',
'<meta http-equiv="X-UA-Compatible" content="IE=Edge">',
'<meta name="subject" content="Daemon IDC">',
'<meta name="description" content="' . self::KEYWORD . '">',
'<meta name="keywords" content="' . self::KEYWORD . '">',
'<meta property="og:type" content="website">',
'<meta property="og:title" content="Daemon IDC">',
'<meta property="og:description" content="' . self::KEYWORD . '">',
],
'stylesheets' => [
'<link rel="icon" href="/favicon.ico">',
'<link rel="stylesheet" href="//code.jquery.com/ui/1.12.1/themes/base/jquery-ui.css">',
'<link rel="stylesheet" href="//cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css">',
'<link rel="stylesheet" href="//cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css">',
'<link rel="stylesheet" href="/css/common/style.css" />',
],
'javascripts' => [
'<script src="//cdn.jsdelivr.net/npm/jquery@3.7.0/dist/jquery.min.js"></script>',
'<script src="//code.jquery.com/ui/1.12.1/jquery-ui.min.js" integrity="sha256-VazP97ZCwtekAsvgPBSUwPFKdrwD3unUfSGVYrahUqU=" crossorigin="anonymous"></script>',
'<script src="//cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>',
'<script src="//cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>',
],
'footerScripts' => []
],
'admin' => [
'title' => '관리자화면',
'path' => 'admin',
'layout' => 'layouts' . DIRECTORY_SEPARATOR . 'admin',
'template' => 'templates' . DIRECTORY_SEPARATOR . 'admin',
'metas' => [
'<meta charset="UTF-8">',
'<meta name="viewport" content="width=device-width, initial-scale=1.0">',
'<meta http-equiv="X-UA-Compatible" content="IE=Edge">',
'<meta name="subject" content="Daemon IDC">',
'<meta name="description" content="' . self::KEYWORD . '">',
'<meta name="keywords" content="' . self::KEYWORD . '">',
'<meta property="og:type" content="website">',
'<meta property="og:title" content="Daemon IDC">',
'<meta property="og:description" content="' . self::KEYWORD . '">',
],
'stylesheets' => [
'<link rel="icon" href="/favicon.ico">',
'<link rel="stylesheet" href="//code.jquery.com/ui/1.12.1/themes/base/jquery-ui.css">',
'<link rel="stylesheet" href="//cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css">',
'<link rel="stylesheet" href="//cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css">',
'<link rel="stylesheet" href="//cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css">',
'<link rel="stylesheet" href="//cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css" />',
'<link rel="stylesheet" href="/assets/tagify/dist/tagify.css">',
'<link rel="stylesheet" href="/css/common/style.css" />',
],
'javascripts' => [
'<script src="//cdn.jsdelivr.net/npm/jquery@3.7.0/dist/jquery.min.js"></script>',
'<script src="//code.jquery.com/ui/1.12.1/jquery-ui.min.js" integrity="sha256-VazP97ZCwtekAsvgPBSUwPFKdrwD3unUfSGVYrahUqU=" crossorigin="anonymous"></script>',
'<script src="//cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>',
'<script src="//cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>',
'<script src="/assets/tinymce/tinymce.min.js" referrerpolicy="origin"></script>',
'<script src="/assets/tagify/dist/tagify.js"></script>'
],
'footerScripts' => []
],
];
}

View File

@ -8,7 +8,7 @@ use App\Services\Auth\GoogleService;
use App\Services\Auth\LocalService;
use App\Services\BoardService;
use App\Services\UserService;
use App\Services\Customer\ClientService;
/**
* Services Configuration file.
*
@ -86,4 +86,15 @@ class Services extends BaseService
new \App\Models\BoardModel(),
);
}
//Customer
public static function customer_clientservice($getShared = true): ClientService
{
if ($getShared) {
return static::getSharedInstance(__FUNCTION__);
}
return new ClientService(
new \App\Models\Customer\ClientModel(),
);
}
}

View File

@ -0,0 +1,70 @@
<?php
namespace App\Controllers\Admin\Customer;
use App\Entities\Customer\ClientEntity;
use CodeIgniter\HTTP\RedirectResponse;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use Psr\Log\LoggerInterface;
use RuntimeException;
class ClientController extends CustomerController
{
public function initController(RequestInterface $request, ResponseInterface $response, LoggerInterface $logger)
{
parent::initController($request, $response, $logger);
if ($this->service === null) {
$this->service = service('customer_clientservice');
}
$this->addActionPaths('client');
}
//기본 함수 작업
//Custom 추가 함수
//고객 상세정보
public function detail(mixed $uid): string|RedirectResponse
{
try {
$action = __FUNCTION__;
$this->action_init_process($action);
//Return Url정의
$this->getAuthContext()->pushCurrentUrl($this->request->getUri()->getPath() . ($this->request->getUri()->getQuery() ? "?" . $this->request->getUri()->getQuery() : ""));
//일괄작업용 Fields정의
$entity = $this->service->getEntity($uid);
if (!$entity instanceof ClientEntity) {
throw new RuntimeException(static::class . '->' . __FUNCTION__ . "에서 {$uid}에 해당하는 고객정보를 찾을수 없습니다.");
}
$this->addViewDatas('totalCounts', service('equipment_serverservice')->getTotalServiceCount(['serviceinfo.clientinfo_uid' => $entity->getPK()]));
$this->addViewDatas('totalAmounts', service('customer_serviceservice')->getTotalAmounts([
'clientinfo_uid' => $entity->getPK(),
'status' => STATUS['AVAILABLE']
]));
//서비스별 미납 Count
$this->addViewDatas('unPaids', service('paymentservice')->getUnPaids('clientinfo_uid', [
'clientinfo_uid' => $entity->getPK()
]));
$this->addViewDatas('serviceEntities', service('customer_serviceservice')->getEntities(['clientinfo_uid' => $entity->getPK()]));
$this->addViewDatas('entity', $entity);
helper(['form']);
return $this->action_render_process($action, $this->getViewDatas(), $this->request->getVar('ActionTemplate') ?? 'client');
} catch (\Throwable $e) {
return $this->action_redirect_process('error', static::class . '->' . __FUNCTION__ . "에서 {$this->getTitle()} 고객 Detail Page 오류:" . $e->getMessage());
}
}
//비고사항 변경
public function history(int $uid): RedirectResponse|string
{
try {
$history = $this->request->getPost('history');
if (!$history) {
throw new RuntimeException(static::class . '->' . __FUNCTION__ . "에서 오류발생: 비고가 정의되지 않았습니다.");
}
$entity = $this->service->modify($uid, ['history' => $history]);
$this->addViewDatas('entity', $entity);
return $this->action_redirect_process('info', static::class . '->' . __FUNCTION__ . "에서 {$this->getTitle()} 비고설정이 완료되었습니다.");
} catch (\Throwable $e) {
return $this->action_redirect_process('error', static::class . '->' . __FUNCTION__ . "에서 {$this->getTitle()} 비고설정 오류:" . $e->getMessage());
}
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Controllers\Admin\Customer;
use App\Controllers\Admin\AdminController;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use Psr\Log\LoggerInterface;
abstract class CustomerController extends AdminController
{
public function initController(RequestInterface $request, ResponseInterface $response, LoggerInterface $logger)
{
parent::initController($request, $response, $logger);
$this->addActionPaths('customer');
}
//Index,FieldForm관련
}

View File

@ -40,7 +40,7 @@ class Home extends AbstractWebController
$this->addViewDatas('boardRequestTaskCount', service('boardservice')->getRequestTaskCount($this->getAuthContext()->getUID()));
//Total 서버 현황
//interval을 기준으로 최근 신규 서비스정보 가져오기
$interval = intval($this->request->getVar('interval') ?? SERVICE['NEW_INTERVAL']);
$interval = intval($this->request->getVar('interval') ?? 7);
$this->addViewDatas('interval', $interval);
$newServiceEntities = $this->service->getNewServiceEntities($interval);
$this->addViewDatas('newServiceEntities', $newServiceEntities);

View File

@ -2,10 +2,40 @@
namespace App\Controllers;
class Home extends BaseController
use App\Controllers\AbstractWebController;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use Psr\Log\LoggerInterface;
class Home extends AbstractWebController
{
private $_layout = 'front';
protected $layouts = [];
public function initController(RequestInterface $request, ResponseInterface $response, LoggerInterface $logger)
{
parent::initController($request, $response, $logger);
if ($this->service === null) {
$this->service = service('customer_clientservice');
}
$this->addActionPaths($this->_layout);
$this->layouts = config('Layout')->layouts[$this->_layout] ?? [];
}
protected function action_init_process(string $action, array $formDatas = []): void
{
parent::action_init_process($action, $formDatas);
$this->addViewDatas('layout', $this->layouts);
$this->addViewDatas('helper', $this->service->getHelper());
$this->service->getActionForm()->action_init_process($action, $formDatas);
$this->addViewDatas('formFields', $this->service->getActionForm()->getFormFields());
$this->addViewDatas('formRules', $this->service->getActionForm()->getFormRules());
$this->addViewDatas('formFilters', $this->service->getActionForm()->getFormFilters());
$this->addViewDatas('formOptions', $this->service->getActionForm()->getFormOptions());
}
//Index,FieldForm관련
public function index(): string
{
return view('welcome_message');
$action = __FUNCTION__;
$this->action_init_process($action);
return $this->action_render_process($action, $this->getViewDatas(), $this->request->getVar('ActionTemplate') ?? "welcome");
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\DTOs\Customer;
use App\DTOs\CommonDTO;
class ClientDTO extends CommonDTO
{
public ?int $uid = null;
public ?int $user_uid = null;
public ?string $id = null;
public ?string $passwd = null;
public string $site = '';
public string $name = '';
public string $phone = '';
public string $email = '';
public array $role = [];
public int $account_balance = 0;
public int $coupon_balance = 0;
public int $point_balance = 0;
public string $status = '';
public string $history = '';
public function __construct(array $datas = [])
{
// 1. role 변환 로직 (기존 유지)
if (isset($datas['role']) && is_string($datas['role'])) {
$datas['role'] = explode(DEFAULTS["DELIMITER_COMMA"], $datas['role']);
}
if (!isset($datas['role'])) {
$datas['role'] = [];
}
// 2. [추가] 잔액 관련 데이터가 null이거나 비어있다면 0으로 보정
// CommonDTO의 parent::__construct가 호출되기 전에 데이터를 정제합니다.
$balanceFields = ['account_balance', 'coupon_balance', 'point_balance'];
foreach ($balanceFields as $field) {
if (!isset($datas[$field]) || $datas[$field] === '' || $datas[$field] === null) {
$datas[$field] = 0;
}
}
parent::__construct($datas);
}
public function getRoleToString(): string
{
return implode(DEFAULTS["DELIMITER_COMMA"], $this->role);
}
}

View File

@ -83,6 +83,43 @@ INSERT INTO `user` VALUES (1,'choi.jh','$2y$10$.vl2FtwJsjMNFCJJm3ISDu7m3vBB85mZ5
UNLOCK TABLES;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
--
-- Table structure for table `clientinfo`
--
DROP TABLE IF EXISTS `clientinfo`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8mb4 */;
CREATE TABLE `clientinfo` (
`uid` int(11) NOT NULL AUTO_INCREMENT COMMENT '고객정보',
`user_uid` int(11) NOT NULL COMMENT '관리자정보',
`id` varchar(20) DEFAULT NULL,
`passwd` varchar(255) DEFAULT NULL,
`site` varchar(20) NOT NULL DEFAULT 'prime' COMMENT 'Site구분',
`role` varchar(50) NOT NULL DEFAULT 'user',
`name` varchar(100) NOT NULL,
`phone` varchar(50) DEFAULT NULL,
`email` varchar(50) NOT NULL,
`history` text DEFAULT NULL COMMENT 'history',
`account_balance` int(11) NOT NULL DEFAULT 0 COMMENT '예치금',
`coupon_balance` int(11) NOT NULL DEFAULT 0 COMMENT '쿠폰수',
`point_balance` int(11) NOT NULL DEFAULT 0 COMMENT '포인트',
`status` varchar(20) NOT NULL DEFAULT 'available',
`updated_at` timestamp NULL DEFAULT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
`deleted_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`uid`),
UNIQUE KEY `unique_site_name` (`site`,`name`),
UNIQUE KEY `uk_id` (`id`),
KEY `FK_user_TO_clientinfo` (`user_uid`),
CONSTRAINT `FK_user_TO_clientinfo` FOREIGN KEY (`user_uid`) REFERENCES `user` (`uid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci COMMENT='고객정보';
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `clientinfo`
--
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
@ -92,3 +129,4 @@ UNLOCK TABLES;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-- Dump completed on 2026-02-09 17:04:26

View File

@ -0,0 +1,142 @@
<?php
namespace App\Entities\Customer;
use App\Models\Customer\ClientModel;
class ClientEntity extends CustomerEntity
{
const PK = ClientModel::PK;
const TITLE = ClientModel::TITLE;
protected array $nullableFields = [
'id',
'passwd',
];
// ✅ role은 반드시 string 기본값
protected $attributes = [
'id' => null,
'passwd' => null,
'site' => '',
'name' => '',
'phone' => '',
'email' => '',
'role' => '', // ✅ [] 금지
'account_balance' => 0,
'coupon_balance' => 0,
'point_balance' => 0,
'status' => '',
'history' => '',
];
public function __construct(array|null $data = null)
{
parent::__construct($data);
}
public function getUserUid(): int|null
{
return $this->user_uid ?? null;
}
public function getCustomTitle(mixed $title = null): string
{
return sprintf("%s/%s", $this->getSite(), $title ? $title : $this->getTitle());
}
public function getName(): string
{
return (string) ($this->attributes['name'] ?? '');
}
public function getSite(): string
{
return (string) ($this->attributes['site'] ?? '');
}
public function getAccountBalance(): int
{
return (int) ($this->attributes['account_balance'] ?? 0);
}
public function getCouponBalance(): int
{
return (int) ($this->attributes['coupon_balance'] ?? 0);
}
public function getPointBalance(): int
{
return (int) ($this->attributes['point_balance'] ?? 0);
}
public function getHistory(): string|null
{
return $this->attributes['history'] ?? null;
}
/**
* role을 배열로 반환
*/
public function getRole(): array
{
$role = $this->attributes['role'] ?? null;
if (is_array($role)) {
return array_values(array_filter($role, fn($v) => (string) $v !== ''));
}
if (is_string($role) && $role !== '') {
$decoded = json_decode($role, true);
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
$clean = array_map(
fn($item) => trim((string) ($item ?? ''), " \t\n\r\0\x0B\""),
$decoded
);
return array_values(array_filter($clean, fn($v) => $v !== ''));
}
$parts = explode(DEFAULTS["DELIMITER_COMMA"], $role);
$clean = array_map(
fn($item) => trim((string) ($item ?? ''), " \t\n\r\0\x0B\""),
$parts
);
return array_values(array_filter($clean, fn($v) => $v !== ''));
}
return [];
}
/**
* role은 DB 저장용 CSV 문자열로 반환
*/
public function setRole($role): string
{
$roleArray = [];
if (is_string($role)) {
$clean = trim($role, " \t\n\r\0\x0B\"");
if ($clean !== '') {
$decoded = json_decode($clean, true);
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
$roleArray = $decoded;
} else {
$roleArray = explode(DEFAULTS["DELIMITER_COMMA"], $clean);
}
}
} elseif (is_array($role)) {
$roleArray = $role;
} else {
$roleArray = [];
}
$cleaned = array_map(
fn($item) => trim((string) ($item ?? ''), " \t\n\r\0\x0B\""),
$roleArray
);
$roleArray = array_values(array_filter($cleaned, fn($v) => $v !== ''));
return implode(DEFAULTS["DELIMITER_COMMA"], $roleArray);
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Entities\Customer;
use App\Entities\CommonEntity;
abstract class CustomerEntity extends CommonEntity
{
public function __construct(array|null $data = null)
{
parent::__construct($data);
}
}

View File

@ -0,0 +1,87 @@
<?php
namespace App\Forms\Customer;
class ClientForm extends CustomerForm
{
public function __construct()
{
parent::__construct();
}
public function action_init_process(string $action, array &$formDatas = []): void
{
$fields = [
'site',
'name',
'email',
'phone',
'role',
'status',
];
$filters = [
'site',
'role',
'status',
];
$indexFilter = $filters;
$batchjobFilters = ['site', 'role', 'status'];
switch ($action) {
case 'view':
$fields = [...$fields, 'status', 'created_at'];
break;
case 'index':
case 'download':
$fields = [
'site',
'name',
'email',
'phone',
'role',
'account_balance',
'coupon_balance',
'point_balance',
'status',
'created_at',
'updated_at',
];
break;
}
$this->setFormFields($fields);
$this->setFormRules($action, $fields);
$this->setFormFilters($filters);
$this->setFormOptions($action, $filters, $formDatas);
$this->setIndexFilters($indexFilter);
$this->setBatchjobFilters($batchjobFilters);
}
public function getFormRule(string $action, string $field, array $formRules): array
{
switch ($field) {
case "name":
$formRules[$field] = sprintf("required|trim|string%s", in_array($action, ["create", "create_form"]) ? "|is_unique[{$this->getAttribute('table')}.{$field}]" : "");
break;
case "site":
$formRules[$field] = "required|trim|string";
break;
case "role":
$formRules[$field] = 'required|is_array|at_least_one';
$formRules['role.*'] = 'permit_empty|trim|in_list[user,vip,reseller]';
break;
case "email":
$formRules[$field] = "permit_empty|trim|valid_email";
break;
case "phone":
case "history":
$formRules[$field] = "permit_empty|trim|string";
break;
case "account_balance":
case "coupon_balance":
case "point_balance":
$formRules[$field] = "permit_empty|numeric";
break;
default:
$formRules = parent::getFormRule($action, $field, $formRules);
break;
}
return $formRules;
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Forms\Customer;
use App\Forms\CommonForm;
abstract class CustomerForm extends CommonForm
{
protected function __construct()
{
parent::__construct();
}
}

View File

@ -0,0 +1,202 @@
<?php
namespace App\Helpers\Customer;
use RuntimeException;
class ClientHelper extends CustomerHelper
{
public function __construct()
{
parent::__construct();
}
public function getFieldForm(string $field, mixed $value, array $viewDatas, array $extras = []): string
{
switch ($field) {
case 'site':
$forms = [];
array_shift($viewDatas['formOptions'][$field]['options']);
foreach ($viewDatas['formOptions'][$field]['options'] as $key => $label)
$forms[] = form_radio($field, $key, $key == $value, $extras) . $label;
$form = implode(" ", $forms);
break;
case 'role':
// 1) value가 배열이면 그대로, 문자열이면 CSV를 배열로 변환
if (is_string($value)) {
$value = trim($value, " \t\n\r\0\x0B\"");
$value = ($value === '') ? [] : explode(DEFAULTS["DELIMITER_COMMA"], $value);
} elseif (!is_array($value)) {
$value = [];
}
// 2) 정리
$currentRoles = array_values(array_filter(
array_map(
fn($item) => strtolower(trim((string) ($item ?? ''), " \t\n\r\0\x0B\"")),
$value
)
));
$form = '';
array_shift($viewDatas['formOptions'][$field]['options']);
foreach ($viewDatas['formOptions'][$field]['options'] as $key => $label) {
$checked = in_array(strtolower(trim((string) $key)), $currentRoles, true);
$form .= '<label class="me-3">';
$form .= form_checkbox('role[]', $key, $checked, ['id' => "role_{$key}", ...$extras]);
$form .= " {$label}";
$form .= '</label>';
}
// dd($form);
break;
default:
$form = parent::getFieldForm($field, $value, $viewDatas, $extras);
break;
}
return $form;
} //
public function getFieldView(string $field, mixed $value, array $viewDatas, array $extras = []): string|null
{
switch ($field) {
case 'name':
$value = "<a href=\"/admin/customer/client/detail/{$viewDatas['entity']->getPK()}\">" . $value . "</a>";
break;
case "email":
case "phone":
//역활이 보안관리자가 아니면 정보숨김
$value = $this->getAuthContext()->isAccessRole([ROLE['USER']['SECURITY']]) ? parent::getFieldView($field, $value, $viewDatas, $extras) : "***********";
break;
case 'account_balance':
$value = form_label(
number_format($value) . "",
$field,
[
"data-src" => "/admin/customer/wallet/account?clientinfo_uid={$viewDatas['entity']->getPK()}&ActionTemplate=popup",
"data-bs-toggle" => "modal",
"data-bs-target" => "#modal_action_form",
"class" => "text-primary",
...$extras,
]
);
break;
case 'coupon_balance':
$value = form_label(
number_format($value) . "",
$field,
[
"data-src" => "/admin/customer/wallet/coupon?clientinfo_uid={$viewDatas['entity']->getPK()}&ActionTemplate=popup",
"data-bs-toggle" => "modal",
"data-bs-target" => "#modal_action_form",
"class" => "text-primary",
...$extras,
]
);
break;
case 'point_balance':
$value = form_label(
number_format($value) . "",
$field,
[
"data-src" => "/admin/customer/wallet/point?clientinfo_uid={$viewDatas['entity']->getPK()}&ActionTemplate=popup",
"data-bs-toggle" => "modal",
"data-bs-target" => "#modal_action_form",
"class" => "text-primary",
...$extras,
]
);
break;
default:
$value = parent::getFieldView($field, $value, $viewDatas, $extras);
break;
}
if (is_array($value)) {
throw new RuntimeException(static::class . "->" . __FUNCTION__ . "에서 오류발생:{$field}에 해당하는 Return 값이 배열형식입니다.\n" . var_export($value, true));
}
return $value;
} //
public function getListButton(string $action, string $label, array $viewDatas, array $extras = []): string
{
switch ($action) {
case 'delete':
case 'batchjob':
case 'batchjob_delete':
//역활이 보안관리자가 아니면 사용불가
$action = $this->getAuthContext()->isAccessRole([ROLE['USER']['SECURITY']]) ? parent::getListButton($action, $label, $viewDatas, $extras) : "";
break;
case 'modify':
//역활이 보안관리자가 아니면 수정불가
$action = $this->getAuthContext()->isAccessRole([ROLE['USER']['SECURITY']]) ? parent::getListButton($action, $label, $viewDatas, $extras) : $label;
break;
case 'history':
$action = form_label(
$label ? $label : ICONS['HISTORY'],
$action,
[
"data-src" => "/admin/customer/client/history?clientinfo_uid={$viewDatas['entity']->getPK()}",
"data-bs-toggle" => "modal",
"data-bs-target" => "#modal_action_form",
"class" => "btn btn-sm btn-primary form-label-sm",
...$extras
]
);
break;
case 'coupon':
case 'account':
case 'point':
$action = form_label(
$label,
$action,
[
"data-src" => "/admin/customer/wallet/{$action}?clientinfo_uid={$viewDatas['entity']->getPK()}&ActionTemplate=popup",
"data-bs-toggle" => "modal",
"data-bs-target" => "#modal_action_form",
"class" => "text-primary",
...$extras
]
);
break;
case 'invoice':
$action = form_label(
$label,
'payment_invoice',
[
"data-src" => "/admin/payment?clientinfo_uid={$viewDatas['entity']->getPK()}&ActionTemplate=popup",
"data-bs-toggle" => "modal",
"data-bs-target" => "#modal_action_form",
"class" => "text-primary form-label-sm",
]
);
break;
case 'addService':
$action = form_label(
'서비스추가',
'create_service',
[
"data-src" => "/admin/customer/service/create?clientinfo_uid={$viewDatas['entity']->getPK()}",
"data-bs-toggle" => "modal",
"data-bs-target" => "#modal_action_form",
"class" => "btn btn-sm btn-primary form-label-sm",
]
);
break;
case 'unpaid':
$action = "{$label} 0원";
if (array_key_exists($viewDatas['entity']->getPK(), $viewDatas['unPaids'])) {
$action = form_label(
sprintf("%s건/%s원", $viewDatas['unPaids'][$viewDatas['entity']->getPK()]['cnt'], number_format($viewDatas['unPaids'][$viewDatas['entity']->getPK()]['amount'])),
'payment_unpaid',
[
"data-src" => "/admin/payment?clientinfo_uid={$viewDatas['entity']->getPK()}&status=unpaid&ActionTemplate=popup",
"data-bs-toggle" => "modal",
"data-bs-target" => "#modal_action_form",
"class" => "text-primary form-label-sm",
]
);
}
break;
default:
$action = parent::getListButton($action, $label, $viewDatas, $extras);
break;
}
return $action;
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Helpers\Customer;
use App\Helpers\CommonHelper;
abstract class CustomerHelper extends CommonHelper
{
protected function __construct()
{
parent::__construct();
}
}

View File

@ -0,0 +1,31 @@
<?php
return [
'title' => "고객정보",
'label' => [
'user_uid' => "관리자UID",
'code' => "고객코드",
'site' => "사이트",
'email' => "메일",
'phone' => "연락처",
'role' => "권한",
'name' => "이름",
'account_balance' => "예치금",
'coupon_balance' => "쿠폰",
'point_balance' => "포인트",
'status' => "상태",
'updated_at' => "갱신일",
'created_at' => "등록일",
'deleted_at' => "삭제일",
],
"SITE" => SITES,
"ROLE" => [
ROLE['CLIENT']['USER'] => "일반회원",
ROLE['CLIENT']['VIP'] => "VIP회원",
ROLE['CLIENT']['RESELLER'] => "리셀러",
],
"STATUS" => [
STATUS['AVAILABLE'] => "사용중",
STATUS['PAUSE'] => "일시정지",
STATUS['TERMINATED'] => "해지",
],
];

View File

@ -0,0 +1,38 @@
<?php
namespace App\Models\Customer;
use App\Entities\Customer\ClientEntity;
class ClientModel extends CustomerModel
{
const TABLE = "clientinfo";
const PK = "uid";
const TITLE = "name";
protected $table = self::TABLE;
// protected $useAutoIncrement = false;
protected $primaryKey = self::PK;
protected $returnType = ClientEntity::class;
protected array $nullableFields = [
'id',
'passwd',
];
protected $allowedFields = [
"uid",
"user_uid",
"site",
"role",
"name",
"phone",
"email",
"history",
"account_balance",
"coupon_balance",
"point_balance",
"status",
];
public function __construct()
{
parent::__construct();
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Models\Customer;
use App\Models\CommonModel;
abstract class CustomerModel extends CommonModel
{
//true이면 모든 delete * 메소드 호출은 실제로 행을 삭제하는 것이 아니라 플래그를 데이터베이스로 설정
protected $useSoftDeletes = true;
protected function __construct()
{
parent::__construct();
}
}

View File

@ -0,0 +1,72 @@
<?php
namespace App\Services\Customer;
use App\DTOs\Customer\ClientDTO;
use App\Entities\Customer\ClientEntity;
use App\Entities\PaymentEntity;
use App\Forms\Customer\ClientForm;
use App\Helpers\Customer\ClientHelper;
use App\Models\Customer\ClientModel;
use RuntimeException;
class ClientService extends CustomerService
{
protected string $formClass = ClientForm::class;
protected string $helperClass = ClientHelper::class;
public function __construct(ClientModel $model)
{
parent::__construct($model);
$this->addClassPaths('Client');
}
public function getDTOClass(): string
{
return ClientDTO::class;
}
public function createDTO(array $formDatas): ClientDTO
{
return new ClientDTO($formDatas);
}
public function getEntityClass(): string
{
return ClientEntity::class;
}
//기본 기능부분
protected function getEntity_process(mixed $entity): ClientEntity
{
return $entity;
}
//List 검색용
//FormFilter 조건절 처리
//검색어조건절처리
//OrderBy 처리
public function setOrderBy(mixed $field = null, mixed $value = null): void
{
$this->model->orderBy("site ASC,name ASC");
parent::setOrderBy($field, $value);
}
protected function action_process_fieldhook(string $field, $value, array $formDatas): array
{
switch ($field) {
case 'role':
if (is_string($value)) {
$value = ($value === '') ? [] : explode(DEFAULTS["DELIMITER_COMMA"], $value);
} elseif (!is_array($value)) {
$value = [];
}
$value = array_values(array_filter(array_map(
fn($v) => trim((string) ($v ?? ''), " \t\n\r\0\x0B\""),
$value
)));
$formDatas[$field] = $value;
break;
default:
$formDatas = parent::action_process_fieldhook($field, $value, $formDatas);
break;
}
return $formDatas;
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace App\Services\Customer;
use App\Models\CommonModel;
use App\Services\CommonService;
abstract class CustomerService extends CommonService
{
protected function __construct(CommonModel $model)
{
parent::__construct($model);
$this->addClassPaths('Customer');
}
}

View File

@ -0,0 +1,9 @@
<?= $this->extend($viewDatas['layout']['layout']) ?>
<?= $this->section('content') ?>
<!-- Layout Middle Start -->
<div class="layout_top"><?= $this->include($viewDatas['layout']['layout'] . '/top'); ?></div>
<!-- Layout Middle Start -->
<!-- Layout Middle End -->
<div class=" layout_footer"><?= $this->include("{$viewDatas['layout']['template']}/index_footer"); ?></div>
<div class="layout_bottom"><?= $this->include($viewDatas['layout']['layout'] . '/bottom'); ?></div>
<?= $this->endSection() ?>

View File

@ -0,0 +1,115 @@
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet">
<style>
.card-clickable {
cursor: pointer;
}
.dashboard-card {
color: white;
}
.bg-blue {
background-color: #337ab7;
}
.bg-yellow {
background-color: #f0ad4e;
}
.bg-green {
background-color: #5cb85c;
}
.bg-red {
background-color: #d9534f;
}
.card-footer a {
color: white;
text-decoration: none;
}
.card-footer .fa {
font-size: 1.2em;
}
.fa-5x {
font-size: 4em;
}
.huge {
font-size: 2.5em;
font-weight: bold;
}
.bg-detail {
background-color: #F5F5F5;
}
.bg-detail .bg_blue_font {
color: #337ab7;
}
.bg-detail .bg_yellow_font {
color: #f0ad4e;
}
.bg-detail .bg_green_font {
color: #5cb85c;
}
.bg-detail .bg_red_font {
color: #d9534f;
}
</style>
</head>
<div class="row">
<div class="col-lg">
<div class="card dashboard-card bg-blue card-clickable" onclick="document.location.href='/admin/board?category=<?= BOARD['CATEGORY']['REQUESTTASK'] ?>&worker_uid=<?= $viewDatas['authContext']->getUID() ?>';">
<div class=" card-body">
<div class="row">
<div class="col-4"><i class="fa fa-comments fa-5x"></i></div>
<div class="col-8 text-end">
<div class="huge"><?= $viewDatas['boardRequestTaskCount'] ?></div>
<div class="bg-blue">요청업무 알림</div>
</div>
</div>
</div>
<div class=" card-footer d-flex justify-content-between align-items-center bg-detail">
<span class="bg_blue_font">자세히보기</span><i class="fa fa-arrow-circle-right bg_blue_font"></i>
</div>
</div>
</div>
<div class="col-lg">
<div class="card dashboard-card bg-yellow card-clickable" onclick="document.location.href='/admin/customer/service';">
<div class=" card-body">
<div class="row">
<div class="col-4"><i class="fa fa-plus-square-o fa-5x"></i></div>
<div class="col-8 text-end">
<div class="huge"><?= $viewDatas['newServiceCount'] ?></div>
<div>최근 <?= $viewDatas['interval'] ?>일간 신규서버수</div>
</div>
</div>
</div>
<div class="card-footer d-flex justify-content-between align-items-center bg-detail">
<span class="bg_yellow_font">자세히보기</span><i class="fa fa-arrow-circle-right bg_yellow_font"></i>
</div>
</div>
</div>
<div class="col-lg">
<div class="card dashboard-card bg-red card-clickable" onclick="document.location.href='/admin/payment'">
<div class="card-body">
<div class="row">
<div class="col-4"><i class="fa fa-support fa-5x"></i></div>
<div class="col-8 text-end">
<div class="huge"><?= number_format($viewDatas['unPaidTotalCount']) ?>건/<?= number_format($viewDatas['unPaidTotalAmount']) ?>원</div>
<div><?= date("Y-m-d") ?> 금일 기준 미납 서비스</div>
</div>
</div>
</div>
<div class="card-footer d-flex justify-content-between align-items-center bg-detail">
<a href="/admin/payment"><span class=" bg_red_font">자세히보기</span></a><i class="fa fa-arrow-circle-right bg_red_font"></i>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,31 @@
<?= $this->extend($viewDatas['layout']['layout']) ?>
<?= $this->section('content') ?>
<!-- Layout Middle Start -->
<div class="layout_top"><?= $this->include($viewDatas['layout']['layout'] . '/top'); ?></div>
<!-- Layout Middle Start -->
<table class="layout_middle">
<tr>
<td class="layout_left">
<!-- Layout Left Start -->
<?= $this->include($viewDatas['layout']['layout'] . '/left_menu'); ?>
<!-- Layout Left End -->
</td>
<td class="layout_right">
<!-- Layout Right Start -->
<?= $this->include("{$viewDatas['layout']['path']}/welcome/banner"); ?>
<div class="row align-items-start mt-3">
<div class="col-8">
<?= $this->include("{$viewDatas['layout']['path']}/welcome/total_service"); ?>
<?= $this->include("{$viewDatas['layout']['path']}/welcome/new_service"); ?>
<?= $this->include("{$viewDatas['layout']['path']}/welcome/stock"); ?>
</div>
<div class="col-4"><?= $this->include("{$viewDatas['layout']['path']}/welcome/mylog"); ?></div>
</div>
<!-- Layout Right End -->
</td>
</tr>
</table>
<!-- Layout Middle End -->
<div class=" layout_footer"><?= $this->include("{$viewDatas['layout']['template']}/index_footer"); ?></div>
<div class="layout_bottom"><?= $this->include($viewDatas['layout']['layout'] . '/bottom'); ?></div>
<?= $this->endSection() ?>

View File

@ -0,0 +1,13 @@
<div class="layout_header">
<ul class="nav nav-tabs">
<li class="nav-item">
<span class="nav-item navbar-brand" aria-current="page">
<h4>&nbsp;&nbsp;<?= icon('SETUP') ?>작업내역&nbsp;&nbsp;</h4>
</span>
</li>
</ul>
</div>
<div style="border-left: 1px solid black; border-right: 1px solid black; padding:20px;">
<?= view_cell("\App\Cells\MylogCell::dashboard") ?>
</div>
<div class="layout_footer"></div>

View File

@ -0,0 +1,39 @@
<div class="layout_header mt-3">
<ul class="nav nav-tabs">
<li class="nav-item">
<span class="nav-item navbar-brand" aria-current="page">
<h4>&nbsp;&nbsp;<?= icon('CHART') ?> 최신 신규 서비스 현황&nbsp;&nbsp;</h4>
</span>
</li>
</ul>
</div>
<div style="border-left: 1px solid black; border-right: 1px solid black; padding:20px;">
<table class="table table-bordered table-hover align-middle">
<thead class="table-light">
<tr class="text-center">
<th>사이트</th>
<th>업체명</th>
<th>
<span class="float-start rounded border border-primary" style="cursor:pointer;"
onclick="copyServerPartsToClipboard()">ALL 📋</span> 장비번호 / 스위치정보 / IP정보 / CS정보
</th>
<th>등록자</th>
</tr>
</thead>
<tbody>
<?php foreach ($viewDatas['newServiceEntities'] as $entity): ?>
<?php $viewDatas['entity'] = $entity ?>
<tr class="text-center">
<td><?= SITES[$entity->getSite()] ?></td>
<td nowrap><?= $viewDatas['helper']->getFieldView('clientinfo_uid', $entity->getClientInfoUid(), $viewDatas) ?>
</td>
<td class="text-start">
<?= $viewDatas['helper']->getFieldView('serverinfo_uid', $entity->getServerInfoUid(), $viewDatas) ?></td>
<td nowrap><?= $viewDatas['helper']->getFieldView('user_uid', $entity->getUserUid(), $viewDatas) ?></td>
</tr>
<?php endforeach ?>
</tbody>
</table>
</div>
<div class="layout_footer"></div>
<script src="/js/admin/clipboard.js"></script>

View File

@ -0,0 +1,24 @@
<div class="layout_header" style="margin-top:20px;">
<ul class="nav nav-tabs">
<li class="nav-item">
<span class="nav-item navbar-brand" aria-current="page">
<h4>&nbsp;&nbsp;<?= icon('SETUP') ?>재고현황&nbsp;&nbsp;</h4>
</span>
</li>
</ul>
</div>
<div style="border-left: 1px solid black; border-right: 1px solid black; padding:20px;">
<table class="table table-bordered table-striped">
<tr>
<th class="text-center" width="33%">사용 서버</th>
<th class="text-center" width="33%">메모리 재고</th>
<th class="text-center">저장장치 재고</th>
</tr>
<tr>
<td><?= view_cell("\App\Cells\Equipment\CHASSISCell::stock") ?></td>
<td><?= view_cell("\App\Cells\Part\RAMCell::stock") ?></td>
<td><?= view_cell("\App\Cells\Part\DISKCell::stock") ?></td>
</tr>
</table>
</div>
<div class="layout_footer"></div>

View File

@ -0,0 +1,45 @@
<div class="layout_header">
<ul class="nav nav-tabs">
<li class="nav-item">
<span class="nav-item navbar-brand" aria-current="page">
<h4>&nbsp;&nbsp;<?= icon('CHART') ?> 전체 서비스 현황&nbsp;&nbsp;</h4>
</span>
</li>
</ul>
</div>
<div style="border-left: 1px solid black; border-right: 1px solid black; padding:20px;">
<table class="table table-bordered table-hover table-align-middle">
<tr class="text-center">
<th rowspan="2" class="bg-light">사이트</th>
<th colspan="2" class="bg-light">일반</th>
<th colspan="2" class="bg-light">방어</th>
<th colspan="2" class="bg-light">전용</th>
<th colspan="2" class="bg-light">대체</th>
<th colspan="2" class="bg-light">VPN</th>
<th colspan="2" class="bg-light">이벤트</th>
<th colspan="2" class="bg-light">테스트</th>
<th colspan="3" class="bg-light">합계</th>
</tr>
<tr class="text-center">
<th class="bg-light">도쿄</th>
<th class="bg-light">치바</th>
<th class="bg-light">도쿄</th>
<th class="bg-light">치바</th>
<th class="bg-light">도쿄</th>
<th class="bg-light">치바</th>
<th class="bg-light">도쿄</th>
<th class="bg-light">치바</th>
<th class="bg-light">도쿄</th>
<th class="bg-light">치바</th>
<th class="bg-light">도쿄</th>
<th class="bg-light">치바</th>
<th class="bg-light">도쿄</th>
<th class="bg-light">치바</th>
<th class="bg-light">도쿄</th>
<th class="bg-light">치바</th>
<th class="bg-light">합계</th>
</tr>
<?= view_cell("\App\Cells\Equipment\ServerCell::totalCountDashboard") ?>
</table>
</div>
<div class="layout_footer"></div>

View File

@ -1,2 +0,0 @@
<!-- left menu start -->
<!-- left menu end -->

View File

@ -1,130 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= esc($title) ?></title>
<script src="https://cdn.tailwindcss.com"></script>
<link
href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;700;800&family=Noto+Sans+KR:wght@400;700;900&display=swap"
rel="stylesheet">
<style>
body {
font-family: 'Noto Sans KR', sans-serif;
}
.bg-grid {
background-image: radial-gradient(circle at 1px 1px, rgba(255, 255, 255, 0.05) 1px, transparent 0);
background-size: 40px 40px;
}
</style>
</head>
<body class="bg-[#101922] text-white selection:bg-blue-500/30">
<!-- Navbar -->
<nav class="fixed w-full z-50 bg-[#101922]/80 backdrop-blur-md border-b border-white/5">
<div class="max-w-7xl mx-auto px-6 h-20 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="bg-[#2b8cee] p-2 rounded-lg">
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4">
</path>
</svg>
</div>
<span class="text-xl font-black uppercase tracking-tighter">Daemon-IDC</span>
</div>
<div class="hidden md:flex gap-10 text-sm font-bold text-slate-400">
<a href="#" class="hover:text-white transition-colors">서비스</a>
<a href="#" class="hover:text-white transition-colors">요금제</a>
<a href="#" class="hover:text-white transition-colors">고객지원</a>
<button class="bg-[#2b8cee] text-white px-5 py-2 rounded-lg -mt-1">로그인</button>
</div>
</div>
</nav>
<!-- Hero Section -->
<header class="relative min-h-screen flex items-center pt-20 overflow-hidden">
<div class="absolute inset-0 z-0">
<div class="absolute inset-0 bg-gradient-to-r from-[#101922] via-[#101922]/90 to-transparent z-10"></div>
<!-- 핫링크 테스트를 위한 컨트롤러 경로 이미지 예시 (가상) -->
<img src="https://images.unsplash.com/photo-1558494949-ef010cbdcc51?auto=format&fit=crop&q=80&w=2000"
alt="IDC Background" class="w-full h-full object-cover opacity-30">
</div>
<div class="relative z-20 max-w-7xl mx-auto px-6 w-full">
<div class="max-w-2xl space-y-8">
<div
class="inline-block px-3 py-1 bg-[#2b8cee]/10 border border-[#2b8cee]/30 text-[#2b8cee] text-xs font-bold rounded-full uppercase">
CodeIgniter 4.6.4 Powered</div>
<h1 class="text-6xl md:text-7xl font-black leading-tight tracking-tighter">일본 비즈니스의<br><span
class="text-[#2b8cee]">견고한 뿌리</span></h1>
<p class="text-xl text-slate-400 font-medium leading-relaxed">PHP 8.3 기반의 초고속 백엔드와 일본 현지 데이터센터의 결합. 가장
신뢰받는 인프라를 지금 바로 경험하십시오.</p>
<div class="flex gap-4">
<button
class="bg-[#2b8cee] px-8 py-4 rounded-xl font-black shadow-lg shadow-[#2b8cee]/30 hover:scale-105 transition-transform">서비스
상담하기</button>
</div>
</div>
</div>
</header>
<!-- Services Grid -->
<section class="py-32 bg-grid">
<div class="max-w-7xl mx-auto px-6">
<h2 class="text-3xl font-black mb-16 text-center">전문적인 인프라 솔루션</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<?php foreach ($services as $s): ?>
<div
class="p-8 bg-white/5 border border-white/10 rounded-2xl hover:border-[#2b8cee]/50 transition-colors group">
<div
class="w-12 h-12 bg-[#2b8cee]/10 rounded-xl mb-6 flex items-center justify-center text-[#2b8cee]">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z">
</path>
</svg>
</div>
<h3 class="text-xl font-bold mb-3"><?= esc($s['title']) ?></h3>
<p class="text-slate-500 text-sm"><?= esc($s['desc']) ?></p>
</div>
<?php endforeach; ?>
</div>
</div>
</section>
<!-- Pricing -->
<section class="py-32 bg-[#0b1117]">
<div class="max-w-7xl mx-auto px-6 text-center">
<h2 class="text-3xl font-black mb-16">투명한 요금제</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 text-left">
<?php foreach ($pricing as $p): ?>
<div
class="p-8 rounded-2xl bg-[#101922] border <?= $p['pop'] ? 'border-[#2b8cee] ring-1 ring-[#2b8cee]' : 'border-white/5' ?> relative overflow-hidden">
<?php if ($p['pop']): ?>
<div
class="absolute top-0 right-0 bg-[#2b8cee] text-white text-[10px] px-3 py-1 font-bold uppercase">
Popular</div><?php endif; ?>
<h4 class="text-slate-400 font-bold mb-4"><?= esc($p['name']) ?></h4>
<div class="flex items-baseline gap-1 mb-8">
<span class="text-4xl font-black"><?= esc($p['price']) ?></span>
<span class="text-slate-500 font-bold"><?= esc($p['unit']) ?></span>
</div>
<button
class="w-full py-3 rounded-lg font-bold <?= $p['pop'] ? 'bg-[#2b8cee] text-white' : 'bg-slate-800 text-slate-300' ?> hover:opacity-90 transition-opacity">선택하기</button>
</div>
<?php endforeach; ?>
</div>
</div>
</section>
<footer
class="py-20 border-t border-white/5 text-center text-slate-600 font-bold text-xs uppercase tracking-widest">
© 2024 DAEMON-IDC. Built with CodeIgniter 4.6.4 & PHP 8.3.
</footer>
</body>
</html>