diff --git a/app/Http/Controllers/CommonCodeController.php b/app/Http/Controllers/CommonCodeController.php new file mode 100644 index 00000000..094bfa86 --- /dev/null +++ b/app/Http/Controllers/CommonCodeController.php @@ -0,0 +1,373 @@ + '품목유형', + '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' => '폭시공비', + ]; + + /** + * 공통코드 관리 페이지 + */ + public function index(Request $request): View|Response + { + // HTMX 요청 시 전체 페이지 리로드 + if ($request->header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('common-codes.index')); + } + + $tenantId = session('selected_tenant_id'); + $tenant = $tenantId ? Tenant::find($tenantId) : null; + $isHQ = $tenant?->tenant_type === 'HQ'; + + // 선택된 코드 그룹 (기본: item_type) + $selectedGroup = $request->get('group', 'item_type'); + + // 코드 그룹 목록 (실제 존재하는 그룹만) + $existingGroups = CommonCode::query() + ->select('code_group') + ->distinct() + ->pluck('code_group') + ->toArray(); + + $codeGroups = collect(self::CODE_GROUP_LABELS) + ->filter(fn($label, $group) => in_array($group, $existingGroups)) + ->toArray(); + + // 선택된 그룹의 코드 목록 + $globalCodes = collect(); + $tenantCodes = collect(); + + if ($tenantId && isset($codeGroups[$selectedGroup])) { + // 글로벌 코드 (tenant_id IS NULL) + $globalCodes = CommonCode::query() + ->whereNull('tenant_id') + ->where('code_group', $selectedGroup) + ->orderBy('sort_order') + ->get(); + + // 테넌트 코드 + $tenantCodes = CommonCode::query() + ->where('tenant_id', $tenantId) + ->where('code_group', $selectedGroup) + ->orderBy('sort_order') + ->get(); + } + + return view('common-codes.index', [ + 'tenant' => $tenant, + 'isHQ' => $isHQ, + 'codeGroups' => $codeGroups, + 'selectedGroup' => $selectedGroup, + 'globalCodes' => $globalCodes, + 'tenantCodes' => $tenantCodes, + ]); + } + + /** + * 코드 저장 (신규/수정) + */ + public function store(Request $request): RedirectResponse + { + $tenantId = session('selected_tenant_id'); + $tenant = $tenantId ? Tenant::find($tenantId) : null; + + if (! $tenantId) { + return redirect()->back()->with('error', '테넌트를 먼저 선택해주세요.'); + } + + $isHQ = $tenant?->tenant_type === 'HQ'; + $isGlobal = $request->boolean('is_global'); + + // 글로벌 코드는 HQ만 생성 가능 + if ($isGlobal && ! $isHQ) { + return redirect()->back()->with('error', '글로벌 코드는 본사만 생성할 수 있습니다.'); + } + + $validated = $request->validate([ + 'code_group' => 'required|string|max:50', + 'code' => 'required|string|max:50', + 'name' => 'required|string|max:100', + 'sort_order' => 'nullable|integer|min:0|max:9999', + 'attributes' => 'nullable|json', + 'is_global' => 'nullable|boolean', + ]); + + // 중복 체크 + $targetTenantId = $isGlobal ? null : $tenantId; + $exists = CommonCode::query() + ->where('tenant_id', $targetTenantId) + ->where('code_group', $validated['code_group']) + ->where('code', $validated['code']) + ->exists(); + + if ($exists) { + return redirect()->back() + ->with('error', '이미 존재하는 코드입니다.') + ->withInput(); + } + + CommonCode::create([ + 'tenant_id' => $targetTenantId, + 'code_group' => $validated['code_group'], + 'code' => $validated['code'], + 'name' => $validated['name'], + 'sort_order' => $validated['sort_order'] ?? 0, + 'attributes' => $validated['attributes'] ? json_decode($validated['attributes'], true) : null, + 'is_active' => true, + ]); + + return redirect() + ->route('common-codes.index', ['group' => $validated['code_group']]) + ->with('success', '코드가 추가되었습니다.'); + } + + /** + * 코드 수정 + */ + public function update(Request $request, int $id): RedirectResponse|JsonResponse + { + $tenantId = session('selected_tenant_id'); + $tenant = $tenantId ? Tenant::find($tenantId) : null; + + if (! $tenantId) { + if ($request->ajax()) { + return response()->json(['error' => '테넌트를 먼저 선택해주세요.'], 400); + } + return redirect()->back()->with('error', '테넌트를 먼저 선택해주세요.'); + } + + $isHQ = $tenant?->tenant_type === 'HQ'; + + $code = CommonCode::find($id); + if (! $code) { + if ($request->ajax()) { + return response()->json(['error' => '코드를 찾을 수 없습니다.'], 404); + } + return redirect()->back()->with('error', '코드를 찾을 수 없습니다.'); + } + + // 권한 체크: 글로벌 코드는 HQ만, 테넌트 코드는 해당 테넌트만 + if ($code->tenant_id === null && ! $isHQ) { + if ($request->ajax()) { + return response()->json(['error' => '글로벌 코드는 본사만 수정할 수 있습니다.'], 403); + } + return redirect()->back()->with('error', '글로벌 코드는 본사만 수정할 수 있습니다.'); + } + + if ($code->tenant_id !== null && $code->tenant_id !== $tenantId) { + if ($request->ajax()) { + return response()->json(['error' => '다른 테넌트의 코드는 수정할 수 없습니다.'], 403); + } + return redirect()->back()->with('error', '다른 테넌트의 코드는 수정할 수 없습니다.'); + } + + $validated = $request->validate([ + 'name' => 'sometimes|required|string|max:100', + 'sort_order' => 'sometimes|nullable|integer|min:0|max:9999', + 'attributes' => 'sometimes|nullable|json', + 'is_active' => 'sometimes|boolean', + ]); + + // 필드별 업데이트 + if (isset($validated['name'])) { + $code->name = $validated['name']; + } + if (array_key_exists('sort_order', $validated)) { + $code->sort_order = $validated['sort_order'] ?? 0; + } + if (array_key_exists('attributes', $validated)) { + $code->attributes = $validated['attributes'] ? json_decode($validated['attributes'], true) : null; + } + if (isset($validated['is_active'])) { + $code->is_active = $validated['is_active']; + } + + $code->save(); + + if ($request->ajax()) { + return response()->json(['success' => true, 'message' => '수정되었습니다.']); + } + + return redirect() + ->route('common-codes.index', ['group' => $code->code_group]) + ->with('success', '코드가 수정되었습니다.'); + } + + /** + * 활성화 토글 (AJAX) + */ + public function toggle(Request $request, int $id): JsonResponse + { + $tenantId = session('selected_tenant_id'); + $tenant = $tenantId ? Tenant::find($tenantId) : null; + + if (! $tenantId) { + return response()->json(['error' => '테넌트를 먼저 선택해주세요.'], 400); + } + + $isHQ = $tenant?->tenant_type === 'HQ'; + + $code = CommonCode::find($id); + if (! $code) { + return response()->json(['error' => '코드를 찾을 수 없습니다.'], 404); + } + + // 권한 체크 + if ($code->tenant_id === null && ! $isHQ) { + return response()->json(['error' => '글로벌 코드는 본사만 수정할 수 있습니다.'], 403); + } + + if ($code->tenant_id !== null && $code->tenant_id !== $tenantId) { + return response()->json(['error' => '다른 테넌트의 코드는 수정할 수 없습니다.'], 403); + } + + $code->is_active = ! $code->is_active; + $code->save(); + + return response()->json([ + 'success' => true, + 'is_active' => $code->is_active, + 'message' => $code->is_active ? '활성화되었습니다.' : '비활성화되었습니다.', + ]); + } + + /** + * 글로벌 코드를 테넌트용으로 복사 + */ + public function copy(Request $request, int $id): RedirectResponse|JsonResponse + { + $tenantId = session('selected_tenant_id'); + + if (! $tenantId) { + if ($request->ajax()) { + return response()->json(['error' => '테넌트를 먼저 선택해주세요.'], 400); + } + return redirect()->back()->with('error', '테넌트를 먼저 선택해주세요.'); + } + + $globalCode = CommonCode::whereNull('tenant_id')->find($id); + if (! $globalCode) { + if ($request->ajax()) { + return response()->json(['error' => '글로벌 코드를 찾을 수 없습니다.'], 404); + } + return redirect()->back()->with('error', '글로벌 코드를 찾을 수 없습니다.'); + } + + // 이미 복사된 코드가 있는지 확인 + $exists = CommonCode::query() + ->where('tenant_id', $tenantId) + ->where('code_group', $globalCode->code_group) + ->where('code', $globalCode->code) + ->exists(); + + if ($exists) { + if ($request->ajax()) { + return response()->json(['error' => '이미 복사된 코드가 있습니다.'], 400); + } + return redirect()->back()->with('error', '이미 복사된 코드가 있습니다.'); + } + + // 복사 + CommonCode::create([ + 'tenant_id' => $tenantId, + 'code_group' => $globalCode->code_group, + 'code' => $globalCode->code, + 'name' => $globalCode->name, + 'sort_order' => $globalCode->sort_order, + 'attributes' => $globalCode->attributes, + 'is_active' => true, + ]); + + if ($request->ajax()) { + return response()->json(['success' => true, 'message' => '코드가 복사되었습니다.']); + } + + return redirect() + ->route('common-codes.index', ['group' => $globalCode->code_group]) + ->with('success', '글로벌 코드가 테넌트용으로 복사되었습니다.'); + } + + /** + * 코드 삭제 (테넌트 코드만) + */ + public function destroy(Request $request, int $id): RedirectResponse|JsonResponse + { + $tenantId = session('selected_tenant_id'); + $tenant = $tenantId ? Tenant::find($tenantId) : null; + + if (! $tenantId) { + if ($request->ajax()) { + return response()->json(['error' => '테넌트를 먼저 선택해주세요.'], 400); + } + return redirect()->back()->with('error', '테넌트를 먼저 선택해주세요.'); + } + + $isHQ = $tenant?->tenant_type === 'HQ'; + + $code = CommonCode::find($id); + if (! $code) { + if ($request->ajax()) { + return response()->json(['error' => '코드를 찾을 수 없습니다.'], 404); + } + return redirect()->back()->with('error', '코드를 찾을 수 없습니다.'); + } + + // 글로벌 코드 삭제는 HQ만 + if ($code->tenant_id === null && ! $isHQ) { + if ($request->ajax()) { + return response()->json(['error' => '글로벌 코드는 본사만 삭제할 수 있습니다.'], 403); + } + return redirect()->back()->with('error', '글로벌 코드는 본사만 삭제할 수 있습니다.'); + } + + // 다른 테넌트 코드 삭제 불가 + if ($code->tenant_id !== null && $code->tenant_id !== $tenantId) { + if ($request->ajax()) { + return response()->json(['error' => '다른 테넌트의 코드는 삭제할 수 없습니다.'], 403); + } + return redirect()->back()->with('error', '다른 테넌트의 코드는 삭제할 수 없습니다.'); + } + + $codeGroup = $code->code_group; + $code->delete(); + + if ($request->ajax()) { + return response()->json(['success' => true, 'message' => '코드가 삭제되었습니다.']); + } + + return redirect() + ->route('common-codes.index', ['group' => $codeGroup]) + ->with('success', '코드가 삭제되었습니다.'); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/MenuSyncController.php b/app/Http/Controllers/MenuSyncController.php new file mode 100644 index 00000000..a99e5cad --- /dev/null +++ b/app/Http/Controllers/MenuSyncController.php @@ -0,0 +1,467 @@ +where('tenant_id', $this->tenantId) + ->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('menus.sync.index')); + } + + $environments = $this->getEnvironments(); + $selectedEnv = $request->get('env', 'dev'); + + // 로컬 메뉴 조회 (트리 구조) + $localMenus = $this->getMenuTree(); + + // 원격 메뉴 조회 + $remoteMenus = []; + $remoteError = null; + + if (! empty($environments[$selectedEnv]['url'])) { + try { + $remoteMenus = $this->fetchRemoteMenus($environments[$selectedEnv]); + } catch (\Exception $e) { + $remoteError = $e->getMessage(); + } + } + + // 차이점 계산 + $diff = $this->calculateDiff($localMenus, $remoteMenus); + + return view('menus.sync', [ + 'environments' => $environments, + 'selectedEnv' => $selectedEnv, + 'localMenus' => $localMenus, + 'remoteMenus' => $remoteMenus, + 'remoteError' => $remoteError, + 'diff' => $diff, + ]); + } + + /** + * 환경 설정 저장 + */ + public function saveSettings(Request $request): JsonResponse + { + $validated = $request->validate([ + 'environments' => 'required|array', + 'environments.*.name' => 'required|string|max:50', + 'environments.*.url' => 'nullable|url|max:255', + 'environments.*.api_key' => 'nullable|string|max:255', + ]); + + TenantSetting::withoutGlobalScopes()->updateOrCreate( + [ + 'tenant_id' => $this->tenantId, + 'setting_group' => 'menu_sync', + 'setting_key' => 'environments', + ], + [ + 'setting_value' => $validated['environments'], + 'description' => '메뉴 동기화 환경 설정', + ] + ); + + return response()->json(['success' => true, 'message' => '환경 설정이 저장되었습니다.']); + } + + /** + * 메뉴 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); + } + + $menus = $this->getMenuTree(); + + return response()->json([ + 'success' => true, + 'environment' => config('app.env'), + 'exported_at' => now()->toIso8601String(), + 'menus' => $menus, + ]); + } + + /** + * 메뉴 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([ + 'menus' => 'required|array', + 'menus.*.name' => 'required|string|max:100', + 'menus.*.url' => 'required|string|max:255', + 'menus.*.icon' => 'nullable|string|max:50', + 'menus.*.sort_order' => 'nullable|integer', + 'menus.*.options' => 'nullable|array', + 'menus.*.parent_name' => 'nullable|string', // 부모 메뉴 이름으로 연결 + 'menus.*.children' => 'nullable|array', + ]); + + $imported = 0; + foreach ($validated['menus'] as $menuData) { + $this->importMenu($menuData); + $imported++; + } + + return response()->json([ + 'success' => true, + 'message' => "{$imported}개 메뉴가 동기화되었습니다.", + 'imported' => $imported, + ]); + } + + /** + * 개별 메뉴 Push (로컬 → 원격) + */ + public function push(Request $request): JsonResponse + { + $validated = $request->validate([ + 'env' => 'required|string|in:dev,prod', + 'menu_ids' => 'required|array|min:1', + 'menu_ids.*' => 'integer', + ]); + + $environments = $this->getEnvironments(); + $env = $environments[$validated['env']] ?? null; + + if (! $env || empty($env['url'])) { + return response()->json(['error' => '환경 설정이 없습니다.'], 400); + } + + // 선택된 메뉴 조회 + $menus = Menu::withoutGlobalScopes() + ->where('tenant_id', $this->tenantId) + ->whereIn('id', $validated['menu_ids']) + ->get(); + + if ($menus->isEmpty()) { + return response()->json(['error' => '선택된 메뉴가 없습니다.'], 400); + } + + // 메뉴 데이터 준비 (부모 정보 포함) + $menuData = $menus->map(function ($menu) { + $parent = $menu->parent_id + ? Menu::withoutGlobalScopes()->find($menu->parent_id) + : null; + + return [ + 'name' => $menu->name, + 'url' => $menu->url, + 'icon' => $menu->icon, + 'sort_order' => $menu->sort_order, + 'options' => $menu->options, + 'parent_name' => $parent?->name, + 'children' => $this->getChildrenData($menu->id), + ]; + })->toArray(); + + // 원격 서버로 전송 + try { + $response = Http::withHeaders([ + 'X-Menu-Sync-Key' => $env['api_key'], + 'Accept' => 'application/json', + ])->post(rtrim($env['url'], '/') . '/menu-sync/import', [ + 'menus' => $menuData, + ]); + + if ($response->successful()) { + return response()->json([ + 'success' => true, + 'message' => $response->json('message', '동기화 완료'), + ]); + } + + 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', + 'menu_names' => 'required|array|min:1', + 'menu_names.*' => 'string', + ]); + + $environments = $this->getEnvironments(); + $env = $environments[$validated['env']] ?? null; + + if (! $env || empty($env['url'])) { + return response()->json(['error' => '환경 설정이 없습니다.'], 400); + } + + // 원격 메뉴 조회 + try { + $remoteMenus = $this->fetchRemoteMenus($env); + } catch (\Exception $e) { + return response()->json(['error' => $e->getMessage()], 500); + } + + // 선택된 메뉴만 필터링 + $selectedMenus = $this->filterMenusByName($remoteMenus, $validated['menu_names']); + + if (empty($selectedMenus)) { + return response()->json(['error' => '선택된 메뉴를 찾을 수 없습니다.'], 400); + } + + // 로컬에 Import + $imported = 0; + foreach ($selectedMenus as $menuData) { + $this->importMenu($menuData); + $imported++; + } + + return response()->json([ + 'success' => true, + 'message' => "{$imported}개 메뉴가 동기화되었습니다.", + ]); + } + + /** + * 연결 테스트 + */ + public function testConnection(Request $request): JsonResponse + { + $validated = $request->validate([ + 'url' => 'required|url', + 'api_key' => 'required|string', + ]); + + try { + $response = Http::withHeaders([ + 'X-Menu-Sync-Key' => $validated['api_key'], + 'Accept' => 'application/json', + ])->timeout(10)->get(rtrim($validated['url'], '/') . '/menu-sync/export'); + + if ($response->successful()) { + $data = $response->json(); + return response()->json([ + 'success' => true, + 'message' => '연결 성공', + 'environment' => $data['environment'] ?? 'unknown', + 'menu_count' => count($data['menus'] ?? []), + ]); + } + + return response()->json([ + 'success' => false, + 'message' => 'API 오류: ' . $response->status(), + ]); + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => '연결 실패: ' . $e->getMessage(), + ]); + } + } + + /** + * 메뉴 트리 조회 + */ + private function getMenuTree(?int $parentId = null): array + { + $menus = Menu::withoutGlobalScopes() + ->where('tenant_id', $this->tenantId) + ->where('parent_id', $parentId) + ->orderBy('sort_order') + ->get(); + + return $menus->map(function ($menu) { + return [ + 'id' => $menu->id, + 'name' => $menu->name, + 'url' => $menu->url, + 'icon' => $menu->icon, + 'sort_order' => $menu->sort_order, + 'options' => $menu->options, + 'children' => $this->getMenuTree($menu->id), + ]; + })->toArray(); + } + + /** + * 자식 메뉴 데이터 조회 + */ + private function getChildrenData(int $parentId): array + { + $children = Menu::withoutGlobalScopes() + ->where('tenant_id', $this->tenantId) + ->where('parent_id', $parentId) + ->orderBy('sort_order') + ->get(); + + return $children->map(function ($menu) { + return [ + 'name' => $menu->name, + 'url' => $menu->url, + 'icon' => $menu->icon, + 'sort_order' => $menu->sort_order, + 'options' => $menu->options, + 'children' => $this->getChildrenData($menu->id), + ]; + })->toArray(); + } + + /** + * 원격 메뉴 조회 + */ + private function fetchRemoteMenus(array $env): array + { + $response = Http::withHeaders([ + 'X-Menu-Sync-Key' => $env['api_key'], + 'Accept' => 'application/json', + ])->timeout(10)->get(rtrim($env['url'], '/') . '/menu-sync/export'); + + if (! $response->successful()) { + throw new \Exception('API 오류: HTTP ' . $response->status()); + } + + $data = $response->json(); + if (! isset($data['menus'])) { + throw new \Exception('잘못된 응답 형식'); + } + + return $data['menus']; + } + + /** + * 메뉴 차이점 계산 + */ + private function calculateDiff(array $localMenus, array $remoteMenus): array + { + $localNames = $this->flattenMenuNames($localMenus); + $remoteNames = $this->flattenMenuNames($remoteMenus); + + return [ + 'local_only' => array_diff($localNames, $remoteNames), + 'remote_only' => array_diff($remoteNames, $localNames), + 'both' => array_intersect($localNames, $remoteNames), + ]; + } + + /** + * 메뉴 이름 평탄화 + */ + private function flattenMenuNames(array $menus, string $prefix = ''): array + { + $names = []; + foreach ($menus as $menu) { + $fullName = $prefix ? "{$prefix} > {$menu['name']}" : $menu['name']; + $names[] = $menu['name']; + if (! empty($menu['children'])) { + $names = array_merge($names, $this->flattenMenuNames($menu['children'], $fullName)); + } + } + return $names; + } + + /** + * 이름으로 메뉴 필터링 + */ + private function filterMenusByName(array $menus, array $names): array + { + $result = []; + foreach ($menus as $menu) { + if (in_array($menu['name'], $names)) { + $result[] = $menu; + } + if (! empty($menu['children'])) { + $result = array_merge($result, $this->filterMenusByName($menu['children'], $names)); + } + } + return $result; + } + + /** + * 메뉴 Import + */ + private function importMenu(array $data, ?int $parentId = null): void + { + // 부모 메뉴 찾기 + if (! $parentId && ! empty($data['parent_name'])) { + $parent = Menu::withoutGlobalScopes() + ->where('tenant_id', $this->tenantId) + ->where('name', $data['parent_name']) + ->first(); + $parentId = $parent?->id; + } + + // 기존 메뉴 찾기 또는 생성 + $menu = Menu::withoutGlobalScopes()->updateOrCreate( + [ + 'tenant_id' => $this->tenantId, + 'name' => $data['name'], + ], + [ + 'parent_id' => $parentId, + 'url' => $data['url'], + 'icon' => $data['icon'] ?? null, + 'sort_order' => $data['sort_order'] ?? 0, + 'options' => $data['options'] ?? null, + 'is_active' => true, + ] + ); + + // 자식 메뉴 처리 + if (! empty($data['children'])) { + foreach ($data['children'] as $child) { + $this->importMenu($child, $menu->id); + } + } + } +} \ No newline at end of file diff --git a/app/Http/Controllers/TenantSettingController.php b/app/Http/Controllers/TenantSettingController.php new file mode 100644 index 00000000..1d342363 --- /dev/null +++ b/app/Http/Controllers/TenantSettingController.php @@ -0,0 +1,127 @@ +header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('tenant-settings.index')); + } + + $tenantId = session('selected_tenant_id'); + $tenant = $tenantId ? Tenant::find($tenantId) : null; + + // 품목유형 목록 (common_codes에서 조회) + $itemTypeLabels = $tenantId ? CommonCode::getItemTypes($tenantId) : []; + + // 테넌트 미선택 시 빈 설정 + $stockSettings = collect(); + if ($tenantId) { + $stockSettings = TenantSetting::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('setting_group', 'stock') + ->get() + ->keyBy('setting_key'); + } + + // 설정값 (저장된 값이 없으면 빈 배열/기본값) + $hasSettings = $stockSettings->isNotEmpty(); + $stockItemTypes = $stockSettings->get('stock_item_types')?->setting_value ?? []; + $defaultSafetyStock = $stockSettings->get('default_safety_stock')?->setting_value ?? 10; + $lowStockAlert = $stockSettings->get('low_stock_alert')?->setting_value ?? true; + + return view('tenant-settings.index', [ + 'tenant' => $tenant, + 'hasSettings' => $hasSettings, + 'itemTypeLabels' => $itemTypeLabels, + 'stockItemTypes' => $stockItemTypes, + 'defaultSafetyStock' => $defaultSafetyStock, + 'lowStockAlert' => $lowStockAlert, + ]); + } + + /** + * 설정 저장 + */ + public function store(Request $request): RedirectResponse + { + $tenantId = session('selected_tenant_id'); + + if (! $tenantId) { + return redirect()->route('tenant-settings.index') + ->with('error', '테넌트를 먼저 선택해주세요.'); + } + + $userId = Auth::id(); + + // 유효한 품목유형 목록 조회 + $validItemTypes = array_keys(CommonCode::getItemTypes($tenantId)); + + $validated = $request->validate([ + 'stock_item_types' => 'required|array|min:1', + 'stock_item_types.*' => 'string|in:'.implode(',', $validItemTypes), + 'default_safety_stock' => 'required|integer|min:0|max:9999', + 'low_stock_alert' => 'nullable|boolean', + ]); + + // 재고관리 품목유형 저장 + TenantSetting::withoutGlobalScopes()->updateOrCreate( + [ + 'tenant_id' => $tenantId, + 'setting_group' => 'stock', + 'setting_key' => 'stock_item_types', + ], + [ + 'setting_value' => $validated['stock_item_types'], + 'description' => '재고관리 대상 품목유형', + 'updated_by' => $userId, + ] + ); + + // 기본 안전재고 저장 + TenantSetting::withoutGlobalScopes()->updateOrCreate( + [ + 'tenant_id' => $tenantId, + 'setting_group' => 'stock', + 'setting_key' => 'default_safety_stock', + ], + [ + 'setting_value' => (int) $validated['default_safety_stock'], + 'description' => '안전재고 기본값', + 'updated_by' => $userId, + ] + ); + + // 재고부족 알림 저장 + TenantSetting::withoutGlobalScopes()->updateOrCreate( + [ + 'tenant_id' => $tenantId, + 'setting_group' => 'stock', + 'setting_key' => 'low_stock_alert', + ], + [ + 'setting_value' => isset($validated['low_stock_alert']) && $validated['low_stock_alert'], + 'description' => '재고부족 알림 활성화', + 'updated_by' => $userId, + ] + ); + + return redirect()->route('tenant-settings.index') + ->with('success', '설정이 저장되었습니다.'); + } +} \ No newline at end of file diff --git a/app/Models/Products/CommonCode.php b/app/Models/Products/CommonCode.php new file mode 100644 index 00000000..fccfb52f --- /dev/null +++ b/app/Models/Products/CommonCode.php @@ -0,0 +1,49 @@ + 'array', + 'is_active' => 'boolean', + ]; + + /** + * 테넌트별 품목유형 목록 조회 (tenant_id 또는 글로벌) + * + * @return array [code => name] + */ + public static function getItemTypes(int $tenantId): array + { + return static::query() + ->where(function ($query) use ($tenantId) { + $query->where('tenant_id', $tenantId) + ->orWhereNull('tenant_id'); + }) + ->where('code_group', 'item_type') + ->where('is_active', true) + ->orderBy('sort_order') + ->pluck('name', 'code') + ->toArray(); + } +} \ No newline at end of file diff --git a/app/Models/Tenants/TenantSetting.php b/app/Models/Tenants/TenantSetting.php new file mode 100644 index 00000000..b52b9775 --- /dev/null +++ b/app/Models/Tenants/TenantSetting.php @@ -0,0 +1,36 @@ + 'array', + ]; +} \ No newline at end of file diff --git a/database/seeders/MngMenuSeeder.php b/database/seeders/MngMenuSeeder.php index 6d92cb8d..f146e39b 100644 --- a/database/seeders/MngMenuSeeder.php +++ b/database/seeders/MngMenuSeeder.php @@ -143,6 +143,14 @@ protected function seedMainMenus(): void 'sort_order' => $systemSubOrder++, 'options' => ['route_name' => 'menus.index', 'section' => 'main'], ]); + $this->createMenu([ + 'parent_id' => $systemGroup->id, + 'name' => '메뉴 동기화', + 'url' => '/menus/sync', + 'icon' => 'refresh', + 'sort_order' => $systemSubOrder++, + 'options' => ['route_name' => 'menus.sync.index', 'section' => 'main'], + ]); // ======================================== // 권한 관리 그룹 @@ -239,6 +247,22 @@ protected function seedMainMenus(): void 'sort_order' => $prodSubOrder++, 'options' => ['route_name' => 'quote-formulas.index', 'section' => 'main'], ]); + $this->createMenu([ + 'parent_id' => $productionGroup->id, + 'name' => '재고 설정', + 'url' => '/tenant-settings', + 'icon' => 'cog', + 'sort_order' => $prodSubOrder++, + 'options' => ['route_name' => 'tenant-settings.index', 'section' => 'main'], + ]); + $this->createMenu([ + 'parent_id' => $productionGroup->id, + 'name' => '공통코드 관리', + 'url' => '/common-codes', + 'icon' => 'collection', + 'sort_order' => $prodSubOrder++, + 'options' => ['route_name' => 'common-codes.index', 'section' => 'main'], + ]); $this->createMenu([ 'parent_id' => $productionGroup->id, 'name' => '제품 관리', diff --git a/resources/views/common-codes/index.blade.php b/resources/views/common-codes/index.blade.php new file mode 100644 index 00000000..5b4a4242 --- /dev/null +++ b/resources/views/common-codes/index.blade.php @@ -0,0 +1,454 @@ +@extends('layouts.app') + +@section('title', '공통코드 관리') + +@section('content') +
+ +
+
+

공통코드 관리

+

+ @if($tenant) + + {{ $tenant->company_name }} + @if($isHQ) + 본사 + @endif + 코드를 관리합니다. + + @else + 테넌트별 공통코드를 관리합니다. + @endif +

+
+ @if($tenant) + + @endif +
+ + + @if(!$tenant) +
+ + + + 헤더에서 테넌트를 선택해주세요. +
+ @else + + @if(session('success')) +
+ + + + {{ session('success') }} +
+ @endif + @if(session('error')) +
+ + + + {{ session('error') }} +
+ @endif + + +
+
+ +
+
+ + +
+ +
+
+
+ + + + + +

글로벌 코드

+ ({{ $globalCodes->count() }}) +
+ @if(!$isHQ) + 본사만 편집 가능 + @endif +
+
+ + + + + + + + + + + + @forelse($globalCodes as $code) + + + + + + + + @empty + + + + @endforelse + +
코드이름순서활성액션
+ {{ $code->code }} + {{ $code->name }}{{ $code->sort_order }} + @if($isHQ) + + @else + + {{ $code->is_active ? 'ON' : 'OFF' }} + + @endif + +
+ @if($isHQ) + + @endif + + @if($isHQ) + + @endif +
+
+ 글로벌 코드가 없습니다. +
+
+
+ + +
+
+
+ + + + + +

테넌트 코드

+ ({{ $tenantCodes->count() }}) +
+
+
+ + + + + + + + + + + + @forelse($tenantCodes as $code) + + + + + + + + @empty + + + + @endforelse + +
코드이름순서활성액션
+ {{ $code->code }} + {{ $code->name }}{{ $code->sort_order }} + + +
+ + +
+
+ 테넌트 코드가 없습니다.
+ 글로벌 코드를 복사하거나 새로 추가하세요. +
+
+
+
+ @endif +
+ + + + + + + + + + + + +@endsection + +@push('scripts') + +@endpush \ No newline at end of file diff --git a/resources/views/menus/_sync_menu_item.blade.php b/resources/views/menus/_sync_menu_item.blade.php new file mode 100644 index 00000000..66ac41e5 --- /dev/null +++ b/resources/views/menus/_sync_menu_item.blade.php @@ -0,0 +1,99 @@ +@php + $isLocalOnly = $side === 'local' && in_array($menu['name'], $diff['local_only'] ?? []); + $isRemoteOnly = $side === 'remote' && in_array($menu['name'], $diff['remote_only'] ?? []); + $isBoth = in_array($menu['name'], $diff['both'] ?? []); + + $bgClass = ''; + $badgeClass = ''; + $badgeText = ''; + + if ($isLocalOnly) { + $bgClass = 'bg-green-50 border-green-200'; + $badgeClass = 'bg-green-100 text-green-700'; + $badgeText = 'NEW'; + } elseif ($isRemoteOnly) { + $bgClass = 'bg-purple-50 border-purple-200'; + $badgeClass = 'bg-purple-100 text-purple-700'; + $badgeText = 'NEW'; + } else { + $bgClass = 'bg-white hover:bg-gray-50'; + } + + $paddingLeft = ($depth * 1.5) + 0.5; +@endphp + +
+
+ + @if($side === 'local') + + @else + + @endif + + + @if(!empty($menu['icon'])) + + @switch($menu['icon']) + @case('home') + + + + @break + @case('folder') + + + + @break + @case('cog') + + + + + @break + @case('cube') + + + + @break + @case('collection') + + + + @break + @default + + + + @endswitch + + @endif + + + {{ $menu['name'] }} + + + @if($badgeText) + {{ $badgeText }} + @endif + + + @if($menu['url'] !== '#') + {{ Str::limit($menu['url'], 20) }} + @endif +
+
+ + +@if(!empty($menu['children'])) + @foreach($menu['children'] as $child) + @include('menus._sync_menu_item', [ + 'menu' => $child, + 'side' => $side, + 'diff' => $diff, + 'depth' => $depth + 1 + ]) + @endforeach +@endif \ No newline at end of file diff --git a/resources/views/menus/sync.blade.php b/resources/views/menus/sync.blade.php new file mode 100644 index 00000000..fa6ad3b2 --- /dev/null +++ b/resources/views/menus/sync.blade.php @@ -0,0 +1,450 @@ +@extends('layouts.app') + +@section('title', '메뉴 동기화') + +@section('content') +
+ +
+
+

메뉴 동기화

+

로컬과 원격 환경 간 메뉴를 동기화합니다.

+
+ +
+ + + + + @if($remoteError) +
+ + + + 원격 서버 연결 실패: {{ $remoteError }} +
+ @endif + + @if(empty($environments[$selectedEnv]['url'])) +
+ + + + 환경 설정에서 {{ $environments[$selectedEnv]['name'] ?? strtoupper($selectedEnv) }} 서버 URL을 설정해주세요. +
+ @endif + + + @if(!empty($environments[$selectedEnv]['url']) && !$remoteError) +
+
+
+ + + + + +
+

로컬에만 있음

+

{{ count($diff['local_only']) }}

+
+
+
+
+
+ + + + + +
+

양쪽 모두

+

{{ count($diff['both']) }}

+
+
+
+
+
+ + + + + +
+

원격에만 있음

+

{{ count($diff['remote_only']) }}

+
+
+
+
+ @endif + + +
+ +
+
+
+ + + + + +

로컬 (현재)

+ ({{ count($localMenus) }}개 그룹) +
+ @if(!empty($environments[$selectedEnv]['url']) && !$remoteError) + + @endif +
+
+ @foreach($localMenus as $menu) + @include('menus._sync_menu_item', [ + 'menu' => $menu, + 'side' => 'local', + 'diff' => $diff, + 'depth' => 0 + ]) + @endforeach +
+
+ + +
+
+
+ + + + + +

{{ $environments[$selectedEnv]['name'] ?? strtoupper($selectedEnv) }}

+ ({{ count($remoteMenus) }}개 그룹) +
+ @if(!empty($environments[$selectedEnv]['url']) && !$remoteError) + + @endif +
+
+ @if(empty($environments[$selectedEnv]['url'])) +
+

환경을 설정해주세요

+
+ @elseif($remoteError) +
+

연결 실패

+
+ @elseif(empty($remoteMenus)) +
+

메뉴가 없습니다

+
+ @else + @foreach($remoteMenus as $menu) + @include('menus._sync_menu_item', [ + 'menu' => $menu, + 'side' => 'remote', + 'diff' => $diff, + 'depth' => 0 + ]) + @endforeach + @endif +
+
+
+
+ + + +@endsection + +@push('scripts') + +@endpush \ No newline at end of file diff --git a/resources/views/tenant-settings/index.blade.php b/resources/views/tenant-settings/index.blade.php new file mode 100644 index 00000000..82febc3e --- /dev/null +++ b/resources/views/tenant-settings/index.blade.php @@ -0,0 +1,181 @@ +@extends('layouts.app') + +@section('title', '테넌트 설정') + +@section('content') +
+ +
+
+

테넌트 설정

+

+ @if($tenant) + + {{ $tenant->company_name }} + 설정을 관리합니다. + + @else + 테넌트별 시스템 설정을 관리합니다. + @endif +

+
+
+ + + @if(!$tenant) +
+ + + + 헤더에서 테넌트를 선택해주세요. +
+ @elseif(!$hasSettings) +
+ + + + 이 테넌트는 아직 설정이 저장되지 않았습니다. 재고관리할 품목유형을 선택해주세요. +
+ @endif + + + @if(session('success')) +
+ + + + {{ session('success') }} +
+ @endif + + + @if(session('error')) +
+ + + + {{ session('error') }} +
+ @endif + + +
+ @csrf + +
+ +
+
+
+ + + +
+
+

재고관리 품목유형

+

재고현황에 표시할 품목유형

+
+
+ + @error('stock_item_types') +
{{ $message }}
+ @enderror + +
+ @foreach($itemTypeLabels as $code => $label) + + @endforeach +
+
+ + +
+
+
+ + + +
+
+

안전재고 설정

+

품목별 설정 없을 시 적용

+
+
+ +
+
+ +
+ + +
+ @error('default_safety_stock') +
{{ $message }}
+ @enderror +
+
+
+ + +
+
+
+ + + +
+
+

알림 설정

+

재고 관련 알림

+
+
+ +
+
+
+

재고부족 알림

+

안전재고 이하 시 알림

+
+ +
+
+
+
+ + +
+ +
+
+
+@endsection \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index 4ba12cc2..97e609af 100644 --- a/routes/web.php +++ b/routes/web.php @@ -24,6 +24,9 @@ use App\Http\Controllers\RoleController; use App\Http\Controllers\RolePermissionController; use App\Http\Controllers\TenantController; +use App\Http\Controllers\TenantSettingController; +use App\Http\Controllers\CommonCodeController; +use App\Http\Controllers\MenuSyncController; use App\Http\Controllers\UserController; use Illuminate\Support\Facades\Route; @@ -48,6 +51,16 @@ Route::post('/auth/refresh-session', [LoginController::class, 'refreshSession']) ->name('auth.refresh-session'); +/* +|-------------------------------------------------------------------------- +| Menu Sync API (외부 서버 호출용, API Key 인증) +|-------------------------------------------------------------------------- +*/ +Route::prefix('menu-sync')->group(function () { + Route::get('/export', [MenuSyncController::class, 'export']); + Route::post('/import', [MenuSyncController::class, 'import']); +}); + /* |-------------------------------------------------------------------------- | Authenticated Routes (인증 필요) @@ -107,6 +120,15 @@ Route::get('/global', [MenuController::class, 'globalIndex'])->name('global.index'); Route::get('/global/create', [MenuController::class, 'globalCreate'])->name('global.create'); Route::get('/global/{id}/edit', [MenuController::class, 'globalEdit'])->name('global.edit'); + + // 메뉴 동기화 + Route::prefix('sync')->name('sync.')->group(function () { + Route::get('/', [MenuSyncController::class, 'index'])->name('index'); + Route::post('/settings', [MenuSyncController::class, 'saveSettings'])->name('settings'); + Route::post('/test', [MenuSyncController::class, 'testConnection'])->name('test'); + Route::post('/push', [MenuSyncController::class, 'push'])->name('push'); + Route::post('/pull', [MenuSyncController::class, 'pull'])->name('pull'); + }); }); // 권한 관리 (Blade 화면만) @@ -269,6 +291,22 @@ Route::get('/simulator', [QuoteFormulaController::class, 'simulator'])->name('simulator'); }); + // 테넌트 설정 (재고 설정 등) + Route::prefix('tenant-settings')->name('tenant-settings.')->group(function () { + Route::get('/', [TenantSettingController::class, 'index'])->name('index'); + Route::post('/', [TenantSettingController::class, 'store'])->name('store'); + }); + + // 공통코드 관리 + Route::prefix('common-codes')->name('common-codes.')->group(function () { + Route::get('/', [CommonCodeController::class, 'index'])->name('index'); + Route::post('/', [CommonCodeController::class, 'store'])->name('store'); + Route::put('/{id}', [CommonCodeController::class, 'update'])->name('update'); + 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'); + }); + /* |-------------------------------------------------------------------------- | 바로빌 Routes