diff --git a/app/Config/Routes.php b/app/Config/Routes.php
index d8c2a93..8fbb16e 100644
--- a/app/Config/Routes.php
+++ b/app/Config/Routes.php
@@ -256,6 +256,8 @@ $routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): vo
$routes->post('users/unlock-login/(:num)', 'Admin\User::unlockLogin/$1');
$routes->post('users/delete/(:num)', 'Admin\User::delete/$1');
$routes->get('access/login-history', 'Admin\Access::loginHistory');
+ $routes->get('access/activity-logs', 'Admin\ActivityLog::index');
+ $routes->get('access/activity-logs/(:num)', 'Admin\ActivityLog::show/$1');
$routes->get('access/approvals', 'Admin\Access::approvals');
$routes->post('access/approve/(:num)', 'Admin\Access::approve/$1');
$routes->post('access/reject/(:num)', 'Admin\Access::reject/$1');
diff --git a/app/Controllers/Admin/ActivityLog.php b/app/Controllers/Admin/ActivityLog.php
new file mode 100644
index 0000000..de1f49d
--- /dev/null
+++ b/app/Controllers/Admin/ActivityLog.php
@@ -0,0 +1,164 @@
+ '회원',
+ 'member_approval_request' => '회원 승인요청',
+ 'company' => '업체',
+ 'designated_shop' => '지정판매소',
+ 'sales_agency' => '판매 대행소',
+ 'free_recipient' => '무료 수령처',
+ 'manager' => '담당자',
+ 'local_government' => '지자체',
+ 'code_kind' => '기본코드 종류',
+ 'code_detail' => '기본코드',
+ 'packaging_unit' => '포장 단위',
+ 'packaging_unit_history' => '포장 단위 이력',
+ 'bag_price' => '단가',
+ 'bag_price_history' => '단가 이력',
+ 'bag_order' => '발주',
+ 'bag_order_item' => '발주 품목',
+ 'bag_receiving' => '입고',
+ 'bag_inventory' => '재고',
+ 'bag_sale' => '판매',
+ 'bag_issue' => '무료용 불출',
+ 'bag_issue_item_code' => '불출 품목코드',
+ 'shop_order' => '전화 주문',
+ 'shop_order_item' => '전화 주문 품목',
+ 'menu' => '메뉴',
+ 'menu_type' => '메뉴 유형',
+ 'feedback' => '사용자 의견',
+ 'feedback_file' => '의견 첨부',
+ 'blockchain_ledger' => '원장',
+ ];
+
+ /** action → 한글 라벨 */
+ private const ACTION_LABELS = [
+ 'create' => '등록',
+ 'update' => '수정',
+ 'delete' => '삭제',
+ ];
+
+ public function __construct()
+ {
+ $this->logModel = model(ActivityLogModel::class);
+ }
+
+ public function index(): string
+ {
+ helper('admin');
+
+ $start = (string) ($this->request->getGet('start') ?? '');
+ $end = (string) ($this->request->getGet('end') ?? '');
+ $action = (string) ($this->request->getGet('action') ?? '');
+ $table = (string) ($this->request->getGet('table') ?? '');
+ $user = trim((string) ($this->request->getGet('user') ?? ''));
+
+ $builder = $this->logModel
+ ->select('activity_log.*, member.mb_id, member.mb_name, member.mb_lg_idx')
+ ->join('member', 'member.mb_idx = activity_log.al_mb_idx', 'left');
+
+ // 테넌트 분리: 슈퍼/본부는 전체, 그 외 관리자는 소속 지자체 사용자만
+ $level = (int) session()->get('mb_level');
+ if (! Roles::isSuperAdminEquivalent($level)) {
+ $lgIdx = admin_effective_lg_idx();
+ $builder->where('member.mb_lg_idx', $lgIdx);
+ }
+
+ if ($start !== '') {
+ $builder->where('activity_log.al_regdate >=', $start . ' 00:00:00');
+ }
+ if ($end !== '') {
+ $builder->where('activity_log.al_regdate <=', $end . ' 23:59:59');
+ }
+ if (isset(self::ACTION_LABELS[$action])) {
+ $builder->where('activity_log.al_action', $action);
+ }
+ if ($table !== '' && isset(self::TABLE_LABELS[$table])) {
+ $builder->where('activity_log.al_table', $table);
+ }
+ if ($user !== '') {
+ $builder->groupStart()
+ ->like('member.mb_id', $user)
+ ->orLike('member.mb_name', $user)
+ ->groupEnd();
+ }
+
+ $list = $builder->orderBy('activity_log.al_idx', 'DESC')->paginate(20);
+ $pager = $this->logModel->pager;
+
+ return view('admin/layout', [
+ 'title' => '활동 로그',
+ 'content' => view('admin/access/activity_log', [
+ 'list' => $list,
+ 'pager' => $pager,
+ 'start' => $start,
+ 'end' => $end,
+ 'action' => $action,
+ 'table' => $table,
+ 'user' => $user,
+ 'tableLabels' => self::TABLE_LABELS,
+ 'actionLabels' => self::ACTION_LABELS,
+ ]),
+ ]);
+ }
+
+ public function show(int $id): string
+ {
+ helper('admin');
+ $row = $this->logModel
+ ->select('activity_log.*, member.mb_id, member.mb_name, member.mb_lg_idx')
+ ->join('member', 'member.mb_idx = activity_log.al_mb_idx', 'left')
+ ->where('activity_log.al_idx', $id)
+ ->first();
+
+ if ($row === null) {
+ return view('admin/layout', [
+ 'title' => '활동 로그',
+ 'content' => '
로그를 찾을 수 없습니다.
',
+ ]);
+ }
+
+ // 테넌트 분리 검증
+ $level = (int) session()->get('mb_level');
+ if (! Roles::isSuperAdminEquivalent($level)) {
+ $lgIdx = admin_effective_lg_idx();
+ if ((int) ($row->mb_lg_idx ?? 0) !== (int) $lgIdx) {
+ return view('admin/layout', [
+ 'title' => '활동 로그',
+ 'content' => '접근 권한이 없습니다.
',
+ ]);
+ }
+ }
+
+ $before = $row->al_data_before ? (json_decode((string) $row->al_data_before, true) ?: []) : [];
+ $after = $row->al_data_after ? (json_decode((string) $row->al_data_after, true) ?: []) : [];
+
+ return view('admin/layout', [
+ 'title' => '활동 로그 상세',
+ 'content' => view('admin/access/activity_log_detail', [
+ 'row' => $row,
+ 'before' => $before,
+ 'after' => $after,
+ 'tableLabels' => self::TABLE_LABELS,
+ 'actionLabels' => self::ACTION_LABELS,
+ ]),
+ ]);
+ }
+}
diff --git a/app/Models/BagInventoryModel.php b/app/Models/BagInventoryModel.php
index 7df5310..1ce74fc 100644
--- a/app/Models/BagInventoryModel.php
+++ b/app/Models/BagInventoryModel.php
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class BagInventoryModel extends Model
{
+ use \App\Models\Traits\Auditable;
+
protected $table = 'bag_inventory';
protected $primaryKey = 'bi_idx';
protected $returnType = 'object';
diff --git a/app/Models/BagIssueItemCodeModel.php b/app/Models/BagIssueItemCodeModel.php
index 60eb0a3..27ad605 100644
--- a/app/Models/BagIssueItemCodeModel.php
+++ b/app/Models/BagIssueItemCodeModel.php
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class BagIssueItemCodeModel extends Model
{
+ use \App\Models\Traits\Auditable;
+
protected $table = 'bag_issue_item_code';
protected $primaryKey = 'bic_idx';
protected $returnType = 'object';
diff --git a/app/Models/BagIssueModel.php b/app/Models/BagIssueModel.php
index 855ecc9..0a9cb7f 100644
--- a/app/Models/BagIssueModel.php
+++ b/app/Models/BagIssueModel.php
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class BagIssueModel extends Model
{
+ use \App\Models\Traits\Auditable;
+
protected $table = 'bag_issue';
protected $primaryKey = 'bi2_idx';
protected $returnType = 'object';
diff --git a/app/Models/BagOrderItemModel.php b/app/Models/BagOrderItemModel.php
index 9a317ab..324685f 100644
--- a/app/Models/BagOrderItemModel.php
+++ b/app/Models/BagOrderItemModel.php
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class BagOrderItemModel extends Model
{
+ use \App\Models\Traits\Auditable;
+
protected $table = 'bag_order_item';
protected $primaryKey = 'boi_idx';
protected $returnType = 'object';
diff --git a/app/Models/BagOrderModel.php b/app/Models/BagOrderModel.php
index ead93ca..d4275b7 100644
--- a/app/Models/BagOrderModel.php
+++ b/app/Models/BagOrderModel.php
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class BagOrderModel extends Model
{
+ use \App\Models\Traits\Auditable;
+
protected $table = 'bag_order';
protected $primaryKey = 'bo_idx';
protected $returnType = 'object';
diff --git a/app/Models/BagPriceHistoryModel.php b/app/Models/BagPriceHistoryModel.php
index e886cac..64658c1 100644
--- a/app/Models/BagPriceHistoryModel.php
+++ b/app/Models/BagPriceHistoryModel.php
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class BagPriceHistoryModel extends Model
{
+ use \App\Models\Traits\Auditable;
+
protected $table = 'bag_price_history';
protected $primaryKey = 'bph_idx';
protected $returnType = 'object';
diff --git a/app/Models/BagPriceModel.php b/app/Models/BagPriceModel.php
index 4adc3fd..e005ff3 100644
--- a/app/Models/BagPriceModel.php
+++ b/app/Models/BagPriceModel.php
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class BagPriceModel extends Model
{
+ use \App\Models\Traits\Auditable;
+
protected $table = 'bag_price';
protected $primaryKey = 'bp_idx';
protected $returnType = 'object';
diff --git a/app/Models/BagReceivingModel.php b/app/Models/BagReceivingModel.php
index a21baad..d3f35ef 100644
--- a/app/Models/BagReceivingModel.php
+++ b/app/Models/BagReceivingModel.php
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class BagReceivingModel extends Model
{
+ use \App\Models\Traits\Auditable;
+
protected $table = 'bag_receiving';
protected $primaryKey = 'br_idx';
protected $returnType = 'object';
diff --git a/app/Models/BagSaleModel.php b/app/Models/BagSaleModel.php
index 4769681..de310b4 100644
--- a/app/Models/BagSaleModel.php
+++ b/app/Models/BagSaleModel.php
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class BagSaleModel extends Model
{
+ use \App\Models\Traits\Auditable;
+
protected $table = 'bag_sale';
protected $primaryKey = 'bs_idx';
protected $returnType = 'object';
diff --git a/app/Models/BlockchainLedgerModel.php b/app/Models/BlockchainLedgerModel.php
index 887bd55..0cf6aee 100644
--- a/app/Models/BlockchainLedgerModel.php
+++ b/app/Models/BlockchainLedgerModel.php
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class BlockchainLedgerModel extends Model
{
+ use \App\Models\Traits\Auditable;
+
protected $table = 'blockchain_ledger';
protected $primaryKey = 'bl_idx';
protected $returnType = 'object';
diff --git a/app/Models/CodeDetailModel.php b/app/Models/CodeDetailModel.php
index a82413b..8945367 100644
--- a/app/Models/CodeDetailModel.php
+++ b/app/Models/CodeDetailModel.php
@@ -8,6 +8,8 @@ use CodeIgniter\Model;
class CodeDetailModel extends Model
{
+ use \App\Models\Traits\Auditable;
+
protected $table = 'code_detail';
protected $primaryKey = 'cd_idx';
protected $returnType = 'object';
diff --git a/app/Models/CodeKindModel.php b/app/Models/CodeKindModel.php
index c34bb93..e3f1b4e 100644
--- a/app/Models/CodeKindModel.php
+++ b/app/Models/CodeKindModel.php
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class CodeKindModel extends Model
{
+ use \App\Models\Traits\Auditable;
+
protected $table = 'code_kind';
protected $primaryKey = 'ck_idx';
protected $returnType = 'object';
diff --git a/app/Models/CompanyModel.php b/app/Models/CompanyModel.php
index fcaec19..92bf0d7 100644
--- a/app/Models/CompanyModel.php
+++ b/app/Models/CompanyModel.php
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class CompanyModel extends Model
{
+ use \App\Models\Traits\Auditable;
+
protected $table = 'company';
protected $primaryKey = 'cp_idx';
protected $returnType = 'object';
diff --git a/app/Models/DesignatedShopModel.php b/app/Models/DesignatedShopModel.php
index f731e80..f7bcd72 100644
--- a/app/Models/DesignatedShopModel.php
+++ b/app/Models/DesignatedShopModel.php
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class DesignatedShopModel extends Model
{
+ use \App\Models\Traits\Auditable;
+
protected $table = 'designated_shop';
protected $primaryKey = 'ds_idx';
protected $returnType = 'object';
diff --git a/app/Models/FeedbackFileModel.php b/app/Models/FeedbackFileModel.php
index b7b9215..4bae932 100644
--- a/app/Models/FeedbackFileModel.php
+++ b/app/Models/FeedbackFileModel.php
@@ -11,6 +11,8 @@ use CodeIgniter\Model;
*/
class FeedbackFileModel extends Model
{
+ use \App\Models\Traits\Auditable;
+
protected $table = 'feedback_file';
protected $primaryKey = 'ff_idx';
protected $returnType = 'object';
diff --git a/app/Models/FeedbackModel.php b/app/Models/FeedbackModel.php
index b03d451..36d08c9 100644
--- a/app/Models/FeedbackModel.php
+++ b/app/Models/FeedbackModel.php
@@ -11,6 +11,8 @@ use CodeIgniter\Model;
*/
class FeedbackModel extends Model
{
+ use \App\Models\Traits\Auditable;
+
protected $table = 'feedback';
protected $primaryKey = 'fb_idx';
protected $returnType = 'object';
diff --git a/app/Models/FreeRecipientModel.php b/app/Models/FreeRecipientModel.php
index e9c9317..0edde8b 100644
--- a/app/Models/FreeRecipientModel.php
+++ b/app/Models/FreeRecipientModel.php
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class FreeRecipientModel extends Model
{
+ use \App\Models\Traits\Auditable;
+
protected $table = 'free_recipient';
protected $primaryKey = 'fr_idx';
protected $returnType = 'object';
diff --git a/app/Models/LocalGovernmentModel.php b/app/Models/LocalGovernmentModel.php
index 6f47ff2..5606335 100644
--- a/app/Models/LocalGovernmentModel.php
+++ b/app/Models/LocalGovernmentModel.php
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class LocalGovernmentModel extends Model
{
+ use \App\Models\Traits\Auditable;
+
protected $table = 'local_government';
protected $primaryKey = 'lg_idx';
protected $returnType = 'object';
diff --git a/app/Models/ManagerModel.php b/app/Models/ManagerModel.php
index e3de857..2ae9d45 100644
--- a/app/Models/ManagerModel.php
+++ b/app/Models/ManagerModel.php
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class ManagerModel extends Model
{
+ use \App\Models\Traits\Auditable;
+
protected $table = 'manager';
protected $primaryKey = 'mg_idx';
protected $returnType = 'object';
diff --git a/app/Models/MemberApprovalRequestModel.php b/app/Models/MemberApprovalRequestModel.php
index 46dd0dc..d2efb8b 100644
--- a/app/Models/MemberApprovalRequestModel.php
+++ b/app/Models/MemberApprovalRequestModel.php
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class MemberApprovalRequestModel extends Model
{
+ use \App\Models\Traits\Auditable;
+
public const STATUS_PENDING = 'pending';
public const STATUS_APPROVED = 'approved';
public const STATUS_REJECTED = 'rejected';
diff --git a/app/Models/MemberModel.php b/app/Models/MemberModel.php
index 252d838..aef5ad0 100644
--- a/app/Models/MemberModel.php
+++ b/app/Models/MemberModel.php
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class MemberModel extends Model
{
+ use \App\Models\Traits\Auditable;
+
protected $table = 'member';
protected $primaryKey = 'mb_idx';
protected $returnType = 'object';
diff --git a/app/Models/MenuModel.php b/app/Models/MenuModel.php
index 1950f8f..0631e53 100644
--- a/app/Models/MenuModel.php
+++ b/app/Models/MenuModel.php
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class MenuModel extends Model
{
+ use \App\Models\Traits\Auditable;
+
protected $table = 'menu';
protected $primaryKey = 'mm_idx';
protected $returnType = 'object';
diff --git a/app/Models/MenuTypeModel.php b/app/Models/MenuTypeModel.php
index 36e5bac..c1c264f 100644
--- a/app/Models/MenuTypeModel.php
+++ b/app/Models/MenuTypeModel.php
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class MenuTypeModel extends Model
{
+ use \App\Models\Traits\Auditable;
+
protected $table = 'menu_type';
protected $primaryKey = 'mt_idx';
protected $returnType = 'object';
diff --git a/app/Models/PackagingUnitHistoryModel.php b/app/Models/PackagingUnitHistoryModel.php
index 6206787..2e733ae 100644
--- a/app/Models/PackagingUnitHistoryModel.php
+++ b/app/Models/PackagingUnitHistoryModel.php
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class PackagingUnitHistoryModel extends Model
{
+ use \App\Models\Traits\Auditable;
+
protected $table = 'packaging_unit_history';
protected $primaryKey = 'puh_idx';
protected $returnType = 'object';
diff --git a/app/Models/PackagingUnitModel.php b/app/Models/PackagingUnitModel.php
index dc7317d..8044b39 100644
--- a/app/Models/PackagingUnitModel.php
+++ b/app/Models/PackagingUnitModel.php
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class PackagingUnitModel extends Model
{
+ use \App\Models\Traits\Auditable;
+
protected $table = 'packaging_unit';
protected $primaryKey = 'pu_idx';
protected $returnType = 'object';
diff --git a/app/Models/SalesAgencyModel.php b/app/Models/SalesAgencyModel.php
index 2c9b54b..639156f 100644
--- a/app/Models/SalesAgencyModel.php
+++ b/app/Models/SalesAgencyModel.php
@@ -8,6 +8,8 @@ use CodeIgniter\Model;
class SalesAgencyModel extends Model
{
+ use \App\Models\Traits\Auditable;
+
protected $table = 'sales_agency';
protected $primaryKey = 'sa_idx';
protected $returnType = 'object';
diff --git a/app/Models/ShopOrderItemModel.php b/app/Models/ShopOrderItemModel.php
index c0ae5eb..8b11f29 100644
--- a/app/Models/ShopOrderItemModel.php
+++ b/app/Models/ShopOrderItemModel.php
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class ShopOrderItemModel extends Model
{
+ use \App\Models\Traits\Auditable;
+
protected $table = 'shop_order_item';
protected $primaryKey = 'soi_idx';
protected $returnType = 'object';
diff --git a/app/Models/ShopOrderModel.php b/app/Models/ShopOrderModel.php
index cfbd7e3..d3a13b1 100644
--- a/app/Models/ShopOrderModel.php
+++ b/app/Models/ShopOrderModel.php
@@ -6,6 +6,8 @@ use CodeIgniter\Model;
class ShopOrderModel extends Model
{
+ use \App\Models\Traits\Auditable;
+
protected $table = 'shop_order';
protected $primaryKey = 'so_idx';
protected $returnType = 'object';
diff --git a/app/Models/Traits/Auditable.php b/app/Models/Traits/Auditable.php
new file mode 100644
index 0000000..9fc9e57
--- /dev/null
+++ b/app/Models/Traits/Auditable.php
@@ -0,0 +1,148 @@
+ row) */
+ protected array $auditBeforeRows = [];
+
+ /**
+ * 모델 이벤트(콜백) 등록.
+ * BaseModel 이 이미 $afterInsert 등 속성을 선언하므로(속성 충돌 방지),
+ * 트레잇에서는 속성 대신 initialize()에서 런타임에 콜백을 덧붙인다.
+ */
+ protected function initialize()
+ {
+ $this->allowCallbacks = true;
+ $this->afterInsert[] = 'auditAfterInsert';
+ $this->beforeUpdate[] = 'auditBeforeUpdate';
+ $this->afterUpdate[] = 'auditAfterUpdate';
+ $this->beforeDelete[] = 'auditBeforeDelete';
+ $this->afterDelete[] = 'auditAfterDelete';
+ }
+
+ /** 마스킹 대상 필드명 */
+ protected array $auditMaskFields = ['mb_passwd', 'mb_totp_secret', 'mb_session_token', 'mb_email', 'mb_phone'];
+
+ private function auditMask(?array $row): ?array
+ {
+ if ($row === null) {
+ return null;
+ }
+ foreach ($this->auditMaskFields as $f) {
+ if (array_key_exists($f, $row) && (string) $row[$f] !== '') {
+ $row[$f] = '***';
+ }
+ }
+
+ return $row;
+ }
+
+ /** find 콜백 재귀를 피하려고 쿼리빌더로 원본 행을 직접 조회 */
+ private function auditFetchRaw(int|string $id): ?array
+ {
+ try {
+ $row = $this->db->table($this->table)
+ ->where($this->primaryKey, $id)
+ ->get()
+ ->getRowArray();
+
+ return $row ?: null;
+ } catch (\Throwable $e) {
+ return null;
+ }
+ }
+
+ public function auditAfterInsert(array $eventData): array
+ {
+ helper('audit');
+ $after = is_array($eventData['data'] ?? null) ? $eventData['data'] : null;
+ audit_log('create', $this->table, (int) ($eventData['id'] ?? 0), null, $this->auditMask($after));
+
+ return $eventData;
+ }
+
+ public function auditBeforeUpdate(array $eventData): array
+ {
+ $this->auditBeforeRows = [];
+ $ids = $eventData['id'] ?? null;
+ if ($ids !== null) {
+ foreach ((array) $ids as $one) {
+ $row = $this->auditFetchRaw($one);
+ if ($row !== null) {
+ $this->auditBeforeRows[(string) $one] = $row;
+ }
+ }
+ }
+
+ return $eventData;
+ }
+
+ public function auditAfterUpdate(array $eventData): array
+ {
+ helper('audit');
+ $ids = $eventData['id'] ?? null;
+ $changes = is_array($eventData['data'] ?? null) ? $eventData['data'] : [];
+
+ if ($ids !== null) {
+ foreach ((array) $ids as $one) {
+ $before = $this->auditBeforeRows[(string) $one] ?? null;
+ $after = $before !== null ? array_merge($before, $changes) : $changes;
+ audit_log('update', $this->table, (int) $one, $this->auditMask($before), $this->auditMask($after));
+ }
+ } else {
+ // 조건(where) 기반 일괄 수정: 대상 PK 미상 → 변경값만 기록
+ audit_log('update', $this->table, 0, null, $this->auditMask($changes));
+ }
+ $this->auditBeforeRows = [];
+
+ return $eventData;
+ }
+
+ public function auditBeforeDelete(array $eventData): array
+ {
+ $this->auditBeforeRows = [];
+ $ids = $eventData['id'] ?? null;
+ if ($ids !== null) {
+ foreach ((array) $ids as $one) {
+ $row = $this->auditFetchRaw($one);
+ if ($row !== null) {
+ $this->auditBeforeRows[(string) $one] = $row;
+ }
+ }
+ }
+
+ return $eventData;
+ }
+
+ public function auditAfterDelete(array $eventData): array
+ {
+ helper('audit');
+ $ids = $eventData['id'] ?? null;
+ if ($ids !== null) {
+ foreach ((array) $ids as $one) {
+ $before = $this->auditBeforeRows[(string) $one] ?? null;
+ audit_log('delete', $this->table, (int) $one, $this->auditMask($before), null);
+ }
+ } else {
+ audit_log('delete', $this->table, 0, null, null);
+ }
+ $this->auditBeforeRows = [];
+
+ return $eventData;
+ }
+}
diff --git a/app/Views/admin/access/activity_log.php b/app/Views/admin/access/activity_log.php
new file mode 100644
index 0000000..f0fcd82
--- /dev/null
+++ b/app/Views/admin/access/activity_log.php
@@ -0,0 +1,94 @@
+ $list
+ * @var string $start
+ * @var string $end
+ * @var string $action
+ * @var string $table
+ * @var string $user
+ * @var array $tableLabels
+ * @var array $actionLabels
+ */
+$actionBadge = static function (string $a): string {
+ $map = [
+ 'create' => 'background:#dcfce7;color:#166534;',
+ 'update' => 'background:#dbeafe;color:#1e40af;',
+ 'delete' => 'background:#fee2e2;color:#991b1b;',
+ ];
+ return $map[$a] ?? 'background:#f3f4f6;color:#374151;';
+};
+?>
+= view('components/print_header', ['printTitle' => '활동 로그']) ?>
+
+
+
+
+
+
+
+ | 일시 |
+ 사용자 |
+ 작업 |
+ 대상 |
+ 대상 번호 |
+ IP |
+ 상세 |
+
+
+
+
+ al_action ?? '');
+ $tbl = (string) ($row->al_table ?? '');
+ $who = trim((string) ($row->mb_name ?? '') . (($row->mb_id ?? '') !== '' ? ' (' . $row->mb_id . ')' : ''));
+ ?>
+
+ | = esc((string) ($row->al_regdate ?? '')) ?> |
+ = $who !== '' ? esc($who) : '시스템' ?> |
+
+
+ = esc($actionLabels[$act] ?? $act) ?>
+
+ |
+ = esc($tableLabels[$tbl] ?? $tbl) ?> |
+ = (int) ($row->al_record_id ?? 0) ?: '-' ?> |
+ = esc((string) ($row->al_ip ?? '')) ?> |
+
+ 보기
+ |
+
+
+
+ | 조회된 로그가 없습니다. |
+
+
+
+
+= $pager->links() ?>
diff --git a/app/Views/admin/access/activity_log_detail.php b/app/Views/admin/access/activity_log_detail.php
new file mode 100644
index 0000000..7388f18
--- /dev/null
+++ b/app/Views/admin/access/activity_log_detail.php
@@ -0,0 +1,69 @@
+ $before
+ * @var array $after
+ * @var array $tableLabels
+ * @var array $actionLabels
+ */
+$act = (string) ($row->al_action ?? '');
+$tbl = (string) ($row->al_table ?? '');
+$who = trim((string) ($row->mb_name ?? '') . (($row->mb_id ?? '') !== '' ? ' (' . $row->mb_id . ')' : ''));
+
+// 변경된 키 강조용: before/after 값이 다른 키
+$allKeys = array_values(array_unique(array_merge(array_keys($before), array_keys($after))));
+$fmt = static function ($v): string {
+ if (is_array($v)) {
+ return json_encode($v, JSON_UNESCAPED_UNICODE);
+ }
+ return (string) ($v ?? '');
+};
+?>
+
+
+
+
+
+ | 일시 | = esc((string) ($row->al_regdate ?? '')) ?> |
+ | 사용자 | = $who !== '' ? esc($who) : '시스템' ?> |
+ | 작업 | = esc($actionLabels[$act] ?? $act) ?> |
+ | 대상 | = esc($tableLabels[$tbl] ?? $tbl) ?> (= esc($tbl) ?>) · 번호 = (int) ($row->al_record_id ?? 0) ?> |
+ | IP | = esc((string) ($row->al_ip ?? '')) ?> |
+
+
+
+ 변경 내역 (전 → 후)
+
+ 상세 데이터가 없습니다.
+
+
+
+
+
+ | 항목 |
+ 변경 전 |
+ 변경 후 |
+
+
+
+
+
+ >
+ | = esc((string) $k) ?> |
+ = $b === null ? '—' : esc($b) ?> |
+ = $a === null ? '—' : esc($a) ?> |
+
+
+
+
+
+
+
diff --git a/app/Views/admin/layout.php b/app/Views/admin/layout.php
index 7de9d0d..8a1bdd5 100644
--- a/app/Views/admin/layout.php
+++ b/app/Views/admin/layout.php
@@ -33,7 +33,7 @@ if ($navItems === []) {
$navItems = [
['idx' => 0, 'name' => '대시보드', 'href' => 'admin', 'url' => base_url('admin'), 'children' => [], 'hasChildren' => false],
['idx' => 0, 'name' => '회원·접근', 'href' => '', 'url' => '', 'hasChildren' => true, 'children' => [
- $mk('회원 관리', 'admin/users'), $mk('로그인 이력', 'admin/access/login-history'), $mk('승인 대기', 'admin/access/approvals'),
+ $mk('회원 관리', 'admin/users'), $mk('로그인 이력', 'admin/access/login-history'), $mk('활동 로그', 'admin/access/activity-logs'), $mk('승인 대기', 'admin/access/approvals'),
]],
['idx' => 0, 'name' => '시스템', 'href' => '', 'url' => '', 'hasChildren' => true, 'children' => [
$mk('역할', 'admin/roles'), $mk('메뉴', 'admin/menus'),
diff --git a/writable/database/activity_log_menu_add.sql b/writable/database/activity_log_menu_add.sql
new file mode 100644
index 0000000..c4d7509
--- /dev/null
+++ b/writable/database/activity_log_menu_add.sql
@@ -0,0 +1,11 @@
+-- 활동 로그 조회 메뉴 추가 (각 지자체의 '로그인 이력' 메뉴 옆에 삽입, 멱등)
+INSERT INTO `menu` (`mt_idx`, `lg_idx`, `mm_name`, `mm_link`, `mm_pidx`, `mm_dep`, `mm_num`, `mm_cnode`, `mm_level`, `mm_is_view`)
+SELECT lh.`mt_idx`, lh.`lg_idx`, '활동 로그', 'admin/access/activity-logs',
+ lh.`mm_pidx`, lh.`mm_dep`, lh.`mm_num` + 1, lh.`mm_cnode`, lh.`mm_level`, lh.`mm_is_view`
+FROM `menu` lh
+WHERE lh.`mm_link` = 'admin/access/login-history'
+ AND NOT EXISTS (
+ SELECT 1 FROM `menu` x
+ WHERE x.`lg_idx` = lh.`lg_idx`
+ AND x.`mm_link` = 'admin/access/activity-logs'
+ );