fix: [tenant-console] 테넌트 콘솔 분리작업

- 라우트 파라미터 충돌 수정 (Layer 4 확장)
- TenantScope 글로벌 스코프가 테넌트 콘솔에서 올바른 tenant_id 사용하도록 수정
- 감사로그 상세 테넌트 콘솔 레이아웃 적용
- 테넌트 전환: 모달 → 컨텍스트 메뉴로 이동, 스타일 변경 (녹색+전환아이콘)
- 테넌트 전환 이벤트를 openTenantConsole 호출로 통일
- 사이드바 스타일 메인과 통일 + 리포트 주의사항 정리
This commit is contained in:
2026-03-12 18:58:34 +09:00
parent a077bd5710
commit 8da1702e47
71 changed files with 1179 additions and 429 deletions

View File

@@ -0,0 +1,101 @@
<?php
namespace App\Helpers;
/**
* 테넌트 컨텍스트 헬퍼
*
* 테넌트 콘솔(새창)에서는 URL의 tenantId를,
* 메인 관리자 페이지에서는 세션의 selected_tenant_id를 사용
*/
class TenantHelper
{
/**
* 메인 라우트명 → 테넌트 콘솔 라우트명 매핑
* (패턴이 다른 것만 명시, 나머지는 자동 매핑)
*/
private static array $routeMap = [
'item-management.index' => 'tenant-console.production.items.index',
'item-fields.index' => 'tenant-console.production.item-fields.index',
'quote-formulas.index' => 'tenant-console.production.quote-formulas.index',
'quote-formulas.create' => 'tenant-console.production.quote-formulas.create',
'quote-formulas.edit' => 'tenant-console.production.quote-formulas.edit',
'quote-formulas.simulator' => 'tenant-console.production.quote-formulas.simulator',
'quote-formulas.categories.index' => 'tenant-console.production.quote-formulas.categories.index',
];
/**
* 현재 유효한 테넌트 ID 반환
*
* 우선순위:
* 1. request attributes (tenant_console_id) - 테넌트 콘솔 새창
* 2. session (selected_tenant_id) - 메인 관리자 페이지
*/
public static function getEffectiveTenantId(?int $default = null): ?int
{
// 테넌트 콘솔 컨텍스트 우선
$consoleTenantId = request()->attributes->get('tenant_console_id');
if ($consoleTenantId) {
return (int) $consoleTenantId;
}
// 메인 관리자 페이지: 세션 기반
$sessionTenantId = session('selected_tenant_id');
if ($sessionTenantId && $sessionTenantId !== 'all') {
return (int) $sessionTenantId;
}
return $default;
}
/**
* 세션의 raw 값 반환 (all 포함)
* 메인 관리자에서 "전체" 선택 여부 판단 시 사용
*/
public static function getRawTenantId(): mixed
{
$consoleTenantId = request()->attributes->get('tenant_console_id');
if ($consoleTenantId) {
return (int) $consoleTenantId;
}
return session('selected_tenant_id');
}
/**
* 테넌트 콘솔(새창) 컨텍스트인지 확인
*/
public static function isTenantConsole(): bool
{
return (bool) request()->attributes->get('tenant_console_id');
}
/**
* 컨텍스트 인식 라우트 URL 생성
*
* 테넌트 콘솔이면 tenant-console.* 라우트로,
* 메인이면 기존 라우트로 URL 생성
*
* @param string $name 메인 라우트명 (예: 'common-codes.index')
* @param array $parameters 추가 파라미터
*/
public static function route(string $name, array $parameters = []): string
{
if (! self::isTenantConsole()) {
return route($name, $parameters);
}
$tenantId = self::getEffectiveTenantId();
$consoleName = self::$routeMap[$name] ?? 'tenant-console.'.$name;
return route($consoleName, array_merge(['tenantId' => $tenantId], $parameters));
}
/**
* 컨텍스트 인식 리다이렉트
*/
public static function redirect(string $name, array $parameters = []): \Illuminate\Http\RedirectResponse
{
return redirect(self::route($name, $parameters));
}
}

View File

@@ -22,7 +22,7 @@ public function index(Request $request): View|JsonResponse
\Log::info('Board request all:', $request->all());
\Log::info('Board request query:', $request->query());
$filters = $request->only(['search', 'board_type', 'is_active', 'trashed', 'sort_by', 'sort_direction']);
$filters = $request->only(['search', 'board_type', 'is_active', 'trashed', 'sort_by', 'sort_direction', 'tenant_id']);
\Log::info('Board filters:', $filters);
@@ -30,7 +30,15 @@ public function index(Request $request): View|JsonResponse
// HTMX 요청이면 HTML 파셜 반환
if ($request->header('HX-Request')) {
return view('boards.partials.table', compact('boards'));
$consoleTenantId = $request->get('tenant_console_id');
$isTenantConsole = ! empty($consoleTenantId);
// TenantHelper가 테넌트 콘솔 컨텍스트를 인식하도록 request attribute 설정
if ($isTenantConsole) {
$request->attributes->set('tenant_console_id', $consoleTenantId);
}
return view('boards.partials.table', compact('boards', 'isTenantConsole', 'consoleTenantId'));
}
// 일반 요청이면 JSON

View File

@@ -18,7 +18,7 @@ class CategoryApiController extends Controller
*/
public function list(Request $request): JsonResponse
{
$tenantId = session('selected_tenant_id');
$tenantId = $request->input('tenant_id') ?: session('selected_tenant_id');
if (! $tenantId) {
return response()->json(['success' => false, 'data' => []]);
}
@@ -44,7 +44,7 @@ public function list(Request $request): JsonResponse
*/
public function tree(Request $request): View
{
$tenantId = session('selected_tenant_id');
$tenantId = $request->input('tenant_id') ?: session('selected_tenant_id');
$codeGroup = $request->input('code_group', 'product');
$categories = collect();
@@ -82,7 +82,7 @@ public function show(int $id): JsonResponse
*/
public function store(Request $request): JsonResponse
{
$tenantId = session('selected_tenant_id');
$tenantId = $request->input('tenant_id') ?: session('selected_tenant_id');
if (! $tenantId) {
return response()->json(['success' => false, 'message' => '테넌트를 선택해주세요.'], 400);
}
@@ -218,10 +218,10 @@ public function toggle(int $id): JsonResponse
/**
* 테넌트 카테고리를 글로벌로 복사 (HQ 또는 슈퍼관리자)
*/
public function promoteToGlobal(int $id): JsonResponse
public function promoteToGlobal(Request $request, int $id): JsonResponse
{
$user = Auth::user();
$tenantId = session('selected_tenant_id');
$tenantId = $request->input('tenant_id') ?: session('selected_tenant_id');
$tenant = $tenantId ? \App\Models\Tenants\Tenant::find($tenantId) : null;
$isHQ = $tenant?->tenant_type === 'HQ';
$isSuperAdmin = $user?->isSuperAdmin() ?? false;

View File

@@ -21,6 +21,11 @@ public function __construct(DepartmentPermissionService $departmentPermissionSer
*/
protected function getEffectiveTenantId(Request $request, ?int $departmentId = null): ?int
{
// 명시적 tenant_id 파라미터 우선 (테넌트 콘솔 API 호출)
if ($request->has('tenant_id') && $request->input('tenant_id')) {
return (int) $request->input('tenant_id');
}
$sessionTenantId = session('selected_tenant_id');
// 세션에 특정 테넌트가 선택되어 있으면 그것을 사용

View File

@@ -27,7 +27,14 @@ public function index(Request $request)
// HTMX 요청 시 부분 HTML 반환
if ($request->header('HX-Request')) {
return view('permissions.partials.table', compact('permissions'));
$consoleTenantId = $request->get('tenant_console_id');
$isTenantConsole = ! empty($consoleTenantId);
if ($isTenantConsole) {
$request->attributes->set('tenant_console_id', $consoleTenantId);
}
return view('permissions.partials.table', compact('permissions', 'isTenantConsole', 'consoleTenantId'));
}
// 일반 요청 시 JSON 반환

View File

@@ -27,7 +27,14 @@ public function index(Request $request): JsonResponse|\Illuminate\Contracts\View
// HTMX 요청 시 HTML 반환
if ($request->header('HX-Request')) {
return view('roles.partials.table', compact('roles'));
$consoleTenantId = $request->get('tenant_console_id');
$isTenantConsole = ! empty($consoleTenantId);
if ($isTenantConsole) {
$request->attributes->set('tenant_console_id', $consoleTenantId);
}
return view('roles.partials.table', compact('roles', 'isTenantConsole', 'consoleTenantId'));
}
// 일반 요청 시 JSON 반환

View File

@@ -21,6 +21,11 @@ public function __construct(RolePermissionService $rolePermissionService)
*/
protected function getEffectiveTenantId(Request $request, ?int $roleId = null): ?int
{
// 명시적 tenant_id 파라미터 우선 (테넌트 콘솔 API 호출)
if ($request->has('tenant_id') && $request->input('tenant_id')) {
return (int) $request->input('tenant_id');
}
$sessionTenantId = session('selected_tenant_id');
// 세션에 특정 테넌트가 선택되어 있으면 그것을 사용

View File

@@ -29,8 +29,13 @@ public function index(Request $request): View
$query->where('action', $request->action);
}
// 테넌트 필터
if ($request->filled('tenant_id')) {
// 테넌트 필터 (테넌트 콘솔에서는 해당 테넌트만)
$consoleTenantId = \App\Helpers\TenantHelper::isTenantConsole()
? \App\Helpers\TenantHelper::getEffectiveTenantId()
: null;
if ($consoleTenantId) {
$query->where('tenant_id', $consoleTenantId);
} elseif ($request->filled('tenant_id')) {
$query->where('tenant_id', $request->tenant_id);
}
@@ -68,9 +73,12 @@ public function index(Request $request): View
/**
* 감사 로그 상세
* tenant-console/{tenantId}/audit-logs/{id} 에서
* $id에 tenantId가 들어오는 문제 방지 → route('id')로 명시적 추출
*/
public function show(int $id): View
{
$id = (int) (request()->route('id') ?? $id);
$log = AuditLog::with(['tenant', 'actor'])->findOrFail($id);
return view('audit-logs.show', compact('log'));

View File

@@ -2,6 +2,8 @@
namespace App\Http\Controllers;
use App\Helpers\TenantHelper;
use App\Models\Tenants\Tenant;
use App\Services\BoardService;
use Illuminate\View\View;
@@ -17,7 +19,10 @@ public function __construct(
public function index(): View
{
$boardTypes = $this->boardService->getBoardTypes();
$currentTenant = auth()->user()->currentTenant();
$consoleTenantId = TenantHelper::getEffectiveTenantId();
$currentTenant = TenantHelper::isTenantConsole()
? Tenant::find($consoleTenantId)
: auth()->user()->currentTenant();
return view('boards.index', compact('boardTypes', 'currentTenant'));
}
@@ -32,9 +37,12 @@ public function create(): View
/**
* 게시판 수정 화면
* tenant-console/{tenantId}/boards/{id}/edit 에서
* $id에 tenantId가 들어오는 문제 방지 → route('id')로 명시적 추출
*/
public function edit(int $id): View
{
$id = (int) (request()->route('id') ?? $id);
// systemOnly=false: 테넌트 게시판도 수정 가능
$board = $this->boardService->getBoardById($id, true, false);

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Helpers\TenantHelper;
use App\Models\Category;
use App\Models\GlobalCategory;
use App\Models\Tenants\Tenant;
@@ -18,7 +19,7 @@ class CategoryController extends Controller
*/
private function getCustomGroupLabels(): array
{
$tenantId = session('selected_tenant_id');
$tenantId = TenantHelper::getEffectiveTenantId();
if (! $tenantId) {
return [];
}
@@ -37,7 +38,7 @@ private function getCustomGroupLabels(): array
*/
public function storeGroup(Request $request): RedirectResponse
{
$tenantId = session('selected_tenant_id');
$tenantId = TenantHelper::getEffectiveTenantId();
if (! $tenantId) {
return redirect()->back()->with('error', '테넌트를 먼저 선택해주세요.');
}
@@ -83,7 +84,7 @@ public function storeGroup(Request $request): RedirectResponse
);
return redirect()
->route('categories.index', ['group' => $groupCode])
->to(TenantHelper::route('categories.index', ['group' => $groupCode]))
->with('success', "'{$groupLabel}' 그룹이 추가되었습니다.");
}
@@ -94,10 +95,10 @@ public function index(Request $request): View|Response
{
// HTMX 요청 시 전체 페이지 리로드
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('categories.index'));
return response('', 200)->header('HX-Redirect', TenantHelper::route('categories.index'));
}
$tenantId = session('selected_tenant_id');
$tenantId = TenantHelper::getEffectiveTenantId();
$tenant = $tenantId ? Tenant::find($tenantId) : null;
$isHQ = $tenantId == 1;

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Helpers\TenantHelper;
use App\Models\Products\CommonCode;
use App\Models\Tenants\Tenant;
use App\Models\Tenants\TenantSetting;
@@ -21,10 +22,10 @@ public function index(Request $request): View|Response
{
// HTMX 요청 시 전체 페이지 리로드
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('common-codes.index'));
return response('', 200)->header('HX-Redirect', TenantHelper::route('common-codes.index'));
}
$tenantId = session('selected_tenant_id');
$tenantId = TenantHelper::getEffectiveTenantId();
$tenant = $tenantId ? Tenant::find($tenantId) : null;
$isHQ = $tenant?->tenant_type === 'HQ';
@@ -132,7 +133,7 @@ public function index(Request $request): View|Response
*/
private function getCustomGroupLabels(): array
{
$tenantId = session('selected_tenant_id');
$tenantId = TenantHelper::getEffectiveTenantId();
if (! $tenantId) {
return [];
}
@@ -151,7 +152,7 @@ private function getCustomGroupLabels(): array
*/
public function storeGroup(Request $request): RedirectResponse
{
$tenantId = session('selected_tenant_id');
$tenantId = TenantHelper::getEffectiveTenantId();
if (! $tenantId) {
return redirect()->back()->with('error', '테넌트를 먼저 선택해주세요.');
}
@@ -197,7 +198,7 @@ public function storeGroup(Request $request): RedirectResponse
);
return redirect()
->route('common-codes.index', ['group' => $groupCode])
->to(TenantHelper::route('common-codes.index', ['group' => $groupCode]))
->with('success', "'{$groupLabel}' 그룹이 추가되었습니다.");
}
@@ -206,7 +207,7 @@ public function storeGroup(Request $request): RedirectResponse
*/
public function store(Request $request): RedirectResponse
{
$tenantId = session('selected_tenant_id');
$tenantId = TenantHelper::getEffectiveTenantId();
$tenant = $tenantId ? Tenant::find($tenantId) : null;
if (! $tenantId) {
@@ -255,7 +256,7 @@ public function store(Request $request): RedirectResponse
]);
return redirect()
->route('common-codes.index', ['group' => $validated['code_group']])
->to(TenantHelper::route('common-codes.index', ['group' => $validated['code_group']]))
->with('success', '코드가 추가되었습니다.');
}
@@ -264,7 +265,8 @@ public function store(Request $request): RedirectResponse
*/
public function update(Request $request, int $id): RedirectResponse|JsonResponse
{
$tenantId = session('selected_tenant_id');
$id = (int) (request()->route('id') ?? $id);
$tenantId = TenantHelper::getEffectiveTenantId();
$tenant = $tenantId ? Tenant::find($tenantId) : null;
if (! $tenantId) {
@@ -337,7 +339,7 @@ public function update(Request $request, int $id): RedirectResponse|JsonResponse
}
return redirect()
->route('common-codes.index', ['group' => $code->code_group])
->to(TenantHelper::route('common-codes.index', ['group' => $code->code_group]))
->with('success', '코드가 수정되었습니다.');
}
@@ -346,7 +348,8 @@ public function update(Request $request, int $id): RedirectResponse|JsonResponse
*/
public function toggle(Request $request, int $id): JsonResponse
{
$tenantId = session('selected_tenant_id');
$id = (int) (request()->route('id') ?? $id);
$tenantId = TenantHelper::getEffectiveTenantId();
$tenant = $tenantId ? Tenant::find($tenantId) : null;
if (! $tenantId) {
@@ -388,7 +391,7 @@ public function toggle(Request $request, int $id): JsonResponse
*/
public function bulkPromoteToGlobal(Request $request): RedirectResponse|JsonResponse
{
$tenant = session('selected_tenant_id') ? Tenant::find(session('selected_tenant_id')) : null;
$tenant = TenantHelper::getEffectiveTenantId() ? Tenant::find(TenantHelper::getEffectiveTenantId()) : null;
$isHQ = $tenant?->tenant_type === 'HQ';
$isSuperAdmin = auth()->user()?->isSuperAdmin() ?? false;
@@ -462,7 +465,7 @@ public function bulkPromoteToGlobal(Request $request): RedirectResponse|JsonResp
}
return redirect()
->route('common-codes.index', ['group' => $codeGroup ?? 'item_type'])
->to(TenantHelper::route('common-codes.index', ['group' => $codeGroup ?? 'item_type']))
->with('success', $message);
}
@@ -471,7 +474,8 @@ public function bulkPromoteToGlobal(Request $request): RedirectResponse|JsonResp
*/
public function copy(Request $request, int $id): RedirectResponse|JsonResponse
{
$tenantId = session('selected_tenant_id');
$id = (int) (request()->route('id') ?? $id);
$tenantId = TenantHelper::getEffectiveTenantId();
if (! $tenantId) {
if ($request->ajax()) {
@@ -521,7 +525,7 @@ public function copy(Request $request, int $id): RedirectResponse|JsonResponse
}
return redirect()
->route('common-codes.index', ['group' => $globalCode->code_group])
->to(TenantHelper::route('common-codes.index', ['group' => $globalCode->code_group]))
->with('success', '글로벌 코드가 테넌트용으로 복사되었습니다.');
}
@@ -530,7 +534,7 @@ public function copy(Request $request, int $id): RedirectResponse|JsonResponse
*/
public function bulkCopy(Request $request): RedirectResponse|JsonResponse
{
$tenantId = session('selected_tenant_id');
$tenantId = TenantHelper::getEffectiveTenantId();
if (! $tenantId) {
if ($request->ajax()) {
@@ -619,7 +623,7 @@ public function bulkCopy(Request $request): RedirectResponse|JsonResponse
}
return redirect()
->route('common-codes.index', ['group' => $codeGroup ?? 'item_type'])
->to(TenantHelper::route('common-codes.index', ['group' => $codeGroup ?? 'item_type']))
->with('success', $message);
}
@@ -628,7 +632,8 @@ public function bulkCopy(Request $request): RedirectResponse|JsonResponse
*/
public function promoteToGlobal(Request $request, int $id): RedirectResponse|JsonResponse
{
$tenantId = session('selected_tenant_id');
$id = (int) (request()->route('id') ?? $id);
$tenantId = TenantHelper::getEffectiveTenantId();
$tenant = $tenantId ? Tenant::find($tenantId) : null;
$isHQ = $tenant?->tenant_type === 'HQ';
$isSuperAdmin = auth()->user()?->isSuperAdmin() ?? false;
@@ -680,7 +685,7 @@ public function promoteToGlobal(Request $request, int $id): RedirectResponse|Jso
}
return redirect()
->route('common-codes.index', ['group' => $tenantCode->code_group])
->to(TenantHelper::route('common-codes.index', ['group' => $tenantCode->code_group]))
->with('success', '테넌트 코드가 글로벌로 복사되었습니다.');
}
@@ -689,7 +694,8 @@ public function promoteToGlobal(Request $request, int $id): RedirectResponse|Jso
*/
public function destroy(Request $request, int $id): RedirectResponse|JsonResponse
{
$tenantId = session('selected_tenant_id');
$id = (int) (request()->route('id') ?? $id);
$tenantId = TenantHelper::getEffectiveTenantId();
$tenant = $tenantId ? Tenant::find($tenantId) : null;
if (! $tenantId) {
@@ -742,7 +748,7 @@ public function destroy(Request $request, int $id): RedirectResponse|JsonRespons
}
return redirect()
->route('common-codes.index', ['group' => $codeGroup])
->to(TenantHelper::route('common-codes.index', ['group' => $codeGroup]))
->with('success', '코드가 삭제되었습니다.');
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Helpers\TenantHelper;
use App\Models\Tenants\Department;
use App\Models\Tenants\Tenant;
use App\Services\DepartmentPermissionService;
@@ -21,7 +22,7 @@ public function __construct(DepartmentPermissionService $departmentPermissionSer
*/
public function index(Request $request)
{
$tenantId = session('selected_tenant_id');
$tenantId = TenantHelper::getRawTenantId();
// 부서 목록 조회
$departmentsQuery = Department::query()->orderBy('tenant_id')->orderBy('sort_order')->orderBy('name');

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Helpers\TenantHelper;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
@@ -15,7 +16,7 @@ class ItemManagementController extends Controller
public function index(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('item-management.index'));
return response('', 200)->header('HX-Redirect', TenantHelper::route('item-management.index'));
}
return view('item-management.index');

View File

@@ -26,9 +26,12 @@ public function create()
/**
* 권한 수정 화면
* tenant-console/{tenantId}/permissions/{id}/edit 에서
* $id에 tenantId가 들어오는 문제 방지 → route('id')로 명시적 추출
*/
public function edit($id)
{
$id = request()->route('id') ?? $id;
$tenants = Tenant::orderBy('company_name')->get();
return view('permissions.edit', compact('id', 'tenants'));

View File

@@ -49,9 +49,12 @@ public function create(): View
/**
* 역할 수정 화면
* tenant-console/{tenantId}/roles/{id}/edit 에서
* $id에 tenantId가 들어오는 문제 방지 → route('id')로 명시적 추출
*/
public function edit(int $id): View
{
$id = (int) (request()->route('id') ?? $id);
$role = $this->roleService->getRoleById($id);
if (! $role) {

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Helpers\TenantHelper;
use App\Models\Role;
use App\Models\Tenants\Tenant;
use App\Services\RolePermissionService;
@@ -21,7 +22,7 @@ public function __construct(RolePermissionService $rolePermissionService)
*/
public function index(Request $request)
{
$tenantId = session('selected_tenant_id');
$tenantId = TenantHelper::getRawTenantId();
// 역할 목록 조회
$rolesQuery = Role::query()->orderBy('tenant_id')->orderBy('name');

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers\System;
use App\Helpers\TenantHelper;
use App\Http\Controllers\Controller;
use App\Models\System\AiConfig;
use Illuminate\Http\JsonResponse;
@@ -17,7 +18,7 @@ class AiConfigController extends Controller
public function index(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('system.ai-config.index'));
return response('', 200)->header('HX-Redirect', TenantHelper::route('system.ai-config.index'));
}
// AI 설정 (gemini, claude, openai)

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers\System;
use App\Helpers\TenantHelper;
use App\Http\Controllers\Controller;
use App\Models\System\Holiday;
use Illuminate\Http\JsonResponse;
@@ -14,7 +15,7 @@ class HolidayController extends Controller
public function index(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('system.holidays.index'));
return response('', 200)->header('HX-Redirect', TenantHelper::route('system.holidays.index'));
}
return view('system.holidays.index');
@@ -22,7 +23,7 @@ public function index(Request $request): View|Response
public function list(Request $request): JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
$tenantId = TenantHelper::getEffectiveTenantId(1);
$year = $request->input('year', now()->year);
$holidays = Holiday::forTenant($tenantId)
@@ -59,7 +60,7 @@ public function store(Request $request): JsonResponse
'memo' => 'nullable|string|max:500',
]);
$tenantId = session('selected_tenant_id', 1);
$tenantId = TenantHelper::getEffectiveTenantId(1);
// 중복 체크: 같은 날짜 + 같은 이름
$exists = Holiday::forTenant($tenantId)
@@ -94,7 +95,7 @@ public function store(Request $request): JsonResponse
public function update(Request $request, int $id): JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
$tenantId = TenantHelper::getEffectiveTenantId(1);
$holiday = Holiday::forTenant($tenantId)->findOrFail($id);
$request->validate([
@@ -123,7 +124,7 @@ public function update(Request $request, int $id): JsonResponse
public function destroy(int $id): JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
$tenantId = TenantHelper::getEffectiveTenantId(1);
$holiday = Holiday::forTenant($tenantId)->findOrFail($id);
$holiday->delete();
@@ -144,7 +145,7 @@ public function bulkStore(Request $request): JsonResponse
'holidays.*.isRecurring' => 'boolean',
]);
$tenantId = session('selected_tenant_id', 1);
$tenantId = TenantHelper::getEffectiveTenantId(1);
$count = 0;
$skipped = 0;
@@ -194,7 +195,7 @@ public function destroyByYear(Request $request): JsonResponse
'year' => 'required|integer|min:2000|max:2100',
]);
$tenantId = session('selected_tenant_id', 1);
$tenantId = TenantHelper::getEffectiveTenantId(1);
$year = $request->input('year');
$count = Holiday::forTenant($tenantId)

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Controllers;
use App\Models\Tenants\Tenant;
use Illuminate\Http\Request;
use Illuminate\View\View;
class TenantConsoleController extends Controller
{
/**
* 테넌트 콘솔 대시보드 (새창 진입점)
*/
public function index(Request $request, int $tenantId): View
{
$tenant = $request->attributes->get('tenant_console');
return view('tenant-console.index', [
'tenant' => $tenant,
'tenantId' => $tenantId,
]);
}
}

View File

@@ -55,23 +55,4 @@ public function edit(int $id): View
return view('tenants.edit', compact('tenant', 'displayCompanyName'));
}
/**
* 테넌트 전환 (기존 기능 유지)
*/
public function switch(Request $request)
{
$tenantId = $request->input('tenant_id');
if ($tenantId === 'all') {
// "전체 보기" 대신 사용자의 HQ 테넌트로 설정
$hqTenant = auth()->user()->getHQTenant();
if ($hqTenant) {
$request->session()->put('selected_tenant_id', $hqTenant->id);
}
} else {
$request->session()->put('selected_tenant_id', $tenantId);
}
return redirect()->back();
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Helpers\TenantHelper;
use App\Services\UserPermissionService;
use Illuminate\Http\Request;
@@ -19,7 +20,7 @@ public function __construct(UserPermissionService $userPermissionService)
*/
public function index(Request $request)
{
$tenantId = session('selected_tenant_id');
$tenantId = TenantHelper::getRawTenantId();
$selectedUserId = $request->query('user_id');
// 테넌트 미선택 또는 전체 선택 시

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Http\Middleware;
use App\Models\Tenants\Tenant;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class SetTenantContext
{
/**
* 테넌트 콘솔 새창에서 URL의 tenantId를 컨텍스트로 설정
* 메인 창의 세션에는 영향을 주지 않음
*/
public function handle(Request $request, Closure $next): Response
{
$tenantId = $request->route('tenantId');
if (! $tenantId) {
abort(404, '테넌트 ID가 필요합니다.');
}
$tenant = Tenant::find($tenantId);
if (! $tenant) {
abort(404, '테넌트를 찾을 수 없습니다.');
}
// 요청 범위에서만 테넌트 컨텍스트 설정 (세션 변경 없음)
$request->attributes->set('tenant_console_id', $tenantId);
$request->attributes->set('tenant_console', $tenant);
$request->attributes->set('tenant_id', (int) $tenantId);
// 뷰에서 사용할 수 있도록 공유
view()->share('consoleTenant', $tenant);
view()->share('consoleTenantId', $tenantId);
view()->share('isTenantConsole', true);
return $next($request);
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class SetTenantFromApiRequest
{
/**
* 테넌트 콘솔에서 API 호출 시 tenant_id 파라미터를 세션에 반영
* (요청 처리 중에만 적용, 요청 종료 후 원래 세션 값 복원)
*/
public function handle(Request $request, Closure $next): Response
{
$requestTenantId = $request->input('tenant_id');
$consoleTenantId = $request->input('tenant_console_id');
// 테넌트 콘솔에서 온 요청이면 세션을 임시로 설정
if ($consoleTenantId && $requestTenantId) {
$originalTenantId = session('selected_tenant_id');
// 요청 범위에서만 세션 덮어쓰기
session(['selected_tenant_id' => (int) $requestTenantId]);
// 요청 속성에도 테넌트 콘솔 컨텍스트 설정
$request->attributes->set('tenant_console_id', $consoleTenantId);
$response = $next($request);
// 원래 세션 값 복원
if ($originalTenantId !== null) {
session(['selected_tenant_id' => $originalTenantId]);
} else {
session()->forget('selected_tenant_id');
}
return $response;
}
return $next($request);
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Services;
use App\Helpers\TenantHelper;
use App\Models\Boards\Board;
use App\Models\Boards\BoardSetting;
use App\Models\Tenants\Tenant;
@@ -101,9 +102,14 @@ public function getBoardById(int $id, bool $withTrashed = false, bool $systemOnl
*/
public function createBoard(array $data): Board
{
// 시스템 게시판 설정
$data['is_system'] = true;
$data['tenant_id'] = null;
// 테넌트 콘솔이면 테넌트 게시판, 아니면 시스템 게시판
if (TenantHelper::isTenantConsole()) {
$data['is_system'] = false;
$data['tenant_id'] = TenantHelper::getEffectiveTenantId();
} else {
$data['is_system'] = true;
$data['tenant_id'] = null;
}
$data['created_by'] = auth()->id();
$board = Board::create($data);
@@ -283,11 +289,15 @@ public function reorderBoardFields(int $boardId, array $fieldIds): bool
*/
public function getBoardTypes(): array
{
return Board::systemOnly()
->whereNotNull('board_type')
->distinct()
->pluck('board_type')
->toArray();
$query = Board::query()->whereNotNull('board_type');
if (TenantHelper::isTenantConsole()) {
$query->where('tenant_id', TenantHelper::getEffectiveTenantId());
} else {
$query->systemOnly();
}
return $query->distinct()->pluck('board_type')->toArray();
}
// =========================================================================
@@ -411,28 +421,37 @@ public function getAllBoards(array $filters = [], int $perPage = 15): LengthAwar
->withCount(['fields', 'posts'])
->withTrashed();
// 헤더에서 선택한 테넌트 기준
$selectedTenantId = session('selected_tenant_id');
if ($selectedTenantId && $selectedTenantId !== 'all') {
// 선택된 테넌트가 본사(HQ)인지 확인
$isHQ = Tenant::where('id', $selectedTenantId)
->where('tenant_type', 'HQ')
->exists();
if ($isHQ) {
// 본사: 시스템 게시판 + 본사 테넌트 게시판
$query->where(function ($q) use ($selectedTenantId) {
$q->where('is_system', true)
->orWhere('tenant_id', $selectedTenantId);
});
} else {
// 일반 테넌트: 해당 테넌트 게시판만 (시스템 게시판 제외)
$query->where('tenant_id', $selectedTenantId);
}
// 명시적 tenant_id 필터 (테넌트 콘솔 API 호출에서 전달)
if (! empty($filters['tenant_id'])) {
$query->where('tenant_id', $filters['tenant_id']);
} elseif (TenantHelper::isTenantConsole()) {
// 테넌트 콘솔인 경우: 해당 테넌트 게시판만
$consoleTenantId = TenantHelper::getEffectiveTenantId();
$query->where('tenant_id', $consoleTenantId);
} else {
// 전체 보기: 시스템 게시판만 (테넌트 게시판은 테넌트 선택 후 표시)
$query->where('is_system', true);
// 메인 관리자: 헤더에서 선택한 테넌트 기준
$selectedTenantId = TenantHelper::getRawTenantId();
if ($selectedTenantId && $selectedTenantId !== 'all') {
// 선택된 테넌트가 본사(HQ)인지 확인
$isHQ = Tenant::where('id', $selectedTenantId)
->where('tenant_type', 'HQ')
->exists();
if ($isHQ) {
// 본사: 시스템 게시판 + 본사 테넌트 게시판
$query->where(function ($q) use ($selectedTenantId) {
$q->where('is_system', true)
->orWhere('tenant_id', $selectedTenantId);
});
} else {
// 일반 테넌트: 해당 테넌트 게시판만 (시스템 게시판 제외)
$query->where('tenant_id', $selectedTenantId);
}
} else {
// 전체 보기: 시스템 게시판만 (테넌트 게시판은 테넌트 선택 후 표시)
$query->where('is_system', true);
}
}
// 검색 필터

View File

@@ -22,12 +22,14 @@ public function getPermissions(array $filters = [], int $perPage = 20): LengthAw
});
}
// 테넌트 필터
$selectedTenantId = session('selected_tenant_id');
if ($selectedTenantId && $selectedTenantId !== 'all') {
$query->where('tenant_id', $selectedTenantId);
} elseif (isset($filters['tenant_id'])) {
// 테넌트 필터 (명시적 tenant_id > 세션)
if (! empty($filters['tenant_id'])) {
$query->where('tenant_id', $filters['tenant_id']);
} else {
$selectedTenantId = session('selected_tenant_id');
if ($selectedTenantId && $selectedTenantId !== 'all') {
$query->where('tenant_id', $selectedTenantId);
}
}
// guard_name 필터

View File

@@ -15,8 +15,6 @@ class RoleService
*/
public function getRoles(array $filters = [], int $perPage = 15): LengthAwarePaginator
{
$tenantId = session('selected_tenant_id');
$query = Role::query()->withCount('permissions');
// Guard 필터링 (선택된 경우에만)
@@ -24,14 +22,19 @@ public function getRoles(array $filters = [], int $perPage = 15): LengthAwarePag
$query->where('guard_name', $filters['guard_name']);
}
// 전체 보기일 때 tenant 정보 로드
if (! $tenantId || $tenantId === 'all') {
$query->with('tenant');
}
// Tenant 필터링 (선택된 경우에만)
if ($tenantId && $tenantId !== 'all') {
$query->where('tenant_id', $tenantId);
// 테넌트 필터 (명시적 tenant_id > 세션)
if (! empty($filters['tenant_id'])) {
$query->where('tenant_id', $filters['tenant_id']);
} else {
$tenantId = session('selected_tenant_id');
// 전체 보기일 때 tenant 정보 로드
if (! $tenantId || $tenantId === 'all') {
$query->with('tenant');
}
// Tenant 필터링 (선택된 경우에만)
if ($tenantId && $tenantId !== 'all') {
$query->where('tenant_id', $tenantId);
}
}
// 검색 필터

View File

@@ -19,6 +19,7 @@
'hq.member' => \App\Http\Middleware\EnsureHQMember::class,
'super.admin' => \App\Http\Middleware\EnsureSuperAdmin::class,
'password.changed' => \App\Http\Middleware\EnsurePasswordChanged::class,
'set.tenant.context' => \App\Http\Middleware\SetTenantContext::class,
]);
// CSRF 토큰 검증 예외 (외부 API 호출용)
@@ -32,6 +33,7 @@
// web 미들웨어 그룹에 자동 재인증 추가
$middleware->appendToGroup('web', [
\App\Http\Middleware\AutoLoginViaRemember::class,
\App\Http\Middleware\SetTenantFromApiRequest::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {

View File

@@ -0,0 +1,136 @@
# 테넌트 콘솔 컨텍스트 수정 리포트
**작성일**: 2026-03-12
**브랜치**: sam-kkk
---
## 1. 문제 요약
테넌트 콘솔(`/tenant-console/{tenantId}/*`)에서:
- API 라우트 호출 시 `session('selected_tenant_id')`가 메인창 값으로 폴백 → 다른 테넌트 데이터 표시/생성
- 라우트 파라미터 충돌: `{tenantId}``{id}` 대신 컨트롤러에 전달
- `TenantScope` 글로벌 스코프가 테넌트 콘솔 컨텍스트를 인식하지 못함
- 일부 뷰에서 레이아웃/링크가 테넌트 콘솔 미대응
---
## 2. 솔루션 아키텍처 (5-Layer)
### Layer 1: Frontend 글로벌 인터셉터
**파일**: `resources/views/layouts/tenant-console.blade.php`
| 호출 방식 | 처리 |
|----------|------|
| HTMX | `htmx:configRequest`에서 `tenant_id` + `tenant_console_id` 자동 추가 |
| fetch GET | URL 쿼리스트링에 추가 |
| fetch POST/PUT/DELETE | JSON body에 추가 |
### Layer 2: API 미들웨어
**파일**: `app/Http/Middleware/SetTenantFromApiRequest.php`
`tenant_console_id` 감지 → 세션 임시 덮어쓰기 → 컨트롤러 실행 → 세션 복원
### Layer 3: 개별 컨트롤러/서비스 보강
명시적 `$request->input('tenant_id')` > `session()` 우선순위 적용.
### Layer 4: 라우트 파라미터 충돌 수정
`$id = (int) (request()->route('id') ?? $id);` 로 named parameter 명시적 추출.
| 컨트롤러 | 메서드 |
|----------|--------|
| `PermissionController` | `edit()` |
| `RoleController` | `edit()` |
| `BoardController` | `edit()` |
| `AuditLogController` | `show()` |
| `CommonCodeController` | `update()`, `toggle()`, `copy()`, `promoteToGlobal()`, `destroy()` |
### Layer 5: TenantScope 글로벌 스코프 연동
**파일**: `app/Http/Middleware/SetTenantContext.php`
`$request->attributes->set('tenant_id', (int) $tenantId)` 추가.
`TenantScope``$request->attributes->get('tenant_id')`로 올바른 tenantId 사용.
`BelongsToTenant` trait 사용 모델(Category 등)이 테넌트 콘솔에서 정상 동작.
---
## 3. 수정 파일 목록
### 신규 파일
| 파일 | 설명 |
|------|------|
| `app/Http/Middleware/SetTenantFromApiRequest.php` | API 요청 시 세션 임시 설정 |
| `app/Helpers/TenantHelper.php` | 테넌트 콘솔 컨텍스트 헬퍼 |
| `app/Http/Controllers/TenantConsoleController.php` | 테넌트 콘솔 컨트롤러 |
| `app/Http/Middleware/SetTenantContext.php` | 테넌트 콘솔 URL에서 컨텍스트 설정 |
| `resources/views/layouts/tenant-console.blade.php` | 테넌트 콘솔 레이아웃 |
### 미들웨어/인프라
| 파일 | 변경 |
|------|------|
| `bootstrap/app.php` | `SetTenantFromApiRequest` 미들웨어 등록 |
| `app/Http/Middleware/SetTenantContext.php` | `tenant_id` attribute 추가 (Layer 5) |
### 웹 컨트롤러 (Layer 4)
| 파일 | 변경 |
|------|------|
| `PermissionController.php` | `edit()`: route('id') 추출 |
| `RoleController.php` | `edit()`: route('id') 추출 |
| `BoardController.php` | `edit()`: route('id') 추출 |
| `AuditLogController.php` | `show()`: route('id') 추출 + `index()`: 테넌트 필터링 |
| `CommonCodeController.php` | 5개 메서드: route('id') 추출 |
### API 컨트롤러 (Layer 3)
| 파일 | 변경 |
|------|------|
| `Api/Admin/BoardController.php` | HTMX 응답에 `$isTenantConsole` 전달 |
| `Api/Admin/PermissionController.php` | HTMX 응답에 `$isTenantConsole` 전달 |
| `Api/Admin/RoleController.php` | HTMX 응답에 `$isTenantConsole` 전달 |
| `Api/Admin/RolePermissionController.php` | 명시적 `tenant_id` 우선 |
| `Api/Admin/DepartmentPermissionController.php` | 명시적 `tenant_id` 우선 |
| `Api/Admin/CategoryApiController.php` | 4개 메서드에 명시적 `tenant_id` 추가 |
### 서비스 레이어
| 파일 | 변경 |
|------|------|
| `BoardService.php` | 명시적 `tenant_id` > TenantHelper > 세션 |
| `PermissionService.php` | 명시적 `tenant_id` > 세션 |
| `RoleService.php` | 명시적 `tenant_id` > 세션 |
### 뷰
| 파일 | 변경 |
|------|------|
| `boards/index.blade.php` | fetch에 tenant 파라미터 추가 |
| `boards/create.blade.php` | 리다이렉트 URL + 자동 테넌트 선택 |
| `permissions/index.blade.php` | hidden input 추가 |
| `roles/index.blade.php` | hidden input 추가 |
| `audit-logs/index.blade.php` | 라우트 파라미터명 수정, `TenantHelper::route()` |
| `audit-logs/show.blade.php` | 테넌트 콘솔 레이아웃 적용 + 뒤로가기 링크 수정 |
| `quote-formulas/index.blade.php` | 테넌트 콘솔 시 `target="_blank"` 추가 |
| `system/alerts/index.blade.php` | `TenantHelper::route()` 변환 (4개소) |
---
## 4. 현재 처리 상태
| 항목 | 상태 | 설명 |
|------|------|------|
| FormData POST | ✅ 해결됨 | Layer 1 fetch 래퍼에서 JSON body 자동 처리. FormData 사용 페이지는 현재 없음 |
| 견적수식 하위 | ✅ 대응완료 | categories/simulator/create → `target="_blank"` 새창 처리로 정상 동작 |
| TenantScope 캐시 | ✅ 정상동작 | Layer 5에서 `tenant_id` attribute 설정 → static 캐시가 올바른 tenantId로 초기화됨 |
---
## 5. 자동 처리 현황 (Layer 2)
`SetTenantFromApiRequest` 미들웨어로 **80+ API 컨트롤러**의 `session('selected_tenant_id')` 호출이 자동 처리됨.
개별 수정 불필요한 주요 컨트롤러: `ItemFieldController`(18회), `DocumentApiController`(11회), `ApprovalApiController`(10회), `DocumentTemplateApiController`(7회) 등.
---
## 6. 설계 확인 사항
| 항목 | 동작 | 비고 |
|------|------|------|
| 공용/시스템 게시판 | 테넌트 콘솔에 미표시 | 의도된 설계 — `BoardService::getAllBoards()`에서 `where('tenant_id', $consoleTenantId)`로 해당 테넌트 게시판만 조회 |
| 카테고리 빈 목록 | 테넌트별 카테고리 없으면 빈 목록 정상 | DB에 해당 테넌트 데이터 없는 경우 (버그 아님) |

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
{
"resources/css/app.css": {
"file": "assets/app-DHf1Hpon.css",
"file": "assets/app-Cnl6bvuS.css",
"src": "resources/css/app.css",
"isEntry": true,
"name": "app",
@@ -9,9 +9,9 @@
]
},
"resources/js/app.js": {
"file": "assets/app-BX8sFXki.js",
"file": "assets/app-2HgzUazK.js",
"name": "app",
"src": "resources/js/app.js",
"isEntry": true
}
}
}

View File

@@ -121,7 +121,9 @@ class ContextMenu {
window.location.href = `/tenants/${id}/edit`;
break;
case 'switch-tenant':
this.switchTenant(id, name);
if (typeof openTenantConsole === 'function') {
openTenantConsole(id, name);
}
break;
case 'view-user':
if (typeof UserModal !== 'undefined') {
@@ -150,36 +152,6 @@ class ContextMenu {
this.hide();
}
// 테넌트 전환
async switchTenant(tenantId, tenantName) {
try {
const form = document.createElement('form');
form.method = 'POST';
form.action = '/tenant/switch';
// CSRF 토큰
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = '_token';
csrfInput.value = csrfToken;
form.appendChild(csrfInput);
// 테넌트 ID
const tenantInput = document.createElement('input');
tenantInput.type = 'hidden';
tenantInput.name = 'tenant_id';
tenantInput.value = tenantId;
form.appendChild(tenantInput);
document.body.appendChild(form);
form.submit();
} catch (error) {
console.error('Failed to switch tenant:', error);
showToast('테넌트 전환에 실패했습니다.', 'error');
}
}
// 사용자 삭제 (fallback)
async deleteUser(userId) {
try {

View File

@@ -1,4 +1,4 @@
@extends('layouts.app')
@extends(($isTenantConsole ?? false) ? 'layouts.tenant-console' : 'layouts.app')
@section('title', '감사 로그')
@@ -79,7 +79,7 @@ class="w-full border rounded-lg px-3 py-2 text-sm">
<button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm">
검색
</button>
<a href="{{ route('audit-logs.index') }}" class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg text-sm ml-1">
<a href="{{ \App\Helpers\TenantHelper::route('audit-logs.index') }}" class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg text-sm ml-1">
초기화
</a>
</div>
@@ -158,7 +158,7 @@ class="w-full border rounded-lg px-3 py-2 text-sm">
@endif
</td>
<td class="px-4 py-2 whitespace-nowrap text-sm">
<a href="{{ route('audit-logs.show', $log->id) }}"
<a href="{{ \App\Helpers\TenantHelper::route('audit-logs.show', ['id' => $log->id]) }}"
class="p-1 text-gray-600 hover:text-gray-800 hover:bg-gray-50 rounded"
title="상세 보기"
onclick="event.stopPropagation()">

View File

@@ -1,4 +1,4 @@
@extends('layouts.app')
@extends(($isTenantConsole ?? false) ? 'layouts.tenant-console' : 'layouts.app')
@section('title', '감사 로그 상세')
@@ -6,7 +6,7 @@
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mb-6">
<div class="flex items-center gap-4">
<a href="{{ route('audit-logs.index') }}" class="p-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg">
<a href="{{ \App\Helpers\TenantHelper::route('audit-logs.index') }}" class="p-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>

View File

@@ -1,4 +1,4 @@
@extends('layouts.app')
@extends(($isTenantConsole ?? false) ? 'layouts.tenant-console' : 'layouts.app')
@section('title', '게시판 생성')
@@ -6,7 +6,7 @@
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800"> 게시판 생성</h1>
<a href="{{ route('boards.index') }}" class="text-gray-600 hover:text-gray-900">
<a href="{{ \App\Helpers\TenantHelper::route('boards.index') }}" class="text-gray-600 hover:text-gray-900">
&larr; 목록으로
</a>
</div>
@@ -271,7 +271,7 @@ class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
&larr; 이전 단계
</button>
<div class="space-x-4">
<a href="{{ route('boards.index') }}"
<a href="{{ \App\Helpers\TenantHelper::route('boards.index') }}"
class="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition">
취소
</a>
@@ -307,7 +307,7 @@ class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition"
</div>
<div class="flex space-x-3">
<a href="{{ route('boards.index') }}" class="flex-1 px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 text-center">
<a href="{{ \App\Helpers\TenantHelper::route('boards.index') }}" class="flex-1 px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 text-center">
목록으로
</a>
<button onclick="closeSuccessModal()" class="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">
@@ -330,10 +330,21 @@ class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition"
let tenants = [];
let createdBoardId = null;
// 테넌트 콘솔 컨텍스트
const isTenantConsole = {{ ($isTenantConsole ?? false) ? 'true' : 'false' }};
const consoleTenantId = {{ ($isTenantConsole ?? false) ? \App\Helpers\TenantHelper::getEffectiveTenantId() : 'null' }};
// 초기화
document.addEventListener('DOMContentLoaded', async function() {
await loadTemplates();
await loadTenants();
// 테넌트 콘솔에서는 자동으로 테넌트 게시판 선택
if (isTenantConsole && consoleTenantId) {
selectedScope = 'tenant';
selectedTenantId = consoleTenantId;
goToStep(2);
}
});
// 템플릿 로드
@@ -638,7 +649,8 @@ function closeSuccessModal() {
document.getElementById('successModal').classList.remove('flex');
// 커스텀 필드 추가를 위해 편집 페이지로 이동
if (createdBoardId) {
window.location.href = `/boards/${createdBoardId}/edit`;
const editUrl = @json(\App\Helpers\TenantHelper::route('boards.edit', ['id' => '__ID__'])).replace('__ID__', createdBoardId);
window.location.href = editUrl;
}
}
</script>

View File

@@ -1,4 +1,4 @@
@extends('layouts.app')
@extends(($isTenantConsole ?? false) ? 'layouts.tenant-console' : 'layouts.app')
@section('title', '게시판 수정')
@@ -6,7 +6,7 @@
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">📋 게시판 수정: {{ $board->name }}</h1>
<a href="{{ route('boards.index') }}" class="text-gray-600 hover:text-gray-900">
<a href="{{ \App\Helpers\TenantHelper::route('boards.index') }}" class="text-gray-600 hover:text-gray-900">
&larr; 목록으로
</a>
</div>
@@ -134,7 +134,7 @@ class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<!-- 버튼 -->
<div class="flex justify-end space-x-4">
<a href="{{ route('boards.index') }}"
<a href="{{ \App\Helpers\TenantHelper::route('boards.index') }}"
class="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition">
취소
</a>

View File

@@ -1,4 +1,4 @@
@extends('layouts.app')
@extends(($isTenantConsole ?? false) ? 'layouts.tenant-console' : 'layouts.app')
@section('title', ($currentTenant?->company_name ?? '') . ' 게시판 관리')
@@ -11,7 +11,8 @@
@endif
게시판 관리
</h1>
<a href="{{ route('boards.create') }}" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition text-center w-full sm:w-auto">
<a href="{{ \App\Helpers\TenantHelper::route('boards.create') }}"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition text-center w-full sm:w-auto">
+ 게시판
</a>
</div>
@@ -83,9 +84,16 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
const boardTable = document.getElementById('board-table');
const filterForm = document.getElementById('filterForm');
// 테넌트 콘솔 컨텍스트
const consoleTenantId = {{ ($isTenantConsole ?? false) ? \App\Helpers\TenantHelper::getEffectiveTenantId() : 'null' }};
// 테이블 로드 함수
function loadTable() {
const formData = new FormData(filterForm);
if (consoleTenantId) {
formData.set('tenant_id', consoleTenantId);
formData.set('tenant_console_id', consoleTenantId);
}
const params = new URLSearchParams(formData).toString();
fetch(`/api/admin/boards?${params}`, {

View File

@@ -19,7 +19,13 @@
<tbody class="bg-white divide-y divide-gray-200">
@forelse($boards as $board)
<tr class="{{ $board->trashed() ? 'bg-red-50 opacity-60' : 'hover:bg-gray-50 cursor-pointer' }}"
@unless($board->trashed()) onclick="window.location='{{ route('boards.posts.index', ['boardCode' => $board->board_code, 't' => $board->tenant_id]) }}'" @endunless>
@unless($board->trashed())
@if($isTenantConsole ?? false)
onclick="window.location='{{ \App\Helpers\TenantHelper::route('boards.edit', ['id' => $board->id]) }}'"
@else
onclick="window.location='{{ route('boards.posts.index', ['boardCode' => $board->board_code, 't' => $board->tenant_id]) }}'"
@endif
@endunless>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ $board->id }}
</td>
@@ -97,7 +103,7 @@ class="text-red-600 hover:text-red-900">영구삭제</button>
@endif
@else
<!-- 일반 항목 액션 -->
<a href="{{ route('boards.edit', $board->id) }}"
<a href="{{ \App\Helpers\TenantHelper::route('boards.edit', ['id' => $board->id]) }}"
onclick="event.stopPropagation()"
class="text-indigo-600 hover:text-indigo-900">수정</a>
<button onclick="event.stopPropagation(); confirmDelete({{ $board->id }}, '{{ $board->name }}')"

View File

@@ -1,4 +1,4 @@
@extends('layouts.app')
@extends(($isTenantConsole ?? false) ? 'layouts.tenant-console' : 'layouts.app')
@section('title', '카테고리 관리')
@@ -23,6 +23,7 @@
</p>
</div>
<div class="flex items-center gap-2">
@unless($isTenantConsole ?? false)
<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">
@@ -30,6 +31,7 @@ class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white font-medium rounded-lg
</svg>
동기화
</a>
@endunless
@if($tenant)
<button type="button"
onclick="openAddModal()"
@@ -80,7 +82,7 @@ class="group-filter-btn text-[10px] px-1.5 py-0.5 rounded font-medium transition
<nav class="overflow-y-auto flex-1 min-h-0" aria-label="Tabs">
@foreach($codeGroups as $group => $label)
@php $scope = $groupScopes[$group] ?? 'custom'; @endphp
<a href="{{ route('categories.index', ['group' => $group]) }}"
<a href="{{ \App\Helpers\TenantHelper::route('categories.index', ['group' => $group]) }}"
data-scope="{{ $scope }}"
class="group-item block px-2 py-1.5 border-l-3 transition-colors
{{ $selectedGroup === $group
@@ -338,7 +340,7 @@ class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm fon
<!-- 코드 그룹 추가 모달 -->
<div id="groupModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-sm mx-4">
<form action="{{ route('categories.store-group') }}" method="POST">
<form action="{{ \App\Helpers\TenantHelper::route('categories.store-group') }}" method="POST">
@csrf
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-semibold text-gray-800">코드 그룹 추가</h3>

View File

@@ -1,4 +1,4 @@
@extends('layouts.app')
@extends(($isTenantConsole ?? false) ? 'layouts.tenant-console' : 'layouts.app')
@section('title', '공통코드 관리')
@@ -23,6 +23,7 @@
</p>
</div>
<div class="flex items-center gap-2">
@unless($isTenantConsole ?? false)
<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">
@@ -30,6 +31,7 @@ class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white font-medium rounded-lg
</svg>
동기화
</a>
@endunless
@if($tenant)
<button type="button"
onclick="openAddModal()"
@@ -98,7 +100,7 @@ class="group-filter-btn text-[10px] px-1.5 py-0.5 rounded font-medium transition
<nav class="overflow-y-auto flex-1 min-h-0" aria-label="Tabs">
@foreach($codeGroups as $group => $label)
@php $scope = $groupScopes[$group] ?? 'custom'; @endphp
<a href="{{ route('common-codes.index', ['group' => $group]) }}"
<a href="{{ \App\Helpers\TenantHelper::route('common-codes.index', ['group' => $group]) }}"
data-scope="{{ $scope }}"
class="group-item block px-2 py-1.5 border-l-3 transition-colors
{{ $selectedGroup === $group
@@ -351,7 +353,7 @@ class="p-1 text-gray-400 hover:text-red-600 transition-colors"
<!-- 코드 추가 모달 -->
<div id="addModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
<form action="{{ route('common-codes.store') }}" method="POST">
<form action="{{ \App\Helpers\TenantHelper::route('common-codes.store') }}" method="POST">
@csrf
<input type="hidden" name="code_group" value="{{ $selectedGroup }}">
@@ -466,13 +468,13 @@ class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm fon
</form>
<!-- 일괄 글로벌 승격 (hidden) -->
<form id="bulkPromoteForm" action="{{ route('common-codes.bulk-promote') }}" method="POST" class="hidden">
<form id="bulkPromoteForm" action="{{ \App\Helpers\TenantHelper::route('common-codes.bulk-promote') }}" method="POST" class="hidden">
@csrf
<input type="hidden" name="ids_json" id="bulkPromoteIds">
</form>
<!-- 일괄 복사 (hidden) -->
<form id="bulkCopyForm" action="{{ route('common-codes.bulk-copy') }}" method="POST" class="hidden">
<form id="bulkCopyForm" action="{{ \App\Helpers\TenantHelper::route('common-codes.bulk-copy') }}" method="POST" class="hidden">
@csrf
<input type="hidden" name="ids_json" id="bulkCopyIds">
</form>
@@ -480,7 +482,7 @@ class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm fon
<!-- 코드 그룹 추가 모달 -->
<div id="groupModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-sm mx-4">
<form action="{{ route('common-codes.store-group') }}" method="POST">
<form action="{{ \App\Helpers\TenantHelper::route('common-codes.store-group') }}" method="POST">
@csrf
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-semibold text-gray-800">코드 그룹 추가</h3>
@@ -520,6 +522,8 @@ class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm fon
@push('scripts')
<script>
const commonCodesBaseUrl = @json(rtrim(\App\Helpers\TenantHelper::route('common-codes.index'), '/'));
// 그룹 스코프 필터
function filterGroups(filter) {
const items = document.querySelectorAll('.group-item');
@@ -575,7 +579,7 @@ function closeGroupModal() {
}
function openEditModal(code) {
document.getElementById('editForm').action = `/common-codes/${code.id}`;
document.getElementById('editForm').action = `${commonCodesBaseUrl}/${code.id}`;
document.getElementById('editCode').value = code.code;
document.getElementById('editName').value = code.name;
document.getElementById('editSortOrder').value = code.sort_order || 0;
@@ -592,7 +596,7 @@ function closeEditModal() {
// 활성화 토글
function toggleActive(id) {
fetch(`/common-codes/${id}/toggle`, {
fetch(`${commonCodesBaseUrl}/${id}/toggle`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -616,7 +620,7 @@ function copyCode(id) {
if (!confirm('이 글로벌 코드를 테넌트용으로 복사하시겠습니까?')) return;
const form = document.getElementById('copyForm');
form.action = `/common-codes/${id}/copy`;
form.action = `${commonCodesBaseUrl}/${id}/copy`;
form.submit();
}
@@ -704,7 +708,7 @@ function deleteCode(id, code) {
if (!confirm(`'${code}' 코드를 삭제하시겠습니까?`)) return;
const form = document.getElementById('deleteForm');
form.action = `/common-codes/${id}`;
form.action = `${commonCodesBaseUrl}/${id}`;
form.submit();
}

View File

@@ -28,16 +28,13 @@ class="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex i
<button type="button"
data-menu-for="tenant"
onclick="handleContextMenuAction('switch-tenant')"
class="w-full px-4 py-2 text-left text-sm text-green-700 hover:bg-green-50 flex items-center gap-2">
class="w-full px-4 py-2 text-left text-sm text-green-600 hover:bg-gray-100 flex items-center gap-2">
<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="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</svg>
테넌트로 전환
</button>
{{-- 구분선 --}}
<div data-menu-for="tenant" class="border-t border-gray-100 my-1"></div>
{{-- 사용자 관련 메뉴 --}}
<button type="button"
data-menu-for="user"

View File

@@ -47,7 +47,7 @@ class="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-5
<!-- 회사 선택 -->
<div class="flex-1">
<label class="block text-sm font-medium text-gray-700 mb-1">회사 선택</label>
<select id="devToolsAuthTenantSelect" onchange="DevToolsAuth.switchTenant(this.value)" class="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<select id="devToolsAuthTenantSelect" class="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" disabled title="HQ 테넌트 고정">
<option value="">회사를 선택하세요</option>
@if(isset($globalTenants) && $globalTenants->isNotEmpty())
@foreach($globalTenants as $tenant)

View File

@@ -18,7 +18,6 @@
const STORAGE_KEY = 'devToolsAuth';
const USERS_ENDPOINT = '{{ route("dev-tools.api-explorer.users") }}';
const TENANT_SWITCH_ENDPOINT = '{{ route("tenant.switch") }}';
const ISSUE_TOKEN_ENDPOINT = '{{ route("dev-tools.api-explorer.issue-token") }}';
const CSRF_TOKEN = '{{ csrf_token() }}';
@@ -372,48 +371,6 @@ function notifyChange() {
}
},
// 테넌트 변경
async switchTenant(tenantId) {
if (!tenantId) return;
try {
const formData = new FormData();
formData.append('tenant_id', tenantId);
const response = await fetch(TENANT_SWITCH_ENDPOINT, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': CSRF_TOKEN,
'Accept': 'application/json'
},
body: formData
});
if (!response.ok) {
throw new Error('테넌트 변경 실패');
}
// 사용자 목록 새로고침
usersLoaded = false;
await loadUsers();
// 헤더의 테넌트 선택도 동기화
const headerTenantSelect = document.getElementById('tenant-select');
if (headerTenantSelect) {
headerTenantSelect.value = tenantId;
}
if (typeof showToast === 'function') {
showToast('회사가 변경되었습니다.', 'success');
}
} catch (err) {
console.error('테넌트 변경 실패:', err);
if (typeof showToast === 'function') {
showToast('회사 변경에 실패했습니다.', 'error');
}
}
},
// 토큰 복사
async copyToken() {
const displayToken = state.actualToken || state.token;

View File

@@ -1,4 +1,4 @@
@extends('layouts.app')
@extends(($isTenantConsole ?? false) ? 'layouts.tenant-console' : 'layouts.app')
@section('title', '품목기준 필드 관리')

View File

@@ -1,4 +1,4 @@
@extends('layouts.app')
@extends(($isTenantConsole ?? false) ? 'layouts.tenant-console' : 'layouts.app')
@section('title', '품목관리')

View File

@@ -0,0 +1,176 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>@yield('title', '테넌트 콘솔') - {{ $consoleTenant->company_name ?? '' }}</title>
@vite(['resources/css/app.css', 'resources/js/app.js'])
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/remixicon@4.6.0/fonts/remixicon.min.css">
<script>
window.SAM_CONFIG = {
apiBaseUrl: '{{ config('services.api.base_url', 'https://api.codebridge-x.com') }}',
apiKey: '{{ config('services.api.key', '') }}',
consoleTenantId: {{ $consoleTenantId ?? 0 }},
};
</script>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/collapse@3.x.x/dist/cdn.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script>
document.addEventListener('htmx:configRequest', (event) => {
const csrfToken = document.querySelector('meta[name="csrf-token"]');
if (csrfToken) {
event.detail.headers['X-CSRF-TOKEN'] = csrfToken.getAttribute('content');
}
// 테넌트 콘솔 컨텍스트 자동 주입 (API 호출 시 테넌트 해제 방지)
if (window.SAM_CONFIG && window.SAM_CONFIG.consoleTenantId) {
event.detail.parameters['tenant_console_id'] = window.SAM_CONFIG.consoleTenantId;
event.detail.parameters['tenant_id'] = window.SAM_CONFIG.consoleTenantId;
}
});
// fetch() 호출에도 테넌트 콘솔 컨텍스트 자동 주입
(function() {
if (!window.SAM_CONFIG || !window.SAM_CONFIG.consoleTenantId) return;
const tenantId = window.SAM_CONFIG.consoleTenantId;
const originalFetch = window.fetch;
window.fetch = function(url, options) {
options = options || {};
const method = (options.method || 'GET').toUpperCase();
// GET 요청: URL 파라미터에 추가
if (method === 'GET' && typeof url === 'string' && url.includes('/api/')) {
const separator = url.includes('?') ? '&' : '?';
url = url + separator + 'tenant_console_id=' + tenantId + '&tenant_id=' + tenantId;
}
// POST/PUT/DELETE 요청: body에 추가
if (method !== 'GET' && typeof url === 'string' && url.includes('/api/')) {
const contentType = (options.headers && (options.headers['Content-Type'] || options.headers['content-type'])) || '';
if (contentType.includes('application/json') && options.body) {
try {
const body = JSON.parse(options.body);
body.tenant_console_id = tenantId;
body.tenant_id = tenantId;
options.body = JSON.stringify(body);
} catch(e) {}
}
}
return originalFetch.call(this, url, options);
};
})();
</script>
<style>
select { padding-right: 2rem !important; }
</style>
@stack('styles')
</head>
<body class="bg-gray-100">
<div class="flex h-screen overflow-hidden">
<!-- Sidebar -->
@include('partials.tenant-console-sidebar')
<!-- Main Content -->
<div class="flex-1 flex flex-col overflow-hidden min-w-0">
<!-- Header -->
<header class="bg-white border-b border-gray-200 px-6 py-3 flex items-center justify-between shrink-0">
<div class="flex items-center gap-3">
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800">
<i class="ri-building-line mr-1.5"></i>
{{ $consoleTenant->company_name ?? '테넌트' }}
</span>
<span class="text-sm text-gray-500">테넌트 관리 콘솔</span>
</div>
<button onclick="window.close()" class="text-gray-400 hover:text-gray-600 transition" title="창 닫기">
<i class="ri-close-line text-xl"></i>
</button>
</header>
<!-- Page Content -->
<main class="flex-1 overflow-y-auto bg-gray-100 p-6">
@yield('content')
</main>
</div>
</div>
<!-- SweetAlert2 공통 함수 -->
<script>
const SwalTailwind = Swal.mixin({
customClass: {
popup: 'rounded-xl shadow-2xl border-0',
title: 'text-gray-900 font-semibold',
htmlContainer: 'text-gray-600',
confirmButton: 'bg-blue-600 hover:bg-blue-700 text-white font-medium px-5 py-2.5 rounded-lg transition-colors',
cancelButton: 'bg-gray-200 hover:bg-gray-300 text-gray-800 font-medium px-5 py-2.5 rounded-lg transition-colors',
actions: 'gap-3',
},
buttonsStyling: false,
});
const Toast = Swal.mixin({
toast: true,
position: 'top-end',
showConfirmButton: false,
timer: 3000,
timerProgressBar: true,
customClass: { popup: 'rounded-lg shadow-lg' },
didOpen: (toast) => {
toast.onmouseenter = Swal.stopTimer;
toast.onmouseleave = Swal.resumeTimer;
}
});
function showToast(message, type = 'info', timer = 3000) {
Toast.fire({ icon: type, title: message, timer });
}
function showConfirm(message, onConfirm, options = {}) {
SwalTailwind.fire({
title: options.title || '확인',
icon: options.icon || 'question',
html: message,
confirmButtonText: options.confirmText || '확인',
cancelButtonText: options.cancelText || '취소',
showCancelButton: true,
reverseButtons: true,
}).then((result) => {
if (result.isConfirmed && typeof onConfirm === 'function') onConfirm();
});
}
function showDeleteConfirm(itemName, onConfirm) {
SwalTailwind.fire({
title: '삭제 확인',
html: `<span class="text-red-600 font-medium">"${itemName}"</span>을(를) 삭제하시겠습니까?`,
icon: 'warning',
showCancelButton: true,
confirmButtonText: '삭제',
cancelButtonText: '취소',
reverseButtons: true,
customClass: {
popup: 'rounded-xl shadow-2xl border-0',
confirmButton: 'bg-red-600 hover:bg-red-700 text-white font-medium px-5 py-2.5 rounded-lg',
cancelButton: 'bg-gray-200 hover:bg-gray-300 text-gray-800 font-medium px-5 py-2.5 rounded-lg',
actions: 'gap-3',
},
buttonsStyling: false,
}).then((result) => {
if (result.isConfirmed && typeof onConfirm === 'function') onConfirm();
});
}
function showSuccess(message, onClose = null) {
SwalTailwind.fire({ title: '완료', text: message, icon: 'success', confirmButtonText: '확인' })
.then(() => { if (typeof onClose === 'function') onClose(); });
}
function showError(message) {
SwalTailwind.fire({ title: '오류', text: message, icon: 'error', confirmButtonText: '확인' });
}
</script>
<script src="{{ asset('js/pagination.js') }}"></script>
<script src="{{ asset('js/table-sort.js') }}"></script>
@stack('scripts')
</body>
</html>

View File

@@ -14,68 +14,10 @@ class="p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transi
</svg>
</button>
<!-- 모바일 로고 + 테넌트 뱃지 (lg 미만에서만 표시) -->
<!-- 모바일 로고 (lg 미만에서만 표시) -->
<div class="flex items-center gap-2 lg:hidden">
<span class="text-lg font-bold text-gray-900">{{ config('app.name') }}</span>
@unless(request()->routeIs('sales.*') || request()->routeIs('finance.settlement*'))
@php
$mobileTenant = $globalTenants->firstWhere('id', session('selected_tenant_id'));
@endphp
@if($mobileTenant)
<button
type="button"
onclick="openMobileSidebar()"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-primary/10 text-primary"
title="테넌트 변경"
>
{{ Str::limit($mobileTenant->company_name, 8) }}
</button>
@endif
@endunless
</div>
@unless(request()->routeIs('sales.*') || request()->routeIs('finance.settlement*'))
<!-- 테넌트 셀렉터 (데스크톱: 전체 표시, 모바일: 축소) -->
<div class="hidden lg:flex items-center gap-2">
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
<label for="tenant-select" class="text-sm font-medium text-gray-700">테넌트 선택:</label>
</div>
<form action="{{ route('tenant.switch') }}" method="POST" id="tenant-switch-form" class="hidden lg:block">
@csrf
<select
name="tenant_id"
id="tenant-select"
onchange="document.getElementById('tenant-switch-form').submit()"
class="border-gray-300 rounded-lg text-sm focus:ring-primary focus:border-primary min-w-[200px]"
>
@foreach($globalTenants as $tenant)
<option value="{{ $tenant->id }}" {{ session('selected_tenant_id') == $tenant->id ? 'selected' : '' }}>
{{ $tenant->company_name }}
</option>
@endforeach
</select>
</form>
<!-- 현재 테넌트 정보 (데스크톱에서만 표시) -->
@php
$currentTenant = $globalTenants->firstWhere('id', session('selected_tenant_id'));
@endphp
@if($currentTenant)
<span class="hidden lg:inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-primary/10 text-primary cursor-pointer hover:bg-primary/20"
data-context-menu="tenant"
data-entity-id="{{ $currentTenant->id }}"
data-entity-name="{{ $currentTenant->company_name }}"
title="클릭하여 메뉴 열기">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ $currentTenant->company_name }}
</span>
@endif
@endunless
</div>
<!-- Right Side Actions -->

View File

@@ -74,31 +74,6 @@ class="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 text-gray-400 hover:text-
<div id="menu-search-info" class="hidden mt-2 text-xs text-gray-500"></div>
</div>
<!-- 모바일 전용: 테넌트 셀렉터 (lg 미만에서만 표시, 영업 메뉴에서는 숨김) -->
<div class="lg:hidden border-b border-gray-200 p-3 bg-gray-50 {{ request()->routeIs('sales.*') ? 'hidden' : '' }}">
<div class="flex items-center gap-2 mb-2">
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
<span class="text-xs font-medium text-gray-500">테넌트 선택</span>
</div>
<form action="{{ route('tenant.switch') }}" method="POST" id="mobile-tenant-switch-form">
@csrf
<select
name="tenant_id"
id="mobile-tenant-select"
onchange="document.getElementById('mobile-tenant-switch-form').submit()"
class="w-full border-gray-300 rounded-lg text-sm focus:ring-primary focus:border-primary"
>
@foreach($globalTenants as $tenant)
<option value="{{ $tenant->id }}" {{ session('selected_tenant_id') == $tenant->id ? 'selected' : '' }}>
{{ $tenant->company_name }}
</option>
@endforeach
</select>
</form>
</div>
<!-- Navigation Menu -->
<nav class="flex-1 overflow-y-auto p-4 sidebar-nav"
hx-boost="true"

View File

@@ -0,0 +1,113 @@
{{-- 테넌트 콘솔 전용 사이드바 (메인 사이드바 스타일 통일) --}}
@php
$tenantId = $consoleTenantId ?? 0;
$currentUrl = request()->url();
$baseUrl = "/tenant-console/{$tenantId}";
$menuGroups = [
[
'title' => '권한 관리',
'icon' => 'ri-shield-keyhole-line',
'items' => [
['name' => '권한 관리', 'url' => "{$baseUrl}/permissions", 'icon' => 'ri-key-2-line'],
['name' => '역할 관리', 'url' => "{$baseUrl}/roles", 'icon' => 'ri-user-settings-line'],
['name' => '역할-권한 매핑', 'url' => "{$baseUrl}/role-permissions", 'icon' => 'ri-links-line'],
['name' => '부서-권한 매핑', 'url' => "{$baseUrl}/department-permissions", 'icon' => 'ri-building-2-line'],
['name' => '사용자-권한 매핑', 'url' => "{$baseUrl}/user-permissions", 'icon' => 'ri-user-star-line'],
],
],
[
'title' => '시스템 관리',
'icon' => 'ri-settings-3-line',
'items' => [
['name' => 'AI 설정', 'url' => "{$baseUrl}/system/ai-config", 'icon' => 'ri-robot-line'],
['name' => '휴일 관리', 'url' => "{$baseUrl}/system/holidays", 'icon' => 'ri-calendar-event-line'],
['name' => '시스템 알림', 'url' => "{$baseUrl}/system/alerts", 'icon' => 'ri-notification-3-line'],
['name' => '공통코드', 'url' => "{$baseUrl}/common-codes", 'icon' => 'ri-code-s-slash-line'],
['name' => '카테고리', 'url' => "{$baseUrl}/categories", 'icon' => 'ri-node-tree'],
['name' => '감사 로그', 'url' => "{$baseUrl}/audit-logs", 'icon' => 'ri-file-list-3-line'],
],
],
[
'title' => '생산관리',
'icon' => 'ri-instance-line',
'items' => [
['name' => '품목관리', 'url' => "{$baseUrl}/production/items", 'icon' => 'ri-box-3-line'],
['name' => '품목필드', 'url' => "{$baseUrl}/production/item-fields", 'icon' => 'ri-list-settings-line'],
['name' => '견적수식', 'url' => "{$baseUrl}/production/quote-formulas", 'icon' => 'ri-calculator-line'],
['name' => '카테고리', 'url' => "{$baseUrl}/production/categories", 'icon' => 'ri-node-tree'],
],
],
[
'title' => '게시판관리',
'icon' => 'ri-article-line',
'items' => [
['name' => '게시판 목록', 'url' => "{$baseUrl}/boards", 'icon' => 'ri-layout-grid-line'],
],
],
];
@endphp
<aside class="w-64 bg-white border-r border-gray-200 flex flex-col shrink-0">
{{-- 테넌트 정보 헤더 --}}
<div class="flex items-center h-16 border-b border-gray-200 px-3">
<a href="{{ $baseUrl }}" class="flex items-center gap-2 text-gray-800 hover:text-blue-600 transition">
<i class="ri-building-line text-xl text-blue-600"></i>
<div class="min-w-0">
<div class="font-semibold text-sm truncate">{{ $consoleTenant->company_name ?? '테넌트' }}</div>
<div class="text-xs text-gray-500">관리 콘솔</div>
</div>
</a>
</div>
{{-- 메뉴 영역 --}}
<nav class="flex-1 overflow-y-auto p-4">
<ul class="space-y-1">
@foreach($menuGroups as $groupIndex => $group)
{{-- 1depth: 그룹 헤더 (메인 사이드바 menu-group 스타일) --}}
<li class="pt-4 pb-1 border-t border-gray-200 mt-2">
<button @click="$refs.group{{ $groupIndex }}.classList.toggle('hidden')"
class="w-full flex items-center justify-between px-3 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-50 rounded"
style="padding-left: 0.75rem">
<span class="flex items-center gap-2">
<i class="{{ $group['icon'] }} w-4 h-4 text-base"></i>
{{ $group['title'] }}
</span>
<svg class="w-3 h-3 transition-transform rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
{{-- 2depth: 메뉴 아이템 (메인 사이드바 menu-item 스타일) --}}
<ul x-ref="group{{ $groupIndex }}" class="space-y-1 mt-1">
@foreach($group['items'] as $item)
@php
$isActive = str_starts_with($currentUrl, url($item['url']));
$activeClass = $isActive
? 'bg-primary text-white hover:bg-primary'
: 'text-gray-700 hover:bg-gray-100';
@endphp
<li>
<a href="{{ $item['url'] }}"
class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm {{ $activeClass }}"
style="padding-left: 1.5rem">
<i class="{{ $item['icon'] }} text-base {{ $isActive ? '' : 'text-gray-400' }}"></i>
{{ $item['name'] }}
</a>
</li>
@endforeach
</ul>
</li>
@endforeach
</ul>
</nav>
{{-- 하단: 메인으로 돌아가기 --}}
<div class="px-4 py-3 border-t border-gray-200">
<a href="/tenants" target="_opener"
class="flex items-center gap-2 text-sm text-gray-500 hover:text-gray-700 transition">
<i class="ri-arrow-go-back-line"></i>
테넌트 목록으로
</a>
</div>
</aside>

View File

@@ -1,63 +0,0 @@
<!-- Tenant Selector Card -->
<div class="bg-white rounded-lg shadow overflow-hidden">
<div class="p-6">
<div class="flex items-center justify-between">
<!-- 좌측: 테넌트 선택 -->
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
<label for="tenant-select" class="text-sm font-medium text-gray-700">테넌트 선택:</label>
</div>
<form action="{{ route('tenant.switch') }}" method="POST" id="tenant-switch-form">
@csrf
<select
name="tenant_id"
id="tenant-select"
onchange="document.getElementById('tenant-switch-form').submit()"
class="border-gray-300 rounded-lg text-sm focus:ring-primary focus:border-primary min-w-[200px]"
>
<option value="all" {{ session('selected_tenant_id') === null ? 'selected' : '' }}>
전체 보기
</option>
@if($globalTenants->isNotEmpty())
<option disabled>─────────</option>
@endif
@foreach($globalTenants as $tenant)
<option value="{{ $tenant->id }}" {{ session('selected_tenant_id') == $tenant->id ? 'selected' : '' }}>
{{ $tenant->company_name }}
</option>
@endforeach
</select>
</form>
</div>
<!-- 우측: 현재 테넌트 정보 -->
<div class="flex items-center gap-2">
@if(session('selected_tenant_id'))
@php
$currentTenant = $globalTenants->firstWhere('id', session('selected_tenant_id'));
@endphp
@if($currentTenant)
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-primary/10 text-primary cursor-pointer hover:bg-primary/20"
data-context-menu="tenant"
data-entity-id="{{ $currentTenant->id }}"
data-entity-name="{{ $currentTenant->company_name }}"
title="클릭하여 메뉴 열기">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ $currentTenant->company_name }} 데이터만 표시
</span>
@endif
@else
<span class="text-xs text-gray-500">
전체 테넌트 데이터 표시
</span>
@endif
</div>
</div>
</div>
</div>

View File

@@ -1,4 +1,4 @@
@extends('layouts.app')
@extends(($isTenantConsole ?? false) ? 'layouts.tenant-console' : 'layouts.app')
@section('title', '권한 생성')
@@ -7,7 +7,7 @@
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">🛡️ 권한 생성</h1>
<a href="{{ route('permissions.index') }}" class="text-gray-600 hover:text-gray-800">
<a href="{{ \App\Helpers\TenantHelper::route('permissions.index') }}" class="text-gray-600 hover:text-gray-800">
목록으로
</a>
</div>
@@ -53,7 +53,7 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">전체 (마스터 권한)</option>
@foreach($tenants as $tenant)
<option value="{{ $tenant->id }}" {{ session('selected_tenant_id') == $tenant->id ? 'selected' : '' }}>
<option value="{{ $tenant->id }}" {{ \App\Helpers\TenantHelper::getEffectiveTenantId() == $tenant->id ? 'selected' : '' }}>
{{ $tenant->company_name }}
</option>
@endforeach
@@ -66,7 +66,7 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
<button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg transition">
생성
</button>
<a href="{{ route('permissions.index') }}" class="bg-gray-200 hover:bg-gray-300 text-gray-800 px-6 py-2 rounded-lg transition">
<a href="{{ \App\Helpers\TenantHelper::route('permissions.index') }}" class="bg-gray-200 hover:bg-gray-300 text-gray-800 px-6 py-2 rounded-lg transition">
취소
</a>
</div>
@@ -96,7 +96,7 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
.then(data => {
if (data.success) {
showToast(data.message, 'success');
window.location.href = '{{ route("permissions.index") }}';
window.location.href = '{{ \App\Helpers\TenantHelper::route("permissions.index") }}';
} else {
showToast(data.message || '권한 생성에 실패했습니다.', 'error');
}

View File

@@ -1,4 +1,4 @@
@extends('layouts.app')
@extends(($isTenantConsole ?? false) ? 'layouts.tenant-console' : 'layouts.app')
@section('title', '권한 수정')
@@ -7,7 +7,7 @@
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">🛡️ 권한 수정</h1>
<a href="{{ route('permissions.index') }}" class="text-gray-600 hover:text-gray-800">
<a href="{{ \App\Helpers\TenantHelper::route('permissions.index') }}" class="text-gray-600 hover:text-gray-800">
목록으로
</a>
</div>
@@ -80,7 +80,7 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
<button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg transition">
수정
</button>
<a href="{{ route('permissions.index') }}" class="bg-gray-200 hover:bg-gray-300 text-gray-800 px-6 py-2 rounded-lg transition">
<a href="{{ \App\Helpers\TenantHelper::route('permissions.index') }}" class="bg-gray-200 hover:bg-gray-300 text-gray-800 px-6 py-2 rounded-lg transition">
취소
</a>
</div>
@@ -116,12 +116,12 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
document.getElementById('formContainer').style.display = 'block';
} else {
showToast('권한 정보를 불러올 수 없습니다.', 'error');
window.location.href = '{{ route("permissions.index") }}';
window.location.href = '{{ \App\Helpers\TenantHelper::route("permissions.index") }}';
}
})
.catch(error => {
showToast('권한 정보 로드 중 오류가 발생했습니다.', 'error');
window.location.href = '{{ route("permissions.index") }}';
window.location.href = '{{ \App\Helpers\TenantHelper::route("permissions.index") }}';
});
// 폼 제출
@@ -144,7 +144,7 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
.then(data => {
if (data.success) {
showToast(data.message, 'success');
window.location.href = '{{ route("permissions.index") }}';
window.location.href = '{{ \App\Helpers\TenantHelper::route("permissions.index") }}';
} else {
showToast(data.message || '권한 수정에 실패했습니다.', 'error');
}

View File

@@ -1,4 +1,4 @@
@extends('layouts.app')
@extends(($isTenantConsole ?? false) ? 'layouts.tenant-console' : 'layouts.app')
@section('title', '권한 관리')
@@ -6,7 +6,7 @@
<!-- 페이지 헤더 -->
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
<h1 class="text-2xl font-bold text-gray-800">권한 관리</h1>
<a href="{{ route('permissions.create') }}" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition text-center w-full sm:w-auto">
<a href="{{ \App\Helpers\TenantHelper::route('permissions.create') }}" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition text-center w-full sm:w-auto">
+ 권한
</a>
</div>
@@ -14,6 +14,10 @@
<!-- 필터 영역 -->
<x-filter-collapsible id="filterForm">
<form id="filterForm" class="flex flex-wrap gap-2 sm:gap-4">
@if($isTenantConsole ?? false)
<input type="hidden" name="tenant_id" value="{{ \App\Helpers\TenantHelper::getEffectiveTenantId() }}">
<input type="hidden" name="tenant_console_id" value="{{ \App\Helpers\TenantHelper::getEffectiveTenantId() }}">
@endif
<!-- 검색 -->
<div class="flex-1 min-w-0 w-full sm:w-auto">
<input type="text"

View File

@@ -175,7 +175,7 @@ function getPermissionConfig(string $type): array
{{ $permission->updated_at?->format('Y-m-d H:i') ?? '-' }}
</td>
<td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium">
<a href="{{ route('permissions.edit', $permission->id) }}"
<a href="{{ \App\Helpers\TenantHelper::route('permissions.edit', ['id' => $permission->id]) }}"
class="text-blue-600 hover:text-blue-900 mr-3">
수정
</a>

View File

@@ -1,4 +1,4 @@
@extends('layouts.app')
@extends(($isTenantConsole ?? false) ? 'layouts.tenant-console' : 'layouts.app')
@section('title', '견적수식 관리')
@@ -12,14 +12,17 @@
</div>
<div class="flex flex-wrap gap-2">
<a href="{{ route('quote-formulas.categories.index') }}"
@if($isTenantConsole ?? false) target="_blank" @endif
class="flex-1 sm:flex-none bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors text-center">
카테고리 관리
</a>
<a href="{{ route('quote-formulas.simulator') }}"
@if($isTenantConsole ?? false) target="_blank" @endif
class="flex-1 sm:flex-none bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors text-center">
시뮬레이터
</a>
<a href="{{ route('quote-formulas.create') }}"
@if($isTenantConsole ?? false) target="_blank" @endif
class="w-full sm:w-auto bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors text-center">
+ 수식 추가
</a>

View File

@@ -1,4 +1,4 @@
@extends('layouts.app')
@extends(($isTenantConsole ?? false) ? 'layouts.tenant-console' : 'layouts.app')
@section('title', '역할 권한 관리')

View File

@@ -1,4 +1,4 @@
@extends('layouts.app')
@extends(($isTenantConsole ?? false) ? 'layouts.tenant-console' : 'layouts.app')
@section('title', '역할 생성')
@@ -7,7 +7,7 @@
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">역할 생성</h1>
<a href="{{ route('roles.index') }}" class="text-gray-600 hover:text-gray-800">
<a href="{{ \App\Helpers\TenantHelper::route('roles.index') }}" class="text-gray-600 hover:text-gray-800">
목록으로
</a>
</div>
@@ -18,7 +18,7 @@
<div class="text-yellow-600 text-5xl mb-4">⚠️</div>
<h2 class="text-xl font-semibold text-yellow-800 mb-2">테넌트 선택이 필요합니다</h2>
<p class="text-yellow-700 mb-4">역할을 생성하려면 먼저 상단 헤더에서 테넌트를 선택해주세요.</p>
<a href="{{ route('roles.index') }}" class="inline-block px-4 py-2 bg-yellow-600 hover:bg-yellow-700 text-white rounded-lg transition">
<a href="{{ \App\Helpers\TenantHelper::route('roles.index') }}" class="inline-block px-4 py-2 bg-yellow-600 hover:bg-yellow-700 text-white rounded-lg transition">
목록으로 돌아가기
</a>
</div>
@@ -139,7 +139,7 @@ class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-
<!-- 버튼 영역 -->
<div class="flex justify-end gap-3">
<a href="{{ route('roles.index') }}"
<a href="{{ \App\Helpers\TenantHelper::route('roles.index') }}"
class="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition">
취소
</a>
@@ -184,7 +184,7 @@ function selectViewOnly() {
const response = JSON.parse(event.detail.xhr.response);
if (response.success) {
showToast(response.message, 'success');
window.location.href = response.redirect;
window.location.href = '{{ \App\Helpers\TenantHelper::route("roles.index") }}';
} else {
showToast(response.message || '역할 생성에 실패했습니다.', 'error');
}

View File

@@ -1,4 +1,4 @@
@extends('layouts.app')
@extends(($isTenantConsole ?? false) ? 'layouts.tenant-console' : 'layouts.app')
@section('title', '역할 수정')
@@ -7,7 +7,7 @@
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">역할 수정</h1>
<a href="{{ route('roles.index') }}" class="text-gray-600 hover:text-gray-800">
<a href="{{ \App\Helpers\TenantHelper::route('roles.index') }}" class="text-gray-600 hover:text-gray-800">
목록으로
</a>
</div>
@@ -156,7 +156,7 @@ class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-
<!-- 버튼 영역 -->
<div class="flex justify-end gap-3">
<a href="{{ route('roles.index') }}"
<a href="{{ \App\Helpers\TenantHelper::route('roles.index') }}"
class="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition">
취소
</a>
@@ -200,7 +200,7 @@ function selectViewOnly() {
const response = JSON.parse(event.detail.xhr.response);
if (response.success) {
showToast(response.message, 'success');
window.location.href = response.redirect;
window.location.href = '{{ \App\Helpers\TenantHelper::route("roles.index") }}';
} else {
showToast(response.message || '역할 수정에 실패했습니다.', 'error');
}

View File

@@ -1,4 +1,4 @@
@extends('layouts.app')
@extends(($isTenantConsole ?? false) ? 'layouts.tenant-console' : 'layouts.app')
@section('title', '역할 관리')
@@ -13,7 +13,7 @@ class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg transition fl
disabled>
선택 삭제 (<span id="deleteCount">0</span>)
</button>
<a href="{{ route('roles.create') }}" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition text-center">
<a href="{{ \App\Helpers\TenantHelper::route('roles.create') }}" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition text-center">
+ 역할
</a>
</div>
@@ -22,6 +22,10 @@ class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg transition fl
<!-- 필터 영역 -->
<x-filter-collapsible id="filterForm">
<form id="filterForm" class="flex flex-wrap gap-2 sm:gap-4">
@if($isTenantConsole ?? false)
<input type="hidden" name="tenant_id" value="{{ \App\Helpers\TenantHelper::getEffectiveTenantId() }}">
<input type="hidden" name="tenant_console_id" value="{{ \App\Helpers\TenantHelper::getEffectiveTenantId() }}">
@endif
<!-- Guard 선택 -->
<div class="w-full sm:w-32">
<select name="guard_name"

View File

@@ -68,7 +68,7 @@
</td>
<td class="px-4 py-3 whitespace-nowrap text-center">
<div class="inline-flex gap-1">
<a href="{{ route('roles.edit', $role->id) }}"
<a href="{{ \App\Helpers\TenantHelper::route('roles.edit', ['id' => $role->id]) }}"
class="px-2.5 py-1 text-xs font-medium rounded bg-blue-100 text-blue-700 hover:bg-blue-200 transition text-center">
수정
</a>

View File

@@ -1,4 +1,4 @@
@extends('layouts.app')
@extends(($isTenantConsole ?? false) ? 'layouts.tenant-console' : 'layouts.app')
@section('title', 'AI 설정 관리')

View File

@@ -1,4 +1,4 @@
@extends('layouts.app')
@extends(($isTenantConsole ?? false) ? 'layouts.tenant-console' : 'layouts.app')
@section('title', '시스템 알림')
@@ -8,7 +8,7 @@
<h1 class="text-2xl font-bold text-gray-800">시스템 알림</h1>
<div class="flex items-center gap-2">
@if($stats['unread'] > 0)
<button hx-post="{{ route('system.alerts.read-all') }}"
<button hx-post="{{ \App\Helpers\TenantHelper::route('system.alerts.read-all') }}"
hx-confirm="모든 알림을 읽음 처리하시겠습니까?"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm">
전체 읽음 ({{ $stats['unread'] }})
@@ -94,7 +94,7 @@ class="border rounded-lg px-3 py-2 text-sm">
<button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm">
검색
</button>
<a href="{{ route('system.alerts.index') }}" class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg text-sm ml-1">
<a href="{{ \App\Helpers\TenantHelper::route('system.alerts.index') }}" class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg text-sm ml-1">
초기화
</a>
</div>
@@ -149,13 +149,13 @@ class="border rounded-lg px-3 py-2 text-sm">
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm" onclick="event.stopPropagation()">
@if(!$alert->is_read)
<button hx-post="{{ route('system.alerts.read', $alert->id) }}"
<button hx-post="{{ \App\Helpers\TenantHelper::route('system.alerts.read', ['id' => $alert->id]) }}"
class="text-blue-600 hover:text-blue-800 text-xs mr-2">
읽음
</button>
@endif
@if(!$alert->is_resolved)
<button hx-post="{{ route('system.alerts.resolve', $alert->id) }}"
<button hx-post="{{ \App\Helpers\TenantHelper::route('system.alerts.resolve', ['id' => $alert->id]) }}"
hx-confirm="이 알림을 해결 처리하시겠습니까?"
class="text-green-600 hover:text-green-800 text-xs">
해결

View File

@@ -1,4 +1,4 @@
@extends('layouts.app')
@extends(($isTenantConsole ?? false) ? 'layouts.tenant-console' : 'layouts.app')
@section('title', '달력 휴일 관리')
@@ -16,9 +16,14 @@
@push('scripts')
@include('partials.react-cdn')
<script src="https://unpkg.com/lucide@0.469.0?v={{ time() }}"></script>
@endverbatim
<script>
window.__HOLIDAYS_BASE_URL__ = '{{ ($isTenantConsole ?? false) ? "/tenant-console/{$consoleTenantId}/system/holidays" : "/system/holidays" }}';
</script>
@verbatim
<script type="text/babel">
const { useState, useRef, useEffect, useMemo } = React;
const HOLIDAYS_BASE = window.__HOLIDAYS_BASE_URL__;
const createIcon = (name) => {
const _def=((n)=>{const a={'check-circle':'CircleCheck','alert-circle':'CircleAlert','alert-triangle':'TriangleAlert','clipboard-check':'ClipboardCheck','arrow-up-circle':'CircleArrowUp','arrow-down-circle':'CircleArrowDown'};if(a[n]&&lucide[a[n]])return lucide[a[n]];const p=n.split('-').map(w=>w.charAt(0).toUpperCase()+w.slice(1)).join('');return lucide[p]||(lucide.icons&&lucide.icons[n])||null;})(name);
@@ -89,7 +94,7 @@ function HolidayManagement() {
const fetchHolidays = async (year) => {
setLoading(true);
try {
const res = await fetch(`/system/holidays/list?year=${year || selectedYear}`);
const res = await fetch(`${HOLIDAYS_BASE}/list?year=${year || selectedYear}`);
const data = await res.json();
if (data.success) setHolidays(data.data);
} catch (err) {
@@ -144,7 +149,7 @@ function HolidayManagement() {
}
setSaving(true);
try {
const url = modalMode === 'add' ? '/system/holidays' : `/system/holidays/${editingItem.id}`;
const url = modalMode === 'add' ? HOLIDAYS_BASE : `${HOLIDAYS_BASE}/${editingItem.id}`;
const res = await fetch(url, {
method: modalMode === 'add' ? 'POST' : 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
@@ -169,7 +174,7 @@ function HolidayManagement() {
const handleDelete = async (id) => {
if (!confirm('정말 삭제하시겠습니까?')) return;
try {
const res = await fetch(`/system/holidays/${id}`, {
const res = await fetch(`${HOLIDAYS_BASE}/${id}`, {
method: 'DELETE',
headers: { 'X-CSRF-TOKEN': csrfToken },
});
@@ -185,7 +190,7 @@ function HolidayManagement() {
const handleDeleteYear = async () => {
if (!confirm(`${selectedYear}년도의 모든 휴일(${totalHolidays}건)을 삭제하시겠습니까?\n\n이 작업은 되돌릴 수 없습니다.`)) return;
try {
const res = await fetch('/system/holidays/destroy-year', {
const res = await fetch(`${HOLIDAYS_BASE}/destroy-year`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -235,7 +240,7 @@ function HolidayManagement() {
}
setSaving(true);
try {
const res = await fetch('/system/holidays/bulk', {
const res = await fetch(`${HOLIDAYS_BASE}/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
body: JSON.stringify({ holidays: parsed }),

View File

@@ -0,0 +1,101 @@
@extends('layouts.tenant-console')
@section('title', '대시보드')
@section('content')
<div class="max-w-7xl mx-auto">
<!-- 테넌트 정보 카드 -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<h1 class="text-xl font-bold text-gray-800">
<i class="ri-building-line text-blue-600 mr-2"></i>
{{ $tenant->company_name }}
</h1>
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium
{{ match($tenant->tenant_st_code) {
'active' => 'bg-green-100 text-green-800',
'trial' => 'bg-yellow-100 text-yellow-800',
'suspended', 'expired' => 'bg-red-100 text-red-800',
default => 'bg-gray-100 text-gray-800',
} }}">
{{ $tenant->status_label }}
</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div>
<span class="text-gray-500">코드:</span>
<span class="font-medium text-gray-800 ml-1">{{ $tenant->code ?? '-' }}</span>
</div>
<div>
<span class="text-gray-500">대표자:</span>
<span class="font-medium text-gray-800 ml-1">{{ $tenant->ceo_name ?? '-' }}</span>
</div>
<div>
<span class="text-gray-500">이메일:</span>
<span class="font-medium text-gray-800 ml-1">{{ $tenant->email ?? '-' }}</span>
</div>
<div>
<span class="text-gray-500">전화:</span>
<span class="font-medium text-gray-800 ml-1">{{ $tenant->phone_formatted ?? '-' }}</span>
</div>
<div>
<span class="text-gray-500">사업자번호:</span>
<span class="font-medium text-gray-800 ml-1">{{ $tenant->business_num ?? '-' }}</span>
</div>
<div>
<span class="text-gray-500">유형:</span>
<span class="font-medium text-gray-800 ml-1">{{ $tenant->tenant_type ?? '-' }}</span>
</div>
</div>
</div>
<!-- 메뉴 바로가기 카드 -->
<h2 class="text-lg font-semibold text-gray-700 mb-4">관리 메뉴</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<a href="/tenant-console/{{ $tenantId }}/permissions"
class="bg-white rounded-xl shadow-sm border border-gray-200 p-5 hover:shadow-md hover:border-blue-300 transition group">
<div class="flex items-center gap-3 mb-2">
<div class="w-10 h-10 bg-indigo-100 rounded-lg flex items-center justify-center group-hover:bg-indigo-200 transition">
<i class="ri-shield-keyhole-line text-xl text-indigo-600"></i>
</div>
<h3 class="font-semibold text-gray-800">권한 관리</h3>
</div>
<p class="text-sm text-gray-500">역할, 부서, 사용자별 권한을 관리합니다.</p>
</a>
<a href="/tenant-console/{{ $tenantId }}/system/ai-config"
class="bg-white rounded-xl shadow-sm border border-gray-200 p-5 hover:shadow-md hover:border-blue-300 transition group">
<div class="flex items-center gap-3 mb-2">
<div class="w-10 h-10 bg-emerald-100 rounded-lg flex items-center justify-center group-hover:bg-emerald-200 transition">
<i class="ri-settings-3-line text-xl text-emerald-600"></i>
</div>
<h3 class="font-semibold text-gray-800">시스템 관리</h3>
</div>
<p class="text-sm text-gray-500">AI설정, 휴일, 알림, 공통코드를 관리합니다.</p>
</a>
<a href="/tenant-console/{{ $tenantId }}/production/items"
class="bg-white rounded-xl shadow-sm border border-gray-200 p-5 hover:shadow-md hover:border-blue-300 transition group">
<div class="flex items-center gap-3 mb-2">
<div class="w-10 h-10 bg-orange-100 rounded-lg flex items-center justify-center group-hover:bg-orange-200 transition">
<i class="ri-instance-line text-xl text-orange-600"></i>
</div>
<h3 class="font-semibold text-gray-800">생산관리</h3>
</div>
<p class="text-sm text-gray-500">품목, BOM, 견적수식을 관리합니다.</p>
</a>
<a href="/tenant-console/{{ $tenantId }}/boards"
class="bg-white rounded-xl shadow-sm border border-gray-200 p-5 hover:shadow-md hover:border-blue-300 transition group">
<div class="flex items-center gap-3 mb-2">
<div class="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center group-hover:bg-purple-200 transition">
<i class="ri-article-line text-xl text-purple-600"></i>
</div>
<h3 class="font-semibold text-gray-800">게시판관리</h3>
</div>
<p class="text-sm text-gray-500">게시판 생성, 수정, 삭제를 관리합니다.</p>
</a>
</div>
</div>
@endsection

View File

@@ -107,6 +107,19 @@ class="bg-white rounded-lg shadow-sm overflow-hidden">
}, { title: '복원 확인', icon: 'question' });
};
// 테넌트 관리 콘솔 새창 열기
window.openTenantConsole = function(tenantId, tenantName) {
const width = 1400;
const height = 900;
const left = (screen.width - width) / 2;
const top = (screen.height - height) / 2;
window.open(
`/tenant-console/${tenantId}`,
`tenant_${tenantId}`,
`width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`
);
};
// 영구삭제 확인
window.confirmForceDelete = function(id, name) {
showPermanentDeleteConfirm(name, () => {

View File

@@ -111,6 +111,11 @@ class="w-full px-2.5 py-1 text-xs font-medium rounded bg-red-100 text-red-700 ho
</div>
@else
<div class="inline-flex gap-1">
<button onclick="openTenantConsole({{ $tenant->id }}, '{{ $tenant->company_name }}')"
class="px-2.5 py-1 text-xs font-medium rounded bg-purple-100 text-purple-700 hover:bg-purple-200 transition text-center"
title="새창에서 테넌트 관리 콘솔 열기">
<i class="ri-external-link-line"></i> 설정
</button>
<a href="{{ route('tenants.edit', $tenant->id) }}"
class="px-2.5 py-1 text-xs font-medium rounded bg-blue-100 text-blue-700 hover:bg-blue-200 transition text-center">
수정

View File

@@ -78,6 +78,7 @@
use App\Http\Controllers\System\SystemAlertController;
use App\Http\Controllers\System\SystemGuideController;
use App\Http\Controllers\System\TenantMailConfigController;
use App\Http\Controllers\TenantConsoleController;
use App\Http\Controllers\TenantController;
use App\Http\Controllers\TenantSettingController;
use App\Http\Controllers\TriggerAuditController;
@@ -146,8 +147,6 @@
Route::post('/reorder', [\App\Http\Controllers\Api\MenuFavoriteController::class, 'reorder'])->name('reorder');
});
// 테넌트 전환
Route::post('/tenant/switch', [TenantController::class, 'switch'])->name('tenant.switch');
// 프로필 설정
Route::prefix('profile')->name('profile.')->group(function () {
@@ -2037,6 +2036,108 @@
Route::get('/guide', [\App\Http\Controllers\EquipmentController::class, 'guide'])->name('guide');
});
/*
|--------------------------------------------------------------------------
| Tenant Console Routes (새창 전용 - 테넌트별 독립 관리)
|--------------------------------------------------------------------------
| /tenant-console/{tenantId}/* 형태의 URL로 접근
| SetTenantContext 미들웨어가 URL의 tenantId를 컨텍스트로 설정
*/
Route::prefix('tenant-console/{tenantId}')
->middleware(['auth', 'hq.member', 'password.changed', 'set.tenant.context'])
->name('tenant-console.')
->group(function () {
// 콘솔 대시보드
Route::get('/', [TenantConsoleController::class, 'index'])->name('index');
// 권한 관리
Route::prefix('permissions')->name('permissions.')->group(function () {
Route::get('/', [PermissionController::class, 'index'])->name('index');
Route::get('/create', [PermissionController::class, 'create'])->name('create');
Route::get('/{id}/edit', [PermissionController::class, 'edit'])->name('edit');
});
Route::prefix('roles')->name('roles.')->group(function () {
Route::get('/', [RoleController::class, 'index'])->name('index');
Route::get('/create', [RoleController::class, 'create'])->name('create');
Route::get('/{id}/edit', [RoleController::class, 'edit'])->name('edit');
});
Route::get('/role-permissions', [RolePermissionController::class, 'index'])->name('role-permissions.index');
Route::get('/department-permissions', [PermissionController::class, 'index'])->name('department-permissions.index');
Route::get('/user-permissions', [PermissionController::class, 'index'])->name('user-permissions.index');
// 시스템 관리
Route::prefix('system')->name('system.')->group(function () {
Route::prefix('ai-config')->name('ai-config.')->group(function () {
Route::get('/', [AiConfigController::class, 'index'])->name('index');
Route::post('/', [AiConfigController::class, 'store'])->name('store');
Route::put('/{id}', [AiConfigController::class, 'update'])->name('update');
Route::delete('/{id}', [AiConfigController::class, 'destroy'])->name('destroy');
Route::post('/{id}/toggle', [AiConfigController::class, 'toggle'])->name('toggle');
Route::post('/test', [AiConfigController::class, 'test'])->name('test');
Route::post('/test-gcs', [AiConfigController::class, 'testGcs'])->name('test-gcs');
});
Route::prefix('holidays')->name('holidays.')->group(function () {
Route::get('/', [HolidayController::class, 'index'])->name('index');
Route::get('/list', [HolidayController::class, 'list'])->name('list');
Route::post('/', [HolidayController::class, 'store'])->name('store');
Route::post('/bulk', [HolidayController::class, 'bulkStore'])->name('bulk');
Route::post('/destroy-year', [HolidayController::class, 'destroyByYear'])->name('destroy-year');
Route::put('/{id}', [HolidayController::class, 'update'])->name('update');
Route::delete('/{id}', [HolidayController::class, 'destroy'])->name('destroy');
});
Route::prefix('alerts')->name('alerts.')->group(function () {
Route::get('/', [SystemAlertController::class, 'index'])->name('index');
Route::post('/{id}/read', [SystemAlertController::class, 'markAsRead'])->name('read');
Route::post('/{id}/resolve', [SystemAlertController::class, 'resolve'])->name('resolve');
Route::post('/read-all', [SystemAlertController::class, 'markAllAsRead'])->name('read-all');
});
});
// 공통코드 관리
Route::prefix('common-codes')->name('common-codes.')->group(function () {
Route::get('/', [CommonCodeController::class, 'index'])->name('index');
Route::post('/', [CommonCodeController::class, 'store'])->name('store');
Route::post('/store-group', [CommonCodeController::class, 'storeGroup'])->name('store-group');
Route::post('/bulk-copy', [CommonCodeController::class, 'bulkCopy'])->name('bulk-copy');
Route::post('/bulk-promote', [CommonCodeController::class, 'bulkPromoteToGlobal'])->name('bulk-promote');
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::post('/{id}/promote', [CommonCodeController::class, 'promoteToGlobal'])->name('promote');
Route::delete('/{id}', [CommonCodeController::class, 'destroy'])->name('destroy');
});
// 카테고리 관리
Route::prefix('categories')->name('categories.')->group(function () {
Route::get('/', [CategoryController::class, 'index'])->name('index');
Route::post('/store-group', [CategoryController::class, 'storeGroup'])->name('store-group');
});
Route::prefix('audit-logs')->name('audit-logs.')->group(function () {
Route::get('/', [AuditLogController::class, 'index'])->name('index');
Route::get('/{id}', [AuditLogController::class, 'show'])->name('show');
});
// 생산관리
Route::prefix('production')->name('production.')->group(function () {
Route::get('/items', [ItemManagementController::class, 'index'])->name('items.index');
Route::get('/item-fields', [ItemFieldController::class, 'index'])->name('item-fields.index');
Route::get('/quote-formulas', [QuoteFormulaController::class, 'index'])->name('quote-formulas.index');
Route::get('/categories', [CategoryController::class, 'index'])->name('categories.index');
});
// 게시판관리
Route::prefix('boards')->name('boards.')->group(function () {
Route::get('/', [BoardController::class, 'index'])->name('index');
Route::get('/create', [BoardController::class, 'create'])->name('create');
Route::get('/{id}/edit', [BoardController::class, 'edit'])->name('edit');
});
});
/*
|--------------------------------------------------------------------------
| SAM E-Sign Public Routes (인증 불필요 - 서명자용)