feat:공통코드/카테고리 동기화 기능 추가
- CommonCodeSyncController, CategorySyncController 생성 - 환경설정은 메뉴 동기화와 공유 (TenantSetting) - Export/Import API 추가 (/common-code-sync, /category-sync) - Push(로컬→원격), Pull(원격→로컬) 양방향 동기화 - 동일 코드 존재 시 체크박스 비활성화 (충돌 방지) - 글로벌 + 테넌트 코드 모두 동기화 가능 - 공통코드/카테고리 관리 페이지에 동기화 버튼 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
490
app/Http/Controllers/CategorySyncController.php
Normal file
490
app/Http/Controllers/CategorySyncController.php
Normal file
@@ -0,0 +1,490 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\GlobalCategory;
|
||||
use App\Models\Tenants\TenantSetting;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class CategorySyncController extends Controller
|
||||
{
|
||||
/**
|
||||
* 현재 선택된 테넌트 ID
|
||||
*/
|
||||
protected function getTenantId(): int
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
return ($tenantId && $tenantId !== 'all') ? (int) $tenantId : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 환경 설정 조회 (메뉴 동기화와 공유)
|
||||
*/
|
||||
private function getEnvironments(): array
|
||||
{
|
||||
$setting = TenantSetting::withoutGlobalScopes()
|
||||
->where('tenant_id', $this->getTenantId())
|
||||
->where('setting_group', 'menu_sync')
|
||||
->where('setting_key', 'environments')
|
||||
->first();
|
||||
|
||||
return $setting?->setting_value ?? [
|
||||
'dev' => ['name' => '개발', 'url' => '', 'api_key' => ''],
|
||||
'prod' => ['name' => '운영', 'url' => '', 'api_key' => ''],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 동기화 페이지
|
||||
*/
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('categories.sync.index'));
|
||||
}
|
||||
|
||||
$environments = $this->getEnvironments();
|
||||
$selectedEnv = $request->get('env', 'dev');
|
||||
|
||||
// 로컬 카테고리 조회
|
||||
$localCategories = $this->getCategoryList();
|
||||
|
||||
// 원격 카테고리 조회
|
||||
$remoteCategories = [];
|
||||
$remoteError = null;
|
||||
|
||||
if (! empty($environments[$selectedEnv]['url'])) {
|
||||
try {
|
||||
$remoteCategories = $this->fetchRemoteCategories($environments[$selectedEnv]);
|
||||
} catch (\Exception $e) {
|
||||
$remoteError = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// 차이점 계산
|
||||
$diff = $this->calculateDiff($localCategories, $remoteCategories);
|
||||
|
||||
return view('categories.sync', [
|
||||
'environments' => $environments,
|
||||
'selectedEnv' => $selectedEnv,
|
||||
'localCategories' => $localCategories,
|
||||
'remoteCategories' => $remoteCategories,
|
||||
'remoteError' => $remoteError,
|
||||
'diff' => $diff,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 Export API (다른 환경에서 호출)
|
||||
*/
|
||||
public function export(Request $request): JsonResponse
|
||||
{
|
||||
// API Key 검증
|
||||
$apiKey = $request->header('X-Menu-Sync-Key');
|
||||
$validKey = config('app.menu_sync_api_key', env('MENU_SYNC_API_KEY'));
|
||||
|
||||
if (empty($validKey) || $apiKey !== $validKey) {
|
||||
return response()->json(['error' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
$categories = $this->getCategoryList();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'environment' => config('app.env'),
|
||||
'exported_at' => now()->toIso8601String(),
|
||||
'categories' => $categories,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 Import API (다른 환경에서 호출)
|
||||
*/
|
||||
public function import(Request $request): JsonResponse
|
||||
{
|
||||
// API Key 검증
|
||||
$apiKey = $request->header('X-Menu-Sync-Key');
|
||||
$validKey = config('app.menu_sync_api_key', env('MENU_SYNC_API_KEY'));
|
||||
|
||||
if (empty($validKey) || $apiKey !== $validKey) {
|
||||
return response()->json(['error' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'categories' => 'required|array',
|
||||
'categories.*.is_global' => 'required|boolean',
|
||||
'categories.*.tenant_id' => 'nullable|integer',
|
||||
'categories.*.code_group' => 'required|string|max:50',
|
||||
'categories.*.code' => 'required|string|max:50',
|
||||
'categories.*.name' => 'required|string|max:100',
|
||||
'categories.*.parent_code' => 'nullable|string|max:50',
|
||||
'categories.*.sort_order' => 'nullable|integer',
|
||||
'categories.*.description' => 'nullable|string',
|
||||
'categories.*.is_active' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
$imported = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($validated['categories'] as $catData) {
|
||||
if ($catData['is_global']) {
|
||||
// 글로벌 카테고리
|
||||
$exists = GlobalCategory::where('code_group', $catData['code_group'])
|
||||
->where('code', $catData['code'])
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 부모 찾기
|
||||
$parentId = null;
|
||||
if (! empty($catData['parent_code'])) {
|
||||
$parent = GlobalCategory::where('code_group', $catData['code_group'])
|
||||
->where('code', $catData['parent_code'])
|
||||
->first();
|
||||
$parentId = $parent?->id;
|
||||
}
|
||||
|
||||
GlobalCategory::create([
|
||||
'parent_id' => $parentId,
|
||||
'code_group' => $catData['code_group'],
|
||||
'code' => $catData['code'],
|
||||
'name' => $catData['name'],
|
||||
'sort_order' => $catData['sort_order'] ?? 0,
|
||||
'description' => $catData['description'] ?? null,
|
||||
'is_active' => $catData['is_active'] ?? true,
|
||||
]);
|
||||
} else {
|
||||
// 테넌트 카테고리
|
||||
$tenantId = $catData['tenant_id'] ?? $this->getTenantId();
|
||||
|
||||
$exists = Category::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('code_group', $catData['code_group'])
|
||||
->where('code', $catData['code'])
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 부모 찾기
|
||||
$parentId = null;
|
||||
if (! empty($catData['parent_code'])) {
|
||||
$parent = Category::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('code_group', $catData['code_group'])
|
||||
->where('code', $catData['parent_code'])
|
||||
->first();
|
||||
$parentId = $parent?->id;
|
||||
}
|
||||
|
||||
Category::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'parent_id' => $parentId,
|
||||
'code_group' => $catData['code_group'],
|
||||
'code' => $catData['code'],
|
||||
'name' => $catData['name'],
|
||||
'sort_order' => $catData['sort_order'] ?? 0,
|
||||
'description' => $catData['description'] ?? null,
|
||||
'is_active' => $catData['is_active'] ?? true,
|
||||
]);
|
||||
}
|
||||
$imported++;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "{$imported}개 카테고리가 동기화되었습니다." . ($skipped > 0 ? " ({$skipped}개 스킵)" : ''),
|
||||
'imported' => $imported,
|
||||
'skipped' => $skipped,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Push (로컬 → 원격)
|
||||
*/
|
||||
public function push(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'env' => 'required|string|in:dev,prod',
|
||||
'category_keys' => 'required|array|min:1',
|
||||
'category_keys.*' => 'string',
|
||||
]);
|
||||
|
||||
$environments = $this->getEnvironments();
|
||||
$env = $environments[$validated['env']] ?? null;
|
||||
|
||||
if (! $env || empty($env['url'])) {
|
||||
return response()->json(['error' => '환경 설정이 없습니다.'], 400);
|
||||
}
|
||||
|
||||
// 선택된 카테고리 조회
|
||||
$localCategories = $this->getCategoryList();
|
||||
$selectedCategories = array_filter($localCategories, function ($cat) use ($validated) {
|
||||
$key = $this->makeCategoryKey($cat);
|
||||
return in_array($key, $validated['category_keys']);
|
||||
});
|
||||
|
||||
if (empty($selectedCategories)) {
|
||||
return response()->json(['error' => '선택된 카테고리가 없습니다.'], 400);
|
||||
}
|
||||
|
||||
// 원격 서버로 전송
|
||||
try {
|
||||
$response = Http::withHeaders([
|
||||
'X-Menu-Sync-Key' => $env['api_key'],
|
||||
'Accept' => 'application/json',
|
||||
])->post(rtrim($env['url'], '/') . '/category-sync/import', [
|
||||
'categories' => array_values($selectedCategories),
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $response->json('message', '동기화 완료'),
|
||||
'imported' => $response->json('imported', 0),
|
||||
'skipped' => $response->json('skipped', 0),
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'error' => $response->json('error', '원격 서버 오류'),
|
||||
], $response->status());
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['error' => '연결 실패: ' . $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull (원격 → 로컬)
|
||||
*/
|
||||
public function pull(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'env' => 'required|string|in:dev,prod',
|
||||
'category_keys' => 'required|array|min:1',
|
||||
'category_keys.*' => 'string',
|
||||
]);
|
||||
|
||||
$environments = $this->getEnvironments();
|
||||
$env = $environments[$validated['env']] ?? null;
|
||||
|
||||
if (! $env || empty($env['url'])) {
|
||||
return response()->json(['error' => '환경 설정이 없습니다.'], 400);
|
||||
}
|
||||
|
||||
// 원격 카테고리 조회
|
||||
try {
|
||||
$remoteCategories = $this->fetchRemoteCategories($env);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['error' => $e->getMessage()], 500);
|
||||
}
|
||||
|
||||
// 선택된 카테고리만 필터링
|
||||
$selectedCategories = array_filter($remoteCategories, function ($cat) use ($validated) {
|
||||
$key = $this->makeCategoryKey($cat);
|
||||
return in_array($key, $validated['category_keys']);
|
||||
});
|
||||
|
||||
if (empty($selectedCategories)) {
|
||||
return response()->json(['error' => '선택된 카테고리를 찾을 수 없습니다.'], 400);
|
||||
}
|
||||
|
||||
// 로컬에 Import
|
||||
$imported = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($selectedCategories as $catData) {
|
||||
if ($catData['is_global']) {
|
||||
$exists = GlobalCategory::where('code_group', $catData['code_group'])
|
||||
->where('code', $catData['code'])
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$parentId = null;
|
||||
if (! empty($catData['parent_code'])) {
|
||||
$parent = GlobalCategory::where('code_group', $catData['code_group'])
|
||||
->where('code', $catData['parent_code'])
|
||||
->first();
|
||||
$parentId = $parent?->id;
|
||||
}
|
||||
|
||||
GlobalCategory::create([
|
||||
'parent_id' => $parentId,
|
||||
'code_group' => $catData['code_group'],
|
||||
'code' => $catData['code'],
|
||||
'name' => $catData['name'],
|
||||
'sort_order' => $catData['sort_order'] ?? 0,
|
||||
'description' => $catData['description'] ?? null,
|
||||
'is_active' => $catData['is_active'] ?? true,
|
||||
]);
|
||||
} else {
|
||||
$tenantId = $this->getTenantId();
|
||||
|
||||
$exists = Category::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('code_group', $catData['code_group'])
|
||||
->where('code', $catData['code'])
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$parentId = null;
|
||||
if (! empty($catData['parent_code'])) {
|
||||
$parent = Category::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('code_group', $catData['code_group'])
|
||||
->where('code', $catData['parent_code'])
|
||||
->first();
|
||||
$parentId = $parent?->id;
|
||||
}
|
||||
|
||||
Category::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'parent_id' => $parentId,
|
||||
'code_group' => $catData['code_group'],
|
||||
'code' => $catData['code'],
|
||||
'name' => $catData['name'],
|
||||
'sort_order' => $catData['sort_order'] ?? 0,
|
||||
'description' => $catData['description'] ?? null,
|
||||
'is_active' => $catData['is_active'] ?? true,
|
||||
]);
|
||||
}
|
||||
$imported++;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "{$imported}개 카테고리가 동기화되었습니다." . ($skipped > 0 ? " ({$skipped}개 스킵)" : ''),
|
||||
'imported' => $imported,
|
||||
'skipped' => $skipped,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 목록 조회 (글로벌 + 테넌트)
|
||||
*/
|
||||
private function getCategoryList(): array
|
||||
{
|
||||
$tenantId = $this->getTenantId();
|
||||
$categories = [];
|
||||
|
||||
// 글로벌 카테고리
|
||||
$globalCategories = GlobalCategory::whereNull('deleted_at')
|
||||
->orderBy('code_group')
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
|
||||
foreach ($globalCategories as $cat) {
|
||||
$parentCode = null;
|
||||
if ($cat->parent_id) {
|
||||
$parent = GlobalCategory::find($cat->parent_id);
|
||||
$parentCode = $parent?->code;
|
||||
}
|
||||
|
||||
$categories[] = [
|
||||
'is_global' => true,
|
||||
'tenant_id' => null,
|
||||
'code_group' => $cat->code_group,
|
||||
'code' => $cat->code,
|
||||
'name' => $cat->name,
|
||||
'parent_code' => $parentCode,
|
||||
'sort_order' => $cat->sort_order,
|
||||
'description' => $cat->description,
|
||||
'is_active' => $cat->is_active,
|
||||
];
|
||||
}
|
||||
|
||||
// 테넌트 카테고리
|
||||
$tenantCategories = Category::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNull('deleted_at')
|
||||
->orderBy('code_group')
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
|
||||
foreach ($tenantCategories as $cat) {
|
||||
$parentCode = null;
|
||||
if ($cat->parent_id) {
|
||||
$parent = Category::withoutGlobalScopes()->find($cat->parent_id);
|
||||
$parentCode = $parent?->code;
|
||||
}
|
||||
|
||||
$categories[] = [
|
||||
'is_global' => false,
|
||||
'tenant_id' => $cat->tenant_id,
|
||||
'code_group' => $cat->code_group,
|
||||
'code' => $cat->code,
|
||||
'name' => $cat->name,
|
||||
'parent_code' => $parentCode,
|
||||
'sort_order' => $cat->sort_order,
|
||||
'description' => $cat->description,
|
||||
'is_active' => $cat->is_active,
|
||||
];
|
||||
}
|
||||
|
||||
return $categories;
|
||||
}
|
||||
|
||||
/**
|
||||
* 원격 카테고리 조회
|
||||
*/
|
||||
private function fetchRemoteCategories(array $env): array
|
||||
{
|
||||
$response = Http::withHeaders([
|
||||
'X-Menu-Sync-Key' => $env['api_key'],
|
||||
'Accept' => 'application/json',
|
||||
])->timeout(10)->get(rtrim($env['url'], '/') . '/category-sync/export');
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new \Exception('API 오류: HTTP ' . $response->status());
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
if (! isset($data['categories'])) {
|
||||
throw new \Exception('잘못된 응답 형식');
|
||||
}
|
||||
|
||||
return $data['categories'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 키 생성 (유니크 식별자)
|
||||
*/
|
||||
private function makeCategoryKey(array $cat): string
|
||||
{
|
||||
$typePart = $cat['is_global'] ? 'global' : "tenant:{$cat['tenant_id']}";
|
||||
return "{$typePart}:{$cat['code_group']}:{$cat['code']}";
|
||||
}
|
||||
|
||||
/**
|
||||
* 차이점 계산
|
||||
*/
|
||||
private function calculateDiff(array $localCategories, array $remoteCategories): array
|
||||
{
|
||||
$localKeys = array_map(fn($c) => $this->makeCategoryKey($c), $localCategories);
|
||||
$remoteKeys = array_map(fn($c) => $this->makeCategoryKey($c), $remoteCategories);
|
||||
|
||||
return [
|
||||
'local_only' => array_values(array_diff($localKeys, $remoteKeys)),
|
||||
'remote_only' => array_values(array_diff($remoteKeys, $localKeys)),
|
||||
'both' => array_values(array_intersect($localKeys, $remoteKeys)),
|
||||
];
|
||||
}
|
||||
}
|
||||
388
app/Http/Controllers/CommonCodeSyncController.php
Normal file
388
app/Http/Controllers/CommonCodeSyncController.php
Normal file
@@ -0,0 +1,388 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Products\CommonCode;
|
||||
use App\Models\Tenants\TenantSetting;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class CommonCodeSyncController extends Controller
|
||||
{
|
||||
/**
|
||||
* 현재 선택된 테넌트 ID
|
||||
*/
|
||||
protected function getTenantId(): int
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
return ($tenantId && $tenantId !== 'all') ? (int) $tenantId : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 환경 설정 조회 (메뉴 동기화와 공유)
|
||||
*/
|
||||
private function getEnvironments(): array
|
||||
{
|
||||
$setting = TenantSetting::withoutGlobalScopes()
|
||||
->where('tenant_id', $this->getTenantId())
|
||||
->where('setting_group', 'menu_sync')
|
||||
->where('setting_key', 'environments')
|
||||
->first();
|
||||
|
||||
return $setting?->setting_value ?? [
|
||||
'dev' => ['name' => '개발', 'url' => '', 'api_key' => ''],
|
||||
'prod' => ['name' => '운영', 'url' => '', 'api_key' => ''],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 공통코드 동기화 페이지
|
||||
*/
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('common-codes.sync.index'));
|
||||
}
|
||||
|
||||
$environments = $this->getEnvironments();
|
||||
$selectedEnv = $request->get('env', 'dev');
|
||||
|
||||
// 로컬 코드 조회
|
||||
$localCodes = $this->getCodeList();
|
||||
|
||||
// 원격 코드 조회
|
||||
$remoteCodes = [];
|
||||
$remoteError = null;
|
||||
|
||||
if (! empty($environments[$selectedEnv]['url'])) {
|
||||
try {
|
||||
$remoteCodes = $this->fetchRemoteCodes($environments[$selectedEnv]);
|
||||
} catch (\Exception $e) {
|
||||
$remoteError = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// 차이점 계산
|
||||
$diff = $this->calculateDiff($localCodes, $remoteCodes);
|
||||
|
||||
return view('common-codes.sync', [
|
||||
'environments' => $environments,
|
||||
'selectedEnv' => $selectedEnv,
|
||||
'localCodes' => $localCodes,
|
||||
'remoteCodes' => $remoteCodes,
|
||||
'remoteError' => $remoteError,
|
||||
'diff' => $diff,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 공통코드 Export API (다른 환경에서 호출)
|
||||
*/
|
||||
public function export(Request $request): JsonResponse
|
||||
{
|
||||
// API Key 검증
|
||||
$apiKey = $request->header('X-Menu-Sync-Key');
|
||||
$validKey = config('app.menu_sync_api_key', env('MENU_SYNC_API_KEY'));
|
||||
|
||||
if (empty($validKey) || $apiKey !== $validKey) {
|
||||
return response()->json(['error' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
$codes = $this->getCodeList();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'environment' => config('app.env'),
|
||||
'exported_at' => now()->toIso8601String(),
|
||||
'codes' => $codes,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 공통코드 Import API (다른 환경에서 호출)
|
||||
*/
|
||||
public function import(Request $request): JsonResponse
|
||||
{
|
||||
// API Key 검증
|
||||
$apiKey = $request->header('X-Menu-Sync-Key');
|
||||
$validKey = config('app.menu_sync_api_key', env('MENU_SYNC_API_KEY'));
|
||||
|
||||
if (empty($validKey) || $apiKey !== $validKey) {
|
||||
return response()->json(['error' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'codes' => 'required|array',
|
||||
'codes.*.tenant_id' => 'nullable|integer',
|
||||
'codes.*.code_group' => 'required|string|max:50',
|
||||
'codes.*.code' => 'required|string|max:50',
|
||||
'codes.*.name' => 'required|string|max:100',
|
||||
'codes.*.sort_order' => 'nullable|integer',
|
||||
'codes.*.attributes' => 'nullable|array',
|
||||
'codes.*.is_active' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
$imported = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($validated['codes'] as $codeData) {
|
||||
// 동일 코드 존재 확인
|
||||
$exists = CommonCode::query()
|
||||
->where('tenant_id', $codeData['tenant_id'] ?? null)
|
||||
->where('code_group', $codeData['code_group'])
|
||||
->where('code', $codeData['code'])
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
CommonCode::create([
|
||||
'tenant_id' => $codeData['tenant_id'] ?? null,
|
||||
'code_group' => $codeData['code_group'],
|
||||
'code' => $codeData['code'],
|
||||
'name' => $codeData['name'],
|
||||
'sort_order' => $codeData['sort_order'] ?? 0,
|
||||
'attributes' => $codeData['attributes'] ?? null,
|
||||
'is_active' => $codeData['is_active'] ?? true,
|
||||
]);
|
||||
$imported++;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "{$imported}개 코드가 동기화되었습니다." . ($skipped > 0 ? " ({$skipped}개 스킵)" : ''),
|
||||
'imported' => $imported,
|
||||
'skipped' => $skipped,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Push (로컬 → 원격)
|
||||
*/
|
||||
public function push(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'env' => 'required|string|in:dev,prod',
|
||||
'code_keys' => 'required|array|min:1',
|
||||
'code_keys.*' => 'string',
|
||||
]);
|
||||
|
||||
$environments = $this->getEnvironments();
|
||||
$env = $environments[$validated['env']] ?? null;
|
||||
|
||||
if (! $env || empty($env['url'])) {
|
||||
return response()->json(['error' => '환경 설정이 없습니다.'], 400);
|
||||
}
|
||||
|
||||
// 선택된 코드 조회
|
||||
$localCodes = $this->getCodeList();
|
||||
$selectedCodes = array_filter($localCodes, function ($code) use ($validated) {
|
||||
$key = $this->makeCodeKey($code);
|
||||
return in_array($key, $validated['code_keys']);
|
||||
});
|
||||
|
||||
if (empty($selectedCodes)) {
|
||||
return response()->json(['error' => '선택된 코드가 없습니다.'], 400);
|
||||
}
|
||||
|
||||
// 원격 서버로 전송
|
||||
try {
|
||||
$response = Http::withHeaders([
|
||||
'X-Menu-Sync-Key' => $env['api_key'],
|
||||
'Accept' => 'application/json',
|
||||
])->post(rtrim($env['url'], '/') . '/common-code-sync/import', [
|
||||
'codes' => array_values($selectedCodes),
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $response->json('message', '동기화 완료'),
|
||||
'imported' => $response->json('imported', 0),
|
||||
'skipped' => $response->json('skipped', 0),
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'error' => $response->json('error', '원격 서버 오류'),
|
||||
], $response->status());
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['error' => '연결 실패: ' . $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull (원격 → 로컬)
|
||||
*/
|
||||
public function pull(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'env' => 'required|string|in:dev,prod',
|
||||
'code_keys' => 'required|array|min:1',
|
||||
'code_keys.*' => 'string',
|
||||
]);
|
||||
|
||||
$environments = $this->getEnvironments();
|
||||
$env = $environments[$validated['env']] ?? null;
|
||||
|
||||
if (! $env || empty($env['url'])) {
|
||||
return response()->json(['error' => '환경 설정이 없습니다.'], 400);
|
||||
}
|
||||
|
||||
// 원격 코드 조회
|
||||
try {
|
||||
$remoteCodes = $this->fetchRemoteCodes($env);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['error' => $e->getMessage()], 500);
|
||||
}
|
||||
|
||||
// 선택된 코드만 필터링
|
||||
$selectedCodes = array_filter($remoteCodes, function ($code) use ($validated) {
|
||||
$key = $this->makeCodeKey($code);
|
||||
return in_array($key, $validated['code_keys']);
|
||||
});
|
||||
|
||||
if (empty($selectedCodes)) {
|
||||
return response()->json(['error' => '선택된 코드를 찾을 수 없습니다.'], 400);
|
||||
}
|
||||
|
||||
// 로컬에 Import
|
||||
$imported = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($selectedCodes as $codeData) {
|
||||
// 동일 코드 존재 확인
|
||||
$exists = CommonCode::query()
|
||||
->where('tenant_id', $codeData['tenant_id'] ?? null)
|
||||
->where('code_group', $codeData['code_group'])
|
||||
->where('code', $codeData['code'])
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
CommonCode::create([
|
||||
'tenant_id' => $codeData['tenant_id'] ?? null,
|
||||
'code_group' => $codeData['code_group'],
|
||||
'code' => $codeData['code'],
|
||||
'name' => $codeData['name'],
|
||||
'sort_order' => $codeData['sort_order'] ?? 0,
|
||||
'attributes' => $codeData['attributes'] ?? null,
|
||||
'is_active' => $codeData['is_active'] ?? true,
|
||||
]);
|
||||
$imported++;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "{$imported}개 코드가 동기화되었습니다." . ($skipped > 0 ? " ({$skipped}개 스킵)" : ''),
|
||||
'imported' => $imported,
|
||||
'skipped' => $skipped,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 목록 조회 (글로벌 + 테넌트)
|
||||
*/
|
||||
private function getCodeList(): array
|
||||
{
|
||||
$tenantId = $this->getTenantId();
|
||||
|
||||
// 글로벌 코드 (tenant_id IS NULL)
|
||||
$globalCodes = CommonCode::query()
|
||||
->whereNull('tenant_id')
|
||||
->orderBy('code_group')
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
|
||||
// 테넌트 코드
|
||||
$tenantCodes = CommonCode::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->orderBy('code_group')
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
|
||||
$codes = [];
|
||||
|
||||
foreach ($globalCodes as $code) {
|
||||
$codes[] = [
|
||||
'tenant_id' => null,
|
||||
'code_group' => $code->code_group,
|
||||
'code' => $code->code,
|
||||
'name' => $code->name,
|
||||
'sort_order' => $code->sort_order,
|
||||
'attributes' => $code->attributes,
|
||||
'is_active' => $code->is_active,
|
||||
'is_global' => true,
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($tenantCodes as $code) {
|
||||
$codes[] = [
|
||||
'tenant_id' => $code->tenant_id,
|
||||
'code_group' => $code->code_group,
|
||||
'code' => $code->code,
|
||||
'name' => $code->name,
|
||||
'sort_order' => $code->sort_order,
|
||||
'attributes' => $code->attributes,
|
||||
'is_active' => $code->is_active,
|
||||
'is_global' => false,
|
||||
];
|
||||
}
|
||||
|
||||
return $codes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 원격 코드 조회
|
||||
*/
|
||||
private function fetchRemoteCodes(array $env): array
|
||||
{
|
||||
$response = Http::withHeaders([
|
||||
'X-Menu-Sync-Key' => $env['api_key'],
|
||||
'Accept' => 'application/json',
|
||||
])->timeout(10)->get(rtrim($env['url'], '/') . '/common-code-sync/export');
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new \Exception('API 오류: HTTP ' . $response->status());
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
if (! isset($data['codes'])) {
|
||||
throw new \Exception('잘못된 응답 형식');
|
||||
}
|
||||
|
||||
return $data['codes'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 키 생성 (유니크 식별자)
|
||||
*/
|
||||
private function makeCodeKey(array $code): string
|
||||
{
|
||||
$tenantPart = $code['tenant_id'] ?? 'global';
|
||||
return "{$tenantPart}:{$code['code_group']}:{$code['code']}";
|
||||
}
|
||||
|
||||
/**
|
||||
* 차이점 계산
|
||||
*/
|
||||
private function calculateDiff(array $localCodes, array $remoteCodes): array
|
||||
{
|
||||
$localKeys = array_map(fn($c) => $this->makeCodeKey($c), $localCodes);
|
||||
$remoteKeys = array_map(fn($c) => $this->makeCodeKey($c), $remoteCodes);
|
||||
|
||||
return [
|
||||
'local_only' => array_values(array_diff($localKeys, $remoteKeys)),
|
||||
'remote_only' => array_values(array_diff($remoteKeys, $localKeys)),
|
||||
'both' => array_values(array_intersect($localKeys, $remoteKeys)),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -22,16 +22,25 @@
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
@if($tenant)
|
||||
<button type="button"
|
||||
onclick="openAddModal()"
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors flex items-center gap-2 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="{{ route('categories.sync.index') }}"
|
||||
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white font-medium rounded-lg transition-colors flex items-center gap-2 text-sm">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||
</svg>
|
||||
카테고리 추가
|
||||
</button>
|
||||
@endif
|
||||
동기화
|
||||
</a>
|
||||
@if($tenant)
|
||||
<button type="button"
|
||||
onclick="openAddModal()"
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors flex items-center gap-2 text-sm">
|
||||
<svg class="w-4 h-4" 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>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 테넌트 미선택 경고 -->
|
||||
|
||||
369
resources/views/categories/sync.blade.php
Normal file
369
resources/views/categories/sync.blade.php
Normal file
@@ -0,0 +1,369 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '카테고리 동기화')
|
||||
|
||||
@section('content')
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6 flex-shrink-0">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">카테고리 동기화</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">로컬과 원격 환경 간 카테고리를 동기화합니다.</p>
|
||||
</div>
|
||||
<a href="{{ route('menus.sync.index') }}"
|
||||
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white font-medium rounded-lg transition-colors flex items-center gap-2 text-sm">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
환경 설정 (메뉴 동기화)
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 환경 선택 탭 -->
|
||||
<div class="bg-white rounded-lg shadow-sm mb-4">
|
||||
<div class="border-b border-gray-200">
|
||||
<nav class="flex -mb-px" aria-label="Tabs">
|
||||
@foreach($environments as $key => $env)
|
||||
<a href="{{ route('categories.sync.index', ['env' => $key]) }}"
|
||||
class="px-6 py-3 text-sm font-medium border-b-2 transition-colors
|
||||
{{ $selectedEnv === $key
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' }}">
|
||||
로컬 ↔ {{ $env['name'] ?? strtoupper($key) }}
|
||||
@if(empty($env['url']))
|
||||
<span class="ml-1 text-xs text-gray-400">(미설정)</span>
|
||||
@endif
|
||||
</a>
|
||||
@endforeach
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($remoteError)
|
||||
<div class="mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
원격 서버 연결 실패: {{ $remoteError }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(empty($environments[$selectedEnv]['url']))
|
||||
<div class="mb-4 bg-yellow-50 border border-yellow-200 text-yellow-700 px-4 py-3 rounded-lg flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<a href="{{ route('menus.sync.index') }}" class="underline">메뉴 동기화 환경 설정</a>에서 {{ $environments[$selectedEnv]['name'] ?? strtoupper($selectedEnv) }} 서버 URL을 설정해주세요.
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- 동기화 요약 -->
|
||||
@if(!empty($environments[$selectedEnv]['url']) && !$remoteError)
|
||||
<div class="grid grid-cols-3 gap-4 mb-4">
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</span>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-green-800">로컬에만 있음</p>
|
||||
<p class="text-2xl font-bold text-green-600">{{ count($diff['local_only']) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||
</svg>
|
||||
</span>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-blue-800">양쪽 모두</p>
|
||||
<p class="text-2xl font-bold text-blue-600">{{ count($diff['both']) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-8 h-8 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16l-4-4m0 0l4-4m-4 4h18" />
|
||||
</svg>
|
||||
</span>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-purple-800">원격에만 있음</p>
|
||||
<p class="text-2xl font-bold text-purple-600">{{ count($diff['remote_only']) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- 카테고리 비교 테이블 -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 flex-1 min-h-0">
|
||||
<!-- 로컬 카테고리 -->
|
||||
<div class="bg-white rounded-lg shadow-sm flex flex-col">
|
||||
<div class="px-4 py-3 border-b border-gray-200 flex items-center justify-between flex-shrink-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" id="selectAllLocal" onchange="toggleSelectAll('local', this.checked)"
|
||||
class="w-4 h-4 text-green-600 border-gray-300 rounded focus:ring-green-500">
|
||||
<span class="w-6 h-6 bg-green-100 rounded flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
<span class="text-xs text-gray-500">({{ count($localCategories) }}개)</span>
|
||||
</div>
|
||||
@if(!empty($environments[$selectedEnv]['url']) && !$remoteError)
|
||||
<div class="flex items-center gap-2">
|
||||
<span id="localSelectedCount" class="text-xs text-gray-500">0개 선택</span>
|
||||
<button type="button" onclick="pushSelected()"
|
||||
class="px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white text-xs font-medium rounded transition-colors flex items-center gap-1">
|
||||
Push →
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="overflow-auto flex-1">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 sticky top-0">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-center w-10"></th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">타입</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">그룹</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">코드</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">이름</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">상위</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@forelse($localCategories as $cat)
|
||||
@php
|
||||
$typePart = $cat['is_global'] ? 'global' : 'tenant:' . $cat['tenant_id'];
|
||||
$key = $typePart . ':' . $cat['code_group'] . ':' . $cat['code'];
|
||||
$inBoth = in_array($key, $diff['both']);
|
||||
$localOnly = in_array($key, $diff['local_only']);
|
||||
@endphp
|
||||
<tr class="{{ $inBoth ? 'bg-blue-50/50' : ($localOnly ? 'bg-green-50/50' : '') }}">
|
||||
<td class="px-3 py-2 text-center">
|
||||
<input type="checkbox" name="local_cat" value="{{ $key }}"
|
||||
class="w-4 h-4 text-green-600 border-gray-300 rounded focus:ring-green-500"
|
||||
{{ $inBoth ? 'disabled' : '' }}>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
@if($cat['is_global'])
|
||||
<span class="px-1.5 py-0.5 bg-purple-100 text-purple-700 text-xs rounded">글로벌</span>
|
||||
@else
|
||||
<span class="px-1.5 py-0.5 bg-blue-100 text-blue-700 text-xs rounded">테넌트</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-3 py-2 font-mono text-xs text-gray-600">{{ $cat['code_group'] }}</td>
|
||||
<td class="px-3 py-2 font-mono text-xs">{{ $cat['code'] }}</td>
|
||||
<td class="px-3 py-2">{{ $cat['name'] }}</td>
|
||||
<td class="px-3 py-2 text-xs text-gray-500">{{ $cat['parent_code'] ?? '-' }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="6" class="px-3 py-8 text-center text-gray-400">카테고리가 없습니다.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 원격 카테고리 -->
|
||||
<div class="bg-white rounded-lg shadow-sm flex flex-col">
|
||||
<div class="px-4 py-3 border-b border-gray-200 flex items-center justify-between flex-shrink-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" id="selectAllRemote" onchange="toggleSelectAll('remote', this.checked)"
|
||||
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500">
|
||||
<span class="w-6 h-6 bg-purple-100 rounded flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
<span class="text-xs text-gray-500">({{ count($remoteCategories) }}개)</span>
|
||||
</div>
|
||||
@if(!empty($environments[$selectedEnv]['url']) && !$remoteError)
|
||||
<div class="flex items-center gap-2">
|
||||
<span id="remoteSelectedCount" class="text-xs text-gray-500">0개 선택</span>
|
||||
<button type="button" onclick="pullSelected()"
|
||||
class="px-3 py-1.5 bg-purple-600 hover:bg-purple-700 text-white text-xs font-medium rounded transition-colors flex items-center gap-1">
|
||||
← Pull
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="overflow-auto flex-1">
|
||||
@if(empty($environments[$selectedEnv]['url']))
|
||||
<div class="flex items-center justify-center h-full text-gray-400">
|
||||
<p>환경을 설정해주세요</p>
|
||||
</div>
|
||||
@elseif($remoteError)
|
||||
<div class="flex items-center justify-center h-full text-red-400">
|
||||
<p>연결 실패</p>
|
||||
</div>
|
||||
@elseif(empty($remoteCategories))
|
||||
<div class="flex items-center justify-center h-full text-gray-400">
|
||||
<p>카테고리가 없습니다</p>
|
||||
</div>
|
||||
@else
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 sticky top-0">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-center w-10"></th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">타입</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">그룹</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">코드</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">이름</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">상위</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@foreach($remoteCategories as $cat)
|
||||
@php
|
||||
$typePart = $cat['is_global'] ? 'global' : 'tenant:' . ($cat['tenant_id'] ?? '');
|
||||
$key = $typePart . ':' . $cat['code_group'] . ':' . $cat['code'];
|
||||
$inBoth = in_array($key, $diff['both']);
|
||||
$remoteOnly = in_array($key, $diff['remote_only']);
|
||||
@endphp
|
||||
<tr class="{{ $inBoth ? 'bg-blue-50/50' : ($remoteOnly ? 'bg-purple-50/50' : '') }}">
|
||||
<td class="px-3 py-2 text-center">
|
||||
<input type="checkbox" name="remote_cat" value="{{ $key }}"
|
||||
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
|
||||
{{ $inBoth ? 'disabled' : '' }}>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
@if($cat['is_global'])
|
||||
<span class="px-1.5 py-0.5 bg-purple-100 text-purple-700 text-xs rounded">글로벌</span>
|
||||
@else
|
||||
<span class="px-1.5 py-0.5 bg-blue-100 text-blue-700 text-xs rounded">테넌트</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-3 py-2 font-mono text-xs text-gray-600">{{ $cat['code_group'] }}</td>
|
||||
<td class="px-3 py-2 font-mono text-xs">{{ $cat['code'] }}</td>
|
||||
<td class="px-3 py-2">{{ $cat['name'] }}</td>
|
||||
<td class="px-3 py-2 text-xs text-gray-500">{{ $cat['parent_code'] ?? '-' }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
const selectedEnv = '{{ $selectedEnv }}';
|
||||
const csrfToken = '{{ csrf_token() }}';
|
||||
|
||||
// 전체 선택
|
||||
function toggleSelectAll(side, checked) {
|
||||
const checkboxes = document.querySelectorAll(`input[name="${side}_cat"]:not(:disabled)`);
|
||||
checkboxes.forEach(cb => cb.checked = checked);
|
||||
updateSelectedCount(side);
|
||||
}
|
||||
|
||||
// 선택된 개수 업데이트
|
||||
function updateSelectedCount(side) {
|
||||
const checkboxes = document.querySelectorAll(`input[name="${side}_cat"]:checked`);
|
||||
const countEl = document.getElementById(`${side}SelectedCount`);
|
||||
if (countEl) {
|
||||
countEl.textContent = `${checkboxes.length}개 선택`;
|
||||
}
|
||||
}
|
||||
|
||||
// 체크박스 변경 이벤트 리스너
|
||||
document.addEventListener('change', function(e) {
|
||||
if (e.target.name === 'local_cat') {
|
||||
updateSelectedCount('local');
|
||||
} else if (e.target.name === 'remote_cat') {
|
||||
updateSelectedCount('remote');
|
||||
}
|
||||
});
|
||||
|
||||
// Push
|
||||
async function pushSelected() {
|
||||
const checkboxes = document.querySelectorAll('input[name="local_cat"]:checked');
|
||||
if (checkboxes.length === 0) {
|
||||
alert('Push할 카테고리를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const categoryKeys = Array.from(checkboxes).map(cb => cb.value);
|
||||
|
||||
if (!confirm(`${categoryKeys.length}개 카테고리를 ${selectedEnv === 'dev' ? '개발' : '운영'} 서버로 Push 하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ route("categories.sync.push") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ env: selectedEnv, category_keys: categoryKeys })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
alert(result.message);
|
||||
location.reload();
|
||||
} else {
|
||||
alert(result.error || 'Push 실패');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('오류 발생: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Pull
|
||||
async function pullSelected() {
|
||||
const checkboxes = document.querySelectorAll('input[name="remote_cat"]:checked');
|
||||
if (checkboxes.length === 0) {
|
||||
alert('Pull할 카테고리를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const categoryKeys = Array.from(checkboxes).map(cb => cb.value);
|
||||
|
||||
if (!confirm(`${categoryKeys.length}개 카테고리를 로컬로 Pull 하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ route("categories.sync.pull") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ env: selectedEnv, category_keys: categoryKeys })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
alert(result.message);
|
||||
location.reload();
|
||||
} else {
|
||||
alert(result.error || 'Pull 실패');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('오류 발생: ' + e.message);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
@@ -22,16 +22,25 @@
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
@if($tenant)
|
||||
<button type="button"
|
||||
onclick="openAddModal()"
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors flex items-center gap-2 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="{{ route('common-codes.sync.index') }}"
|
||||
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white font-medium rounded-lg transition-colors flex items-center gap-2 text-sm">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||
</svg>
|
||||
코드 추가
|
||||
</button>
|
||||
@endif
|
||||
동기화
|
||||
</a>
|
||||
@if($tenant)
|
||||
<button type="button"
|
||||
onclick="openAddModal()"
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors flex items-center gap-2 text-sm">
|
||||
<svg class="w-4 h-4" 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>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 테넌트 미선택 경고 -->
|
||||
|
||||
370
resources/views/common-codes/sync.blade.php
Normal file
370
resources/views/common-codes/sync.blade.php
Normal file
@@ -0,0 +1,370 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '공통코드 동기화')
|
||||
|
||||
@section('content')
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6 flex-shrink-0">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">공통코드 동기화</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">로컬과 원격 환경 간 공통코드를 동기화합니다.</p>
|
||||
</div>
|
||||
<a href="{{ route('menus.sync.index') }}"
|
||||
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white font-medium rounded-lg transition-colors flex items-center gap-2 text-sm">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
환경 설정 (메뉴 동기화)
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 환경 선택 탭 -->
|
||||
<div class="bg-white rounded-lg shadow-sm mb-4">
|
||||
<div class="border-b border-gray-200">
|
||||
<nav class="flex -mb-px" aria-label="Tabs">
|
||||
@foreach($environments as $key => $env)
|
||||
<a href="{{ route('common-codes.sync.index', ['env' => $key]) }}"
|
||||
class="px-6 py-3 text-sm font-medium border-b-2 transition-colors
|
||||
{{ $selectedEnv === $key
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' }}">
|
||||
로컬 ↔ {{ $env['name'] ?? strtoupper($key) }}
|
||||
@if(empty($env['url']))
|
||||
<span class="ml-1 text-xs text-gray-400">(미설정)</span>
|
||||
@endif
|
||||
</a>
|
||||
@endforeach
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($remoteError)
|
||||
<div class="mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
원격 서버 연결 실패: {{ $remoteError }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(empty($environments[$selectedEnv]['url']))
|
||||
<div class="mb-4 bg-yellow-50 border border-yellow-200 text-yellow-700 px-4 py-3 rounded-lg flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<a href="{{ route('menus.sync.index') }}" class="underline">메뉴 동기화 환경 설정</a>에서 {{ $environments[$selectedEnv]['name'] ?? strtoupper($selectedEnv) }} 서버 URL을 설정해주세요.
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- 동기화 요약 -->
|
||||
@if(!empty($environments[$selectedEnv]['url']) && !$remoteError)
|
||||
<div class="grid grid-cols-3 gap-4 mb-4">
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</span>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-green-800">로컬에만 있음</p>
|
||||
<p class="text-2xl font-bold text-green-600">{{ count($diff['local_only']) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||
</svg>
|
||||
</span>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-blue-800">양쪽 모두</p>
|
||||
<p class="text-2xl font-bold text-blue-600">{{ count($diff['both']) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-8 h-8 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16l-4-4m0 0l4-4m-4 4h18" />
|
||||
</svg>
|
||||
</span>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-purple-800">원격에만 있음</p>
|
||||
<p class="text-2xl font-bold text-purple-600">{{ count($diff['remote_only']) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- 코드 비교 테이블 -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 flex-1 min-h-0">
|
||||
<!-- 로컬 코드 -->
|
||||
<div class="bg-white rounded-lg shadow-sm flex flex-col">
|
||||
<div class="px-4 py-3 border-b border-gray-200 flex items-center justify-between flex-shrink-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" id="selectAllLocal" onchange="toggleSelectAll('local', this.checked)"
|
||||
class="w-4 h-4 text-green-600 border-gray-300 rounded focus:ring-green-500">
|
||||
<span class="w-6 h-6 bg-green-100 rounded flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
<span class="text-xs text-gray-500">({{ count($localCodes) }}개)</span>
|
||||
</div>
|
||||
@if(!empty($environments[$selectedEnv]['url']) && !$remoteError)
|
||||
<div class="flex items-center gap-2">
|
||||
<span id="localSelectedCount" class="text-xs text-gray-500">0개 선택</span>
|
||||
<button type="button" onclick="pushSelected()"
|
||||
class="px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white text-xs font-medium rounded transition-colors flex items-center gap-1">
|
||||
Push →
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="overflow-auto flex-1">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 sticky top-0">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-center w-10"></th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">타입</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">그룹</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">코드</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">이름</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@php
|
||||
$localCodeMap = [];
|
||||
foreach ($localCodes as $code) {
|
||||
$key = ($code['tenant_id'] ?? 'global') . ':' . $code['code_group'] . ':' . $code['code'];
|
||||
$localCodeMap[$key] = $code;
|
||||
}
|
||||
@endphp
|
||||
@forelse($localCodes as $code)
|
||||
@php
|
||||
$key = ($code['tenant_id'] ?? 'global') . ':' . $code['code_group'] . ':' . $code['code'];
|
||||
$inBoth = in_array($key, $diff['both']);
|
||||
$localOnly = in_array($key, $diff['local_only']);
|
||||
@endphp
|
||||
<tr class="{{ $inBoth ? 'bg-blue-50/50' : ($localOnly ? 'bg-green-50/50' : '') }}">
|
||||
<td class="px-3 py-2 text-center">
|
||||
<input type="checkbox" name="local_code" value="{{ $key }}"
|
||||
class="w-4 h-4 text-green-600 border-gray-300 rounded focus:ring-green-500"
|
||||
{{ $inBoth ? 'disabled' : '' }}>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
@if($code['is_global'] ?? false)
|
||||
<span class="px-1.5 py-0.5 bg-purple-100 text-purple-700 text-xs rounded">글로벌</span>
|
||||
@else
|
||||
<span class="px-1.5 py-0.5 bg-blue-100 text-blue-700 text-xs rounded">테넌트</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-3 py-2 font-mono text-xs text-gray-600">{{ $code['code_group'] }}</td>
|
||||
<td class="px-3 py-2 font-mono text-xs">{{ $code['code'] }}</td>
|
||||
<td class="px-3 py-2">{{ $code['name'] }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="5" class="px-3 py-8 text-center text-gray-400">코드가 없습니다.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 원격 코드 -->
|
||||
<div class="bg-white rounded-lg shadow-sm flex flex-col">
|
||||
<div class="px-4 py-3 border-b border-gray-200 flex items-center justify-between flex-shrink-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" id="selectAllRemote" onchange="toggleSelectAll('remote', this.checked)"
|
||||
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500">
|
||||
<span class="w-6 h-6 bg-purple-100 rounded flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
<span class="text-xs text-gray-500">({{ count($remoteCodes) }}개)</span>
|
||||
</div>
|
||||
@if(!empty($environments[$selectedEnv]['url']) && !$remoteError)
|
||||
<div class="flex items-center gap-2">
|
||||
<span id="remoteSelectedCount" class="text-xs text-gray-500">0개 선택</span>
|
||||
<button type="button" onclick="pullSelected()"
|
||||
class="px-3 py-1.5 bg-purple-600 hover:bg-purple-700 text-white text-xs font-medium rounded transition-colors flex items-center gap-1">
|
||||
← Pull
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="overflow-auto flex-1">
|
||||
@if(empty($environments[$selectedEnv]['url']))
|
||||
<div class="flex items-center justify-center h-full text-gray-400">
|
||||
<p>환경을 설정해주세요</p>
|
||||
</div>
|
||||
@elseif($remoteError)
|
||||
<div class="flex items-center justify-center h-full text-red-400">
|
||||
<p>연결 실패</p>
|
||||
</div>
|
||||
@elseif(empty($remoteCodes))
|
||||
<div class="flex items-center justify-center h-full text-gray-400">
|
||||
<p>코드가 없습니다</p>
|
||||
</div>
|
||||
@else
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 sticky top-0">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-center w-10"></th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">타입</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">그룹</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">코드</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">이름</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@foreach($remoteCodes as $code)
|
||||
@php
|
||||
$key = ($code['tenant_id'] ?? 'global') . ':' . $code['code_group'] . ':' . $code['code'];
|
||||
$inBoth = in_array($key, $diff['both']);
|
||||
$remoteOnly = in_array($key, $diff['remote_only']);
|
||||
@endphp
|
||||
<tr class="{{ $inBoth ? 'bg-blue-50/50' : ($remoteOnly ? 'bg-purple-50/50' : '') }}">
|
||||
<td class="px-3 py-2 text-center">
|
||||
<input type="checkbox" name="remote_code" value="{{ $key }}"
|
||||
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
|
||||
{{ $inBoth ? 'disabled' : '' }}>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
@if($code['is_global'] ?? false)
|
||||
<span class="px-1.5 py-0.5 bg-purple-100 text-purple-700 text-xs rounded">글로벌</span>
|
||||
@else
|
||||
<span class="px-1.5 py-0.5 bg-blue-100 text-blue-700 text-xs rounded">테넌트</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-3 py-2 font-mono text-xs text-gray-600">{{ $code['code_group'] }}</td>
|
||||
<td class="px-3 py-2 font-mono text-xs">{{ $code['code'] }}</td>
|
||||
<td class="px-3 py-2">{{ $code['name'] }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
const selectedEnv = '{{ $selectedEnv }}';
|
||||
const csrfToken = '{{ csrf_token() }}';
|
||||
|
||||
// 전체 선택
|
||||
function toggleSelectAll(side, checked) {
|
||||
const checkboxes = document.querySelectorAll(`input[name="${side}_code"]:not(:disabled)`);
|
||||
checkboxes.forEach(cb => cb.checked = checked);
|
||||
updateSelectedCount(side);
|
||||
}
|
||||
|
||||
// 선택된 개수 업데이트
|
||||
function updateSelectedCount(side) {
|
||||
const checkboxes = document.querySelectorAll(`input[name="${side}_code"]:checked`);
|
||||
const countEl = document.getElementById(`${side}SelectedCount`);
|
||||
if (countEl) {
|
||||
countEl.textContent = `${checkboxes.length}개 선택`;
|
||||
}
|
||||
}
|
||||
|
||||
// 체크박스 변경 이벤트 리스너
|
||||
document.addEventListener('change', function(e) {
|
||||
if (e.target.name === 'local_code') {
|
||||
updateSelectedCount('local');
|
||||
} else if (e.target.name === 'remote_code') {
|
||||
updateSelectedCount('remote');
|
||||
}
|
||||
});
|
||||
|
||||
// Push
|
||||
async function pushSelected() {
|
||||
const checkboxes = document.querySelectorAll('input[name="local_code"]:checked');
|
||||
if (checkboxes.length === 0) {
|
||||
alert('Push할 코드를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const codeKeys = Array.from(checkboxes).map(cb => cb.value);
|
||||
|
||||
if (!confirm(`${codeKeys.length}개 코드를 ${selectedEnv === 'dev' ? '개발' : '운영'} 서버로 Push 하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ route("common-codes.sync.push") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ env: selectedEnv, code_keys: codeKeys })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
alert(result.message);
|
||||
location.reload();
|
||||
} else {
|
||||
alert(result.error || 'Push 실패');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('오류 발생: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Pull
|
||||
async function pullSelected() {
|
||||
const checkboxes = document.querySelectorAll('input[name="remote_code"]:checked');
|
||||
if (checkboxes.length === 0) {
|
||||
alert('Pull할 코드를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const codeKeys = Array.from(checkboxes).map(cb => cb.value);
|
||||
|
||||
if (!confirm(`${codeKeys.length}개 코드를 로컬로 Pull 하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ route("common-codes.sync.pull") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ env: selectedEnv, code_keys: codeKeys })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
alert(result.message);
|
||||
location.reload();
|
||||
} else {
|
||||
alert(result.error || 'Pull 실패');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('오류 발생: ' + e.message);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
@@ -21,6 +21,8 @@
|
||||
use App\Http\Controllers\Lab\StrategyController;
|
||||
use App\Http\Controllers\MenuController;
|
||||
use App\Http\Controllers\MenuSyncController;
|
||||
use App\Http\Controllers\CommonCodeSyncController;
|
||||
use App\Http\Controllers\CategorySyncController;
|
||||
use App\Http\Controllers\PermissionController;
|
||||
use App\Http\Controllers\PostController;
|
||||
use App\Http\Controllers\ProfileController;
|
||||
@@ -65,6 +67,16 @@
|
||||
Route::post('/import', [MenuSyncController::class, 'import']);
|
||||
});
|
||||
|
||||
Route::prefix('common-code-sync')->group(function () {
|
||||
Route::get('/export', [CommonCodeSyncController::class, 'export']);
|
||||
Route::post('/import', [CommonCodeSyncController::class, 'import']);
|
||||
});
|
||||
|
||||
Route::prefix('category-sync')->group(function () {
|
||||
Route::get('/export', [CategorySyncController::class, 'export']);
|
||||
Route::post('/import', [CategorySyncController::class, 'import']);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authenticated Routes (인증 필요)
|
||||
@@ -310,6 +322,13 @@
|
||||
Route::post('/{id}/toggle', [CommonCodeController::class, 'toggle'])->name('toggle');
|
||||
Route::post('/{id}/copy', [CommonCodeController::class, 'copy'])->name('copy');
|
||||
Route::delete('/{id}', [CommonCodeController::class, 'destroy'])->name('destroy');
|
||||
|
||||
// 공통코드 동기화
|
||||
Route::prefix('sync')->name('sync.')->group(function () {
|
||||
Route::get('/', [CommonCodeSyncController::class, 'index'])->name('index');
|
||||
Route::post('/push', [CommonCodeSyncController::class, 'push'])->name('push');
|
||||
Route::post('/pull', [CommonCodeSyncController::class, 'pull'])->name('pull');
|
||||
});
|
||||
});
|
||||
|
||||
// 문서양식 관리
|
||||
@@ -341,7 +360,16 @@
|
||||
Route::post('/api/business-card-ocr', [BusinessCardOcrController::class, 'process'])->name('api.business-card-ocr');
|
||||
|
||||
// 카테고리 관리
|
||||
Route::get('/categories', [CategoryController::class, 'index'])->name('categories.index');
|
||||
Route::prefix('categories')->name('categories.')->group(function () {
|
||||
Route::get('/', [CategoryController::class, 'index'])->name('index');
|
||||
|
||||
// 카테고리 동기화
|
||||
Route::prefix('sync')->name('sync.')->group(function () {
|
||||
Route::get('/', [CategorySyncController::class, 'index'])->name('index');
|
||||
Route::post('/push', [CategorySyncController::class, 'push'])->name('push');
|
||||
Route::post('/pull', [CategorySyncController::class, 'pull'])->name('pull');
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user