diff --git a/src/app/[locale]/(protected)/sales/order-management-sales/[id]/page.tsx b/src/app/[locale]/(protected)/sales/order-management-sales/[id]/page.tsx index bac7c48a..7f828a02 100644 --- a/src/app/[locale]/(protected)/sales/order-management-sales/[id]/page.tsx +++ b/src/app/[locale]/(protected)/sales/order-management-sales/[id]/page.tsx @@ -71,6 +71,7 @@ import { type OrderStatus, } from "@/components/orders"; import { sendSalesOrderNotification } from "@/lib/actions/fcm"; +import { OrderSalesDetailEdit } from "@/components/orders/OrderSalesDetailEdit"; /** * 수량 포맷 함수 @@ -844,6 +845,11 @@ export default function OrderDetailPage() { ); }, [order, handleEdit, handleConfirmOrder, handleProductionOrder, handleViewProductionOrder, handleRevertProduction, handleRevertConfirmation, handleCancel, handleDelete]); + // V2 패턴: ?mode=edit일 때 수정 컴포넌트 렌더링 + if (isEditMode) { + return ; + } + return ( <> ); } diff --git a/src/app/[locale]/(protected)/settings/accounts/new/page.tsx b/src/app/[locale]/(protected)/settings/accounts/new/page.tsx index bbc0fb7e..56e610b5 100644 --- a/src/app/[locale]/(protected)/settings/accounts/new/page.tsx +++ b/src/app/[locale]/(protected)/settings/accounts/new/page.tsx @@ -20,6 +20,7 @@ export default function NewAccountPage() { config={accountConfig} mode="create" onSubmit={handleSubmit} + stickyButtons={true} /> ); } diff --git a/src/components/accounting/CardTransactionInquiry/CardTransactionDetailClient.tsx b/src/components/accounting/CardTransactionInquiry/CardTransactionDetailClient.tsx index 2db818aa..cbd07431 100644 --- a/src/components/accounting/CardTransactionInquiry/CardTransactionDetailClient.tsx +++ b/src/components/accounting/CardTransactionInquiry/CardTransactionDetailClient.tsx @@ -133,7 +133,6 @@ export default function CardTransactionDetailClient({ onSubmit={handleSubmit} onDelete={handleDelete} onModeChange={handleModeChange} - buttonPosition="top" /> ); } diff --git a/src/components/accounting/DepositManagement/DepositDetailClientV2.tsx b/src/components/accounting/DepositManagement/DepositDetailClientV2.tsx index 80465528..f76e0e53 100644 --- a/src/components/accounting/DepositManagement/DepositDetailClientV2.tsx +++ b/src/components/accounting/DepositManagement/DepositDetailClientV2.tsx @@ -138,7 +138,6 @@ export default function DepositDetailClientV2({ onSubmit={handleSubmit} onDelete={handleDelete} onModeChange={handleModeChange} - buttonPosition="top" /> ); } diff --git a/src/components/accounting/PurchaseManagement/PurchaseDetail.tsx b/src/components/accounting/PurchaseManagement/PurchaseDetail.tsx index 045958da..3f31f07d 100644 --- a/src/components/accounting/PurchaseManagement/PurchaseDetail.tsx +++ b/src/components/accounting/PurchaseManagement/PurchaseDetail.tsx @@ -675,7 +675,7 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) { // ===== 동적 config ===== const dynamicConfig = { ...purchaseConfig, - title: isNewMode ? '매입 등록' : '매입 상세', + title: isNewMode ? '매입' : '매입 상세', actions: { ...purchaseConfig.actions, submitLabel: isNewMode ? '등록' : '저장', diff --git a/src/components/accounting/WithdrawalManagement/WithdrawalDetailClientV2.tsx b/src/components/accounting/WithdrawalManagement/WithdrawalDetailClientV2.tsx index c1250fb8..dad9bccd 100644 --- a/src/components/accounting/WithdrawalManagement/WithdrawalDetailClientV2.tsx +++ b/src/components/accounting/WithdrawalManagement/WithdrawalDetailClientV2.tsx @@ -138,7 +138,6 @@ export default function WithdrawalDetailClientV2({ onSubmit={handleSubmit} onDelete={handleDelete} onModeChange={handleModeChange} - buttonPosition="top" /> ); } diff --git a/src/components/approval/DocumentCreate/documentCreateConfig.ts b/src/components/approval/DocumentCreate/documentCreateConfig.ts index cdbd9463..9563e751 100644 --- a/src/components/approval/DocumentCreate/documentCreateConfig.ts +++ b/src/components/approval/DocumentCreate/documentCreateConfig.ts @@ -22,13 +22,13 @@ export const documentCreateConfig: DetailConfig = { export const documentEditConfig: DetailConfig = { ...documentCreateConfig, - title: '문서 수정', + title: '문서', description: '기존 결재 문서를 수정합니다', // actions는 documentCreateConfig에서 상속 (커스텀 버튼 사용) }; export const documentCopyConfig: DetailConfig = { ...documentCreateConfig, - title: '문서 복제', + title: '문서', description: '복제된 문서를 수정 후 상신합니다', }; diff --git a/src/components/board/BoardDetail/index.tsx b/src/components/board/BoardDetail/index.tsx index a2f68c68..f0306369 100644 --- a/src/components/board/BoardDetail/index.tsx +++ b/src/components/board/BoardDetail/index.tsx @@ -18,7 +18,7 @@ import { useState, useCallback } from 'react'; import { useRouter } from 'next/navigation'; import { format } from 'date-fns'; -import { FileText, Download, ArrowLeft } from 'lucide-react'; +import { FileText, Download, ArrowLeft, Trash2, Edit } from 'lucide-react'; import { PageLayout } from '@/components/organisms/PageLayout'; import { PageHeader } from '@/components/organisms/PageHeader'; import { Button } from '@/components/ui/button'; @@ -34,6 +34,7 @@ import { CommentSection } from '../CommentSection'; import { deletePost } from '../actions'; import type { Post, Comment } from '../types'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; +import { useMenuStore } from '@/store/menuStore'; interface BoardDetailProps { post: Post; @@ -43,6 +44,7 @@ interface BoardDetailProps { export function BoardDetail({ post, comments: initialComments, currentUserId }: BoardDetailProps) { const router = useRouter(); + const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const [comments, setComments] = useState(initialComments); @@ -119,28 +121,9 @@ export function BoardDetail({ post, comments: initialComments, currentUserId }: title="게시글 상세" description="게시글을 조회합니다." icon={FileText} - actions={ -
- - {isMyPost && ( - <> - - - - )} -
- } /> +
{/* 게시글 카드 */} @@ -215,6 +198,31 @@ export function BoardDetail({ post, comments: initialComments, currentUserId }: onDeleteComment={handleDeleteComment} /> )} +
+ + {/* 하단 액션 버튼 (sticky) */} +
+ + {isMyPost && ( +
+ + +
+ )} +
{/* 삭제 확인 다이얼로그 */} state.sidebarCollapsed); // 모드 상태 const [mode, setMode] = useState<'view' | 'edit' | 'new'>( @@ -268,47 +270,6 @@ export default function LaborDetailClient({ // 페이지 타이틀 const pageTitle = mode === 'new' ? '노임 등록' : '노임 상세'; - // 액션 버튼 - const actionButtons = ( -
- {mode === 'view' && ( - <> - - - - - )} - {mode === 'edit' && ( - <> - - - - )} - {mode === 'new' && ( - <> - - - - )} -
- ); - if (isLoading && !isNewMode) { return ( @@ -331,9 +292,8 @@ export default function LaborDetailClient({ title={pageTitle} description="노임 정보를 등록하고 관리합니다." icon={Hammer} - actions={actionButtons} /> -
+
{/* 기본 정보 */} @@ -440,6 +400,56 @@ export default function LaborDetailClient({
+ + {/* 하단 액션 버튼 (sticky) */} +
+ +
+ {mode === 'view' && ( + <> + + + + )} + {mode === 'edit' && ( + <> + + + + )} + {mode === 'new' && ( + <> + + + + )} +
+
{/* 삭제 확인 다이얼로그 */} diff --git a/src/components/business/construction/labor-management/LaborDetailClientV2.tsx b/src/components/business/construction/labor-management/LaborDetailClientV2.tsx index 816ab506..2e259fe2 100644 --- a/src/components/business/construction/labor-management/LaborDetailClientV2.tsx +++ b/src/components/business/construction/labor-management/LaborDetailClientV2.tsx @@ -2,7 +2,6 @@ * LaborDetailClientV2 - IntegratedDetailTemplate 기반 노임 상세/등록/수정 * * 기존 LaborDetailClient를 IntegratedDetailTemplate으로 마이그레이션 - * - buttonPosition="top" 사용 (상단 버튼) * - 6개 필드: 노임번호, 구분, 최소M, 최대M, 노임단가, 상태 */ @@ -113,7 +112,6 @@ export default function LaborDetailClientV2({ onSubmit={handleSubmit} onDelete={handleDelete} onModeChange={handleModeChange} - buttonPosition="top" /> ); } diff --git a/src/components/business/construction/pricing-management/PricingDetailClient.tsx b/src/components/business/construction/pricing-management/PricingDetailClient.tsx index 906c4422..5a2b0adc 100644 --- a/src/components/business/construction/pricing-management/PricingDetailClient.tsx +++ b/src/components/business/construction/pricing-management/PricingDetailClient.tsx @@ -2,7 +2,8 @@ import { useState, useEffect, useCallback } from 'react'; import { useRouter } from 'next/navigation'; -import { DollarSign, List } from 'lucide-react'; +import { DollarSign, ArrowLeft, Trash2, Edit, X, Save, Plus, List } from 'lucide-react'; +import { useMenuStore } from '@/store/menuStore'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -63,6 +64,7 @@ const initialFormData: FormData = { export default function PricingDetailClient({ id, mode }: PricingDetailClientProps) { const router = useRouter(); + const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed); const [pricing, setPricing] = useState(null); const [formData, setFormData] = useState(initialFormData); const [vendors, setVendors] = useState<{ id: string; name: string }[]>([]); @@ -243,43 +245,9 @@ export default function PricingDetailClient({ id, mode }: PricingDetailClientPro title={pageTitle} description={pageDescription} icon={DollarSign} - actions={ -
- {isViewMode && ( - <> - - - - - )} - {isEditMode && ( - <> - - - - )} - {isCreateMode && ( - <> - - - - )} -
- } /> + +
기본 정보 @@ -416,6 +384,57 @@ export default function PricingDetailClient({ id, mode }: PricingDetailClientPro
+
+ + {/* 하단 액션 버튼 (sticky) */} +
+ +
+ {isViewMode && ( + <> + + + + )} + {isEditMode && ( + <> + + + + )} + {isCreateMode && ( + <> + + + + )} +
+
{/* 삭제 확인 다이얼로그 */} ); } diff --git a/src/components/clients/ClientDetail.tsx b/src/components/clients/ClientDetail.tsx index 78156317..861c207f 100644 --- a/src/components/clients/ClientDetail.tsx +++ b/src/components/clients/ClientDetail.tsx @@ -27,6 +27,7 @@ import { import { Client } from "../../hooks/useClientList"; import { PageLayout } from "../organisms/PageLayout"; import { PageHeader } from "../organisms/PageHeader"; +import { useMenuStore } from "@/store/menuStore"; interface ClientDetailProps { client: Client; @@ -64,6 +65,8 @@ export function ClientDetail({ onEdit, onDelete, }: ClientDetailProps) { + const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed); + // 금액 포맷 const formatCurrency = (amount: string) => { if (!amount) return "-"; @@ -73,30 +76,14 @@ export function ClientDetail({ return ( - {/* 헤더 - PageHeader 사용으로 등록/수정과 동일한 레이아웃 */} + {/* 헤더 */} - - - - - } /> -
+
{/* 1. 기본 정보 */} @@ -244,6 +231,24 @@ export function ClientDetail({ )}
+ + {/* 하단 액션 버튼 (sticky) */} +
+ +
+ + +
+
); } \ No newline at end of file diff --git a/src/components/clients/clientConfig.ts b/src/components/clients/clientConfig.ts index 38134b6e..53a6962b 100644 --- a/src/components/clients/clientConfig.ts +++ b/src/components/clients/clientConfig.ts @@ -24,7 +24,7 @@ export const clientCreateConfig: DetailConfig = { * 거래처 수정 페이지 Config */ export const clientEditConfig: DetailConfig = { - title: '거래처 수정', + title: '거래처', description: '거래처 정보를 수정합니다', icon: Building2, basePath: '/sales/client-management', diff --git a/src/components/customer-center/InquiryManagement/inquiryConfig.ts b/src/components/customer-center/InquiryManagement/inquiryConfig.ts index c26ea543..33b014c7 100644 --- a/src/components/customer-center/InquiryManagement/inquiryConfig.ts +++ b/src/components/customer-center/InquiryManagement/inquiryConfig.ts @@ -27,7 +27,7 @@ export const inquiryCreateConfig: DetailConfig = { */ export const inquiryEditConfig: DetailConfig = { ...inquiryCreateConfig, - title: '1:1 문의 수정', + title: '1:1 문의', description: '1:1 문의를 수정합니다', actions: { ...inquiryCreateConfig.actions, diff --git a/src/components/hr/EmployeeManagement/EmployeeForm.tsx b/src/components/hr/EmployeeManagement/EmployeeForm.tsx index fabfad4a..bbe37e9a 100644 --- a/src/components/hr/EmployeeManagement/EmployeeForm.tsx +++ b/src/components/hr/EmployeeManagement/EmployeeForm.tsx @@ -1036,6 +1036,7 @@ export function EmployeeForm({ renderForm={renderFormContent} renderView={renderFormContent} initialData={employee as unknown as Record} + stickyButtons={true} /> ); } \ No newline at end of file diff --git a/src/components/items/ItemListClient.tsx b/src/components/items/ItemListClient.tsx index ebe778f9..c9985a66 100644 --- a/src/components/items/ItemListClient.tsx +++ b/src/components/items/ItemListClient.tsx @@ -268,7 +268,7 @@ export default function ItemListClient() { // ID 추출 idField: 'id', - // 테이블 컬럼 + // 테이블 컬럼 (sortable: true로 정렬 가능) columns: [ { key: 'rowNumber', label: '번호', className: 'w-[60px] text-center' }, { key: 'itemCode', label: '품목코드', className: 'min-w-[120px]' }, @@ -276,7 +276,7 @@ export default function ItemListClient() { { key: 'itemName', label: '품목명', className: 'min-w-[150px]' }, { key: 'specification', label: '규격', className: 'min-w-[100px]' }, { key: 'unit', label: '단위', className: 'min-w-[60px]' }, - { key: 'status', label: '품목상태', className: 'min-w-[80px]' }, + { key: 'isActive', label: '품목상태', className: 'min-w-[80px]' }, { key: 'actions', label: '작업', className: 'w-[120px] text-right' }, ], diff --git a/src/components/layout/CommandMenuSearch.tsx b/src/components/layout/CommandMenuSearch.tsx new file mode 100644 index 00000000..236ade82 --- /dev/null +++ b/src/components/layout/CommandMenuSearch.tsx @@ -0,0 +1,199 @@ +'use client'; + +import { useEffect, useState, useMemo, useCallback, useImperativeHandle, forwardRef } from 'react'; +import { useRouter } from 'next/navigation'; +import { useMenuStore, type MenuItem } from '@/store/menuStore'; +import { + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, +} from '@/components/ui/command'; +import { Folder, ChevronRight } from 'lucide-react'; + +// 평탄화된 메뉴 아이템 타입 +interface FlatMenuItem { + id: string; + label: string; + path: string; + icon: React.ComponentType<{ className?: string }>; + breadcrumb: string[]; // 부모 메뉴 경로 (예: ['판매관리', '거래처관리']) + depth: number; +} + +// 외부에서 제어 가능한 ref 타입 +export interface CommandMenuSearchRef { + open: () => void; + close: () => void; + toggle: () => void; +} + +// 메뉴 트리를 평탄화하는 함수 +function flattenMenuItems( + items: MenuItem[], + parentBreadcrumb: string[] = [] +): FlatMenuItem[] { + const result: FlatMenuItem[] = []; + + for (const item of items) { + const currentBreadcrumb = [...parentBreadcrumb, item.label]; + + // 실제 경로가 있는 메뉴만 추가 (# 제외) + if (item.path && item.path !== '#') { + result.push({ + id: item.id, + label: item.label, + path: item.path, + icon: item.icon || Folder, + breadcrumb: currentBreadcrumb, + depth: currentBreadcrumb.length, + }); + } + + // 자식 메뉴 재귀 처리 + if (item.children && item.children.length > 0) { + result.push(...flattenMenuItems(item.children, currentBreadcrumb)); + } + } + + return result; +} + +const CommandMenuSearch = forwardRef((_, ref) => { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(''); + const router = useRouter(); + const { menuItems } = useMenuStore(); + + // 외부에서 제어할 수 있도록 ref 노출 + useImperativeHandle(ref, () => ({ + open: () => setOpen(true), + close: () => setOpen(false), + toggle: () => setOpen((prev) => !prev), + })); + + // 평탄화된 메뉴 목록 (메모이제이션) + const flatMenuItems = useMemo(() => { + return flattenMenuItems(menuItems); + }, [menuItems]); + + // 검색 필터링 (한글 초성 검색 지원) + const filteredItems = useMemo(() => { + if (!search.trim()) { + return flatMenuItems; + } + + const searchLower = search.toLowerCase(); + + return flatMenuItems.filter((item) => { + // 메뉴 이름으로 검색 + if (item.label.toLowerCase().includes(searchLower)) { + return true; + } + + // breadcrumb 전체 경로로 검색 + const fullPath = item.breadcrumb.join(' ').toLowerCase(); + if (fullPath.includes(searchLower)) { + return true; + } + + // 경로로 검색 + if (item.path.toLowerCase().includes(searchLower)) { + return true; + } + + return false; + }); + }, [flatMenuItems, search]); + + // 메뉴 선택 핸들러 + const handleSelect = useCallback( + (item: FlatMenuItem) => { + setOpen(false); + setSearch(''); + router.push(item.path); + }, + [router] + ); + + // 키보드 단축키 (Ctrl+K / Cmd+K) + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + setOpen((prev) => !prev); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, []); + + // 다이얼로그 닫힐 때 검색어 초기화 + const handleOpenChange = (isOpen: boolean) => { + setOpen(isOpen); + if (!isOpen) { + setSearch(''); + } + }; + + return ( + + + + 검색 결과가 없습니다. + + {filteredItems.map((item) => { + const IconComponent = item.icon; + return ( + handleSelect(item)} + className="flex items-center gap-2 cursor-pointer" + > + +
+ {item.breadcrumb.map((crumb, index) => ( + + {index > 0 && ( + + )} + + {crumb} + + + ))} +
+ + {item.path} + +
+ ); + })} +
+
+
+ ); +}); + +CommandMenuSearch.displayName = 'CommandMenuSearch'; + +export default CommandMenuSearch; \ No newline at end of file diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 8e7b4719..a1dd2796 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -75,7 +75,7 @@ function MenuItemComponent({ - -
- + -
+
{/* 기본 정보 */} 기본 정보 -
-
-
공정코드
+ {/* 반응형 6열 그리드: PC 6열, 태블릿 4열, 작은태블릿 2열, 모바일 1열 */} +
+
+
공정코드
{process.processCode}
-
-
공정명
+
+
공정명
{process.processName}
-
-
공정구분
+
+
공정구분
{process.processType}
-
-
담당부서
+
+
담당부서
{process.department}
-
-
작업일지 양식
+
+
작업일지 양식
{process.workLogTemplate || '-'} @@ -115,13 +107,13 @@ export function ProcessDetail({ process }: ProcessDetailProps) { 등록 정보 -
-
-
등록일
+
+
+
등록일
{process.createdAt}
-
-
최종수정일
+
+
최종수정일
{process.updatedAt}
@@ -249,25 +241,37 @@ export function ProcessDetail({ process }: ProcessDetailProps) { 작업 정보 - -
-
필요인원
-
{process.requiredWorkers}명
-
- {process.equipmentInfo && ( -
-
설비정보
-
{process.equipmentInfo}
+ +
+
+
필요인원
+
{process.requiredWorkers}명
+
+
+
설비정보
+
{process.equipmentInfo || '-'}
+
+
+
설명
+
{process.description || '-'}
- )} -
-
설명
-
{process.description || '-'}
+ {/* 하단 액션 버튼 (sticky) */} +
+ + +
+ {/* 작업일지 양식 미리보기 모달 */} 기본 정보 -
-
+ {/* 반응형 6열 그리드: PC 6열, 태블릿 4열, 작은태블릿 2열, 모바일 1열 */} + {/* 4개 필드 → 2+1+2+1 = 6열 채움 */} +
+
-
+
setEquipmentInfo(e.target.value)} - placeholder="예: 미싱기 3대, 절단기 1대" - /> -
-
- - setWorkSteps(e.target.value)} - placeholder="예: 원단절단, 미싱, 핸드작업, 중간검사, 포장" - /> + + {/* 반응형 6열 그리드: PC 6열, 태블릿 4열, 작은태블릿 2열, 모바일 1열 */} +
+
+ + setRequiredWorkers(value ?? 1)} + min={1} + /> +
+
+ + setEquipmentInfo(e.target.value)} + placeholder="예: 미싱기 3대, 절단기 1대" + /> +
+
+ + setWorkSteps(e.target.value)} + placeholder="예: 원단절단, 미싱, 핸드작업, 중간검사, 포장" + /> +
diff --git a/src/components/process-management/actions.ts b/src/components/process-management/actions.ts index 3ee6fa2e..d4e852b5 100644 --- a/src/components/process-management/actions.ts +++ b/src/components/process-management/actions.ts @@ -546,6 +546,7 @@ export async function getProcessStats(): Promise<{ // ============================================================================ export interface DepartmentOption { + id: string; value: string; label: string; } @@ -566,19 +567,30 @@ export async function getDepartmentOptions(): Promise { if (error || !response?.ok) { // 기본 부서 옵션 반환 return [ - { value: '생산부', label: '생산부' }, - { value: '품질관리부', label: '품질관리부' }, - { value: '물류부', label: '물류부' }, - { value: '영업부', label: '영업부' }, + { id: 'default-1', value: '생산부', label: '생산부' }, + { id: 'default-2', value: '품질관리부', label: '품질관리부' }, + { id: 'default-3', value: '물류부', label: '물류부' }, + { id: 'default-4', value: '영업부', label: '영업부' }, ]; } const result = await response.json(); if (result.success && result.data?.data) { - return result.data.data.map((dept: { id: number; name: string }) => ({ - value: dept.name, - label: dept.name, - })); + // 중복 부서명 제거 (같은 이름이 여러 개일 경우 첫 번째만 사용) + const seenNames = new Set(); + return result.data.data + .filter((dept: { id: number; name: string }) => { + if (seenNames.has(dept.name)) { + return false; + } + seenNames.add(dept.name); + return true; + }) + .map((dept: { id: number; name: string }) => ({ + id: String(dept.id), + value: dept.name, + label: dept.name, + })); } return []; diff --git a/src/components/quality/InspectionManagement/InspectionDetail.tsx b/src/components/quality/InspectionManagement/InspectionDetail.tsx index a0280ac0..112c560c 100644 --- a/src/components/quality/InspectionManagement/InspectionDetail.tsx +++ b/src/components/quality/InspectionManagement/InspectionDetail.tsx @@ -231,11 +231,12 @@ export function InspectionDetail({ id }: InspectionDetailProps) { const mode = isEditMode ? 'edit' : 'view'; // 동적 config (모드에 따른 타이틀 변경) + // IntegratedDetailTemplate: edit 모드에서 자동으로 "{title} 수정" 붙음 const dynamicConfig = useMemo(() => { if (isEditMode) { return { ...inspectionConfig, - title: '검사 수정', + title: '검사', }; } return inspectionConfig; diff --git a/src/components/settings/PermissionManagement/PermissionDetailClient.tsx b/src/components/settings/PermissionManagement/PermissionDetailClient.tsx index b05a2efe..8910fccc 100644 --- a/src/components/settings/PermissionManagement/PermissionDetailClient.tsx +++ b/src/components/settings/PermissionManagement/PermissionDetailClient.tsx @@ -13,7 +13,9 @@ import { XCircle, RotateCcw, Loader2, + Plus, } from 'lucide-react'; +import { useMenuStore } from '@/store/menuStore'; import { DetailPageSkeleton } from '@/components/ui/skeleton'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -125,6 +127,7 @@ const PERMISSION_LABELS_MAP: Record = { export function PermissionDetailClient({ permissionId, isNew = false, mode = 'view' }: PermissionDetailClientProps) { const router = useRouter(); + const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed); // 역할 데이터 const [role, setRole] = useState(null); @@ -481,50 +484,9 @@ export function PermissionDetailClient({ permissionId, isNew = false, mode = 'vi title={isNew ? '역할 등록' : mode === 'edit' ? '역할 수정' : '역할 상세'} description={isNew ? '새 역할을 등록합니다' : mode === 'edit' ? '역할 정보를 수정합니다' : '역할 정보와 권한을 관리합니다'} icon={Shield} - actions={ - - } /> - {/* 저장/삭제 버튼 */} -
- {isNew ? ( - - ) : ( - <> - - - - )} -
- +
{/* 기본 정보 */} @@ -658,6 +620,53 @@ export function PermissionDetailClient({ permissionId, isNew = false, mode = 'vi )} +
+ + {/* 하단 액션 버튼 (sticky) */} +
+ +
+ {isNew ? ( + + ) : ( + <> + + + + )} +
+
{/* 삭제 확인 다이얼로그 */} {!isNew && role && ( diff --git a/src/components/templates/IntegratedDetailTemplate/components/DetailActions.tsx b/src/components/templates/IntegratedDetailTemplate/components/DetailActions.tsx index 73ce2ed9..1777136d 100644 --- a/src/components/templates/IntegratedDetailTemplate/components/DetailActions.tsx +++ b/src/components/templates/IntegratedDetailTemplate/components/DetailActions.tsx @@ -1,8 +1,13 @@ /** * DetailActions - 상세 페이지 버튼 영역 컴포넌트 * + * 공통 레이아웃: + * - 왼쪽: 목록으로/취소 (뒤로가기 성격) + * - 오른쪽: [추가액션] 삭제 | 수정/저장/등록 (액션 성격) + * * View 모드: 목록으로 | [추가액션] 삭제 | 수정 - * Form 모드: 취소 | 저장/등록 + * Edit 모드: 취소 | [추가액션] 삭제 | 저장 + * Create 모드: 취소 | [추가액션] 등록 */ 'use client'; @@ -11,6 +16,7 @@ import type { ReactNode } from 'react'; import { ArrowLeft, Save, Trash2, X, Edit } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; +import { useMenuStore } from '@/store/menuStore'; export interface DetailActionsProps { /** 현재 모드 */ @@ -43,8 +49,10 @@ export interface DetailActionsProps { onDelete?: () => void; onEdit?: () => void; onSubmit?: () => void; - /** 추가 액션 (view 모드에서 삭제 버튼 앞에 표시) */ + /** 추가 액션 (삭제 버튼 앞에 표시) */ extraActions?: ReactNode; + /** 하단 고정 (sticky) 모드 */ + sticky?: boolean; /** 추가 클래스 */ className?: string; } @@ -61,10 +69,15 @@ export function DetailActions({ onEdit, onSubmit, extraActions, + sticky = false, className, }: DetailActionsProps) { const isViewMode = mode === 'view'; const isCreateMode = mode === 'create'; + const isEditMode = mode === 'edit'; + + // 사이드바 상태 가져오기 (sticky 모드에서 left 값 동적 계산용) + const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed); const { canEdit = true, @@ -89,56 +102,59 @@ export function DetailActions({ // 실제 submit 라벨 (create 모드면 '등록', 아니면 '저장') const actualSubmitLabel = submitLabel || (isCreateMode ? '등록' : '저장'); - if (isViewMode) { - return ( -
- {/* 왼쪽: 목록으로 */} - {showBack && onBack ? ( + // Fixed 스타일: 화면 하단에 고정 (사이드바 상태에 따라 동적 계산) + // 사이드바 펼침: w-64(256px), 접힘: w-24(96px), 차이: 160px + const stickyStyles = sticky + ? `fixed bottom-6 ${sidebarCollapsed ? 'left-[156px]' : 'left-[316px]'} right-[48px] px-6 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300` + : ''; + + // 공통 레이아웃: 왼쪽(뒤로) | 오른쪽(액션들) + return ( +
+ {/* 왼쪽: 목록으로 (view) 또는 취소 (edit/create) */} + {isViewMode ? ( + showBack && onBack ? ( ) : (
- )} + ) + ) : ( + + )} - {/* 오른쪽: 추가액션 + 삭제 + 수정 */} -
- {extraActions} - {canDelete && showDelete && onDelete && ( - - )} - {canEdit && showEdit && onEdit && ( - - )} -
-
- ); - } - - // Form 모드 (edit/create) - return ( -
- {/* 왼쪽: 취소 */} - - - {/* 오른쪽: 추가액션 + 저장/등록 */} + {/* 오른쪽: 추가액션 + 삭제 + 수정/저장/등록 */}
{extraActions} - {showSave && onSubmit && ( + + {/* 삭제 버튼: view, edit 모드에서 표시 (create는 삭제할 대상 없음) */} + {!isCreateMode && canDelete && showDelete && onDelete && ( + + )} + + {/* 수정 버튼: view 모드에서만 */} + {isViewMode && canEdit && showEdit && onEdit && ( + + )} + + {/* 저장/등록 버튼: edit, create 모드에서만 */} + {!isViewMode && showSave && onSubmit && ( - {/* 검색바 */} -
+ {/* 검색바 - 클릭 시 Command Palette 열기 */} +
commandMenuRef.current?.open()} + > - +
+ 메뉴 검색... + + K + +
@@ -1060,7 +1146,7 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro {/* 데스크톱 사이드바 */}
+ + {/* 메뉴 검색 Command Palette (Ctrl+K / Cmd+K) */} +
); } \ No newline at end of file diff --git a/src/middleware.ts b/src/middleware.ts index 24926a8c..3f4fef80 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -482,8 +482,41 @@ export async function middleware(request: NextRequest) { // 7️⃣.5️⃣ 🔄 토큰 사전 갱신 (Race Condition 방지) // access_token이 없고 refresh_token만 있는 경우, 페이지 렌더링 전에 미리 갱신 - // 이렇게 하면 auth/check와 serverFetch가 동시에 refresh_token을 사용하는 문제 방지 + // + // 🔴 중요: refresh 성공 후 같은 페이지로 리다이렉트해야 함! + // - 미들웨어에서 Set-Cookie를 설정해도 동시에 발생하는 API 요청은 이전 쿠키 사용 + // - 리다이렉트하면 브라우저가 새 쿠키를 적용한 후 다시 요청 + // - 이렇게 해야 클라이언트의 API 호출이 새 토큰을 사용 if (needsRefresh && refreshToken) { + // 🔄 무한 리다이렉트 방지: 이미 refresh 시도 후 돌아온 요청인지 확인 + const url = new URL(request.url); + if (url.searchParams.has('_refreshed')) { + // 이미 리프레시 시도 후 돌아왔는데도 needsRefresh=true면 쿠키 저장 실패 + // 무한 루프 방지를 위해 로그인 페이지로 리다이렉트 + console.warn(`🔴 [Middleware] Cookie not saved after refresh, redirecting to login`); + + const isProduction = process.env.NODE_ENV === 'production'; + const loginUrl = new URL('/login', request.url); + + const response = NextResponse.redirect(loginUrl); + + // 쿠키 삭제 + response.headers.append('Set-Cookie', [ + 'access_token=', 'HttpOnly', ...(isProduction ? ['Secure'] : []), + 'SameSite=Lax', 'Path=/', 'Max-Age=0', + ].join('; ')); + response.headers.append('Set-Cookie', [ + 'refresh_token=', 'HttpOnly', ...(isProduction ? ['Secure'] : []), + 'SameSite=Lax', 'Path=/', 'Max-Age=0', + ].join('; ')); + response.headers.append('Set-Cookie', [ + 'is_authenticated=', ...(isProduction ? ['Secure'] : []), + 'SameSite=Lax', 'Path=/', 'Max-Age=0', + ].join('; ')); + + return response; + } + console.log(`🔄 [Middleware] Pre-refreshing token before page render: ${pathname}`); const refreshResult = await refreshTokenInMiddleware(refreshToken); @@ -491,31 +524,12 @@ export async function middleware(request: NextRequest) { if (refreshResult.success && refreshResult.accessToken) { const isProduction = process.env.NODE_ENV === 'production'; - // 🆕 request headers에 새 토큰 설정 (같은 요청 내 서버 컴포넌트가 읽을 수 있도록) - // Set-Cookie는 응답 헤더에만 설정되어 같은 요청 내 cookies()로 읽을 수 없음 - // 따라서 request headers로 새 토큰을 전달하여 serverFetch에서 사용하도록 함 - const requestHeaders = new Headers(request.headers); - requestHeaders.set('x-refreshed-access-token', refreshResult.accessToken); - requestHeaders.set('x-refreshed-refresh-token', refreshResult.refreshToken || ''); + // 🆕 리다이렉트로 새 쿠키 적용 후 다시 로드 + // 이렇게 해야 클라이언트의 useEffect에서 호출하는 API들이 새 토큰을 사용 + url.searchParams.set('_refreshed', '1'); + const response = NextResponse.redirect(url); - // intlMiddleware 효과를 먼저 가져옴 - const intlResponse = intlMiddleware(request); - - // 새 response 생성: request headers 전달 + intlResponse 헤더 복사 - const response = NextResponse.next({ - request: { - headers: requestHeaders, - }, - }); - - // intlResponse의 헤더를 복사 (locale 관련 헤더 등) - intlResponse.headers.forEach((value, key) => { - if (key.toLowerCase() !== 'set-cookie') { - response.headers.set(key, value); - } - }); - - // 새 access_token 쿠키 설정 (클라이언트의 다음 요청을 위해) + // 새 access_token 쿠키 설정 const accessTokenCookie = [ `access_token=${refreshResult.accessToken}`, 'HttpOnly', @@ -532,16 +546,7 @@ export async function middleware(request: NextRequest) { ...(isProduction ? ['Secure'] : []), 'SameSite=Lax', 'Path=/', - 'Max-Age=604800', // 7 days (하드코딩 유지) - ].join('; '); - - // 토큰 갱신 신호 쿠키 (클라이언트에서 감지용) - const tokenRefreshedCookie = [ - `token_refreshed_at=${Date.now()}`, - ...(isProduction ? ['Secure'] : []), - 'SameSite=Lax', - 'Path=/', - 'Max-Age=60', + 'Max-Age=604800', // 7 days ].join('; '); // 인증 상태 쿠키 @@ -555,16 +560,9 @@ export async function middleware(request: NextRequest) { response.headers.append('Set-Cookie', accessTokenCookie); response.headers.append('Set-Cookie', refreshTokenCookie); - response.headers.append('Set-Cookie', tokenRefreshedCookie); response.headers.append('Set-Cookie', isAuthenticatedCookie); - // 보안 헤더 추가 - response.headers.set('X-Robots-Tag', 'noindex, nofollow, noarchive, nosnippet'); - response.headers.set('X-Content-Type-Options', 'nosniff'); - response.headers.set('X-Frame-Options', 'DENY'); - response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); - - console.log(`✅ [Middleware] Pre-refresh complete, new tokens set in cookies and request headers`); + console.log(`✅ [Middleware] Pre-refresh complete, redirecting to apply new cookies`); return response; } else { // 갱신 실패 시 쿠키 삭제 후 로그인 페이지로