From ea3c403521be3df2e2838fdaa00111f5f48622e4 Mon Sep 17 00:00:00 2001 From: kent Date: Tue, 16 Dec 2025 01:58:09 +0900 Subject: [PATCH] =?UTF-8?q?feat(mng):=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=A0=95=EC=9D=98=20DB=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EA=B4=80=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 주요 변경사항 ### 1. 시스템 필드 정의 DB 마이그레이션 - 기존 하드코딩된 Constants/SystemFieldDefinitions.php 제거 - 신규 Models/SystemFieldDefinition.php 모델 추가 - system_field_definitions 테이블 기반 동적 관리 ### 2. 테이블 등록 시 자동 필드 생성 - DB 실제 테이블 목록에서 선택하여 등록 - MySQL INFORMATION_SCHEMA에서 컬럼 정보 자동 조회 - COLUMN_COMMENT를 필드명(한글)으로 사용 - IS_NULLABLE로 필수 여부 자동 설정 - 시스템 컬럼(id, tenant_id, timestamps 등) 자동 제외 ### 3. 필드명 동기화 기능 - 기존 등록된 테이블의 필드명을 DB COMMENT로 업데이트 - POST /source-tables/{table}/sync-field-names API 추가 ### 4. 시딩 상태 계산 수정 - getFieldCountFor(): is_seed_default=true인 필드만 카운트 - getTotalFieldCountFor(): 전체 활성 필드 카운트 (신규) - "제외" 필드가 있어도 시딩 완료 상태 정상 표시 ### 5. UI 개선 - 시스템 필드 정의 탭에서 테이블별 관리 - 테이블 헤더에 "필드명 동기화", "삭제" 버튼 추가 - 테이블 선택 모달에서 COMMENT(한글명) 표시 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- app/Constants/SystemFieldDefinitions.php | 139 --- .../Api/Admin/ItemFieldController.php | 510 ++++++++++- app/Http/Controllers/ItemFieldController.php | 5 +- app/Models/SystemFieldDefinition.php | 140 +++ app/Services/ItemFieldSeedingService.php | 92 +- resources/views/item-fields/index.blade.php | 808 +++++++++++++++++- .../system-field-definitions.blade.php | 181 ++++ 7 files changed, 1685 insertions(+), 190 deletions(-) delete mode 100644 app/Constants/SystemFieldDefinitions.php create mode 100644 app/Models/SystemFieldDefinition.php create mode 100644 resources/views/item-fields/partials/system-field-definitions.blade.php diff --git a/app/Constants/SystemFieldDefinitions.php b/app/Constants/SystemFieldDefinitions.php deleted file mode 100644 index 060c5dd2..00000000 --- a/app/Constants/SystemFieldDefinitions.php +++ /dev/null @@ -1,139 +0,0 @@ - 'code', 'field_name' => '품목코드', 'field_type' => 'textbox', 'is_required' => true, 'order_no' => 1], - ['field_key' => 'name', 'field_name' => '품목명', 'field_type' => 'textbox', 'is_required' => true, 'order_no' => 2], - ['field_key' => 'unit', 'field_name' => '단위', 'field_type' => 'dropdown', 'is_required' => true, 'order_no' => 3], - ['field_key' => 'product_type', 'field_name' => '제품유형', 'field_type' => 'dropdown', 'order_no' => 4, 'options' => [ - ['label' => '완제품', 'value' => 'FG'], - ['label' => '부품', 'value' => 'PT'], - ]], - ['field_key' => 'category_id', 'field_name' => '카테고리', 'field_type' => 'dropdown', 'order_no' => 5], - ['field_key' => 'is_sellable', 'field_name' => '판매가능', 'field_type' => 'checkbox', 'order_no' => 6, 'default_value' => 'true'], - ['field_key' => 'is_purchasable', 'field_name' => '구매가능', 'field_type' => 'checkbox', 'order_no' => 7], - ['field_key' => 'is_producible', 'field_name' => '생산가능', 'field_type' => 'checkbox', 'order_no' => 8, 'default_value' => 'true'], - ['field_key' => 'is_active', 'field_name' => '활성화', 'field_type' => 'checkbox', 'order_no' => 9, 'default_value' => 'true'], - ['field_key' => 'certification_number', 'field_name' => '인증번호', 'field_type' => 'textbox', 'order_no' => 10], - ['field_key' => 'certification_start_date', 'field_name' => '인증시작일', 'field_type' => 'date', 'order_no' => 11], - ['field_key' => 'certification_end_date', 'field_name' => '인증만료일', 'field_type' => 'date', 'order_no' => 12], - ]; - - /** - * materials 테이블 시스템 필드 정의 - */ - public const MATERIALS = [ - ['field_key' => 'material_code', 'field_name' => '자재코드', 'field_type' => 'textbox', 'is_required' => true, 'order_no' => 1], - ['field_key' => 'name', 'field_name' => '자재명', 'field_type' => 'textbox', 'is_required' => true, 'order_no' => 2], - ['field_key' => 'item_name', 'field_name' => '품목명', 'field_type' => 'textbox', 'order_no' => 3], - ['field_key' => 'specification', 'field_name' => '규격', 'field_type' => 'textbox', 'order_no' => 4], - ['field_key' => 'unit', 'field_name' => '단위', 'field_type' => 'dropdown', 'is_required' => true, 'order_no' => 5], - ['field_key' => 'category_id', 'field_name' => '카테고리', 'field_type' => 'dropdown', 'order_no' => 6], - ['field_key' => 'is_inspection', 'field_name' => '검수필요', 'field_type' => 'checkbox', 'order_no' => 7], - ['field_key' => 'search_tag', 'field_name' => '검색태그', 'field_type' => 'textarea', 'order_no' => 8], - ]; - - /** - * product_components 테이블 (BOM) 시스템 필드 정의 - */ - public const PRODUCT_COMPONENTS = [ - ['field_key' => 'ref_type', 'field_name' => '참조유형', 'field_type' => 'dropdown', 'order_no' => 1, 'options' => [ - ['label' => '제품', 'value' => 'product'], - ['label' => '자재', 'value' => 'material'], - ]], - ['field_key' => 'ref_id', 'field_name' => '참조품목', 'field_type' => 'dropdown', 'order_no' => 2], - ['field_key' => 'quantity', 'field_name' => '수량', 'field_type' => 'number', 'is_required' => true, 'order_no' => 3], - ['field_key' => 'formula', 'field_name' => '계산공식', 'field_type' => 'textbox', 'order_no' => 4], - ['field_key' => 'note', 'field_name' => '비고', 'field_type' => 'textarea', 'order_no' => 5], - ]; - - /** - * material_inspections 테이블 시스템 필드 정의 - */ - public const MATERIAL_INSPECTIONS = [ - ['field_key' => 'inspection_date', 'field_name' => '검수일', 'field_type' => 'date', 'is_required' => true, 'order_no' => 1], - ['field_key' => 'inspector_id', 'field_name' => '검수자', 'field_type' => 'dropdown', 'order_no' => 2], - ['field_key' => 'status', 'field_name' => '검수상태', 'field_type' => 'dropdown', 'order_no' => 3, 'options' => [ - ['label' => '대기', 'value' => 'pending'], - ['label' => '진행중', 'value' => 'in_progress'], - ['label' => '완료', 'value' => 'completed'], - ['label' => '불합격', 'value' => 'rejected'], - ]], - ['field_key' => 'lot_no', 'field_name' => 'LOT번호', 'field_type' => 'textbox', 'order_no' => 4], - ['field_key' => 'quantity', 'field_name' => '검수수량', 'field_type' => 'number', 'order_no' => 5], - ['field_key' => 'passed_quantity', 'field_name' => '합격수량', 'field_type' => 'number', 'order_no' => 6], - ['field_key' => 'rejected_quantity', 'field_name' => '불합격수량', 'field_type' => 'number', 'order_no' => 7], - ['field_key' => 'note', 'field_name' => '비고', 'field_type' => 'textarea', 'order_no' => 8], - ]; - - /** - * material_receipts 테이블 시스템 필드 정의 - */ - public const MATERIAL_RECEIPTS = [ - ['field_key' => 'receipt_date', 'field_name' => '입고일', 'field_type' => 'date', 'is_required' => true, 'order_no' => 1], - ['field_key' => 'lot_no', 'field_name' => 'LOT번호', 'field_type' => 'textbox', 'order_no' => 2], - ['field_key' => 'quantity', 'field_name' => '입고수량', 'field_type' => 'number', 'is_required' => true, 'order_no' => 3], - ['field_key' => 'unit_price', 'field_name' => '단가', 'field_type' => 'number', 'order_no' => 4], - ['field_key' => 'total_price', 'field_name' => '금액', 'field_type' => 'number', 'order_no' => 5], - ['field_key' => 'supplier_id', 'field_name' => '공급업체', 'field_type' => 'dropdown', 'order_no' => 6], - ['field_key' => 'warehouse_id', 'field_name' => '입고창고', 'field_type' => 'dropdown', 'order_no' => 7], - ['field_key' => 'po_number', 'field_name' => '발주번호', 'field_type' => 'textbox', 'order_no' => 8], - ['field_key' => 'invoice_number', 'field_name' => '송장번호', 'field_type' => 'textbox', 'order_no' => 9], - ['field_key' => 'note', 'field_name' => '비고', 'field_type' => 'textarea', 'order_no' => 10], - ]; - - /** - * 소스 테이블 목록 - */ - public const SOURCE_TABLES = [ - 'products' => '제품', - 'materials' => '자재', - 'product_components' => 'BOM', - 'material_inspections' => '자재검수', - 'material_receipts' => '자재입고', - ]; - - /** - * 소스 테이블별 필드 정의 가져오기 - */ - public static function getFieldsFor(string $sourceTable): array - { - return match ($sourceTable) { - 'products' => self::PRODUCTS, - 'materials' => self::MATERIALS, - 'product_components' => self::PRODUCT_COMPONENTS, - 'material_inspections' => self::MATERIAL_INSPECTIONS, - 'material_receipts' => self::MATERIAL_RECEIPTS, - default => [], - }; - } - - /** - * 전체 테이블 필드 수 조회 - */ - public static function getTotalFieldCount(string $sourceTable): int - { - return count(self::getFieldsFor($sourceTable)); - } - - /** - * 모든 소스 테이블의 시스템 필드 키 목록 - */ - public static function getAllSystemFieldKeys(string $sourceTable): array - { - $fields = self::getFieldsFor($sourceTable); - - return array_column($fields, 'field_key'); - } -} diff --git a/app/Http/Controllers/Api/Admin/ItemFieldController.php b/app/Http/Controllers/Api/Admin/ItemFieldController.php index 17d27bfb..b931f392 100644 --- a/app/Http/Controllers/Api/Admin/ItemFieldController.php +++ b/app/Http/Controllers/Api/Admin/ItemFieldController.php @@ -2,12 +2,14 @@ namespace App\Http\Controllers\Api\Admin; -use App\Constants\SystemFieldDefinitions; use App\Http\Controllers\Controller; +use App\Models\SystemFieldDefinition; use App\Services\ItemFieldSeedingService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Schema; use Illuminate\View\View; /** @@ -148,11 +150,12 @@ public function resetAll(Request $request): JsonResponse public function customFields(Request $request): View { $tenantId = session('selected_tenant_id'); + $sourceTables = SystemFieldDefinition::getSourceTableOptions(); if (! $tenantId || $tenantId === 'all') { return view('item-fields.partials.custom-fields', [ 'fields' => collect([]), - 'sourceTables' => SystemFieldDefinitions::SOURCE_TABLES, + 'sourceTables' => $sourceTables, 'error' => '테넌트를 선택해주세요.', ]); } @@ -162,7 +165,7 @@ public function customFields(Request $request): View return view('item-fields.partials.custom-fields', [ 'fields' => $fields, - 'sourceTables' => SystemFieldDefinitions::SOURCE_TABLES, + 'sourceTables' => $sourceTables, ]); } @@ -274,7 +277,7 @@ public function sourceTables(): JsonResponse { return response()->json([ 'success' => true, - 'data' => SystemFieldDefinitions::SOURCE_TABLES, + 'data' => SystemFieldDefinition::getSourceTableOptions(), ]); } @@ -364,4 +367,503 @@ protected function buildErrorReport(array $errorLog, ?int $tenantId): string return $report; } + + // ========================================================================= + // 시스템 필드 정의 관리 (system_field_definitions 테이블) + // ========================================================================= + + /** + * 시스템 필드 정의 목록 (HTMX partial) + */ + public function systemFieldDefinitions(Request $request): View + { + $query = SystemFieldDefinition::query() + ->where('is_active', true) + ->orderBy('source_table') + ->orderBy('order_no'); + + // 소스 테이블 필터 + if ($request->filled('source_table')) { + $query->where('source_table', $request->input('source_table')); + } + + // 검색 + if ($request->filled('search')) { + $search = $request->input('search'); + $query->where(function ($q) use ($search) { + $q->where('field_key', 'like', "%{$search}%") + ->orWhere('field_name', 'like', "%{$search}%"); + }); + } + + $definitions = $query->get(); + $sourceTables = SystemFieldDefinition::getSourceTableOptions(); + + return view('item-fields.partials.system-field-definitions', compact('definitions', 'sourceTables')); + } + + /** + * 시스템 필드 정의 추가 + */ + public function storeSystemFieldDefinition(Request $request): JsonResponse + { + $validated = $request->validate([ + 'source_table' => 'required|string|max:50', + 'source_table_label' => 'required|string|max:50', + 'field_key' => 'required|string|max:100|regex:/^[a-zA-Z0-9_]+$/', + 'field_name' => 'required|string|max:100', + 'field_type' => 'required|string|in:textbox,number,dropdown,checkbox,date,textarea', + 'order_no' => 'nullable|integer|min:0', + 'is_required' => 'nullable|boolean', + 'is_seed_default' => 'nullable|boolean', + 'default_value' => 'nullable|string|max:255', + 'options' => 'nullable|array', + ]); + + // 중복 체크 + $exists = SystemFieldDefinition::where('source_table', $validated['source_table']) + ->where('field_key', $validated['field_key']) + ->exists(); + + if ($exists) { + return response()->json([ + 'success' => false, + 'message' => '이미 존재하는 필드 키입니다.', + ], 400); + } + + // order_no가 없으면 마지막 순서로 설정 + if (! isset($validated['order_no'])) { + $maxOrder = SystemFieldDefinition::where('source_table', $validated['source_table'])->max('order_no'); + $validated['order_no'] = ($maxOrder ?? 0) + 1; + } + + $validated['is_required'] = $validated['is_required'] ?? false; + $validated['is_seed_default'] = $validated['is_seed_default'] ?? true; + $validated['is_active'] = true; + + SystemFieldDefinition::create($validated); + + return response()->json([ + 'success' => true, + 'message' => '시스템 필드 정의가 추가되었습니다.', + ]); + } + + /** + * 시스템 필드 정의 수정 + */ + public function updateSystemFieldDefinition(Request $request, int $id): JsonResponse + { + $definition = SystemFieldDefinition::find($id); + + if (! $definition) { + return response()->json([ + 'success' => false, + 'message' => '시스템 필드 정의를 찾을 수 없습니다.', + ], 404); + } + + $validated = $request->validate([ + 'source_table' => 'nullable|string|max:50', + 'source_table_label' => 'nullable|string|max:50', + 'field_key' => 'nullable|string|max:100|regex:/^[a-zA-Z0-9_]+$/', + 'field_name' => 'required|string|max:100', + 'field_type' => 'required|string|in:textbox,number,dropdown,checkbox,date,textarea', + 'order_no' => 'nullable|integer|min:0', + 'is_required' => 'nullable|boolean', + 'is_seed_default' => 'nullable|boolean', + 'default_value' => 'nullable|string|max:255', + 'options' => 'nullable|array', + ]); + + // source_table 또는 field_key 변경 시 중복 체크 + $newSourceTable = $validated['source_table'] ?? $definition->source_table; + $newFieldKey = $validated['field_key'] ?? $definition->field_key; + + if ($newSourceTable !== $definition->source_table || $newFieldKey !== $definition->field_key) { + $exists = SystemFieldDefinition::where('source_table', $newSourceTable) + ->where('field_key', $newFieldKey) + ->where('id', '!=', $id) + ->exists(); + + if ($exists) { + return response()->json([ + 'success' => false, + 'message' => '이미 존재하는 필드 키입니다.', + ], 400); + } + } + + $definition->update($validated); + + return response()->json([ + 'success' => true, + 'message' => '시스템 필드 정의가 수정되었습니다.', + ]); + } + + /** + * 시스템 필드 정의 삭제 + */ + public function destroySystemFieldDefinition(int $id): JsonResponse + { + $definition = SystemFieldDefinition::find($id); + + if (! $definition) { + return response()->json([ + 'success' => false, + 'message' => '시스템 필드 정의를 찾을 수 없습니다.', + ], 404); + } + + $definition->delete(); + + return response()->json([ + 'success' => true, + 'message' => '시스템 필드 정의가 삭제되었습니다.', + ]); + } + + /** + * 시스템 필드 정의 순서 변경 + */ + public function reorderSystemFieldDefinitions(Request $request): JsonResponse + { + $validated = $request->validate([ + 'items' => 'required|array', + 'items.*.id' => 'required|integer', + 'items.*.order_no' => 'required|integer|min:0', + ]); + + foreach ($validated['items'] as $item) { + SystemFieldDefinition::where('id', $item['id'])->update(['order_no' => $item['order_no']]); + } + + return response()->json([ + 'success' => true, + 'message' => '순서가 변경되었습니다.', + ]); + } + + /** + * 소스 테이블 추가 (DB 테이블의 컬럼을 자동으로 필드 정의로 생성) + */ + public function storeSourceTable(Request $request): JsonResponse + { + $validated = $request->validate([ + 'source_table' => 'required|string|max:50', + 'source_table_label' => 'required|string|max:50', + ]); + + $tableName = $validated['source_table']; + $tableLabel = $validated['source_table_label']; + + // 실제 DB에 테이블이 존재하는지 확인 + if (! Schema::hasTable($tableName)) { + return response()->json([ + 'success' => false, + 'message' => '데이터베이스에 존재하지 않는 테이블입니다.', + ], 400); + } + + // 이미 등록된 테이블인지 확인 + $exists = SystemFieldDefinition::where('source_table', $tableName)->exists(); + + if ($exists) { + return response()->json([ + 'success' => false, + 'message' => '이미 존재하는 테이블입니다.', + ], 400); + } + + // 시스템/메타 컬럼 제외 목록 + $excludedColumns = [ + 'id', 'tenant_id', 'created_at', 'updated_at', 'deleted_at', + 'created_by', 'updated_by', 'deleted_by', + ]; + + // MySQL INFORMATION_SCHEMA에서 컬럼 정보 조회 (COMMENT 포함) + $dbName = config('database.connections.mysql.database'); + $columnInfos = DB::select(" + SELECT COLUMN_NAME, COLUMN_TYPE, COLUMN_COMMENT, IS_NULLABLE + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? + ORDER BY ORDINAL_POSITION + ", [$dbName, $tableName]); + + $createdCount = 0; + $orderNo = 1; + + foreach ($columnInfos as $columnInfo) { + $column = $columnInfo->COLUMN_NAME; + + // 시스템 컬럼 제외 + if (in_array($column, $excludedColumns)) { + continue; + } + + $columnType = Schema::getColumnType($tableName, $column); + $fieldType = $this->suggestFieldType($columnType); + + // COMMENT가 있으면 사용, 없으면 컬럼명 변환 + $fieldName = ! empty($columnInfo->COLUMN_COMMENT) + ? $columnInfo->COLUMN_COMMENT + : ucwords(str_replace('_', ' ', $column)); + + // 필수 여부 (NOT NULL이면 필수) + $isRequired = $columnInfo->IS_NULLABLE === 'NO'; + + SystemFieldDefinition::create([ + 'source_table' => $tableName, + 'source_table_label' => $tableLabel, + 'field_key' => $column, + 'field_name' => $fieldName, + 'field_type' => $fieldType, + 'order_no' => $orderNo++, + 'is_required' => $isRequired, + 'is_seed_default' => true, + 'is_active' => true, + ]); + + $createdCount++; + } + + return response()->json([ + 'success' => true, + 'message' => "'{$tableLabel}' 테이블이 등록되었습니다. ({$createdCount}개 필드 생성)", + 'field_count' => $createdCount, + ]); + } + + /** + * 소스 테이블 삭제 (해당 테이블의 모든 필드 정의 삭제) + */ + public function destroySourceTable(string $sourceTable): JsonResponse + { + $count = SystemFieldDefinition::where('source_table', $sourceTable)->count(); + + if ($count === 0) { + return response()->json([ + 'success' => false, + 'message' => '테이블을 찾을 수 없습니다.', + ], 404); + } + + SystemFieldDefinition::where('source_table', $sourceTable)->delete(); + + return response()->json([ + 'success' => true, + 'message' => "테이블과 {$count}개의 필드 정의가 삭제되었습니다.", + ]); + } + + /** + * 소스 테이블 필드명 동기화 (DB COMMENT로 업데이트) + */ + public function syncSourceTableFieldNames(string $sourceTable): JsonResponse + { + // 등록된 테이블인지 확인 + $fields = SystemFieldDefinition::where('source_table', $sourceTable)->get(); + + if ($fields->isEmpty()) { + return response()->json([ + 'success' => false, + 'message' => '등록되지 않은 테이블입니다.', + ], 404); + } + + // 실제 DB에 테이블이 존재하는지 확인 + if (! Schema::hasTable($sourceTable)) { + return response()->json([ + 'success' => false, + 'message' => '데이터베이스에 존재하지 않는 테이블입니다.', + ], 400); + } + + // MySQL INFORMATION_SCHEMA에서 컬럼 정보 조회 (COMMENT 포함) + $dbName = config('database.connections.mysql.database'); + $columnInfos = DB::select(" + SELECT COLUMN_NAME, COLUMN_COMMENT, IS_NULLABLE + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? + ", [$dbName, $sourceTable]); + + // 컬럼명 => COMMENT 매핑 + $columnComments = collect($columnInfos)->keyBy('COLUMN_NAME'); + + $updatedCount = 0; + + foreach ($fields as $field) { + $columnInfo = $columnComments->get($field->field_key); + + if ($columnInfo && ! empty($columnInfo->COLUMN_COMMENT)) { + // COMMENT가 있고, 현재 필드명과 다르면 업데이트 + if ($field->field_name !== $columnInfo->COLUMN_COMMENT) { + $field->update([ + 'field_name' => $columnInfo->COLUMN_COMMENT, + 'is_required' => $columnInfo->IS_NULLABLE === 'NO', + ]); + $updatedCount++; + } + } + } + + return response()->json([ + 'success' => true, + 'message' => $updatedCount > 0 + ? "{$updatedCount}개 필드명이 DB COMMENT로 업데이트되었습니다." + : '업데이트할 필드가 없습니다. (이미 동기화됨)', + 'updated_count' => $updatedCount, + ]); + } + + /** + * 데이터베이스 테이블 목록 조회 (등록 가능한 테이블) + */ + public function databaseTables(Request $request): JsonResponse + { + // 이미 등록된 테이블 목록 + $registeredTables = SystemFieldDefinition::pluck('source_table')->unique()->toArray(); + + // 시스템 테이블 제외 목록 + $excludedTables = [ + 'migrations', 'password_reset_tokens', 'sessions', 'cache', 'cache_locks', + 'jobs', 'job_batches', 'failed_jobs', 'personal_access_tokens', + 'system_field_definitions', 'item_fields', 'item_field_values', + 'audit_logs', 'activity_log', 'telescope_entries', 'telescope_entries_tags', + 'telescope_monitoring', 'pulse_aggregates', 'pulse_entries', 'pulse_values', + ]; + + // DB 테이블 목록 조회 + $tables = collect(DB::select('SHOW TABLES')) + ->map(fn ($table) => array_values((array) $table)[0]) + ->filter(function ($table) use ($excludedTables, $registeredTables) { + // 시스템 테이블 제외 + if (in_array($table, $excludedTables)) { + return false; + } + // 피벗 테이블 제외 (2개의 테이블명이 _로 연결된 경우) + if (preg_match('/^[a-z]+_[a-z]+_[a-z]+$/', $table) && ! str_contains($table, 'field')) { + return false; + } + + return true; + }) + ->map(function ($table) use ($registeredTables) { + // 테이블 컬럼 정보 조회 + $columns = Schema::getColumnListing($table); + $columnDetails = []; + + foreach ($columns as $column) { + $type = Schema::getColumnType($table, $column); + $columnDetails[] = [ + 'name' => $column, + 'type' => $type, + 'type_label' => $this->getColumnTypeLabel($type), + ]; + } + + return [ + 'table' => $table, + 'columns' => $columnDetails, + 'column_count' => count($columns), + 'is_registered' => in_array($table, $registeredTables), + ]; + }) + ->values(); + + // 검색 필터 + if ($request->filled('search')) { + $search = strtolower($request->input('search')); + $tables = $tables->filter(function ($t) use ($search) { + return str_contains(strtolower($t['table']), $search); + })->values(); + } + + // 등록되지 않은 테이블만 필터 + if ($request->boolean('unregistered_only', false)) { + $tables = $tables->filter(fn ($t) => ! $t['is_registered'])->values(); + } + + return response()->json([ + 'success' => true, + 'data' => $tables, + 'total' => $tables->count(), + ]); + } + + /** + * 특정 테이블의 컬럼 목록 조회 (COMMENT 포함) + */ + public function tableColumns(string $table): JsonResponse + { + if (! Schema::hasTable($table)) { + return response()->json([ + 'success' => false, + 'message' => '테이블을 찾을 수 없습니다.', + ], 404); + } + + // MySQL INFORMATION_SCHEMA에서 컬럼 정보 조회 (COMMENT 포함) + $dbName = config('database.connections.mysql.database'); + $columnInfos = DB::select(" + SELECT COLUMN_NAME, COLUMN_TYPE, DATA_TYPE, COLUMN_COMMENT, IS_NULLABLE + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? + ORDER BY ORDINAL_POSITION + ", [$dbName, $table]); + + $columnDetails = []; + + foreach ($columnInfos as $info) { + $type = Schema::getColumnType($table, $info->COLUMN_NAME); + $columnDetails[] = [ + 'name' => $info->COLUMN_NAME, + 'type' => $type, + 'type_label' => $this->getColumnTypeLabel($type), + 'comment' => $info->COLUMN_COMMENT ?: null, + 'is_nullable' => $info->IS_NULLABLE === 'YES', + 'suggested_field_type' => $this->suggestFieldType($type), + ]; + } + + return response()->json([ + 'success' => true, + 'table' => $table, + 'columns' => $columnDetails, + ]); + } + + /** + * 컬럼 타입 라벨 변환 + */ + private function getColumnTypeLabel(string $type): string + { + return match ($type) { + 'integer', 'bigint', 'smallint', 'tinyint' => '정수', + 'decimal', 'float', 'double' => '실수', + 'string', 'varchar', 'char' => '문자열', + 'text', 'longtext', 'mediumtext' => '텍스트', + 'boolean' => '불린', + 'date' => '날짜', + 'datetime', 'timestamp' => '일시', + 'json' => 'JSON', + default => $type, + }; + } + + /** + * 컬럼 타입에 따른 필드 타입 추천 + */ + private function suggestFieldType(string $type): string + { + return match ($type) { + 'integer', 'bigint', 'smallint', 'tinyint', 'decimal', 'float', 'double' => 'number', + 'boolean' => 'checkbox', + 'date', 'datetime', 'timestamp' => 'date', + 'text', 'longtext', 'mediumtext' => 'textarea', + default => 'textbox', + }; + } } diff --git a/app/Http/Controllers/ItemFieldController.php b/app/Http/Controllers/ItemFieldController.php index 681b3e41..07a493e4 100644 --- a/app/Http/Controllers/ItemFieldController.php +++ b/app/Http/Controllers/ItemFieldController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Models\SystemFieldDefinition; use Illuminate\View\View; /** @@ -14,6 +15,8 @@ class ItemFieldController extends Controller */ public function index(): View { - return view('item-fields.index'); + $sourceTables = SystemFieldDefinition::getSourceTableOptions(); + + return view('item-fields.index', compact('sourceTables')); } } diff --git a/app/Models/SystemFieldDefinition.php b/app/Models/SystemFieldDefinition.php new file mode 100644 index 00000000..7e28ccf4 --- /dev/null +++ b/app/Models/SystemFieldDefinition.php @@ -0,0 +1,140 @@ + 'integer', + 'is_required' => 'boolean', + 'is_seed_default' => 'boolean', + 'is_active' => 'boolean', + 'options' => 'array', + ]; + + /** + * 소스 테이블 목록 조회 (라벨 포함) + * - 등록된 모든 테이블 포함 (_meta_ 플레이스홀더 포함) + */ + public static function getSourceTables(): Collection + { + return self::query() + ->select('source_table', 'source_table_label') + ->groupBy('source_table', 'source_table_label') + ->orderBy('source_table') + ->get(); + } + + /** + * 소스 테이블 목록 (key => label 형태) + * - 등록된 모든 테이블 포함 (드롭다운용) + */ + public static function getSourceTableOptions(): array + { + return self::getSourceTables() + ->pluck('source_table_label', 'source_table') + ->toArray(); + } + + /** + * 활성 필드가 있는 소스 테이블 목록 조회 + * - 시딩 상태 조회 등에서 사용 + */ + public static function getActiveSourceTables(): Collection + { + return self::query() + ->select('source_table', 'source_table_label') + ->where('is_active', true) + ->groupBy('source_table', 'source_table_label') + ->orderBy('source_table') + ->get(); + } + + /** + * 특정 소스 테이블의 필드 목록 조회 + */ + public static function getFieldsFor(string $sourceTable, bool $onlyActive = true): Collection + { + $query = self::query() + ->where('source_table', $sourceTable) + ->orderBy('order_no'); + + if ($onlyActive) { + $query->where('is_active', true); + } + + return $query->get(); + } + + /** + * 특정 소스 테이블의 기본 시딩 대상 필드 목록 조회 + */ + public static function getSeedDefaultFieldsFor(string $sourceTable): Collection + { + return self::query() + ->where('source_table', $sourceTable) + ->where('is_active', true) + ->where('is_seed_default', true) + ->orderBy('order_no') + ->get(); + } + + /** + * 특정 소스 테이블의 기본 시딩 대상 필드 수 조회 + * - 시딩 상태 비교용 (is_seed_default=true인 필드만 카운트) + */ + public static function getFieldCountFor(string $sourceTable): int + { + return self::query() + ->where('source_table', $sourceTable) + ->where('is_active', true) + ->where('is_seed_default', true) + ->count(); + } + + /** + * 특정 소스 테이블의 전체 필드 수 조회 + * - 제외된 필드 포함 전체 카운트 + */ + public static function getTotalFieldCountFor(string $sourceTable): int + { + return self::query() + ->where('source_table', $sourceTable) + ->where('is_active', true) + ->count(); + } + + /** + * 모든 소스 테이블의 시스템 필드 키 목록 + */ + public static function getAllSystemFieldKeys(string $sourceTable): array + { + return self::query() + ->where('source_table', $sourceTable) + ->pluck('field_key') + ->toArray(); + } +} diff --git a/app/Services/ItemFieldSeedingService.php b/app/Services/ItemFieldSeedingService.php index 1777f44c..f2b887d7 100644 --- a/app/Services/ItemFieldSeedingService.php +++ b/app/Services/ItemFieldSeedingService.php @@ -2,16 +2,16 @@ namespace App\Services; -use App\Constants\SystemFieldDefinitions; use App\Models\ItemField; +use App\Models\SystemFieldDefinition; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; /** * 품목기준 필드 시딩 서비스 * * 테넌트별 시스템 필드 시딩 및 커스텀 필드 관리 + * DB 기반 시스템 필드 정의 사용 (system_field_definitions 테이블) */ class ItemFieldSeedingService { @@ -27,8 +27,10 @@ public function getSeedingStatus(int $tenantId): array { $statuses = []; - foreach (SystemFieldDefinitions::SOURCE_TABLES as $sourceTable => $label) { - $totalCount = SystemFieldDefinitions::getTotalFieldCount($sourceTable); + $sourceTables = SystemFieldDefinition::getSourceTableOptions(); + + foreach ($sourceTables as $sourceTable => $label) { + $totalCount = SystemFieldDefinition::getFieldCountFor($sourceTable); // 현재 등록된 시스템 필드 수 $currentCount = ItemField::where('tenant_id', $tenantId) @@ -50,16 +52,41 @@ public function getSeedingStatus(int $tenantId): array } /** - * 단일 테이블 시스템 필드 시딩 + * 특정 소스 테이블의 시딩 가능한 필드 목록 조회 + * + * @param bool $onlyDefault true면 기본 시딩 대상만, false면 전체 */ - public function seedTable(int $tenantId, string $sourceTable): array + public function getAvailableFields(string $sourceTable, bool $onlyDefault = false): Collection { - $fields = SystemFieldDefinitions::getFieldsFor($sourceTable); + if ($onlyDefault) { + return SystemFieldDefinition::getSeedDefaultFieldsFor($sourceTable); + } - if (empty($fields)) { + return SystemFieldDefinition::getFieldsFor($sourceTable); + } + + /** + * 단일 테이블 시스템 필드 시딩 + * + * @param array|null $fieldIds 시딩할 필드 ID 목록 (null이면 is_seed_default=true인 것만) + */ + public function seedTable(int $tenantId, string $sourceTable, ?array $fieldIds = null): array + { + // 필드 ID가 지정되면 해당 필드만, 아니면 기본 시딩 대상만 + if ($fieldIds !== null) { + $fields = SystemFieldDefinition::whereIn('id', $fieldIds) + ->where('source_table', $sourceTable) + ->where('is_active', true) + ->orderBy('order_no') + ->get(); + } else { + $fields = SystemFieldDefinition::getSeedDefaultFieldsFor($sourceTable); + } + + if ($fields->isEmpty()) { return [ 'success' => false, - 'message' => '알 수 없는 소스 테이블입니다.', + 'message' => '시딩할 필드가 없습니다.', 'seeded_count' => 0, ]; } @@ -69,12 +96,12 @@ public function seedTable(int $tenantId, string $sourceTable): array $errorCount = 0; $this->errors = []; - foreach ($fields as $fieldData) { + foreach ($fields as $fieldDef) { try { // 이미 존재하는지 확인 (field_key + source_table 조합) $exists = ItemField::where('tenant_id', $tenantId) ->where('source_table', $sourceTable) - ->where('field_key', $fieldData['field_key']) + ->where('field_key', $fieldDef->field_key) ->exists(); if ($exists) { @@ -84,14 +111,13 @@ public function seedTable(int $tenantId, string $sourceTable): array } // DB 유니크 제약조건 충돌 방지: tenant_id + field_key 조합 체크 - // (DB에 source_table 없이 유니크 키가 있는 경우를 대비) $existsGlobal = ItemField::where('tenant_id', $tenantId) - ->where('field_key', $fieldData['field_key']) + ->where('field_key', $fieldDef->field_key) ->exists(); if ($existsGlobal) { // 다른 source_table에 같은 field_key가 있으면 고유한 키로 변환 - $fieldKey = "{$sourceTable}_{$fieldData['field_key']}"; + $fieldKey = "{$sourceTable}_{$fieldDef->field_key}"; // 변환된 키도 이미 존재하는지 확인 $existsTransformed = ItemField::where('tenant_id', $tenantId) @@ -104,7 +130,7 @@ public function seedTable(int $tenantId, string $sourceTable): array continue; } } else { - $fieldKey = $fieldData['field_key']; + $fieldKey = $fieldDef->field_key; } // 필드 생성 @@ -112,14 +138,14 @@ public function seedTable(int $tenantId, string $sourceTable): array 'tenant_id' => $tenantId, 'group_id' => 1, // 품목관리 그룹 'field_key' => $fieldKey, - 'field_name' => $fieldData['field_name'], - 'field_type' => $fieldData['field_type'], - 'order_no' => $fieldData['order_no'] ?? 0, - 'is_required' => $fieldData['is_required'] ?? false, - 'default_value' => $fieldData['default_value'] ?? null, - 'options' => $fieldData['options'] ?? null, + 'field_name' => $fieldDef->field_name, + 'field_type' => $fieldDef->field_type, + 'order_no' => $fieldDef->order_no ?? 0, + 'is_required' => $fieldDef->is_required ?? false, + 'default_value' => $fieldDef->default_value ?? null, + 'options' => $fieldDef->options ?? null, 'source_table' => $sourceTable, - 'source_column' => $fieldData['field_key'], // 원본 컬럼명 유지 + 'source_column' => $fieldDef->field_key, // 원본 컬럼명 유지 'storage_type' => 'column', 'is_active' => true, 'is_common' => true, @@ -131,8 +157,8 @@ public function seedTable(int $tenantId, string $sourceTable): array } catch (\Illuminate\Database\QueryException $e) { $errorCount++; $errorInfo = [ - 'field_key' => $fieldData['field_key'], - 'field_name' => $fieldData['field_name'], + 'field_key' => $fieldDef->field_key, + 'field_name' => $fieldDef->field_name, 'source_table' => $sourceTable, 'error_code' => $e->getCode(), 'error_message' => $e->getMessage(), @@ -144,14 +170,14 @@ public function seedTable(int $tenantId, string $sourceTable): array Log::error('ItemField 시딩 오류', [ 'tenant_id' => $tenantId, 'source_table' => $sourceTable, - 'field_key' => $fieldData['field_key'], + 'field_key' => $fieldDef->field_key, 'error' => $e->getMessage(), ]); } catch (\Exception $e) { $errorCount++; $this->errors[] = [ - 'field_key' => $fieldData['field_key'], - 'field_name' => $fieldData['field_name'], + 'field_key' => $fieldDef->field_key, + 'field_name' => $fieldDef->field_name, 'source_table' => $sourceTable, 'error_code' => 'UNKNOWN', 'error_message' => $e->getMessage(), @@ -161,7 +187,7 @@ public function seedTable(int $tenantId, string $sourceTable): array Log::error('ItemField 시딩 예외', [ 'tenant_id' => $tenantId, 'source_table' => $sourceTable, - 'field_key' => $fieldData['field_key'], + 'field_key' => $fieldDef->field_key, 'error' => $e->getMessage(), ]); } @@ -237,7 +263,9 @@ public function seedAll(int $tenantId): array $totalSeeded = 0; $totalSkipped = 0; - foreach (array_keys(SystemFieldDefinitions::SOURCE_TABLES) as $sourceTable) { + $sourceTables = array_keys(SystemFieldDefinition::getSourceTableOptions()); + + foreach ($sourceTables as $sourceTable) { $result = $this->seedTable($tenantId, $sourceTable); $results[$sourceTable] = $result; $totalSeeded += $result['seeded_count']; @@ -284,7 +312,9 @@ public function resetAll(int $tenantId): array $totalDeleted = 0; $totalSeeded = 0; - foreach (array_keys(SystemFieldDefinitions::SOURCE_TABLES) as $sourceTable) { + $sourceTables = array_keys(SystemFieldDefinition::getSourceTableOptions()); + + foreach ($sourceTables as $sourceTable) { $result = $this->resetTable($tenantId, $sourceTable); $results[$sourceTable] = $result; $totalDeleted += $result['deleted_count']; @@ -366,7 +396,7 @@ public function createCustomField(int $tenantId, array $data): array $fieldKey = $data['field_key']; // 시스템 예약어 체크 - $systemFieldKeys = SystemFieldDefinitions::getAllSystemFieldKeys($sourceTable); + $systemFieldKeys = SystemFieldDefinition::getAllSystemFieldKeys($sourceTable); if (in_array($fieldKey, $systemFieldKeys)) { return [ 'success' => false, diff --git a/resources/views/item-fields/index.blade.php b/resources/views/item-fields/index.blade.php index 5819a0ce..e3eca22f 100644 --- a/resources/views/item-fields/index.blade.php +++ b/resources/views/item-fields/index.blade.php @@ -33,6 +33,10 @@ class="tab-btn border-b-2 border-blue-500 text-blue-600 py-4 px-1 text-sm font-m class="tab-btn border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 py-4 px-1 text-sm font-medium"> 필드 관리 + + + + + + + + + + + +
+ +
+
+
+
+ + @@ -558,11 +616,9 @@ class="px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium text-gray @@ -739,6 +795,239 @@ class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm fon + + + + + + @endsection @push('scripts') @@ -763,6 +1052,8 @@ function switchTab(tab) { // 탭별 데이터 로드 if (tab === 'custom') { htmx.trigger('#custom-fields', 'customRefresh'); + } else if (tab === 'sysdefs') { + htmx.trigger('#system-definitions', 'sysDefsRefresh'); } else if (tab === 'errors') { htmx.trigger('#error-logs', 'errorRefresh'); } @@ -1269,5 +1560,492 @@ function setFieldCategory(value) { // 즉시 검색 실행 htmx.trigger('#custom-fields', 'customRefresh'); } + + // ========================================================================= + // 시스템 필드 정의 관리 (System Field Definitions CRUD) + // ========================================================================= + + // 시스템 필드 정의 필터 폼 제출 + document.getElementById('sysDefsFilterForm').addEventListener('submit', function(e) { + e.preventDefault(); + htmx.trigger('#system-definitions', 'sysDefsRefresh'); + }); + + // 소스 테이블 드롭다운 변경 (새 테이블 입력 필드 표시/숨김) + document.getElementById('sysDef_source_table').addEventListener('change', function(e) { + const newTableGroup = document.getElementById('newSourceTableGroup'); + const labelGroup = document.getElementById('sourceLabelGroup'); + const labelInput = document.getElementById('sysDef_source_table_label'); + + if (this.value === '_new') { + newTableGroup.classList.remove('hidden'); + labelInput.value = ''; + labelInput.removeAttribute('readonly'); + } else { + newTableGroup.classList.add('hidden'); + document.getElementById('sysDef_new_source_table').value = ''; + + // 기존 테이블 선택 시 라벨 자동 채우기 + const selectedOption = this.options[this.selectedIndex]; + if (selectedOption && selectedOption.dataset.label) { + labelInput.value = selectedOption.dataset.label; + } + } + }); + + // 필드 타입 변경 시 옵션 필드 표시/숨김 + document.getElementById('sysDef_field_type').addEventListener('change', function(e) { + const optionsGroup = document.getElementById('sysDefOptionsGroup'); + if (this.value === 'dropdown') { + optionsGroup.classList.remove('hidden'); + } else { + optionsGroup.classList.add('hidden'); + } + }); + + // 시스템 필드 정의 추가 모달 열기 + function openSysDefCreateModal() { + document.getElementById('sysDefModalTitle').textContent = '시스템 필드 정의 추가'; + document.getElementById('sysDefForm').reset(); + document.getElementById('sysDef_id').value = ''; + document.getElementById('sysDef_is_seed_default').checked = true; + document.getElementById('newSourceTableGroup').classList.add('hidden'); + document.getElementById('sysDefOptionsGroup').classList.add('hidden'); + document.getElementById('sysDefModal').classList.remove('hidden'); + } + + // 시스템 필드 정의 모달 닫기 + function closeSysDefModal() { + document.getElementById('sysDefModal').classList.add('hidden'); + document.getElementById('sysDefForm').reset(); + } + + // 시스템 필드 정의 수정 모달 열기 + window.openSysDefEditModal = function(def) { + document.getElementById('sysDefModalTitle').textContent = '시스템 필드 정의 수정'; + document.getElementById('sysDef_id').value = def.id; + document.getElementById('sysDef_source_table').value = def.source_table; + document.getElementById('sysDef_source_table_label').value = def.source_table_label || ''; + document.getElementById('sysDef_field_key').value = def.field_key; + document.getElementById('sysDef_field_name').value = def.field_name; + document.getElementById('sysDef_field_type').value = def.field_type; + document.getElementById('sysDef_order_no').value = def.order_no || ''; + document.getElementById('sysDef_default_value').value = def.default_value || ''; + document.getElementById('sysDef_is_required').checked = def.is_required; + document.getElementById('sysDef_is_seed_default').checked = def.is_seed_default; + + // 옵션 필드 처리 + const optionsGroup = document.getElementById('sysDefOptionsGroup'); + if (def.field_type === 'dropdown') { + optionsGroup.classList.remove('hidden'); + if (def.options) { + try { + const opts = typeof def.options === 'string' ? JSON.parse(def.options) : def.options; + document.getElementById('sysDef_options').value = JSON.stringify(opts, null, 2); + } catch (e) { + document.getElementById('sysDef_options').value = def.options; + } + } + } else { + optionsGroup.classList.add('hidden'); + } + + document.getElementById('newSourceTableGroup').classList.add('hidden'); + document.getElementById('sysDefModal').classList.remove('hidden'); + }; + + // 시스템 필드 정의 폼 제출 (추가/수정) + function submitSysDefForm(e) { + e.preventDefault(); + const form = e.target; + const formData = new FormData(form); + const id = formData.get('id'); + const isEdit = id && id !== ''; + + // 소스 테이블 처리 (새 테이블 추가 시) + let sourceTable = formData.get('source_table'); + if (sourceTable === '_new') { + sourceTable = document.getElementById('sysDef_new_source_table').value; + if (!sourceTable) { + showToast('새 테이블명을 입력하세요.', 'error'); + return; + } + } + + // 옵션 JSON 파싱 + let options = null; + const optionsText = formData.get('options'); + if (optionsText && optionsText.trim()) { + try { + options = JSON.parse(optionsText); + } catch (e) { + showToast('옵션 JSON 형식이 올바르지 않습니다: ' + e.message, 'error'); + return; + } + } + + const data = { + source_table: sourceTable, + source_table_label: formData.get('source_table_label'), + field_key: formData.get('field_key'), + field_name: formData.get('field_name'), + field_type: formData.get('field_type'), + order_no: formData.get('order_no') ? parseInt(formData.get('order_no')) : null, + default_value: formData.get('default_value') || null, + is_required: formData.get('is_required') === '1', + is_seed_default: formData.get('is_seed_default') === '1', + options: options + }; + + const url = isEdit + ? `/api/admin/item-fields/system-definitions/${id}` + : '/api/admin/item-fields/system-definitions'; + const method = isEdit ? 'PUT' : 'POST'; + + fetch(url, { + method: method, + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': '{{ csrf_token() }}', + 'Accept': 'application/json' + }, + body: JSON.stringify(data) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + showToast(data.message, 'success'); + closeSysDefModal(); + htmx.trigger('#system-definitions', 'sysDefsRefresh'); + } else { + showToast(data.message || '저장에 실패했습니다.', 'error'); + } + }) + .catch(err => { + console.error('Error:', err); + showToast('오류가 발생했습니다.', 'error'); + }); + } + + // 시스템 필드 정의 삭제 + window.deleteSysDefField = function(id, name) { + showDeleteConfirm(`"${name}" 시스템 필드 정의`, () => { + fetch(`/api/admin/item-fields/system-definitions/${id}`, { + method: 'DELETE', + headers: { + 'X-CSRF-TOKEN': '{{ csrf_token() }}', + 'Accept': 'application/json' + } + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + showToast(data.message, 'success'); + htmx.trigger('#system-definitions', 'sysDefsRefresh'); + } else { + showToast(data.message || '삭제에 실패했습니다.', 'error'); + } + }) + .catch(err => { + console.error('Error:', err); + showToast('오류가 발생했습니다.', 'error'); + }); + }); + }; + + // ========================================================================= + // 소스 테이블 관리 + // ========================================================================= + + // DB 테이블 목록 캐시 + let databaseTablesCache = []; + let selectedDbTable = null; + + // 소스 테이블 추가 모달 열기 + function openSourceTableModal() { + document.getElementById('sourceTableModal').classList.remove('hidden'); + document.getElementById('srcTbl_search').value = ''; + document.getElementById('srcTbl_source_table_label').value = ''; + document.getElementById('srcTbl_unregisteredOnly').checked = true; + clearSelectedTable(); + loadDatabaseTables(); + } + + // 소스 테이블 모달 닫기 + function closeSourceTableModal() { + document.getElementById('sourceTableModal').classList.add('hidden'); + selectedDbTable = null; + } + + // DB 테이블 목록 로드 + function loadDatabaseTables() { + const unregisteredOnly = document.getElementById('srcTbl_unregisteredOnly').checked; + const listContainer = document.getElementById('srcTbl_tableList'); + + // 로딩 표시 + listContainer.innerHTML = ` +
+ + + + +

테이블 목록을 불러오는 중...

+
+ `; + + fetch(`/api/admin/item-fields/database-tables?unregistered_only=${unregisteredOnly}`, { + headers: { + 'Accept': 'application/json', + 'X-CSRF-TOKEN': '{{ csrf_token() }}' + } + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + databaseTablesCache = data.data; + renderDatabaseTables(data.data); + document.getElementById('srcTbl_tableCount').textContent = `총 ${data.total}개 테이블`; + } else { + listContainer.innerHTML = `
테이블 목록을 불러오지 못했습니다.
`; + } + }) + .catch(err => { + console.error('Error:', err); + listContainer.innerHTML = `
오류가 발생했습니다.
`; + }); + } + + // 테이블 목록 검색 필터 + function filterDatabaseTables() { + const search = document.getElementById('srcTbl_search').value.toLowerCase(); + const filtered = databaseTablesCache.filter(t => t.table.toLowerCase().includes(search)); + renderDatabaseTables(filtered); + document.getElementById('srcTbl_tableCount').textContent = `${filtered.length}개 검색됨`; + } + + // 테이블 목록 렌더링 + function renderDatabaseTables(tables) { + const listContainer = document.getElementById('srcTbl_tableList'); + + if (tables.length === 0) { + listContainer.innerHTML = ` +
+ + + +

등록 가능한 테이블이 없습니다.

+
+ `; + return; + } + + let html = '
'; + tables.forEach(t => { + const statusBadge = t.is_registered + ? '등록됨' + : '등록 가능'; + + const typeLabels = [...new Set(t.columns.map(c => c.type_label))].slice(0, 4); + const typeBadges = typeLabels.map(label => + `${label}` + ).join(' '); + + html += ` +
+
+ ${t.table} + ${statusBadge} +
+
+ ${t.column_count}개 컬럼 + | + ${typeBadges} +
+
+ `; + }); + html += '
'; + listContainer.innerHTML = html; + } + + // 테이블 선택 + function selectDatabaseTable(tableName) { + selectedDbTable = tableName; + + // 선택된 테이블 정보 표시 + document.getElementById('srcTbl_selectedInfo').classList.remove('hidden'); + document.getElementById('srcTbl_selectedTable').textContent = tableName; + document.getElementById('srcTbl_submitBtn').disabled = false; + + // 컬럼 정보 로드 + const columnsContainer = document.getElementById('srcTbl_columns'); + columnsContainer.innerHTML = '로딩 중...'; + + fetch(`/api/admin/item-fields/database-tables/${tableName}/columns`, { + headers: { + 'Accept': 'application/json', + 'X-CSRF-TOKEN': '{{ csrf_token() }}' + } + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + columnsContainer.innerHTML = data.columns.map(col => { + // COMMENT가 있으면 표시, 없으면 컬럼명만 + const displayName = col.comment + ? `${col.comment} ${col.name}` + : `${col.name}`; + return ` + + ${displayName} + (${col.type_label}) + + `; + }).join(''); + } + }) + .catch(err => { + console.error('Error:', err); + columnsContainer.innerHTML = '컬럼 정보를 불러올 수 없습니다.'; + }); + + // 테이블명 기반 기본 라벨 제안 + const suggestedLabel = tableName + .replace(/_/g, ' ') + .replace(/\b\w/g, c => c.toUpperCase()); + document.getElementById('srcTbl_source_table_label').value = suggestedLabel; + document.getElementById('srcTbl_source_table_label').focus(); + } + + // 컬럼 타입별 색상 + function getColumnTypeColor(type) { + const colors = { + 'integer': 'bg-blue-100 text-blue-700', + 'bigint': 'bg-blue-100 text-blue-700', + 'smallint': 'bg-blue-100 text-blue-700', + 'decimal': 'bg-indigo-100 text-indigo-700', + 'float': 'bg-indigo-100 text-indigo-700', + 'string': 'bg-gray-100 text-gray-700', + 'varchar': 'bg-gray-100 text-gray-700', + 'text': 'bg-yellow-100 text-yellow-700', + 'boolean': 'bg-green-100 text-green-700', + 'date': 'bg-orange-100 text-orange-700', + 'datetime': 'bg-orange-100 text-orange-700', + 'timestamp': 'bg-orange-100 text-orange-700', + 'json': 'bg-purple-100 text-purple-700', + }; + return colors[type] || 'bg-gray-100 text-gray-600'; + } + + // 테이블 선택 해제 + function clearSelectedTable() { + selectedDbTable = null; + document.getElementById('srcTbl_selectedInfo').classList.add('hidden'); + document.getElementById('srcTbl_selectedTable').textContent = ''; + document.getElementById('srcTbl_columns').innerHTML = ''; + document.getElementById('srcTbl_source_table_label').value = ''; + document.getElementById('srcTbl_submitBtn').disabled = true; + } + + // 소스 테이블 폼 제출 + function submitSourceTableForm() { + if (!selectedDbTable) { + showToast('테이블을 선택하세요.', 'error'); + return; + } + + const label = document.getElementById('srcTbl_source_table_label').value.trim(); + if (!label) { + showToast('테이블 라벨을 입력하세요.', 'error'); + document.getElementById('srcTbl_source_table_label').focus(); + return; + } + + const data = { + source_table: selectedDbTable, + source_table_label: label + }; + + fetch('/api/admin/item-fields/source-tables', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': '{{ csrf_token() }}', + 'Accept': 'application/json' + }, + body: JSON.stringify(data) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + showToast(data.message, 'success'); + closeSourceTableModal(); + // 페이지 새로고침으로 소스 테이블 드롭다운 갱신 + location.reload(); + } else { + showToast(data.message || '등록에 실패했습니다.', 'error'); + } + }) + .catch(err => { + console.error('Error:', err); + showToast('오류가 발생했습니다.', 'error'); + }); + } + + // 소스 테이블 필드명 동기화 (DB COMMENT로 업데이트) + window.syncTableFieldNames = function(sourceTable) { + if (!confirm(`"${sourceTable}" 테이블의 필드명을 DB COMMENT에서 동기화하시겠습니까?`)) { + return; + } + + fetch(`/api/admin/item-fields/source-tables/${sourceTable}/sync-field-names`, { + method: 'POST', + headers: { + 'X-CSRF-TOKEN': '{{ csrf_token() }}', + 'Accept': 'application/json' + } + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + showToast(data.message, 'success'); + htmx.trigger('#system-definitions', 'sysDefsRefresh'); + } else { + showToast(data.message || '동기화에 실패했습니다.', 'error'); + } + }) + .catch(err => { + console.error('Error:', err); + showToast('오류가 발생했습니다.', 'error'); + }); + }; + + // 소스 테이블 삭제 + window.deleteSourceTable = function(sourceTable, tableLabel) { + showDeleteConfirm(`"${tableLabel}" 테이블과 모든 필드 정의`, () => { + fetch(`/api/admin/item-fields/source-tables/${sourceTable}`, { + method: 'DELETE', + headers: { + 'X-CSRF-TOKEN': '{{ csrf_token() }}', + 'Accept': 'application/json' + } + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + showToast(data.message, 'success'); + // 페이지 새로고침으로 소스 테이블 드롭다운 갱신 + location.reload(); + } else { + showToast(data.message || '삭제에 실패했습니다.', 'error'); + } + }) + .catch(err => { + console.error('Error:', err); + showToast('오류가 발생했습니다.', 'error'); + }); + }); + }; @endpush diff --git a/resources/views/item-fields/partials/system-field-definitions.blade.php b/resources/views/item-fields/partials/system-field-definitions.blade.php new file mode 100644 index 00000000..2d615f68 --- /dev/null +++ b/resources/views/item-fields/partials/system-field-definitions.blade.php @@ -0,0 +1,181 @@ +@if($definitions->isEmpty()) +
+
+ + + +
+

등록된 시스템 필드 정의가 없습니다.

+

"+ 필드 정의 추가" 버튼을 클릭하여 새 필드를 추가하세요.

+
+@else +
+ + + + + + + + + + + + + + + + @php $currentSourceTable = null; @endphp + @foreach($definitions as $def) + @if($currentSourceTable !== $def->source_table) + @php $currentSourceTable = $def->source_table; @endphp + + + + @endif + + + + + + + + + + + + + + + + + + + + + @endforeach + +
순서소스 테이블필드 키필드명타입필수기본시딩옵션액션
+
+
+ {{ $def->source_table_label ?? $def->source_table }} + {{ $def->source_table }} +
+
+ + +
+
+
+ {{ $def->order_no }} + + {{ $def->source_table }} + + {{ $def->field_key }} + +
+ {{ $def->field_name }} + @if($def->is_required) + * + @endif +
+ @if($def->default_value) +
기본값: {{ $def->default_value }}
+ @endif +
+ @php + $typeLabels = [ + 'textbox' => ['label' => '텍스트', 'color' => 'gray'], + 'number' => ['label' => '숫자', 'color' => 'blue'], + 'dropdown' => ['label' => '드롭다운', 'color' => 'purple'], + 'checkbox' => ['label' => '체크박스', 'color' => 'green'], + 'date' => ['label' => '날짜', 'color' => 'orange'], + 'textarea' => ['label' => '텍스트영역', 'color' => 'gray'], + ]; + $typeInfo = $typeLabels[$def->field_type] ?? ['label' => $def->field_type, 'color' => 'gray']; + @endphp + + {{ $typeInfo['label'] }} + + + @if($def->is_required) + + + + @else + - + @endif + + @if($def->is_seed_default) + + 시딩대상 + + @else + + 제외 + + @endif + + @if(!empty($def->options)) + @php + $optionsData = is_array($def->options) ? $def->options : json_decode($def->options, true); + $optionCount = is_array($optionsData) ? count($optionsData) : 0; + @endphp + + {{ $optionCount }}개 + + @else + - + @endif + +
+ + +
+
+
+ + +
+
+
+ 총 {{ $definitions->count() }}개 + @php + $groupedByTable = $definitions->groupBy('source_table'); + $seedDefaultCount = $definitions->filter(fn($d) => $d->is_seed_default)->count(); + @endphp + @foreach($groupedByTable as $table => $items) + {{ $table }}: {{ $items->count() }}개 + @endforeach +
+
+ 시딩대상: {{ $seedDefaultCount }}개 + 제외: {{ $definitions->count() - $seedDefaultCount }}개 +
+
+
+@endif