From 6ad76239a90287988a2d320bc8c8bda8e2b58e4f Mon Sep 17 00:00:00 2001 From: hskwon Date: Wed, 26 Nov 2025 22:23:37 +0900 Subject: [PATCH] =?UTF-8?q?feat(mng):=20=EC=82=AD=EC=A0=9C=EB=90=9C=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=B0=B1=EC=97=85=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ArchivedRecord, ArchivedRecordRelation 모델 추가 - ArchivedRecordService 추가 (읽기 전용) - 목록/상세 컨트롤러 및 뷰 구현 - HTMX 기반 테이블 필터링 및 페이지네이션 - 사이드바 메뉴 연결 --- .../Api/Admin/ArchivedRecordController.php | 87 +++++++++++ .../Controllers/ArchivedRecordController.php | 37 +++++ app/Models/Archives/ArchivedRecord.php | 90 +++++++++++ .../Archives/ArchivedRecordRelation.php | 53 +++++++ app/Services/ArchivedRecordService.php | 98 ++++++++++++ .../views/archived-records/index.blade.php | 115 ++++++++++++++ .../archived-records/partials/table.blade.php | 141 ++++++++++++++++++ .../views/archived-records/show.blade.php | 112 ++++++++++++++ resources/views/partials/sidebar.blade.php | 4 +- routes/api.php | 7 + routes/web.php | 7 + 11 files changed, 749 insertions(+), 2 deletions(-) create mode 100644 app/Http/Controllers/Api/Admin/ArchivedRecordController.php create mode 100644 app/Http/Controllers/ArchivedRecordController.php create mode 100644 app/Models/Archives/ArchivedRecord.php create mode 100644 app/Models/Archives/ArchivedRecordRelation.php create mode 100644 app/Services/ArchivedRecordService.php create mode 100644 resources/views/archived-records/index.blade.php create mode 100644 resources/views/archived-records/partials/table.blade.php create mode 100644 resources/views/archived-records/show.blade.php diff --git a/app/Http/Controllers/Api/Admin/ArchivedRecordController.php b/app/Http/Controllers/Api/Admin/ArchivedRecordController.php new file mode 100644 index 00000000..7996869a --- /dev/null +++ b/app/Http/Controllers/Api/Admin/ArchivedRecordController.php @@ -0,0 +1,87 @@ +archivedRecordService->getArchivedRecords( + $request->all(), + $request->integer('per_page', 15) + ); + + // HTMX 요청 시 HTML 반환 + if ($request->header('HX-Request')) { + $html = view('archived-records.partials.table', compact('records'))->render(); + + return response()->json([ + 'html' => $html, + ]); + } + + // 일반 요청 시 JSON 반환 + return response()->json([ + 'success' => true, + 'data' => $records->items(), + 'meta' => [ + 'current_page' => $records->currentPage(), + 'last_page' => $records->lastPage(), + 'per_page' => $records->perPage(), + 'total' => $records->total(), + ], + ]); + } + + /** + * 특정 아카이브 레코드 조회 + */ + public function show(Request $request, int $id): JsonResponse + { + $record = $this->archivedRecordService->getArchivedRecordById($id); + + if (! $record) { + return response()->json([ + 'success' => false, + 'message' => '아카이브 레코드를 찾을 수 없습니다.', + ], 404); + } + + // HTMX 요청 시 HTML 반환 + if ($request->header('HX-Request')) { + return response()->json([ + 'html' => view('archived-records.partials.detail', compact('record'))->render(), + ]); + } + + return response()->json([ + 'success' => true, + 'data' => $record, + ]); + } + + /** + * 아카이브 통계 + */ + public function stats(Request $request): JsonResponse + { + $stats = $this->archivedRecordService->getStats(); + + return response()->json([ + 'success' => true, + 'data' => $stats, + ]); + } +} diff --git a/app/Http/Controllers/ArchivedRecordController.php b/app/Http/Controllers/ArchivedRecordController.php new file mode 100644 index 00000000..bb4be804 --- /dev/null +++ b/app/Http/Controllers/ArchivedRecordController.php @@ -0,0 +1,37 @@ +archivedRecordService->getDeletedByUsers(); + + return view('archived-records.index', compact('deletedByUsers')); + } + + /** + * 아카이브 레코드 상세 보기 (Blade 화면만) + */ + public function show(int $id): View + { + $record = $this->archivedRecordService->getArchivedRecordById($id); + + if (! $record) { + abort(404, '아카이브 레코드를 찾을 수 없습니다.'); + } + + return view('archived-records.show', compact('record')); + } +} diff --git a/app/Models/Archives/ArchivedRecord.php b/app/Models/Archives/ArchivedRecord.php new file mode 100644 index 00000000..9f908f0b --- /dev/null +++ b/app/Models/Archives/ArchivedRecord.php @@ -0,0 +1,90 @@ + 'array', + 'deleted_at' => 'datetime', + 'original_id' => 'integer', + 'deleted_by' => 'integer', + ]; + + /** + * 관계: 삭제한 사용자 + */ + public function deletedByUser(): BelongsTo + { + return $this->belongsTo(User::class, 'deleted_by'); + } + + /** + * 관계: 연관 테이블 데이터 + */ + public function relations(): HasMany + { + return $this->hasMany(ArchivedRecordRelation::class, 'archived_record_id'); + } + + /** + * Scope: 테넌트 타입만 + */ + public function scopeTenant($query) + { + return $query->where('record_type', 'tenant'); + } + + /** + * Scope: 사용자 타입만 + */ + public function scopeUser($query) + { + return $query->where('record_type', 'user'); + } + + /** + * 레코드 타입 레이블 + */ + public function getRecordTypeLabelAttribute(): string + { + return match ($this->record_type) { + 'tenant' => '테넌트', + 'user' => '사용자', + default => $this->record_type, + }; + } + + /** + * 메인 데이터 요약 + */ + public function getMainDataSummaryAttribute(): string + { + if (empty($this->main_data)) { + return '-'; + } + + $data = $this->main_data; + $name = $data['name'] ?? $data['title'] ?? '-'; + $id = $data['id'] ?? $this->original_id; + + return "{$name} (ID: {$id})"; + } +} diff --git a/app/Models/Archives/ArchivedRecordRelation.php b/app/Models/Archives/ArchivedRecordRelation.php new file mode 100644 index 00000000..e8610ce2 --- /dev/null +++ b/app/Models/Archives/ArchivedRecordRelation.php @@ -0,0 +1,53 @@ + 'array', + 'record_count' => 'integer', + 'archived_record_id' => 'integer', + ]; + + /** + * 관계: 상위 아카이브 레코드 + */ + public function archivedRecord(): BelongsTo + { + return $this->belongsTo(ArchivedRecord::class, 'archived_record_id'); + } + + /** + * 테이블명 레이블 + */ + public function getTableNameLabelAttribute(): string + { + return match ($this->table_name) { + 'users' => '사용자', + 'roles' => '역할', + 'permissions' => '권한', + 'departments' => '부서', + 'menus' => '메뉴', + 'products' => '제품', + 'materials' => '자재', + 'boms' => 'BOM', + 'categories' => '카테고리', + 'files' => '파일', + 'audit_logs' => '감사 로그', + default => $this->table_name, + }; + } +} diff --git a/app/Services/ArchivedRecordService.php b/app/Services/ArchivedRecordService.php new file mode 100644 index 00000000..5f3cf940 --- /dev/null +++ b/app/Services/ArchivedRecordService.php @@ -0,0 +1,98 @@ +with(['deletedByUser', 'relations']) + ->withCount('relations'); + + // 레코드 타입 필터 + if (! empty($filters['record_type'])) { + $query->where('record_type', $filters['record_type']); + } + + // 삭제자 필터 + if (! empty($filters['deleted_by'])) { + $query->where('deleted_by', $filters['deleted_by']); + } + + // 노트 유무 필터 + if (isset($filters['has_notes'])) { + if ($filters['has_notes'] === 'yes' || $filters['has_notes'] === '1') { + $query->whereNotNull('notes')->where('notes', '!=', ''); + } elseif ($filters['has_notes'] === 'no' || $filters['has_notes'] === '0') { + $query->where(function ($q) { + $q->whereNull('notes')->orWhere('notes', ''); + }); + } + } + + // 검색 + if (! empty($filters['search'])) { + $search = $filters['search']; + $query->where(function ($q) use ($search) { + $q->where('record_type', 'like', "%{$search}%") + ->orWhere('notes', 'like', "%{$search}%") + ->orWhereRaw("JSON_EXTRACT(main_data, '$.name') LIKE ?", ["%{$search}%"]) + ->orWhereRaw("JSON_EXTRACT(main_data, '$.title') LIKE ?", ["%{$search}%"]); + }); + } + + // 정렬 (기본: 삭제일시 내림차순) + $sortBy = $filters['sort_by'] ?? 'deleted_at'; + $sortDirection = $filters['sort_direction'] ?? 'desc'; + $query->orderBy($sortBy, $sortDirection); + + return $query->paginate($perPage); + } + + /** + * 특정 아카이브 레코드 조회 + */ + public function getArchivedRecordById(int $id): ?ArchivedRecord + { + return ArchivedRecord::with(['deletedByUser', 'relations']) + ->find($id); + } + + /** + * 삭제자 목록 조회 (필터용) + */ + public function getDeletedByUsers(): array + { + return ArchivedRecord::query() + ->whereNotNull('deleted_by') + ->with('deletedByUser:id,name') + ->get() + ->pluck('deletedByUser') + ->filter() + ->unique('id') + ->values() + ->toArray(); + } + + /** + * 아카이브 통계 + */ + public function getStats(): array + { + return [ + 'total' => ArchivedRecord::count(), + 'tenants' => ArchivedRecord::tenant()->count(), + 'users' => ArchivedRecord::user()->count(), + 'with_notes' => ArchivedRecord::whereNotNull('notes') + ->where('notes', '!=', '') + ->count(), + ]; + } +} diff --git a/resources/views/archived-records/index.blade.php b/resources/views/archived-records/index.blade.php new file mode 100644 index 00000000..8a32f2ae --- /dev/null +++ b/resources/views/archived-records/index.blade.php @@ -0,0 +1,115 @@ +@extends('layouts.app') + +@section('title', '삭제된 데이터 백업') + +@section('content') + +
+

삭제된 데이터 백업

+
+ + +
+
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + +
+
+ + +
+
+ 범례: +
+ + 테넌트 + + 테넌트 데이터 +
+
+ + 사용자 + + 사용자 데이터 +
+
+
+ + +
+ +
+
+
+
+@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/archived-records/partials/table.blade.php b/resources/views/archived-records/partials/table.blade.php new file mode 100644 index 00000000..f63d5426 --- /dev/null +++ b/resources/views/archived-records/partials/table.blade.php @@ -0,0 +1,141 @@ +
+ + + + + + + + + + + + + + + + @forelse($records as $record) + + + + + + + + + + + + @empty + + + + @endforelse + +
ID타입원본 ID데이터 요약삭제자삭제일시관련 테이블노트작업
+ {{ $record->id }} + + + {{ $record->record_type_label }} + + + {{ $record->original_id }} + +
+ {{ $record->main_data_summary }} +
+
+ {{ $record->deletedByUser?->name ?? '-' }} + + {{ $record->deleted_at?->format('Y-m-d H:i') ?? '-' }} + + @if($record->relations_count > 0) + + {{ $record->relations_count }} + + @else + - + @endif + + @if($record->notes) + + + + + + @else + - + @endif + + + + + + + +
+
+ + + +

백업된 데이터가 없습니다.

+
+
+
+ +{{-- 페이지네이션 --}} +@if($records->hasPages()) +
+
+
+ 총 {{ $records->total() }}개 중 + {{ $records->firstItem() }} - + {{ $records->lastItem() }}개 표시 +
+
+ {{-- 이전 페이지 --}} + @if($records->onFirstPage()) + 이전 + @else + + @endif + + {{-- 페이지 번호 --}} + @foreach(range(max(1, $records->currentPage() - 2), min($records->lastPage(), $records->currentPage() + 2)) as $page) + @if($page == $records->currentPage()) + {{ $page }} + @else + + @endif + @endforeach + + {{-- 다음 페이지 --}} + @if($records->hasMorePages()) + + @else + 다음 + @endif +
+
+
+@endif diff --git a/resources/views/archived-records/show.blade.php b/resources/views/archived-records/show.blade.php new file mode 100644 index 00000000..988c5c5d --- /dev/null +++ b/resources/views/archived-records/show.blade.php @@ -0,0 +1,112 @@ +@extends('layouts.app') + +@section('title', '삭제된 데이터 상세') + +@section('content') + +
+
+ + + + + +

삭제된 데이터 상세

+ + {{ $record->record_type_label }} + +
+
+ + +
+
+

기본 정보

+
+
+
+
+
ID
+
{{ $record->id }}
+
+
+
레코드 타입
+
+ + {{ $record->record_type_label }} + +
+
+
+
원본 ID
+
{{ $record->original_id }}
+
+
+
스키마 버전
+
{{ $record->schema_version ?? '-' }}
+
+
+
삭제자
+
{{ $record->deletedByUser?->name ?? '-' }}
+
+
+
삭제일시
+
{{ $record->deleted_at?->format('Y-m-d H:i:s') ?? '-' }}
+
+ @if($record->notes) +
+
노트
+
{{ $record->notes }}
+
+ @endif +
+
+
+ + +
+
+

메인 데이터

+
+
+ @if($record->main_data) +
{{ json_encode($record->main_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}
+ @else +

데이터가 없습니다.

+ @endif +
+
+ + + @if($record->relations->isNotEmpty()) +
+
+

관련 테이블 데이터

+

총 {{ $record->relations->count() }}개 테이블

+
+
+ @foreach($record->relations as $relation) +
+
+
+ {{ $relation->table_name_label }} + ({{ $relation->table_name }}) +
+ + {{ $relation->record_count }} 건 + +
+
+ @if($relation->data) +
{{ json_encode($relation->data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}
+ @else +

데이터가 없습니다.

+ @endif +
+
+ @endforeach +
+
+ @endif +@endsection diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index 5abf515b..aedad6ae 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -227,8 +227,8 @@ class="flex items-center gap-2 pr-3 py-2 rounded-lg text-sm text-gray-700 hover:
  • - diff --git a/routes/api.php b/routes/api.php index 779784d6..f140af93 100644 --- a/routes/api.php +++ b/routes/api.php @@ -133,4 +133,11 @@ Route::get('/export-csv', [\App\Http\Controllers\Api\Admin\PermissionAnalyzeController::class, 'exportCsv'])->name('exportCsv'); Route::post('/recalculate', [\App\Http\Controllers\Api\Admin\PermissionAnalyzeController::class, 'recalculate'])->name('recalculate'); }); + + // 삭제된 데이터 백업 API + Route::prefix('archived-records')->name('archived-records.')->group(function () { + Route::get('/stats', [\App\Http\Controllers\Api\Admin\ArchivedRecordController::class, 'stats'])->name('stats'); + Route::get('/', [\App\Http\Controllers\Api\Admin\ArchivedRecordController::class, 'index'])->name('index'); + Route::get('/{id}', [\App\Http\Controllers\Api\Admin\ArchivedRecordController::class, 'show'])->name('show'); + }); }); diff --git a/routes/web.php b/routes/web.php index 603b96db..03e7c4b6 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,6 @@ name('permission-analyze.index'); + // 삭제된 데이터 백업 (Blade 화면만) + Route::prefix('archived-records')->name('archived-records.')->group(function () { + Route::get('/', [ArchivedRecordController::class, 'index'])->name('index'); + Route::get('/{id}', [ArchivedRecordController::class, 'show'])->name('show'); + }); + // 대시보드 Route::get('/dashboard', function () { return view('dashboard.index');