feat(WEB): Pretendard 폰트 적용 및 HR/회계 모바일 필터 마이그레이션

- Pretendard Variable 폰트 추가 및 전역 적용
- HR 모듈 모바일 필터 적용:
  - AttendanceManagement: MobileFilter 컴포넌트 적용
  - EmployeeManagement: MobileFilter 컴포넌트 적용
  - SalaryManagement: MobileFilter 컴포넌트 적용
  - VacationManagement: MobileFilter 컴포넌트 적용
- 회계 모듈:
  - VendorManagement: MobileFilter 컴포넌트 적용
- 전자결재:
  - ReferenceBox: 모바일 UI 개선
- AuthenticatedLayout: 레이아웃 개선
- middleware: 설정 업데이트

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2026-01-14 13:46:56 +09:00
parent ea8d701a8d
commit b08366c3f7
14 changed files with 881 additions and 239 deletions

View File

@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import localFont from 'next/font/local';
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
import { notFound } from 'next/navigation';
@@ -7,6 +8,15 @@ import { ThemeProvider } from '@/contexts/ThemeContext';
import { Toaster } from 'sonner';
import "../globals.css";
// 🔧 Pretendard Variable 폰트 - FOUT 완전 방지
const pretendard = localFont({
src: '../../../public/font/PretendardVariable.woff2',
variable: '--font-pretendard',
display: 'swap',
preload: true,
weight: '100 900', // Variable 폰트 weight 범위
});
export const metadata: Metadata = {
title: {
default: "ERP System - Enterprise Resource Planning",
@@ -64,8 +74,8 @@ export default async function RootLayout({
const messages = await getMessages();
return (
<html lang={locale} suppressHydrationWarning>
<body className="antialiased">
<html lang={locale} className={pretendard.variable} suppressHydrationWarning>
<body className={`${pretendard.className} antialiased`}>
<ThemeProvider>
<NextIntlClientProvider messages={messages}>
{children}

View File

@@ -1,6 +1,7 @@
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css');
@import "tailwindcss";
/* 🔧 Pretendard 폰트는 next/font/local로 로드됨 (layout.tsx) */
@variant dark (&:is(.dark *));
@variant senior (&:is(.senior *));
@@ -201,7 +202,7 @@
@layer base {
* {
@apply border-border outline-ring/50;
font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, system-ui, Roboto, 'Helvetica Neue', 'Segoe UI', 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-family: var(--font-pretendard), -apple-system, BlinkMacSystemFont, system-ui, Roboto, 'Helvetica Neue', 'Segoe UI', 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
}
html {
@@ -211,7 +212,7 @@
body {
@apply bg-background text-foreground;
font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, system-ui, Roboto, 'Helvetica Neue', 'Segoe UI', 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-family: var(--font-pretendard), -apple-system, BlinkMacSystemFont, system-ui, Roboto, 'Helvetica Neue', 'Segoe UI', 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-feature-settings: "kern" 1, "liga" 1, "calt" 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;

View File

@@ -22,17 +22,12 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { TableRow, TableCell } from '@/components/ui/table';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
IntegratedListTemplateV2,
type TableColumn,
type StatCard,
type FilterFieldConfig,
type FilterValues,
} from '@/components/templates/IntegratedListTemplateV2';
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
import type {
@@ -370,80 +365,96 @@ export function VendorManagement({ initialData, initialTotal }: VendorManagement
);
}, [handleRowClick, handleEdit, handleDeleteClick]);
// ===== 테이블 헤더 액션 (5개 필터) =====
const tableHeaderActions = (
<div className="flex items-center gap-2 flex-wrap">
{/* 구분 필터 */}
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="구분" />
</SelectTrigger>
<SelectContent>
{VENDOR_CATEGORY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
// ===== filterConfig 방식 모바일 필터 =====
const filterConfig: FilterFieldConfig[] = useMemo(() => [
{
key: 'category',
label: '구분',
type: 'single',
options: VENDOR_CATEGORY_OPTIONS.filter(o => o.value !== 'all').map(o => ({
value: o.value,
label: o.label,
})),
allOptionLabel: '전체',
},
{
key: 'creditRating',
label: '신용등급',
type: 'single',
options: CREDIT_RATING_OPTIONS.filter(o => o.value !== 'all').map(o => ({
value: o.value,
label: o.label,
})),
allOptionLabel: '전체',
},
{
key: 'transactionGrade',
label: '거래등급',
type: 'single',
options: TRANSACTION_GRADE_OPTIONS.filter(o => o.value !== 'all').map(o => ({
value: o.value,
label: o.label,
})),
allOptionLabel: '전체',
},
{
key: 'badDebt',
label: '악성채권',
type: 'single',
options: BAD_DEBT_STATUS_OPTIONS.filter(o => o.value !== 'all').map(o => ({
value: o.value,
label: o.label,
})),
allOptionLabel: '전체',
},
{
key: 'sort',
label: '정렬',
type: 'single',
options: SORT_OPTIONS.map(o => ({
value: o.value,
label: o.label,
})),
},
], []);
{/* 신용등급 필터 */}
<Select value={creditRatingFilter} onValueChange={setCreditRatingFilter}>
<SelectTrigger className="w-[110px]">
<SelectValue placeholder="신용등급" />
</SelectTrigger>
<SelectContent>
{CREDIT_RATING_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
const filterValues: FilterValues = useMemo(() => ({
category: categoryFilter,
creditRating: creditRatingFilter,
transactionGrade: transactionGradeFilter,
badDebt: badDebtFilter,
sort: sortOption,
}), [categoryFilter, creditRatingFilter, transactionGradeFilter, badDebtFilter, sortOption]);
{/* 거래등급 필터 */}
<Select value={transactionGradeFilter} onValueChange={setTransactionGradeFilter}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="거래등급" />
</SelectTrigger>
<SelectContent>
{TRANSACTION_GRADE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
switch (key) {
case 'category':
setCategoryFilter(value as string);
break;
case 'creditRating':
setCreditRatingFilter(value as string);
break;
case 'transactionGrade':
setTransactionGradeFilter(value as string);
break;
case 'badDebt':
setBadDebtFilter(value as string);
break;
case 'sort':
setSortOption(value as SortOption);
break;
}
setCurrentPage(1);
}, []);
{/* 악성채권 필터 */}
<Select value={badDebtFilter} onValueChange={setBadDebtFilter}>
<SelectTrigger className="w-[110px]">
<SelectValue placeholder="악성채권" />
</SelectTrigger>
<SelectContent>
{BAD_DEBT_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 정렬 */}
<Select value={sortOption} onValueChange={(value) => setSortOption(value as SortOption)}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="정렬" />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
const handleFilterReset = useCallback(() => {
setCategoryFilter('all');
setCreditRatingFilter('all');
setTransactionGradeFilter('all');
setBadDebtFilter('all');
setSortOption('latest');
setCurrentPage(1);
}, []);
return (
<>

View File

@@ -1,6 +1,6 @@
'use client';
import { useState, useMemo, useCallback, useEffect, useTransition } from 'react';
import { useState, useMemo, useCallback, useEffect, useTransition, useRef } from 'react';
import {
Files,
Eye,
@@ -152,19 +152,44 @@ export function ReferenceBox() {
}
}, []);
// ===== 초기 로드 및 필터 변경 시 데이터 재로드 =====
useEffect(() => {
loadData();
}, [loadData]);
// ===== 초기 로드 =====
// 마운트 시 1회만 실행 (summary 로드)
useEffect(() => {
loadSummary();
}, [loadSummary]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ===== 데이터 로드 (의존성 명시적 관리) =====
// currentPage, searchQuery, filterOption, sortOption, activeTab 변경 시 데이터 재로드
useEffect(() => {
loadData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPage, searchQuery, filterOption, sortOption, activeTab]);
// ===== 검색어/필터/탭 변경 시 페이지 초기화 =====
// ref로 이전 값 추적하여 불필요한 상태 변경 방지 (무한 루프 방지)
const prevSearchRef = useRef(searchQuery);
const prevFilterRef = useRef(filterOption);
const prevSortRef = useRef(sortOption);
const prevTabRef = useRef(activeTab);
useEffect(() => {
setCurrentPage(1);
}, [searchQuery, filterOption, sortOption, activeTab]);
const searchChanged = prevSearchRef.current !== searchQuery;
const filterChanged = prevFilterRef.current !== filterOption;
const sortChanged = prevSortRef.current !== sortOption;
const tabChanged = prevTabRef.current !== activeTab;
if (searchChanged || filterChanged || sortChanged || tabChanged) {
// 페이지가 1이 아닐 때만 리셋 (불필요한 상태 변경 방지)
if (currentPage !== 1) {
setCurrentPage(1);
}
prevSearchRef.current = searchQuery;
prevFilterRef.current = filterOption;
prevSortRef.current = sortOption;
prevTabRef.current = activeTab;
}
}, [searchQuery, filterOption, sortOption, activeTab, currentPage]);
// ===== 탭 변경 핸들러 =====
const handleTabChange = useCallback((value: string) => {

View File

@@ -17,13 +17,6 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { TableRow, TableCell } from '@/components/ui/table';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { format } from 'date-fns';
import { ko } from 'date-fns/locale';
import {
@@ -31,6 +24,8 @@ import {
type TableColumn,
type StatCard,
type TabOption,
type FilterFieldConfig,
type FilterValues,
} from '@/components/templates/IntegratedListTemplateV2';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
@@ -507,38 +502,51 @@ export function AttendanceManagement() {
</>
);
// 테이블 헤더 액션 (필터 + 정렬 셀렉트) - 사원관리와 동일한 위치
const tableHeaderActions = (
<div className="flex items-center gap-2">
{/* 필터 셀렉트박스 */}
<Select value={filterOption} onValueChange={(value) => setFilterOption(value as FilterOption)}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="필터 선택" />
</SelectTrigger>
<SelectContent>
{FILTER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
// ===== filterConfig 기반 통합 필터 시스템 =====
const filterConfig: FilterFieldConfig[] = useMemo(() => [
{
key: 'filter',
label: '필터',
type: 'single',
options: FILTER_OPTIONS.filter(o => o.value !== 'all').map(o => ({
value: o.value,
label: o.label,
})),
allOptionLabel: '전체',
},
{
key: 'sort',
label: '정렬',
type: 'single',
options: SORT_OPTIONS.map(o => ({
value: o.value,
label: o.label,
})),
},
], []);
{/* 정렬 셀렉트박스 */}
<Select value={sortOption} onValueChange={(value) => setSortOption(value as SortOption)}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="정렬 선택" />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
const filterValues: FilterValues = useMemo(() => ({
filter: filterOption,
sort: sortOption,
}), [filterOption, sortOption]);
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
switch (key) {
case 'filter':
setFilterOption(value as FilterOption);
break;
case 'sort':
setSortOption(value as SortOption);
break;
}
setCurrentPage(1);
}, []);
const handleFilterReset = useCallback(() => {
setFilterOption('all');
setSortOption('dateDesc');
setCurrentPage(1);
}, []);
// 검색 옆 추가 필터 (사유 등록 버튼)
const extraFilters = (
@@ -570,7 +578,11 @@ export function AttendanceManagement() {
onSearchChange={setSearchValue}
searchPlaceholder="이름, 부서 검색..."
extraFilters={extraFilters}
tableHeaderActions={tableHeaderActions}
filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="근태 필터"
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}

View File

@@ -8,13 +8,6 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { TableRow, TableCell } from '@/components/ui/table';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
AlertDialog,
AlertDialogAction,
@@ -30,6 +23,8 @@ import {
type TabOption,
type TableColumn,
type StatCard,
type FilterFieldConfig,
type FilterValues,
} from '@/components/templates/IntegratedListTemplateV2';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
@@ -552,38 +547,51 @@ export function EmployeeManagement() {
</>
);
// 테이블 헤더 액션 (필터/정렬 셀렉트박스)
const tableHeaderActions = (
<div className="flex items-center gap-2">
{/* 필터 셀렉트박스 */}
<Select value={filterOption} onValueChange={(value) => setFilterOption(value as FilterOption)}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="필터 선택" />
</SelectTrigger>
<SelectContent>
{FILTER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
// ===== filterConfig 기반 통합 필터 시스템 =====
const filterConfig: FilterFieldConfig[] = useMemo(() => [
{
key: 'filter',
label: '필터',
type: 'single',
options: FILTER_OPTIONS.filter(o => o.value !== 'all').map(o => ({
value: o.value,
label: o.label,
})),
allOptionLabel: '전체',
},
{
key: 'sort',
label: '정렬',
type: 'single',
options: SORT_OPTIONS.map(o => ({
value: o.value,
label: o.label,
})),
},
], []);
{/* 정렬 셀렉트박스 */}
<Select value={sortOption} onValueChange={(value) => setSortOption(value as SortOption)}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="정렬 선택" />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
const filterValues: FilterValues = useMemo(() => ({
filter: filterOption,
sort: sortOption,
}), [filterOption, sortOption]);
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
switch (key) {
case 'filter':
setFilterOption(value as FilterOption);
break;
case 'sort':
setSortOption(value as SortOption);
break;
}
setCurrentPage(1);
}, []);
const handleFilterReset = useCallback(() => {
setFilterOption('all');
setSortOption('rank');
setCurrentPage(1);
}, []);
// 페이지네이션 설정
const totalPages = Math.ceil(filteredEmployees.length / itemsPerPage);
@@ -602,7 +610,11 @@ export function EmployeeManagement() {
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
tableHeaderActions={tableHeaderActions}
filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="사원 필터"
tableColumns={tableColumns}
data={paginatedData}
totalCount={filteredEmployees.length}

View File

@@ -20,18 +20,13 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { TableRow, TableCell } from '@/components/ui/table';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
IntegratedListTemplateV2,
type TableColumn,
type StatCard,
type TabOption,
type FilterFieldConfig,
type FilterValues,
} from '@/components/templates/IntegratedListTemplateV2';
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
import { SalaryDetailDialog } from './SalaryDetailDialog';
@@ -451,19 +446,36 @@ export function SalaryManagement() {
</div>
);
// ===== 정렬 필터 =====
const extraFilters = (
<Select value={sortOption} onValueChange={(v) => setSortOption(v as SortOption)}>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(SORT_OPTIONS).map(([key, label]) => (
<SelectItem key={key} value={key}>{label}</SelectItem>
))}
</SelectContent>
</Select>
);
// ===== filterConfig 기반 통합 필터 시스템 =====
const filterConfig: FilterFieldConfig[] = useMemo(() => [
{
key: 'sort',
label: '정렬',
type: 'single',
options: Object.entries(SORT_OPTIONS).map(([value, label]) => ({
value,
label,
})),
},
], []);
const filterValues: FilterValues = useMemo(() => ({
sort: sortOption,
}), [sortOption]);
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
switch (key) {
case 'sort':
setSortOption(value as SortOption);
break;
}
setCurrentPage(1);
}, []);
const handleFilterReset = useCallback(() => {
setSortOption('rank');
setCurrentPage(1);
}, []);
return (
<>
@@ -476,7 +488,11 @@ export function SalaryManagement() {
searchValue={searchQuery}
onSearchChange={setSearchQuery}
searchPlaceholder="이름, 부서 검색..."
extraFilters={extraFilters}
filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="급여 필터"
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}

View File

@@ -29,13 +29,6 @@ import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { TableRow, TableCell } from '@/components/ui/table';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
AlertDialog,
AlertDialogAction,
@@ -51,6 +44,8 @@ import {
type TableColumn,
type StatCard,
type TabOption,
type FilterFieldConfig,
type FilterValues,
} from '@/components/templates/IntegratedListTemplateV2';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
@@ -666,38 +661,51 @@ export function VacationManagement() {
</>
);
// ===== 테이블 헤더 액션 (필터 + 정렬 셀렉트) - 사원관리와 동일한 위치 =====
const tableHeaderActions = (
<div className="flex items-center gap-2">
{/* 필터 셀렉트박스 */}
<Select value={filterOption} onValueChange={(value) => setFilterOption(value as FilterOption)}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="필터 선택" />
</SelectTrigger>
<SelectContent>
{FILTER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
// ===== filterConfig 기반 통합 필터 시스템 =====
const filterConfig: FilterFieldConfig[] = useMemo(() => [
{
key: 'filter',
label: '필터',
type: 'single',
options: FILTER_OPTIONS.filter(o => o.value !== 'all').map(o => ({
value: o.value,
label: o.label,
})),
allOptionLabel: '전체',
},
{
key: 'sort',
label: '정렬',
type: 'single',
options: SORT_OPTIONS.map(o => ({
value: o.value,
label: o.label,
})),
},
], []);
{/* 정렬 셀렉트박스 */}
<Select value={sortOption} onValueChange={(value) => setSortOption(value as SortOption)}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="정렬 선택" />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
const filterValues: FilterValues = useMemo(() => ({
filter: filterOption,
sort: sortOption,
}), [filterOption, sortOption]);
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
switch (key) {
case 'filter':
setFilterOption(value as FilterOption);
break;
case 'sort':
setSortOption(value as SortOption);
break;
}
setCurrentPage(1);
}, []);
const handleFilterReset = useCallback(() => {
setFilterOption('all');
setSortOption('rank');
setCurrentPage(1);
}, []);
return (
<>
@@ -711,7 +719,11 @@ export function VacationManagement() {
searchValue={searchQuery}
onSearchChange={setSearchQuery}
searchPlaceholder="이름, 부서 검색..."
tableHeaderActions={tableHeaderActions}
filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="휴가 필터"
tabs={tabs}
activeTab={mainTab}
onTabChange={handleMainTabChange}

View File

@@ -93,6 +93,14 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
// 회사 선택 상태 (목업)
const [selectedCompany, setSelectedCompany] = useState<string>("all");
// 🔧 클라이언트 마운트 상태 (새로고침 시 스피너 표시용)
const [isMounted, setIsMounted] = useState(false);
// 🔧 클라이언트 마운트 확인 (새로고침 시 스피너 표시)
useEffect(() => {
setIsMounted(true);
}, []);
// 메뉴 폴링 (30초마다 메뉴 변경 확인)
// 백엔드 GET /api/v1/menus API 준비되면 자동 동작
const { restartAfterAuth } = useMenuPolling({
@@ -309,6 +317,18 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
// By removing this check, we allow the component to render immediately with default values
// and update once hydration completes through the useEffect above.
// 🔧 새로고침 시 스피너 표시 (hydration 전)
if (!isMounted) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center">
<div className="inline-block h-10 w-10 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
<p className="text-muted-foreground text-sm"> ...</p>
</div>
</div>
);
}
// 현재 페이지가 대시보드인지 확인
const isDashboard = pathname?.includes('/dashboard') || activeMenu === 'dashboard';

View File

@@ -191,6 +191,14 @@ export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const userAgent = request.headers.get('user-agent') || '';
// 🚨 -1⃣ Next.js 내부 요청 필터링
// 동적 라우트 세그먼트가 리터럴로 포함된 요청은 Next.js 내부 컴파일/prefetch
// 예: /[locale]/settings/... 형태의 요청은 실제 사용자 요청이 아님
if (pathname.includes('[') && pathname.includes(']')) {
// console.log(`[Internal Request Skip] Dynamic segment in path: ${pathname}`);
return NextResponse.next();
}
// 🚨 0⃣ Internet Explorer Detection (최우선 처리)
// IE 사용자는 지원 안내 페이지로 리다이렉트
if (isInternetExplorer(userAgent)) {