Merge branch 'master' into master_api_test개발
# Conflicts: # src/app/[locale]/(protected)/sales/pricing-management/page.tsx
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { BoardForm } from '@/components/board/BoardManagement/BoardForm';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import type { Board, BoardFormData } from '@/components/board/BoardManagement/types';
|
||||
|
||||
// TODO: 실제 API에서 데이터 가져오기
|
||||
@@ -35,14 +36,7 @@ export default function BoardEditPage() {
|
||||
};
|
||||
|
||||
if (!board) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
|
||||
<p className="text-muted-foreground">로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <ContentLoadingSpinner text="게시판 정보를 불러오는 중..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import type { Board } from '@/components/board/BoardManagement/types';
|
||||
|
||||
// TODO: 실제 API에서 데이터 가져오기
|
||||
@@ -54,14 +55,7 @@ export default function BoardDetailPage() {
|
||||
};
|
||||
|
||||
if (!board) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
|
||||
<p className="text-muted-foreground">로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <ContentLoadingSpinner text="게시판 정보를 불러오는 중..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { AttendanceManagement } from '@/components/hr/AttendanceManagement';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
/**
|
||||
@@ -22,17 +23,8 @@ export const metadata: Metadata = {
|
||||
|
||||
export default function AttendanceManagementPage() {
|
||||
return (
|
||||
<div>
|
||||
<Suspense fallback={
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
|
||||
<p className="text-muted-foreground">로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<AttendanceManagement />
|
||||
</Suspense>
|
||||
</div>
|
||||
<Suspense fallback={<ContentLoadingSpinner text="근태 정보를 불러오는 중..." />}>
|
||||
<AttendanceManagement />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { CardForm } from '@/components/hr/CardManagement/CardForm';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import type { Card, CardFormData } from '@/components/hr/CardManagement/types';
|
||||
|
||||
// TODO: 실제 API에서 데이터 가져오기
|
||||
@@ -45,14 +46,7 @@ export default function CardEditPage() {
|
||||
};
|
||||
|
||||
if (!card) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
|
||||
<p className="text-muted-foreground">로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <ContentLoadingSpinner text="카드 정보를 불러오는 중..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import type { Card } from '@/components/hr/CardManagement/types';
|
||||
|
||||
// TODO: 실제 API에서 데이터 가져오기
|
||||
@@ -64,14 +65,7 @@ export default function CardDetailPage() {
|
||||
};
|
||||
|
||||
if (!card) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
|
||||
<p className="text-muted-foreground">로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <ContentLoadingSpinner text="카드 정보를 불러오는 중..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { DepartmentManagement } from '@/components/hr/DepartmentManagement';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
/**
|
||||
@@ -19,17 +20,8 @@ export const metadata: Metadata = {
|
||||
|
||||
export default function DepartmentManagementPage() {
|
||||
return (
|
||||
<div>
|
||||
<Suspense fallback={
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
|
||||
<p className="text-muted-foreground">로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<DepartmentManagement />
|
||||
</Suspense>
|
||||
</div>
|
||||
<Suspense fallback={<ContentLoadingSpinner text="부서 정보를 불러오는 중..." />}>
|
||||
<DepartmentManagement />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { EmployeeForm } from '@/components/hr/EmployeeManagement/EmployeeForm';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import type { Employee, EmployeeFormData } from '@/components/hr/EmployeeManagement/types';
|
||||
|
||||
// TODO: 실제 API에서 데이터 가져오기
|
||||
@@ -58,14 +59,7 @@ export default function EmployeeEditPage() {
|
||||
};
|
||||
|
||||
if (!employee) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
|
||||
<p className="text-muted-foreground">로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <ContentLoadingSpinner text="사원 정보를 불러오는 중..." />;
|
||||
}
|
||||
|
||||
return <EmployeeForm mode="edit" employee={employee} onSave={handleSave} />;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import type { Employee } from '@/components/hr/EmployeeManagement/types';
|
||||
|
||||
// TODO: 실제 API에서 데이터 가져오기
|
||||
@@ -77,14 +78,7 @@ export default function EmployeeDetailPage() {
|
||||
};
|
||||
|
||||
if (!employee) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
|
||||
<p className="text-muted-foreground">로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <ContentLoadingSpinner text="사원 정보를 불러오는 중..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { EmployeeManagement } from '@/components/hr/EmployeeManagement';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
/**
|
||||
@@ -22,17 +23,8 @@ export const metadata: Metadata = {
|
||||
|
||||
export default function EmployeeManagementPage() {
|
||||
return (
|
||||
<div>
|
||||
<Suspense fallback={
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
|
||||
<p className="text-muted-foreground">로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<EmployeeManagement />
|
||||
</Suspense>
|
||||
</div>
|
||||
<Suspense fallback={<ContentLoadingSpinner text="사원 정보를 불러오는 중..." />}>
|
||||
<EmployeeManagement />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { SalaryManagement } from '@/components/hr/SalaryManagement';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
/**
|
||||
@@ -22,17 +23,8 @@ export const metadata: Metadata = {
|
||||
|
||||
export default function SalaryManagementPage() {
|
||||
return (
|
||||
<div>
|
||||
<Suspense fallback={
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
|
||||
<p className="text-muted-foreground">로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<SalaryManagement />
|
||||
</Suspense>
|
||||
</div>
|
||||
<Suspense fallback={<ContentLoadingSpinner text="급여 정보를 불러오는 중..." />}>
|
||||
<SalaryManagement />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { VacationManagement } from '@/components/hr/VacationManagement';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
/**
|
||||
@@ -22,17 +23,8 @@ export const metadata: Metadata = {
|
||||
|
||||
export default function VacationManagementPage() {
|
||||
return (
|
||||
<div>
|
||||
<Suspense fallback={
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
|
||||
<p className="text-muted-foreground">로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<VacationManagement />
|
||||
</Suspense>
|
||||
</div>
|
||||
<Suspense fallback={<ContentLoadingSpinner text="휴가 정보를 불러오는 중..." />}>
|
||||
<VacationManagement />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -302,7 +302,7 @@ export default function EditItemPage() {
|
||||
// - PT(부품): DynamicItemForm에서 자동계산한 code 사용 (조립/절곡/구매 각각 다른 규칙)
|
||||
// - Material(SM, RM, CS): material_code = 품목명-규격
|
||||
// 2025-12-15: item_type은 Request Body에서 필수 (ItemUpdateRequest validation)
|
||||
let submitData = { ...data, item_type: itemType };
|
||||
let submitData: DynamicFormData = { ...data, item_type: itemType };
|
||||
|
||||
if (itemType === 'FG') {
|
||||
// FG는 품목명이 품목코드가 되므로 name 값으로 code 설정
|
||||
|
||||
@@ -7,13 +7,8 @@ import { PageLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
* - AuthenticatedLayout 내에서 표시됨 (사이드바, 헤더 유지)
|
||||
* - React Suspense 자동 적용
|
||||
* - 페이지 전환 시 즉각적인 피드백
|
||||
* - 공통 레이아웃 스타일로 통일
|
||||
* - 공통 레이아웃 스타일로 통일 (min-h-[calc(100vh-200px)])
|
||||
*/
|
||||
export default function ProtectedLoading() {
|
||||
return (
|
||||
<PageLoadingSpinner
|
||||
text="페이지를 불러오는 중..."
|
||||
minHeight="min-h-[calc(100vh-200px)]"
|
||||
/>
|
||||
);
|
||||
return <PageLoadingSpinner />;
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { ItemMasterDataManagement } from '@/components/items/ItemMasterDataManagement';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
/**
|
||||
@@ -19,17 +20,8 @@ export const metadata: Metadata = {
|
||||
|
||||
export default function ItemMasterDataManagementPage() {
|
||||
return (
|
||||
<div>
|
||||
<Suspense fallback={
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
|
||||
<p className="text-muted-foreground">로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<ItemMasterDataManagement />
|
||||
</Suspense>
|
||||
</div>
|
||||
<Suspense fallback={<ContentLoadingSpinner text="품목기준정보를 불러오는 중..." />}>
|
||||
<ItemMasterDataManagement />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
clientToFormData,
|
||||
} from "@/hooks/useClientList";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { ContentLoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
|
||||
export default function ClientEditPage() {
|
||||
const router = useRouter();
|
||||
@@ -60,11 +60,7 @@ export default function ClientEditPage() {
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
return <ContentLoadingSpinner text="거래처 정보를 불러오는 중..." />;
|
||||
}
|
||||
|
||||
if (!editingClient) {
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { ContentLoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
|
||||
export default function ClientDetailPage() {
|
||||
const router = useRouter();
|
||||
@@ -80,11 +80,7 @@ export default function ClientDetailPage() {
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
return <ContentLoadingSpinner text="거래처 정보를 불러오는 중..." />;
|
||||
}
|
||||
|
||||
if (!client) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useRouter, useParams } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
import { QuoteRegistration, QuoteFormData, INITIAL_QUOTE_FORM } from "@/components/quotes/QuoteRegistration";
|
||||
import { toast } from "sonner";
|
||||
import { ContentLoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
|
||||
// 샘플 견적 데이터 (TODO: API에서 가져오기)
|
||||
const SAMPLE_QUOTE: QuoteFormData = {
|
||||
@@ -82,14 +83,7 @@ export default function QuoteEditPage() {
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto" />
|
||||
<p className="mt-2 text-sm text-gray-500">견적 정보를 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <ContentLoadingSpinner text="견적 정보를 불러오는 중..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
FileCheck,
|
||||
ShoppingCart,
|
||||
} from "lucide-react";
|
||||
import { ContentLoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
|
||||
// 샘플 견적 데이터 (TODO: API에서 가져오기)
|
||||
const SAMPLE_QUOTE: QuoteFormData = {
|
||||
@@ -140,16 +141,7 @@ export default function QuoteDetailPage() {
|
||||
}, 0) || 0;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto" />
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
견적 정보를 불러오는 중...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <ContentLoadingSpinner text="견적 정보를 불러오는 중..." />;
|
||||
}
|
||||
|
||||
if (!quote) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { MOCK_POPUPS, type Popup } from '@/components/settings/PopupManagement/types';
|
||||
|
||||
export default function PopupDetailPage() {
|
||||
@@ -43,14 +44,7 @@ export default function PopupDetailPage() {
|
||||
};
|
||||
|
||||
if (!popup) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
|
||||
<p className="text-muted-foreground">로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <ContentLoadingSpinner text="팝업 정보를 불러오는 중..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -60,7 +60,7 @@ export async function GET(request: NextRequest) {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
|
||||
'X-API-KEY': process.env.API_KEY || '',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
refresh_token: refreshToken,
|
||||
|
||||
@@ -109,7 +109,7 @@ export async function POST(request: NextRequest) {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
|
||||
'X-API-KEY': process.env.API_KEY || '',
|
||||
},
|
||||
body: JSON.stringify({ user_id, user_pwd }),
|
||||
});
|
||||
|
||||
@@ -39,7 +39,7 @@ export async function POST(request: NextRequest) {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
|
||||
'X-API-KEY': process.env.API_KEY || '',
|
||||
},
|
||||
});
|
||||
console.log('✅ Backend logout API called successfully');
|
||||
|
||||
@@ -49,7 +49,7 @@ export async function POST(request: NextRequest) {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
|
||||
'X-API-KEY': process.env.API_KEY || '',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
refresh_token: refreshToken,
|
||||
|
||||
@@ -64,7 +64,7 @@ export async function POST(request: NextRequest) {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
|
||||
'X-API-KEY': process.env.API_KEY || '',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
@@ -45,7 +45,7 @@ async function refreshAccessToken(refreshToken: string): Promise<{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
|
||||
'X-API-KEY': process.env.API_KEY || '',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
refresh_token: refreshToken,
|
||||
@@ -88,7 +88,7 @@ async function executeBackendRequest(
|
||||
// FormData인 경우 Content-Type을 생략해야 브라우저가 boundary를 자동 설정
|
||||
const headers: Record<string, string> = {
|
||||
'Accept': 'application/json',
|
||||
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
|
||||
'X-API-KEY': process.env.API_KEY || '',
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
};
|
||||
|
||||
|
||||
@@ -491,8 +491,6 @@ export function BillManagement({ initialVendorId, initialBillType }: BillManagem
|
||||
getItemId={(item: BillRecord) => item.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
addButtonLabel="어음 등록"
|
||||
onAddClick={() => router.push('/ko/accounting/bills/new')}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
|
||||
@@ -92,6 +92,7 @@ const generateMockData = (): PurchaseRecord[] => {
|
||||
sourceDocument: i % 3 === 0 ? {
|
||||
type: i % 2 === 0 ? 'proposal' : 'expense_report',
|
||||
documentNo: `DOC-2025-${String(i + 1).padStart(4, '0')}`,
|
||||
title: `${i % 2 === 0 ? '품의' : '지출'} 건 - ${vendors[i % vendors.length]}`,
|
||||
expectedCost: supplyAmount,
|
||||
} : undefined,
|
||||
withdrawalAccount: {
|
||||
|
||||
@@ -168,7 +168,7 @@ export function DocumentCreate() {
|
||||
})),
|
||||
cardInfo: expenseReportData.cardId || '-',
|
||||
totalAmount: expenseReportData.totalAmount,
|
||||
attachments: expenseReportData.attachments,
|
||||
attachments: expenseReportData.attachments.map(f => f.name),
|
||||
approvers,
|
||||
drafter,
|
||||
};
|
||||
@@ -182,7 +182,7 @@ export function DocumentCreate() {
|
||||
description: proposalData.description || '-',
|
||||
reason: proposalData.reason || '-',
|
||||
estimatedCost: proposalData.estimatedCost,
|
||||
attachments: proposalData.attachments,
|
||||
attachments: proposalData.attachments.map(f => f.name),
|
||||
approvers,
|
||||
drafter,
|
||||
};
|
||||
|
||||
@@ -57,6 +57,10 @@ const initialFormData: EmployeeFormData = {
|
||||
confirmPassword: '',
|
||||
role: 'user',
|
||||
accountStatus: 'active',
|
||||
clockInLocation: '',
|
||||
clockOutLocation: '',
|
||||
resignationDate: '',
|
||||
resignationReason: '',
|
||||
};
|
||||
|
||||
export function EmployeeDialog({
|
||||
@@ -103,6 +107,10 @@ export function EmployeeDialog({
|
||||
confirmPassword: '',
|
||||
role: employee.userInfo?.role || 'user',
|
||||
accountStatus: employee.userInfo?.accountStatus || 'active',
|
||||
clockInLocation: employee.clockInLocation || '',
|
||||
clockOutLocation: employee.clockOutLocation || '',
|
||||
resignationDate: employee.resignationDate || '',
|
||||
resignationReason: employee.resignationReason || '',
|
||||
});
|
||||
} else if (open && mode === 'create') {
|
||||
setFormData(initialFormData);
|
||||
|
||||
@@ -125,6 +125,8 @@ export interface Employee {
|
||||
clockOutLocation?: string; // 퇴근 위치
|
||||
resignationDate?: string; // 퇴사일
|
||||
resignationReason?: string; // 퇴직사유
|
||||
concurrentPosition?: string; // 겸직 직위
|
||||
concurrentReason?: string; // 겸직 사유
|
||||
|
||||
// 사용자 정보 (시스템 계정)
|
||||
userInfo?: UserInfo;
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import type { VacationUsageRecord, VacationAdjustment, VacationType } from './types';
|
||||
import type { VacationUsageRecord, VacationAdjustment, AdjustableVacationType } from './types';
|
||||
import { VACATION_TYPE_LABELS } from './types';
|
||||
|
||||
interface VacationAdjustDialogProps {
|
||||
@@ -62,7 +62,7 @@ export function VacationAdjustDialog({
|
||||
}, [open]);
|
||||
|
||||
// 조정값 증가
|
||||
const handleIncrease = (type: VacationType) => {
|
||||
const handleIncrease = (type: AdjustableVacationType) => {
|
||||
setAdjustments(prev => ({
|
||||
...prev,
|
||||
[type]: prev[type] + 1,
|
||||
@@ -70,7 +70,7 @@ export function VacationAdjustDialog({
|
||||
};
|
||||
|
||||
// 조정값 감소
|
||||
const handleDecrease = (type: VacationType) => {
|
||||
const handleDecrease = (type: AdjustableVacationType) => {
|
||||
setAdjustments(prev => ({
|
||||
...prev,
|
||||
[type]: prev[type] - 1,
|
||||
@@ -78,7 +78,7 @@ export function VacationAdjustDialog({
|
||||
};
|
||||
|
||||
// 조정값 직접 입력
|
||||
const handleInputChange = (type: VacationType, value: string) => {
|
||||
const handleInputChange = (type: AdjustableVacationType, value: string) => {
|
||||
const numValue = parseInt(value, 10);
|
||||
if (!isNaN(numValue)) {
|
||||
setAdjustments(prev => ({
|
||||
@@ -97,7 +97,7 @@ export function VacationAdjustDialog({
|
||||
const handleSave = () => {
|
||||
const adjustmentList: VacationAdjustment[] = [];
|
||||
|
||||
(Object.keys(adjustments) as VacationType[]).forEach((type) => {
|
||||
(Object.keys(adjustments) as AdjustableVacationType[]).forEach((type) => {
|
||||
if (adjustments[type] !== 0) {
|
||||
adjustmentList.push({
|
||||
vacationType: type,
|
||||
@@ -116,7 +116,7 @@ export function VacationAdjustDialog({
|
||||
|
||||
if (!record) return null;
|
||||
|
||||
const vacationTypes: VacationType[] = ['annual', 'monthly', 'reward', 'other'];
|
||||
const vacationTypes: AdjustableVacationType[] = ['annual', 'monthly', 'reward', 'other'];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
|
||||
@@ -9,6 +9,9 @@ export type MainTabType = 'usage' | 'grant' | 'request';
|
||||
// 휴가 유형
|
||||
export type VacationType = 'annual' | 'monthly' | 'reward' | 'condolence' | 'other';
|
||||
|
||||
// 조정 가능한 휴가 유형 (VacationAdjustDialog에서 사용)
|
||||
export type AdjustableVacationType = 'annual' | 'monthly' | 'reward' | 'other';
|
||||
|
||||
// 필터 옵션
|
||||
export type FilterOption = 'all' | 'hasVacation' | 'noVacation';
|
||||
|
||||
|
||||
@@ -55,7 +55,24 @@ export function DropdownField({
|
||||
unitOptions,
|
||||
}: DynamicFieldRendererProps) {
|
||||
const fieldKey = field.field_key || `field_${field.id}`;
|
||||
const stringValue = value !== null && value !== undefined ? String(value) : '';
|
||||
|
||||
// is_active 필드인지 확인
|
||||
const isActiveField = fieldKey === 'is_active' || fieldKey.endsWith('_is_active');
|
||||
|
||||
// 옵션을 먼저 정규화 (is_active 값 변환에 필요)
|
||||
const rawOptions = normalizeOptions(field.options);
|
||||
|
||||
// is_active 필드일 때 boolean 값을 옵션에 맞게 변환
|
||||
let stringValue = '';
|
||||
if (value !== null && value !== undefined) {
|
||||
if (isActiveField && rawOptions.length >= 2) {
|
||||
// boolean/숫자 값을 첫번째(활성) 또는 두번째(비활성) 옵션 값으로 매핑
|
||||
const isActive = value === true || value === 'true' || value === 1 || value === '1' || value === '활성';
|
||||
stringValue = isActive ? rawOptions[0].value : rawOptions[1].value;
|
||||
} else {
|
||||
stringValue = String(value);
|
||||
}
|
||||
}
|
||||
|
||||
// field_key 또는 field_name이 '단위'/'unit' 관련이면 unitOptions 사용
|
||||
const isUnitField =
|
||||
@@ -73,8 +90,8 @@ export function DropdownField({
|
||||
value: u.value,
|
||||
}));
|
||||
} else {
|
||||
// field.options를 정규화
|
||||
options = normalizeOptions(field.options);
|
||||
// rawOptions는 이미 위에서 정규화됨
|
||||
options = rawOptions;
|
||||
}
|
||||
|
||||
// 옵션이 없으면 드롭다운을 disabled로 표시
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { DynamicFormData, ItemType, StructuredFieldConfig } from '../types';
|
||||
import { ItemFieldResponse } from '@/types/item';
|
||||
import { DynamicFormData, ItemType, StructuredFieldConfig, ItemFieldResponse } from '../types';
|
||||
|
||||
/**
|
||||
* 부품 유형 탐지 결과
|
||||
@@ -27,7 +26,7 @@ export interface UseFieldDetectionParams {
|
||||
/** 폼 구조 정보 */
|
||||
structure: StructuredFieldConfig | null;
|
||||
/** 현재 선택된 품목 유형 (FG, PT, SM, RM, CS) */
|
||||
selectedItemType: ItemType;
|
||||
selectedItemType: ItemType | '';
|
||||
/** 현재 폼 데이터 */
|
||||
formData: DynamicFormData;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { DynamicFormData, ItemType, StructuredFieldConfig } from '../types';
|
||||
import { BendingFieldKeys, CategoryKeyWithId } from './useItemCodeGeneration';
|
||||
import { BendingDetail } from '@/types/item';
|
||||
import type { DynamicFormData, DynamicFieldValue, ItemType, StructuredFieldConfig } from '../types';
|
||||
import type { BendingFieldKeys, CategoryKeyWithId } from './useItemCodeGeneration';
|
||||
import type { BendingDetail } from '@/types/item';
|
||||
|
||||
/**
|
||||
* usePartTypeHandling 훅 입력 파라미터
|
||||
@@ -20,7 +20,7 @@ export interface UsePartTypeHandlingParams {
|
||||
/** 품목명 필드 키 */
|
||||
itemNameKey: string;
|
||||
/** 필드 값 설정 함수 */
|
||||
setFieldValue: (key: string, value: unknown) => void;
|
||||
setFieldValue: (key: string, value: DynamicFieldValue) => void;
|
||||
/** 현재 폼 데이터 */
|
||||
formData: DynamicFormData;
|
||||
/** 절곡부품 필드 키 정보 */
|
||||
|
||||
@@ -12,6 +12,10 @@ import type {
|
||||
PageStructureResponse,
|
||||
} from '@/types/item-master-api';
|
||||
|
||||
// Re-export types for hooks
|
||||
export type { ItemFieldResponse } from '@/types/item-master-api';
|
||||
export type { ItemType } from '@/types/item';
|
||||
|
||||
// ============================================
|
||||
// 조건부 표시 타입
|
||||
// ============================================
|
||||
@@ -244,4 +248,14 @@ export function convertToFormStructure(
|
||||
orderNo: f.order_no,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 타입 별칭 (하위 호환성)
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* StructuredFieldConfig는 DynamicFormStructure의 별칭
|
||||
* (hooks에서 사용하는 이름)
|
||||
*/
|
||||
export type StructuredFieldConfig = DynamicFormStructure;
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Search, Plus, Edit, Trash2, Package, Loader2 } from 'lucide-react';
|
||||
import { TableLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { useItemList } from '@/hooks/useItemList';
|
||||
import {
|
||||
IntegratedListTemplateV2,
|
||||
@@ -129,12 +130,7 @@ export default function ItemListClient() {
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<span className="ml-3 text-muted-foreground">품목 목록 로딩 중...</span>
|
||||
</div>
|
||||
);
|
||||
return <TableLoadingSpinner text="품목 목록을 불러오는 중..." />;
|
||||
}
|
||||
|
||||
// 유형 변경 핸들러
|
||||
|
||||
@@ -47,11 +47,17 @@ export interface ListMobileCardProps {
|
||||
/** 카드 클릭 핸들러 */
|
||||
onCardClick?: () => void;
|
||||
|
||||
/** 카드 클릭 핸들러 (onCardClick 별칭) */
|
||||
onClick?: () => void;
|
||||
|
||||
/** 체크박스 표시 여부 */
|
||||
showCheckbox?: boolean;
|
||||
|
||||
/** 헤더 영역 뱃지들 (번호, 코드 등) */
|
||||
headerBadges?: ReactNode;
|
||||
|
||||
/** 카드 제목 (주요 정보) */
|
||||
title: string;
|
||||
title: string | ReactNode;
|
||||
|
||||
/** 상태 뱃지 (우측 상단) */
|
||||
statusBadge?: ReactNode;
|
||||
@@ -81,11 +87,13 @@ export interface InfoFieldProps {
|
||||
label: string;
|
||||
value: string | number | ReactNode;
|
||||
valueClassName?: string;
|
||||
/** 추가 className */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function InfoField({ label, value, valueClassName = "" }: InfoFieldProps) {
|
||||
export function InfoField({ label, value, valueClassName = "", className = "" }: InfoFieldProps) {
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<div className={`space-y-0.5 ${className}`}>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<div className={`text-sm font-medium ${valueClassName}`}>{value}</div>
|
||||
</div>
|
||||
@@ -97,6 +105,8 @@ export function ListMobileCard({
|
||||
isSelected,
|
||||
onToggleSelection,
|
||||
onCardClick,
|
||||
onClick,
|
||||
showCheckbox = true,
|
||||
headerBadges,
|
||||
title,
|
||||
statusBadge,
|
||||
@@ -106,6 +116,7 @@ export function ListMobileCard({
|
||||
topContent,
|
||||
bottomContent
|
||||
}: ListMobileCardProps) {
|
||||
const handleCardClick = onClick || onCardClick;
|
||||
return (
|
||||
<div
|
||||
className={`border rounded-lg p-5 space-y-4 bg-white dark:bg-card transition-all cursor-pointer ${
|
||||
@@ -113,7 +124,7 @@ export function ListMobileCard({
|
||||
? 'border-blue-500 bg-blue-50/50'
|
||||
: 'border-gray-200 hover:border-primary/50'
|
||||
} ${className}`}
|
||||
onClick={onCardClick}
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
{/* 상단 추가 콘텐츠 */}
|
||||
{topContent}
|
||||
@@ -121,12 +132,14 @@ export function ListMobileCard({
|
||||
{/* 헤더: 체크박스 + 뱃지 + 제목 / 우측에 상태 뱃지 */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3 flex-1 min-w-0">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={onToggleSelection}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="mt-0.5 h-5 w-5"
|
||||
/>
|
||||
{showCheckbox && (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={onToggleSelection}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="mt-0.5 h-5 w-5"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* 헤더 뱃지들 (번호, 코드 등) */}
|
||||
{headerBadges && (
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
import { ReactNode } from "react";
|
||||
import { LucideIcon } from "lucide-react";
|
||||
|
||||
interface PageHeaderProps {
|
||||
export interface PageHeaderProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
actions?: ReactNode;
|
||||
icon?: LucideIcon;
|
||||
versionBadge?: ReactNode;
|
||||
/** 뒤로가기 핸들러 */
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
export function PageHeader({ title, description, actions, icon: Icon, versionBadge }: PageHeaderProps) {
|
||||
|
||||
@@ -94,7 +94,7 @@ async function getApiHeaders(): Promise<HeadersInit> {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
|
||||
'X-API-KEY': process.env.API_KEY || '',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,50 +1,114 @@
|
||||
// 로딩 스피너 컴포넌트
|
||||
// API 호출 중 로딩 상태 표시용
|
||||
// 대시보드 스타일로 통일 (border-4 border-solid border-primary border-r-transparent)
|
||||
/**
|
||||
* 로딩 스피너 컴포넌트 (표준화됨)
|
||||
*
|
||||
* 사용 가이드:
|
||||
* - LoadingSpinner: 인라인/버튼 내부/작은 영역용
|
||||
* - ContentLoadingSpinner: 컨텐츠 영역 로딩용 (상세/수정 페이지)
|
||||
* - PageLoadingSpinner: 페이지 전환용 (loading.tsx, 전체 페이지)
|
||||
*
|
||||
* 스타일: border-4 border-solid border-primary border-r-transparent
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
// ============================================
|
||||
// 1. 기본 스피너 (인라인/버튼 내부용)
|
||||
// ============================================
|
||||
interface LoadingSpinnerProps {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
xs: 'h-3 w-3 border-2',
|
||||
sm: 'h-4 w-4 border-2',
|
||||
md: 'h-8 w-8 border-3',
|
||||
lg: 'h-12 w-12 border-4'
|
||||
};
|
||||
|
||||
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||
size = 'md',
|
||||
className = '',
|
||||
text
|
||||
}) => {
|
||||
const sizeClasses = {
|
||||
sm: 'h-4 w-4 border-2',
|
||||
md: 'h-8 w-8 border-4',
|
||||
lg: 'h-12 w-12 border-4'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col items-center justify-center gap-2 ${className}`}>
|
||||
<div className={`animate-spin rounded-full border-solid border-primary border-r-transparent ${sizeClasses[size]}`} />
|
||||
<div
|
||||
className={`animate-spin rounded-full border-solid border-primary border-r-transparent ${sizeClasses[size]}`}
|
||||
/>
|
||||
{text && <p className="text-sm text-muted-foreground">{text}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 페이지 레벨 로딩 스피너 (전체 화면 중앙 배치)
|
||||
// ============================================
|
||||
// 2. 컨텐츠 영역 스피너 (상세/수정 페이지용)
|
||||
// ============================================
|
||||
interface ContentLoadingSpinnerProps {
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export const ContentLoadingSpinner: React.FC<ContentLoadingSpinnerProps> = ({
|
||||
text = '불러오는 중...'
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center space-y-3">
|
||||
<div className="inline-block h-10 w-10 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent" />
|
||||
<p className="text-sm text-muted-foreground">{text}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 3. 페이지 레벨 스피너 (페이지 전환용)
|
||||
// ============================================
|
||||
interface PageLoadingSpinnerProps {
|
||||
text?: string;
|
||||
minHeight?: string;
|
||||
}
|
||||
|
||||
export const PageLoadingSpinner: React.FC<PageLoadingSpinnerProps> = ({
|
||||
text = '불러오는 중...',
|
||||
minHeight = 'min-h-[60vh]'
|
||||
text = '페이지를 불러오는 중...'
|
||||
}) => {
|
||||
return (
|
||||
<div className={`flex items-center justify-center ${minHeight}`}>
|
||||
<div className="flex items-center justify-center min-h-[calc(100vh-200px)]">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"></div>
|
||||
<div className="inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent" />
|
||||
<p className="text-muted-foreground font-medium">{text}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 4. 테이블/리스트 오버레이 스피너
|
||||
// ============================================
|
||||
interface TableLoadingSpinnerProps {
|
||||
text?: string;
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
export const TableLoadingSpinner: React.FC<TableLoadingSpinnerProps> = ({
|
||||
text = '데이터를 불러오는 중...',
|
||||
rows = 5
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="text-center space-y-3">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent" />
|
||||
<p className="text-sm text-muted-foreground">{text}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 5. 버튼 내부 스피너 (저장 중 등)
|
||||
// ============================================
|
||||
export const ButtonSpinner: React.FC = () => {
|
||||
return (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-solid border-current border-r-transparent" />
|
||||
);
|
||||
};
|
||||
@@ -8,8 +8,11 @@ const Textarea = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex min-h-[80px] w-full rounded-md border bg-input-background px-3 py-2 text-base transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useState, useEffect } from 'react';
|
||||
*/
|
||||
export function useUserRole() {
|
||||
const [userRole, setUserRole] = useState<string>(() => {
|
||||
// SSR-safe: 서버에서는 기본값 반환
|
||||
if (typeof window === 'undefined') return "CEO";
|
||||
const userDataStr = localStorage.getItem("user");
|
||||
const userData = userDataStr ? JSON.parse(userDataStr) : null;
|
||||
return userData?.role || "CEO";
|
||||
|
||||
@@ -4,33 +4,29 @@
|
||||
/**
|
||||
* API 요청에 사용할 헤더 생성 (프록시 패턴용)
|
||||
* - Content-Type: application/json
|
||||
* - X-API-KEY: 환경변수에서 로드
|
||||
* - Accept: Laravel expectsJson() 체크용
|
||||
*
|
||||
* ⚠️ 중요: Authorization 헤더는 Next.js 프록시에서 서버사이드로 처리
|
||||
* ⚠️ 중요: Authorization과 X-API-KEY는 Next.js 프록시에서 서버사이드로 처리
|
||||
* - HttpOnly 쿠키는 JavaScript로 읽을 수 없음 (보안)
|
||||
* - /api/proxy/* 라우트가 서버에서 쿠키 읽어 Authorization 헤더 추가
|
||||
* - /api/proxy/* 라우트가 서버에서 쿠키와 API_KEY를 헤더에 추가
|
||||
*/
|
||||
export const getAuthHeaders = (): HeadersInit => {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Multipart/form-data 요청에 사용할 헤더 생성 (프록시 패턴용)
|
||||
* - Content-Type은 브라우저가 자동으로 설정 (boundary 포함)
|
||||
* - X-API-KEY만 포함
|
||||
*
|
||||
* ⚠️ 중요: Authorization 헤더는 Next.js 프록시에서 서버사이드로 처리
|
||||
* - /api/proxy/* 라우트가 서버에서 쿠키 읽어 Authorization 헤더 추가
|
||||
* ⚠️ 중요: Authorization과 X-API-KEY는 Next.js 프록시에서 서버사이드로 처리
|
||||
* - /api/proxy/* 라우트가 서버에서 쿠키와 API_KEY를 헤더에 추가
|
||||
*/
|
||||
export const getMultipartHeaders = (): HeadersInit => {
|
||||
return {
|
||||
'Accept': 'application/json',
|
||||
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
|
||||
// Content-Type은 명시하지 않음 (multipart/form-data; boundary=... 자동 설정)
|
||||
};
|
||||
};
|
||||
|
||||
@@ -53,7 +53,7 @@ export async function proxyToPhpBackend(
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
|
||||
'X-API-KEY': process.env.API_KEY || '',
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -53,7 +53,7 @@ export async function refreshTokenServer(refreshToken: string): Promise<{
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${refreshToken}`,
|
||||
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
|
||||
'X-API-KEY': process.env.API_KEY || '',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ export const useThemeStore = create<ThemeState>()(
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'sam-theme',
|
||||
name: 'theme', // ThemeContext와 동일한 키 사용 (마이그레이션 호환성)
|
||||
// Zustand persist 재수화 시 HTML 클래스 복원
|
||||
onRehydrateStorage: () => (state) => {
|
||||
if (state?.theme) {
|
||||
|
||||
Reference in New Issue
Block a user