diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php
index daefa1c7..97b868e4 100644
--- a/app/Http/Controllers/Auth/LoginController.php
+++ b/app/Http/Controllers/Auth/LoginController.php
@@ -4,14 +4,18 @@
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
+use App\Services\ApiTokenService;
use App\Services\AuthService;
+use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
+use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class LoginController extends Controller
{
public function __construct(
- private readonly AuthService $authService
+ private readonly AuthService $authService,
+ private readonly ApiTokenService $apiTokenService
) {}
/**
@@ -59,4 +63,91 @@ public function logout(): RedirectResponse
return redirect('/login')
->with('success', '로그아웃되었습니다.');
}
+
+ /**
+ * 세션 갱신 (Remember Token으로 재인증)
+ * HTMX 401 에러 시 호출되어 세션을 갱신합니다.
+ */
+ public function refreshSession(): JsonResponse
+ {
+ // 이미 인증된 경우 (세션이 아직 유효한 경우)
+ if (Auth::check()) {
+ return response()->json([
+ 'success' => true,
+ 'message' => '세션이 유효합니다.',
+ ]);
+ }
+
+ // Remember Token으로 재인증 시도
+ // Laravel의 Auth::viaRemember()는 remember token 쿠키로 인증 시도
+ if (Auth::viaRemember()) {
+ $user = Auth::user();
+
+ // HQ 테넌트 소속 확인
+ if (! $user->belongsToHQ()) {
+ Auth::logout();
+
+ return response()->json([
+ 'success' => false,
+ 'message' => '본사 소속 직원만 접근할 수 있습니다.',
+ 'redirect' => '/login',
+ ], 401);
+ }
+
+ // 활성 상태 확인
+ if (! $user->is_active) {
+ Auth::logout();
+
+ return response()->json([
+ 'success' => false,
+ 'message' => '비활성화된 계정입니다.',
+ 'redirect' => '/login',
+ ], 401);
+ }
+
+ // HQ 테넌트를 기본 선택
+ $hqTenant = $user->getHQTenant();
+ if ($hqTenant) {
+ session(['selected_tenant_id' => $hqTenant->id]);
+
+ // API 토큰 재발급
+ $this->refreshApiToken($user->id, $hqTenant->id);
+ }
+
+ return response()->json([
+ 'success' => true,
+ 'message' => '세션이 갱신되었습니다.',
+ ]);
+ }
+
+ // Remember Token이 없거나 유효하지 않은 경우
+ return response()->json([
+ 'success' => false,
+ 'message' => '세션이 만료되었습니다. 다시 로그인해주세요.',
+ 'redirect' => '/login',
+ ], 401);
+ }
+
+ /**
+ * API 토큰 재발급
+ */
+ private function refreshApiToken(int $userId, int $tenantId): void
+ {
+ try {
+ $result = $this->apiTokenService->exchangeToken($userId, $tenantId);
+
+ if ($result['success']) {
+ $this->apiTokenService->storeTokenInSession(
+ $result['data']['access_token'],
+ $result['data']['expires_in']
+ );
+ }
+ } catch (\Exception $e) {
+ // API 토큰 재발급 실패해도 세션 갱신은 계속 진행
+ \Log::warning('[LoginController] API token refresh failed', [
+ 'user_id' => $userId,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
}
diff --git a/public/js/menu-sortable.js b/public/js/menu-sortable.js
new file mode 100644
index 00000000..3fd6ff06
--- /dev/null
+++ b/public/js/menu-sortable.js
@@ -0,0 +1,303 @@
+/**
+ * 메뉴 관리 페이지 전용 SortableJS 초기화 스크립트
+ * HTMX hx-boost 네비게이션에서도 동작하도록 전역으로 정의
+ */
+
+// 드래그 상태 관리
+let dragStartX = 0;
+let dragIndicator = null;
+let currentDragItem = null;
+let lastHighlightedRow = null;
+const INDENT_THRESHOLD = 40; // px - 인덴트 임계값
+
+// 드래그 인디케이터 생성
+function createDragIndicator() {
+ const indicator = document.createElement('div');
+ indicator.className = 'drag-indicator reorder';
+ indicator.innerHTML = '↕️ 순서 변경';
+ indicator.style.display = 'none';
+ document.body.appendChild(indicator);
+ return indicator;
+}
+
+// 드래그 인디케이터 업데이트
+function updateDragIndicator(deltaX, mouseX, mouseY, canIndent, canOutdent, targetName) {
+ if (!dragIndicator) return;
+
+ dragIndicator.style.display = 'block';
+ dragIndicator.style.left = (mouseX + 15) + 'px';
+ dragIndicator.style.top = (mouseY - 15) + 'px';
+
+ // 클래스 및 텍스트 업데이트
+ dragIndicator.classList.remove('indent', 'outdent', 'reorder');
+
+ if (deltaX > INDENT_THRESHOLD && canIndent) {
+ dragIndicator.classList.add('indent');
+ dragIndicator.innerHTML = `→ ${targetName}의 하위로`;
+ } else if (deltaX < -INDENT_THRESHOLD && canOutdent) {
+ dragIndicator.classList.add('outdent');
+ dragIndicator.innerHTML = '← 상위 레벨로';
+ } else {
+ dragIndicator.classList.add('reorder');
+ dragIndicator.innerHTML = '↕️ 순서 변경';
+ }
+}
+
+// 행 하이라이트 업데이트
+function updateRowHighlight(prevRow, draggedRow, deltaX, canIndent, canOutdent) {
+ // 이전 하이라이트 제거
+ if (lastHighlightedRow) {
+ lastHighlightedRow.classList.remove('drag-target-indent', 'drag-target-outdent');
+ }
+
+ // 새 하이라이트 적용
+ if (deltaX > INDENT_THRESHOLD && canIndent && prevRow) {
+ // 인덴트: prevRow(새 부모가 될 행) 하이라이트
+ prevRow.classList.add('drag-target-indent');
+ lastHighlightedRow = prevRow;
+ } else if (deltaX < -INDENT_THRESHOLD && canOutdent && draggedRow) {
+ // 아웃덴트: 드래그 중인 행 자체 하이라이트
+ draggedRow.classList.add('drag-target-outdent');
+ lastHighlightedRow = draggedRow;
+ } else {
+ lastHighlightedRow = null;
+ }
+}
+
+// 드래그 인디케이터 제거
+function removeDragIndicator() {
+ if (dragIndicator) {
+ dragIndicator.remove();
+ dragIndicator = null;
+ }
+ if (lastHighlightedRow) {
+ lastHighlightedRow.classList.remove('drag-target-indent', 'drag-target-outdent');
+ lastHighlightedRow = null;
+ }
+}
+
+// SortableJS 초기화 함수 (전역)
+window.initMenuSortable = function() {
+ const tbody = document.getElementById('menu-sortable');
+ if (!tbody) return;
+
+ // Sortable 라이브러리 체크
+ if (typeof Sortable === 'undefined') {
+ console.warn('SortableJS not loaded');
+ return;
+ }
+
+ // 기존 인스턴스 제거
+ if (tbody.sortableInstance) {
+ tbody.sortableInstance.destroy();
+ }
+
+ // 드래그 중 마우스 이동 핸들러
+ function onDragMove(e) {
+ if (!currentDragItem) return;
+
+ const mouseX = e.clientX || e.touches?.[0]?.clientX || 0;
+ const mouseY = e.clientY || e.touches?.[0]?.clientY || 0;
+ const deltaX = mouseX - dragStartX;
+
+ const rows = Array.from(tbody.querySelectorAll('tr.menu-row'));
+ const draggedIndex = rows.indexOf(currentDragItem);
+
+ // 위 행 찾기 (드래그 중인 항목 제외)
+ let prevRow = null;
+ for (let i = draggedIndex - 1; i >= 0; i--) {
+ if (rows[i] !== currentDragItem && !rows[i].classList.contains('sortable-ghost')) {
+ prevRow = rows[i];
+ break;
+ }
+ }
+
+ // 인덴트/아웃덴트 가능 여부 체크
+ const oldParentIdRaw = currentDragItem.dataset.parentId;
+ const oldPid = oldParentIdRaw === '' ? null : (oldParentIdRaw ? parseInt(oldParentIdRaw) : null);
+ const canIndent = prevRow !== null;
+ const canOutdent = oldPid !== null;
+
+ // 타겟 이름 가져오기
+ let targetName = '';
+ if (prevRow && canIndent) {
+ const nameCell = prevRow.querySelector('td:nth-child(3) span');
+ targetName = nameCell?.textContent?.trim() || '위 항목';
+ }
+
+ updateDragIndicator(deltaX, mouseX, mouseY, canIndent, canOutdent, targetName);
+ updateRowHighlight(prevRow, currentDragItem, deltaX, canIndent, canOutdent);
+ }
+
+ // CSRF 토큰 가져오기
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
+
+ // SortableJS 초기화 - 노션 스타일 인덴트
+ tbody.sortableInstance = new Sortable(tbody, {
+ handle: '.drag-handle',
+ animation: 150,
+ forceFallback: true,
+ fallbackClass: 'sortable-fallback',
+ fallbackOnBody: true,
+ ghostClass: 'bg-blue-50',
+ chosenClass: 'bg-blue-100',
+ dragClass: 'shadow-lg',
+
+ // 드래그 시작 시
+ onStart: function(evt) {
+ dragStartX = evt.originalEvent?.clientX || evt.originalEvent?.touches?.[0]?.clientX || 0;
+ currentDragItem = evt.item;
+ dragIndicator = createDragIndicator();
+
+ // 드래그 중 텍스트 선택 방지
+ document.body.classList.add('is-dragging');
+
+ // 마우스 이동 이벤트 리스너 추가
+ document.addEventListener('mousemove', onDragMove);
+ document.addEventListener('touchmove', onDragMove);
+ },
+
+ // 드래그 종료 시 처리
+ onEnd: function(evt) {
+ // 이벤트 리스너 및 인디케이터 정리
+ document.removeEventListener('mousemove', onDragMove);
+ document.removeEventListener('touchmove', onDragMove);
+ removeDragIndicator();
+ currentDragItem = null;
+
+ // 드래그 중 텍스트 선택 방지 해제
+ document.body.classList.remove('is-dragging');
+
+ const movedItem = evt.item;
+ const menuId = parseInt(movedItem.dataset.menuId);
+ const oldParentIdRaw = movedItem.dataset.parentId;
+ const oldPid = oldParentIdRaw === '' ? null : (oldParentIdRaw ? parseInt(oldParentIdRaw) : null);
+
+ const rows = Array.from(tbody.querySelectorAll('tr.menu-row'));
+ const newIndex = rows.indexOf(movedItem);
+
+ // X 이동량 계산
+ const dragEndX = evt.originalEvent?.clientX || evt.originalEvent?.changedTouches?.[0]?.clientX || dragStartX;
+ const deltaX = dragEndX - dragStartX;
+
+ let newParentId = oldPid;
+ let sortOrder = 1;
+
+ // 오른쪽으로 이동 → 인덴트 (위 항목의 자식으로)
+ if (deltaX > INDENT_THRESHOLD && newIndex > 0) {
+ const prevRow = rows[newIndex - 1];
+ if (prevRow) {
+ const prevId = parseInt(prevRow.dataset.menuId);
+ if (prevId !== menuId) {
+ newParentId = prevId;
+ }
+ }
+ }
+ // 왼쪽으로 이동 → 아웃덴트 (부모의 형제로)
+ else if (deltaX < -INDENT_THRESHOLD && oldPid !== null) {
+ const parentRow = rows.find(r => parseInt(r.dataset.menuId) === oldPid);
+ if (parentRow) {
+ const grandParentIdRaw = parentRow.dataset.parentId;
+ newParentId = grandParentIdRaw === '' ? null : (grandParentIdRaw ? parseInt(grandParentIdRaw) : null);
+ } else {
+ newParentId = null;
+ }
+ }
+
+ const parentChanged = oldPid !== newParentId;
+
+ if (parentChanged) {
+ // 새 부모 하위에서의 순서 계산
+ const existingSiblings = rows.filter(row => {
+ if (row === movedItem) return false;
+ const rowParentId = row.dataset.parentId || '';
+ const rowPid = rowParentId === '' ? null : parseInt(rowParentId);
+ return rowPid === newParentId;
+ });
+
+ let siblingsBeforeMe = 0;
+ for (const sibling of existingSiblings) {
+ if (rows.indexOf(sibling) < newIndex) {
+ siblingsBeforeMe++;
+ }
+ }
+ sortOrder = siblingsBeforeMe + 1;
+
+ // 계층 변경 API 호출
+ window.moveMenu(menuId, newParentId, sortOrder, csrfToken);
+ } else {
+ // 같은 레벨 내 순서 변경
+ const siblingRows = rows.filter(row => {
+ const rowParentId = row.dataset.parentId || '';
+ const rowPid = rowParentId === '' ? null : parseInt(rowParentId);
+ return rowPid === oldPid;
+ });
+
+ const items = siblingRows.map((row, index) => ({
+ id: parseInt(row.dataset.menuId),
+ sort_order: index + 1
+ }));
+
+ window.saveMenuOrder(items, csrfToken);
+ }
+ }
+ });
+};
+
+// 메뉴 이동 API 호출 (계층 변경)
+window.moveMenu = function(menuId, newParentId, sortOrder, csrfToken) {
+ fetch('/api/admin/menus/move', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRF-TOKEN': csrfToken,
+ 'Accept': 'application/json'
+ },
+ body: JSON.stringify({
+ menu_id: menuId,
+ new_parent_id: newParentId,
+ sort_order: sortOrder
+ })
+ })
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ htmx.trigger('#menu-table', 'filterSubmit');
+ } else {
+ showToast('메뉴 이동 실패: ' + (data.message || ''), 'error');
+ htmx.trigger('#menu-table', 'filterSubmit');
+ }
+ })
+ .catch(error => {
+ console.error('moveMenu API Error:', error);
+ showToast('메뉴 이동 중 오류 발생', 'error');
+ htmx.trigger('#menu-table', 'filterSubmit');
+ });
+};
+
+// 메뉴 순서 저장 API 호출 (같은 레벨)
+window.saveMenuOrder = function(items, csrfToken) {
+ fetch('/api/admin/menus/reorder', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRF-TOKEN': csrfToken,
+ 'Accept': 'application/json'
+ },
+ body: JSON.stringify({ items: items })
+ })
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ htmx.trigger('#menu-table', 'filterSubmit');
+ } else {
+ showToast('순서 변경 실패: ' + (data.message || ''), 'error');
+ htmx.trigger('#menu-table', 'filterSubmit');
+ }
+ })
+ .catch(error => {
+ console.error('Error:', error);
+ showToast('순서 변경 중 오류 발생', 'error');
+ htmx.trigger('#menu-table', 'filterSubmit');
+ });
+};
\ No newline at end of file
diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php
index 1559d82c..b08c2e0d 100644
--- a/resources/views/layouts/app.blade.php
+++ b/resources/views/layouts/app.blade.php
@@ -48,32 +48,76 @@
@@ -146,6 +220,9 @@ class="fixed inset-0 bg-black/50 z-40 hidden lg:hidden"
+
+
+
-