feat:코드그룹 DB기반 관리, 스코프 필터, 동기화 테넌트명 표시

- 공통코드/카테고리: 하드코딩 그룹 라벨 제거, DB description 기반으로 전환
- 코드그룹 신규 생성 기능 추가 (사이드바 + 모달, TenantSetting 저장)
- 글로벌/테넌트 스코프 분류 및 필터 버튼 (전체/글로벌/테넌트)
- 사이드바 컴팩트 레이아웃 (100+ 그룹 대응)
- 동기화 페이지 3종(메뉴/공통코드/카테고리) 테넌트 회사명 표시

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-30 22:25:05 +09:00
parent f0ea6c71de
commit a0ec103614
10 changed files with 559 additions and 75 deletions

View File

@@ -5,12 +5,87 @@
use App\Models\Category;
use App\Models\GlobalCategory;
use App\Models\Tenants\Tenant;
use App\Models\Tenants\TenantSetting;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
class CategoryController extends Controller
{
/**
* 커스텀 그룹 라벨 조회
*/
private function getCustomGroupLabels(): array
{
$tenantId = session('selected_tenant_id');
if (! $tenantId) {
return [];
}
$setting = TenantSetting::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->where('setting_group', 'category')
->where('setting_key', 'custom_group_labels')
->first();
return $setting?->setting_value ?? [];
}
/**
* 카테고리 그룹 추가
*/
public function storeGroup(Request $request): RedirectResponse
{
$tenantId = session('selected_tenant_id');
if (! $tenantId) {
return redirect()->back()->with('error', '테넌트를 먼저 선택해주세요.');
}
$validated = $request->validate([
'group_code' => ['required', 'string', 'max:50', 'regex:/^[a-z][a-z0-9_]*$/'],
'group_label' => 'required|string|max:50',
], [
'group_code.regex' => '그룹 코드는 영문 소문자, 숫자, 언더스코어만 사용 가능합니다.',
]);
$groupCode = $validated['group_code'];
$groupLabel = $validated['group_label'];
// DB에 이미 존재하는 그룹인지 체크
$existsInGlobal = GlobalCategory::whereNull('deleted_at')
->where('code_group', $groupCode)
->exists();
$existsInTenant = Category::where('code_group', $groupCode)->exists();
if ($existsInGlobal || $existsInTenant) {
return redirect()->back()->with('error', '이미 존재하는 그룹 코드입니다.');
}
// 커스텀 라벨 중복 체크
$customLabels = $this->getCustomGroupLabels();
if (isset($customLabels[$groupCode])) {
return redirect()->back()->with('error', '이미 존재하는 커스텀 그룹 코드입니다.');
}
$customLabels[$groupCode] = $groupLabel;
TenantSetting::withoutGlobalScopes()->updateOrCreate(
[
'tenant_id' => $tenantId,
'setting_group' => 'category',
'setting_key' => 'custom_group_labels',
],
[
'setting_value' => $customLabels,
'description' => '카테고리 커스텀 그룹 라벨',
]
);
return redirect()
->route('categories.index', ['group' => $groupCode])
->with('success', "'{$groupLabel}' 그룹이 추가되었습니다.");
}
/**
* 카테고리 관리 페이지
@@ -26,35 +101,64 @@ public function index(Request $request): View|Response
$tenant = $tenantId ? Tenant::find($tenantId) : null;
$isHQ = $tenantId == 1;
// 모든 code_group 조회 (글로벌 + 테넌트)
$globalGroups = GlobalCategory::whereNull('deleted_at')
->select('code_group')
->distinct()
->pluck('code_group')
// 글로벌 그룹 + description
$globalGroupDescs = GlobalCategory::whereNull('deleted_at')
->selectRaw('code_group, MIN(description) as description')
->groupBy('code_group')
->pluck('description', 'code_group')
->toArray();
$tenantGroups = [];
// 테넌트 그룹 + description
$tenantGroupDescs = [];
if ($tenantId) {
$tenantGroups = Category::where('tenant_id', $tenantId)
$tenantGroupDescs = Category::where('tenant_id', $tenantId)
->whereNull('deleted_at')
->select('code_group')
->distinct()
->pluck('code_group')
->selectRaw('code_group, MIN(description) as description')
->groupBy('code_group')
->pluck('description', 'code_group')
->toArray();
}
$allGroups = array_unique(array_merge($globalGroups, $tenantGroups));
sort($allGroups);
// 커스텀 그룹 라벨 (빈 그룹용 — TenantSetting)
$customLabels = $this->getCustomGroupLabels();
$codeGroupLabels = config('categories.code_group_labels', []);
// config 라벨 (하위호환 폴백)
$configLabels = config('categories.code_group_labels', []);
// 그룹별 스코프 분류
$allGroupKeys = array_unique(array_merge(
array_keys($globalGroupDescs),
array_keys($tenantGroupDescs),
array_keys($customLabels)
));
sort($allGroupKeys);
$codeGroups = [];
foreach ($allGroups as $group) {
$codeGroups[$group] = $codeGroupLabels[$group] ?? $group;
$groupScopes = [];
foreach ($allGroupKeys as $group) {
$inGlobal = isset($globalGroupDescs[$group]);
$inTenant = isset($tenantGroupDescs[$group]);
$codeGroups[$group] = ! empty($globalGroupDescs[$group])
? $globalGroupDescs[$group]
: (! empty($tenantGroupDescs[$group])
? $tenantGroupDescs[$group]
: ($customLabels[$group] ?? $configLabels[$group] ?? $group));
if ($inGlobal && $inTenant) {
$groupScopes[$group] = 'both';
} elseif ($inGlobal) {
$groupScopes[$group] = 'global';
} elseif ($inTenant) {
$groupScopes[$group] = 'tenant';
} else {
$groupScopes[$group] = 'custom';
}
}
if (empty($codeGroups)) {
$codeGroups['product'] = $codeGroupLabels['product'] ?? 'product';
$codeGroups['product'] = $configLabels['product'] ?? 'product';
$groupScopes['product'] = 'custom';
}
$selectedGroup = $request->get('group', array_key_first($codeGroups));
@@ -86,7 +190,8 @@ public function index(Request $request): View|Response
return view('categories.index', [
'codeGroups' => $codeGroups,
'codeGroupLabels' => $codeGroupLabels,
'groupScopes' => $groupScopes,
'codeGroupLabels' => $codeGroups,
'selectedGroup' => $selectedGroup,
'globalCategories' => $globalCategories,
'tenantCategories' => $tenantCategories,
@@ -123,4 +228,4 @@ private function addChildrenRecursive(&$result, $byParent, $parentId, $depth): v
$this->addChildrenRecursive($result, $byParent, $child->id, $depth + 1);
}
}
}
}

View File

@@ -4,6 +4,7 @@
use App\Models\Category;
use App\Models\GlobalCategory;
use App\Models\Tenants\Tenant;
use App\Models\Tenants\TenantSetting;
use Illuminate\Contracts\View\View;
use Illuminate\Http\JsonResponse;
@@ -13,6 +14,8 @@
class CategorySyncController extends Controller
{
private ?string $remoteTenantName = null;
/**
* 현재 선택된 테넌트 ID
*/
@@ -52,12 +55,16 @@ public function index(Request $request): View|Response
$selectedEnv = $request->get('env', 'dev');
$selectedType = $request->get('type', 'global'); // global or tenant
// 로컬 테넌트 정보
$localTenant = Tenant::find($this->getTenantId());
// 로컬 카테고리 조회 (타입 필터 적용)
$localCategories = $this->getCategoryList($selectedType);
// 원격 카테고리 조회
$remoteCategories = [];
$remoteError = null;
$this->remoteTenantName = null;
if (! empty($environments[$selectedEnv]['url'])) {
try {
@@ -78,6 +85,8 @@ public function index(Request $request): View|Response
'remoteCategories' => $remoteCategories,
'remoteError' => $remoteError,
'diff' => $diff,
'localTenantName' => $localTenant?->company_name ?? '알 수 없음',
'remoteTenantName' => $this->remoteTenantName,
]);
}
@@ -97,9 +106,12 @@ public function export(Request $request): JsonResponse
$type = $request->get('type', 'all'); // global, tenant, or all
$categories = $this->getCategoryList($type);
$tenant = Tenant::find($this->getTenantId());
return response()->json([
'success' => true,
'environment' => config('app.env'),
'tenant_name' => $tenant?->company_name ?? '알 수 없음',
'exported_at' => now()->toIso8601String(),
'categories' => $categories,
]);
@@ -473,6 +485,8 @@ private function fetchRemoteCategories(array $env, string $type = 'all'): array
throw new \Exception('잘못된 응답 형식');
}
$this->remoteTenantName = $data['tenant_name'] ?? null;
return $data['categories'];
}
@@ -499,4 +513,4 @@ private function calculateDiff(array $localCategories, array $remoteCategories):
'both' => array_values(array_intersect($localKeys, $remoteKeys)),
];
}
}
}

View File

@@ -4,6 +4,7 @@
use App\Models\Products\CommonCode;
use App\Models\Tenants\Tenant;
use App\Models\Tenants\TenantSetting;
use Illuminate\Contracts\View\View;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
@@ -14,29 +15,6 @@
class CommonCodeController extends Controller
{
/**
* 코드 그룹 라벨
*/
private const CODE_GROUP_LABELS = [
'item_type' => '품목유형',
'material_type' => '자재유형',
'client_type' => '거래처유형',
'order_status' => '주문상태',
'order_type' => '주문유형',
'delivery_method' => '배송방법',
'tenant_type' => '테넌트유형',
'product_category' => '제품분류',
'motor_type' => '모터유형',
'controller_type' => '컨트롤러유형',
'painting_type' => '도장유형',
'position_type' => '위치유형',
'capability_profile' => '생산능력',
'bad_debt_progress' => '대손진행',
'height_construction_cost' => '높이시공비',
'width_construction_cost' => '폭시공비',
'document_type' => '문서분류',
];
/**
* 공통코드 관리 페이지
*/
@@ -54,16 +32,65 @@ public function index(Request $request): View|Response
// 선택된 코드 그룹 (기본: item_type)
$selectedGroup = $request->get('group', 'item_type');
// 코드 그룹 목록 (실제 존재하는 그룹만)
$existingGroups = CommonCode::query()
->select('code_group')
->distinct()
->pluck('code_group')
// 글로벌 그룹 (tenant_id IS NULL)
$globalGroupDescs = CommonCode::query()
->whereNull('tenant_id')
->selectRaw('code_group, MIN(description) as description')
->groupBy('code_group')
->pluck('description', 'code_group')
->toArray();
$codeGroups = collect(self::CODE_GROUP_LABELS)
->filter(fn($label, $group) => in_array($group, $existingGroups))
->toArray();
// 테넌트 그룹
$tenantGroupDescs = [];
if ($tenantId) {
$tenantGroupDescs = CommonCode::query()
->where('tenant_id', $tenantId)
->selectRaw('code_group, MIN(description) as description')
->groupBy('code_group')
->pluck('description', 'code_group')
->toArray();
}
// 커스텀 그룹 라벨 (빈 그룹용 — TenantSetting)
$customLabels = $this->getCustomGroupLabels();
// 그룹별 스코프 분류
$allGroupKeys = array_unique(array_merge(
array_keys($globalGroupDescs),
array_keys($tenantGroupDescs),
array_keys($customLabels)
));
sort($allGroupKeys);
$codeGroups = [];
$groupScopes = [];
foreach ($allGroupKeys as $group) {
$inGlobal = isset($globalGroupDescs[$group]);
$inTenant = isset($tenantGroupDescs[$group]);
// 라벨: 글로벌 description 우선 → 테넌트 → 커스텀 → 키 자체
$codeGroups[$group] = ! empty($globalGroupDescs[$group])
? $globalGroupDescs[$group]
: (! empty($tenantGroupDescs[$group])
? $tenantGroupDescs[$group]
: ($customLabels[$group] ?? $group));
// 스코프: global, both, tenant, custom
if ($inGlobal && $inTenant) {
$groupScopes[$group] = 'both';
} elseif ($inGlobal) {
$groupScopes[$group] = 'global';
} elseif ($inTenant) {
$groupScopes[$group] = 'tenant';
} else {
$groupScopes[$group] = 'custom';
}
}
// 선택된 그룹이 목록에 없으면 첫 번째 그룹으로 대체
if (! isset($codeGroups[$selectedGroup]) && ! empty($codeGroups)) {
$selectedGroup = array_key_first($codeGroups);
}
// 선택된 그룹의 코드 목록
$globalCodes = collect();
@@ -92,6 +119,7 @@ public function index(Request $request): View|Response
'tenant' => $tenant,
'isHQ' => $isHQ,
'codeGroups' => $codeGroups,
'groupScopes' => $groupScopes,
'selectedGroup' => $selectedGroup,
'globalCodes' => $globalCodes,
'tenantCodes' => $tenantCodes,
@@ -100,6 +128,80 @@ public function index(Request $request): View|Response
]);
}
/**
* 커스텀 그룹 라벨 조회
*/
private function getCustomGroupLabels(): array
{
$tenantId = session('selected_tenant_id');
if (! $tenantId) {
return [];
}
$setting = TenantSetting::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->where('setting_group', 'common_code')
->where('setting_key', 'custom_group_labels')
->first();
return $setting?->setting_value ?? [];
}
/**
* 코드 그룹 추가
*/
public function storeGroup(Request $request): RedirectResponse
{
$tenantId = session('selected_tenant_id');
if (! $tenantId) {
return redirect()->back()->with('error', '테넌트를 먼저 선택해주세요.');
}
$validated = $request->validate([
'group_code' => ['required', 'string', 'max:50', 'regex:/^[a-z][a-z0-9_]*$/'],
'group_label' => 'required|string|max:50',
], [
'group_code.regex' => '그룹 코드는 영문 소문자, 숫자, 언더스코어만 사용 가능합니다.',
]);
$groupCode = $validated['group_code'];
$groupLabel = $validated['group_label'];
// DB에 이미 존재하는 그룹인지 체크
$existsInDb = CommonCode::query()
->where('code_group', $groupCode)
->exists();
if ($existsInDb) {
return redirect()->back()->with('error', '이미 존재하는 그룹 코드입니다.');
}
// 커스텀 라벨 조회 후 중복 체크
$customLabels = $this->getCustomGroupLabels();
if (isset($customLabels[$groupCode])) {
return redirect()->back()->with('error', '이미 존재하는 커스텀 그룹 코드입니다.');
}
// 커스텀 라벨에 추가
$customLabels[$groupCode] = $groupLabel;
TenantSetting::withoutGlobalScopes()->updateOrCreate(
[
'tenant_id' => $tenantId,
'setting_group' => 'common_code',
'setting_key' => 'custom_group_labels',
],
[
'setting_value' => $customLabels,
'description' => '공통코드 커스텀 그룹 라벨',
]
);
return redirect()
->route('common-codes.index', ['group' => $groupCode])
->with('success', "'{$groupLabel}' 그룹이 추가되었습니다.");
}
/**
* 코드 저장 (신규/수정)
*/
@@ -623,4 +725,4 @@ public function destroy(Request $request, int $id): RedirectResponse|JsonRespons
->route('common-codes.index', ['group' => $codeGroup])
->with('success', '코드가 삭제되었습니다.');
}
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Models\Products\CommonCode;
use App\Models\Tenants\Tenant;
use App\Models\Tenants\TenantSetting;
use Illuminate\Contracts\View\View;
use Illuminate\Http\JsonResponse;
@@ -12,6 +13,8 @@
class CommonCodeSyncController extends Controller
{
private ?string $remoteTenantName = null;
/**
* 현재 선택된 테넌트 ID
*/
@@ -51,12 +54,16 @@ public function index(Request $request): View|Response
$selectedEnv = $request->get('env', 'dev');
$selectedType = $request->get('type', 'global'); // global or tenant
// 로컬 테넌트 정보
$localTenant = Tenant::find($this->getTenantId());
// 로컬 코드 조회 (타입 필터 적용)
$localCodes = $this->getCodeList($selectedType);
// 원격 코드 조회
$remoteCodes = [];
$remoteError = null;
$this->remoteTenantName = null;
if (! empty($environments[$selectedEnv]['url'])) {
try {
@@ -77,6 +84,8 @@ public function index(Request $request): View|Response
'remoteCodes' => $remoteCodes,
'remoteError' => $remoteError,
'diff' => $diff,
'localTenantName' => $localTenant?->company_name ?? '알 수 없음',
'remoteTenantName' => $this->remoteTenantName,
]);
}
@@ -96,9 +105,12 @@ public function export(Request $request): JsonResponse
$type = $request->get('type', 'all'); // global, tenant, or all
$codes = $this->getCodeList($type);
$tenant = Tenant::find($this->getTenantId());
return response()->json([
'success' => true,
'environment' => config('app.env'),
'tenant_name' => $tenant?->company_name ?? '알 수 없음',
'exported_at' => now()->toIso8601String(),
'codes' => $codes,
]);
@@ -370,6 +382,8 @@ private function fetchRemoteCodes(array $env, string $type = 'all'): array
throw new \Exception('잘못된 응답 형식');
}
$this->remoteTenantName = $data['tenant_name'] ?? null;
return $data['codes'];
}
@@ -396,4 +410,4 @@ private function calculateDiff(array $localCodes, array $remoteCodes): array
'both' => array_values(array_intersect($localKeys, $remoteKeys)),
];
}
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Models\Commons\Menu;
use App\Models\Tenants\Tenant;
use App\Models\Tenants\TenantSetting;
use Illuminate\Contracts\View\View;
use Illuminate\Http\JsonResponse;
@@ -12,6 +13,8 @@
class MenuSyncController extends Controller
{
private ?string $remoteTenantName = null;
/**
* 현재 선택된 테넌트 ID
*/
@@ -50,12 +53,16 @@ public function index(Request $request): View|Response
$environments = $this->getEnvironments();
$selectedEnv = $request->get('env', 'dev');
// 로컬 테넌트 정보
$localTenant = Tenant::find($this->getTenantId());
// 로컬 메뉴 조회 (트리 구조)
$localMenus = $this->getMenuTree();
// 원격 메뉴 조회
$remoteMenus = [];
$remoteError = null;
$this->remoteTenantName = null;
if (! empty($environments[$selectedEnv]['url'])) {
try {
@@ -75,6 +82,8 @@ public function index(Request $request): View|Response
'remoteMenus' => $remoteMenus,
'remoteError' => $remoteError,
'diff' => $diff,
'localTenantName' => $localTenant?->company_name ?? '알 수 없음',
'remoteTenantName' => $this->remoteTenantName,
]);
}
@@ -119,10 +128,12 @@ public function export(Request $request): JsonResponse
}
$menus = $this->getMenuTree();
$tenant = Tenant::find($this->getTenantId());
return response()->json([
'success' => true,
'environment' => config('app.env'),
'tenant_name' => $tenant?->company_name ?? '알 수 없음',
'exported_at' => now()->toIso8601String(),
'menus' => $menus,
]);
@@ -383,6 +394,8 @@ private function fetchRemoteMenus(array $env): array
throw new \Exception('잘못된 응답 형식');
}
$this->remoteTenantName = $data['tenant_name'] ?? null;
return $data['menus'];
}

View File

@@ -55,19 +55,48 @@ class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg
<!-- 메인 레이아웃: 좌측 + 우측 콘텐츠 -->
<div class="flex gap-4 flex-1 min-h-0">
<!-- 좌측: 코드 그룹 (세로) -->
<div class="w-40 flex-shrink-0 bg-white rounded-lg shadow-sm overflow-hidden">
<div class="px-3 py-2 bg-gray-50 border-b border-gray-200">
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wider">코드 그룹</span>
<div class="w-44 flex-shrink-0 bg-white rounded-lg shadow-sm overflow-hidden flex flex-col">
<div class="px-2 py-1.5 bg-gray-50 border-b border-gray-200 flex items-center justify-between flex-shrink-0">
<span class="text-[10px] font-semibold text-gray-500 uppercase tracking-wider">코드 그룹</span>
<button type="button" onclick="openGroupModal()"
class="w-5 h-5 flex items-center justify-center text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors"
title="그룹 추가">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
<nav class="overflow-y-auto max-h-[calc(100vh-220px)]" aria-label="Tabs">
<!-- 스코프 필터 -->
<div class="px-1.5 py-1 border-b border-gray-200 flex flex-wrap gap-0.5 flex-shrink-0">
<button type="button" onclick="filterGroups('all')"
class="group-filter-btn active text-[10px] px-1.5 py-0.5 rounded font-medium transition-colors bg-gray-700 text-white" data-scope="all">전체</button>
<button type="button" onclick="filterGroups('global-based')"
class="group-filter-btn text-[10px] px-1.5 py-0.5 rounded font-medium transition-colors bg-gray-100 text-gray-600 hover:bg-gray-200" data-scope="global-based">글로벌</button>
<button type="button" onclick="filterGroups('tenant-based')"
class="group-filter-btn text-[10px] px-1.5 py-0.5 rounded font-medium transition-colors bg-gray-100 text-gray-600 hover:bg-gray-200" data-scope="tenant-based">테넌트</button>
</div>
<nav class="overflow-y-auto flex-1 min-h-0" aria-label="Tabs">
@foreach($codeGroups as $group => $label)
@php $scope = $groupScopes[$group] ?? 'custom'; @endphp
<a href="{{ route('categories.index', ['group' => $group]) }}"
class="block px-3 py-2.5 border-l-4 transition-colors
data-scope="{{ $scope }}"
class="group-item block px-2 py-1.5 border-l-3 transition-colors
{{ $selectedGroup === $group
? 'border-l-blue-500 bg-blue-50 text-blue-700'
: 'border-l-transparent text-gray-600 hover:bg-gray-50 hover:text-gray-900' }}">
<span class="block text-sm font-medium truncate">{{ $label }}</span>
<span class="block text-xs font-mono {{ $selectedGroup === $group ? 'text-blue-400' : 'text-gray-400' }}">{{ $group }}</span>
<span class="flex items-center gap-1">
<span class="block text-xs font-medium truncate flex-1">{{ $label }}</span>
@if($scope === 'global')
<span class="w-1.5 h-1.5 rounded-full bg-purple-400 flex-shrink-0" title="글로벌"></span>
@elseif($scope === 'both')
<span class="w-1.5 h-1.5 rounded-full bg-green-400 flex-shrink-0" title="글로벌+테넌트"></span>
@elseif($scope === 'tenant')
<span class="w-1.5 h-1.5 rounded-full bg-blue-400 flex-shrink-0" title="테넌트"></span>
@else
<span class="w-1.5 h-1.5 rounded-full bg-gray-300 flex-shrink-0" title="빈 그룹"></span>
@endif
</span>
<span class="block text-[10px] font-mono {{ $selectedGroup === $group ? 'text-blue-400' : 'text-gray-400' }} truncate">{{ $group }}</span>
</a>
@endforeach
</nav>
@@ -295,6 +324,46 @@ class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm fon
</form>
</div>
</div>
<!-- 코드 그룹 추가 모달 -->
<div id="groupModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-sm mx-4">
<form action="{{ route('categories.store-group') }}" method="POST">
@csrf
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-semibold text-gray-800">코드 그룹 추가</h3>
</div>
<div class="px-6 py-4 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">그룹 코드 *</label>
<input type="text" name="group_code" required
pattern="[a-z][a-z0-9_]*" maxlength="50"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="예: shipping_cost">
<p class="mt-1 text-xs text-gray-400">영문 소문자, 숫자, 언더스코어만 사용</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">그룹명 *</label>
<input type="text" name="group_label" required maxlength="50"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="예: 배송비 유형">
</div>
</div>
<div class="px-6 py-4 border-t border-gray-200 flex justify-end gap-3">
<button type="button" onclick="closeGroupModal()"
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium transition-colors">
취소
</button>
<button type="submit"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors">
추가
</button>
</div>
</form>
</div>
</div>
@endsection
@push('scripts')
@@ -302,6 +371,46 @@ class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm fon
const selectedGroup = '{{ $selectedGroup }}';
const csrfToken = '{{ csrf_token() }}';
// 그룹 스코프 필터
function filterGroups(filter) {
const items = document.querySelectorAll('.group-item');
items.forEach(item => {
const scope = item.dataset.scope;
let show = false;
switch (filter) {
case 'all':
show = true;
break;
case 'global-based':
show = (scope === 'global' || scope === 'both');
break;
case 'tenant-based':
show = (scope === 'tenant' || scope === 'both');
break;
}
item.style.display = show ? '' : 'none';
});
document.querySelectorAll('.group-filter-btn').forEach(btn => {
if (btn.dataset.scope === filter) {
btn.className = 'group-filter-btn text-[10px] px-1.5 py-0.5 rounded font-medium transition-colors bg-gray-700 text-white';
} else {
btn.className = 'group-filter-btn text-[10px] px-1.5 py-0.5 rounded font-medium transition-colors bg-gray-100 text-gray-600 hover:bg-gray-200';
}
});
}
// 그룹 모달
function openGroupModal() {
document.getElementById('groupModal').classList.remove('hidden');
document.getElementById('groupModal').classList.add('flex');
}
function closeGroupModal() {
document.getElementById('groupModal').classList.add('hidden');
document.getElementById('groupModal').classList.remove('flex');
}
// 모달 열기 (추가)
function openAddModal() {
document.getElementById('modalTitle').textContent = '카테고리 추가';
@@ -642,10 +751,16 @@ function deleteGlobalCategory(id, name) {
document.getElementById('categoryModal').addEventListener('click', function(e) {
if (e.target === this) closeModal();
});
document.getElementById('groupModal').addEventListener('click', function(e) {
if (e.target === this) closeGroupModal();
});
// ESC 키로 모달 닫기
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeModal();
if (e.key === 'Escape') {
closeModal();
closeGroupModal();
}
});
</script>
@endpush
@endpush

View File

@@ -150,7 +150,8 @@ class="w-4 h-4 text-green-600 border-gray-300 rounded focus:ring-green-500">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</span>
<h3 class="font-semibold text-gray-800">로컬 (현재)</h3>
<h3 class="font-semibold text-gray-800">로컬</h3>
<span class="text-xs text-gray-500">- {{ $localTenantName }}</span>
<span class="text-xs text-gray-500">({{ count($localCategories) }})</span>
</div>
@if(!empty($environments[$selectedEnv]['url']) && !$remoteError)
@@ -215,6 +216,9 @@ class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500">
</svg>
</span>
<h3 class="font-semibold text-gray-800">{{ $environments[$selectedEnv]['name'] ?? strtoupper($selectedEnv) }}</h3>
@if($remoteTenantName)
<span class="text-xs text-gray-500">- {{ $remoteTenantName }}</span>
@endif
<span class="text-xs text-gray-500">({{ count($remoteCategories) }})</span>
</div>
@if(!empty($environments[$selectedEnv]['url']) && !$remoteError)
@@ -391,4 +395,4 @@ function updateSelectedCount(side) {
}
}
</script>
@endpush
@endpush

View File

@@ -73,19 +73,48 @@ class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg
<!-- 메인 레이아웃: 좌측 + 우측 콘텐츠 -->
<div class="flex gap-4 flex-1 min-h-0">
<!-- 좌측: 코드 그룹 (세로) -->
<div class="w-40 flex-shrink-0 bg-white rounded-lg shadow-sm overflow-hidden">
<div class="px-3 py-2 bg-gray-50 border-b border-gray-200">
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wider">코드 그룹</span>
<div class="w-44 flex-shrink-0 bg-white rounded-lg shadow-sm overflow-hidden flex flex-col">
<div class="px-2 py-1.5 bg-gray-50 border-b border-gray-200 flex items-center justify-between flex-shrink-0">
<span class="text-[10px] font-semibold text-gray-500 uppercase tracking-wider">코드 그룹</span>
<button type="button" onclick="openGroupModal()"
class="w-5 h-5 flex items-center justify-center text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors"
title="그룹 추가">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
<nav class="overflow-y-auto max-h-[calc(100vh-220px)]" aria-label="Tabs">
<!-- 스코프 필터 -->
<div class="px-1.5 py-1 border-b border-gray-200 flex flex-wrap gap-0.5 flex-shrink-0">
<button type="button" onclick="filterGroups('all')"
class="group-filter-btn active text-[10px] px-1.5 py-0.5 rounded font-medium transition-colors bg-gray-700 text-white" data-scope="all">전체</button>
<button type="button" onclick="filterGroups('global-based')"
class="group-filter-btn text-[10px] px-1.5 py-0.5 rounded font-medium transition-colors bg-gray-100 text-gray-600 hover:bg-gray-200" data-scope="global-based">글로벌</button>
<button type="button" onclick="filterGroups('tenant-based')"
class="group-filter-btn text-[10px] px-1.5 py-0.5 rounded font-medium transition-colors bg-gray-100 text-gray-600 hover:bg-gray-200" data-scope="tenant-based">테넌트</button>
</div>
<nav class="overflow-y-auto flex-1 min-h-0" aria-label="Tabs">
@foreach($codeGroups as $group => $label)
@php $scope = $groupScopes[$group] ?? 'custom'; @endphp
<a href="{{ route('common-codes.index', ['group' => $group]) }}"
class="block px-3 py-2.5 border-l-4 transition-colors
data-scope="{{ $scope }}"
class="group-item block px-2 py-1.5 border-l-3 transition-colors
{{ $selectedGroup === $group
? 'border-l-blue-500 bg-blue-50 text-blue-700'
: 'border-l-transparent text-gray-600 hover:bg-gray-50 hover:text-gray-900' }}">
<span class="block text-sm font-medium truncate">{{ $label }}</span>
<span class="block text-xs font-mono {{ $selectedGroup === $group ? 'text-blue-400' : 'text-gray-400' }}">{{ $group }}</span>
<span class="flex items-center gap-1">
<span class="block text-xs font-medium truncate flex-1">{{ $label }}</span>
@if($scope === 'global')
<span class="w-1.5 h-1.5 rounded-full bg-purple-400 flex-shrink-0" title="글로벌"></span>
@elseif($scope === 'both')
<span class="w-1.5 h-1.5 rounded-full bg-green-400 flex-shrink-0" title="글로벌+테넌트"></span>
@elseif($scope === 'tenant')
<span class="w-1.5 h-1.5 rounded-full bg-blue-400 flex-shrink-0" title="테넌트"></span>
@else
<span class="w-1.5 h-1.5 rounded-full bg-gray-300 flex-shrink-0" title="빈 그룹"></span>
@endif
</span>
<span class="block text-[10px] font-mono {{ $selectedGroup === $group ? 'text-blue-400' : 'text-gray-400' }} truncate">{{ $group }}</span>
</a>
@endforeach
</nav>
@@ -445,10 +474,80 @@ class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm fon
@csrf
<input type="hidden" name="ids_json" id="bulkCopyIds">
</form>
<!-- 코드 그룹 추가 모달 -->
<div id="groupModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-sm mx-4">
<form action="{{ route('common-codes.store-group') }}" method="POST">
@csrf
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-semibold text-gray-800">코드 그룹 추가</h3>
</div>
<div class="px-6 py-4 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">그룹 코드 *</label>
<input type="text" name="group_code" required
pattern="[a-z][a-z0-9_]*" maxlength="50"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="예: payment_method">
<p class="mt-1 text-xs text-gray-400">영문 소문자, 숫자, 언더스코어만 사용</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">그룹명 *</label>
<input type="text" name="group_label" required maxlength="50"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="예: 결제방법">
</div>
</div>
<div class="px-6 py-4 border-t border-gray-200 flex justify-end gap-3">
<button type="button" onclick="closeGroupModal()"
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium transition-colors">
취소
</button>
<button type="submit"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors">
추가
</button>
</div>
</form>
</div>
</div>
@endsection
@push('scripts')
<script>
// 그룹 스코프 필터
function filterGroups(filter) {
const items = document.querySelectorAll('.group-item');
items.forEach(item => {
const scope = item.dataset.scope;
let show = false;
switch (filter) {
case 'all':
show = true;
break;
case 'global-based':
show = (scope === 'global' || scope === 'both');
break;
case 'tenant-based':
show = (scope === 'tenant' || scope === 'both');
break;
}
item.style.display = show ? '' : 'none';
});
// 버튼 활성 상태
document.querySelectorAll('.group-filter-btn').forEach(btn => {
if (btn.dataset.scope === filter) {
btn.className = 'group-filter-btn text-[10px] px-1.5 py-0.5 rounded font-medium transition-colors bg-gray-700 text-white';
} else {
btn.className = 'group-filter-btn text-[10px] px-1.5 py-0.5 rounded font-medium transition-colors bg-gray-100 text-gray-600 hover:bg-gray-200';
}
});
}
// 모달 열기/닫기
function openAddModal() {
document.getElementById('addModal').classList.remove('hidden');
@@ -460,6 +559,16 @@ function closeAddModal() {
document.getElementById('addModal').classList.remove('flex');
}
function openGroupModal() {
document.getElementById('groupModal').classList.remove('hidden');
document.getElementById('groupModal').classList.add('flex');
}
function closeGroupModal() {
document.getElementById('groupModal').classList.add('hidden');
document.getElementById('groupModal').classList.remove('flex');
}
function openEditModal(code) {
document.getElementById('editForm').action = `/common-codes/${code.id}`;
document.getElementById('editCode').value = code.code;
@@ -601,13 +710,17 @@ function deleteCode(id, code) {
document.getElementById('editModal').addEventListener('click', function(e) {
if (e.target === this) closeEditModal();
});
document.getElementById('groupModal').addEventListener('click', function(e) {
if (e.target === this) closeGroupModal();
});
// ESC 키로 모달 닫기
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeAddModal();
closeEditModal();
closeGroupModal();
}
});
</script>
@endpush
@endpush

View File

@@ -150,7 +150,8 @@ class="w-4 h-4 text-green-600 border-gray-300 rounded focus:ring-green-500">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</span>
<h3 class="font-semibold text-gray-800">로컬 (현재)</h3>
<h3 class="font-semibold text-gray-800">로컬</h3>
<span class="text-xs text-gray-500">- {{ $localTenantName }}</span>
<span class="text-xs text-gray-500">({{ count($localCodes) }})</span>
</div>
@if(!empty($environments[$selectedEnv]['url']) && !$remoteError)
@@ -219,6 +220,9 @@ class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500">
</svg>
</span>
<h3 class="font-semibold text-gray-800">{{ $environments[$selectedEnv]['name'] ?? strtoupper($selectedEnv) }}</h3>
@if($remoteTenantName)
<span class="text-xs text-gray-500">- {{ $remoteTenantName }}</span>
@endif
<span class="text-xs text-gray-500">({{ count($remoteCodes) }})</span>
</div>
@if(!empty($environments[$selectedEnv]['url']) && !$remoteError)
@@ -392,4 +396,4 @@ function updateSelectedCount(side) {
}
}
</script>
@endpush
@endpush

View File

@@ -116,7 +116,7 @@ class="w-4 h-4 text-green-600 border-gray-300 rounded focus:ring-green-500">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</span>
<h3 class="font-semibold text-gray-800">로컬 (현재)</h3>
<h3 class="font-semibold text-gray-800">로컬 - {{ $localTenantName }}</h3>
<span class="text-xs text-gray-500">({{ count($localMenus) }} 그룹)</span>
</div>
@if(!empty($environments[$selectedEnv]['url']) && !$remoteError)
@@ -152,7 +152,7 @@ class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
</svg>
</span>
<h3 class="font-semibold text-gray-800">{{ $environments[$selectedEnv]['name'] ?? strtoupper($selectedEnv) }}</h3>
<h3 class="font-semibold text-gray-800">{{ $environments[$selectedEnv]['name'] ?? strtoupper($selectedEnv) }}{{ $remoteTenantName ? ' - ' . $remoteTenantName : '' }}</h3>
<span class="text-xs text-gray-500">({{ count($remoteMenus) }} 그룹)</span>
</div>
@if(!empty($environments[$selectedEnv]['url']) && !$remoteError)