feat: fetchWrapper 마이그레이션 및 토큰 리프레시 캐싱 구현
- 40+ actions.ts 파일을 fetchWrapper 패턴으로 마이그레이션 - 토큰 리프레시 캐싱 로직 추가 (refresh-token.ts) - ApiErrorContext 추가로 전역 에러 처리 개선 - HR EmployeeForm 컴포넌트 개선 - 참조함(ReferenceBox) 기능 수정 - juil 테스트 URL 페이지 추가 - claudedocs 문서 업데이트 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -68,24 +68,23 @@ export function ApprovalLineSection({ data, onChange }: ApprovalLineSectionProps
|
||||
</div>
|
||||
) : (
|
||||
data.map((person, index) => (
|
||||
<div key={person.id} className="flex items-center gap-2">
|
||||
<div key={`${person.id}-${index}`} className="flex items-center gap-2">
|
||||
<span className="w-8 text-center text-sm text-gray-500">{index + 1}</span>
|
||||
<Select
|
||||
value={person.id.startsWith('temp-') ? '' : person.id}
|
||||
value={person.id.startsWith('temp-') ? undefined : person.id}
|
||||
onValueChange={(value) => handleChange(index, value)}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
{/* 이미 선택된 값이 있으면 직접 표시, 없으면 placeholder */}
|
||||
{person.name && !person.id.startsWith('temp-') ? (
|
||||
<span>{person.department} / {person.position} / {person.name}</span>
|
||||
) : (
|
||||
<SelectValue placeholder="부서명 / 직책명 / 이름 ▼" />
|
||||
)}
|
||||
<SelectValue placeholder="부서명 / 직책명 / 이름 ▼">
|
||||
{person.name && !person.id.startsWith('temp-')
|
||||
? `${person.department || ''} / ${person.position || ''} / ${person.name}`
|
||||
: null}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{employees.map((employee) => (
|
||||
<SelectItem key={employee.id} value={employee.id}>
|
||||
{employee.department} / {employee.position} / {employee.name}
|
||||
{employee.department || ''} / {employee.position || ''} / {employee.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@@ -68,24 +68,23 @@ export function ReferenceSection({ data, onChange }: ReferenceSectionProps) {
|
||||
</div>
|
||||
) : (
|
||||
data.map((person, index) => (
|
||||
<div key={person.id} className="flex items-center gap-2">
|
||||
<div key={`${person.id}-${index}`} className="flex items-center gap-2">
|
||||
<span className="w-8 text-center text-sm text-gray-500">{index + 1}</span>
|
||||
<Select
|
||||
value={person.id.startsWith('temp-') ? '' : person.id}
|
||||
value={person.id.startsWith('temp-') ? undefined : person.id}
|
||||
onValueChange={(value) => handleChange(index, value)}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
{/* 이미 선택된 값이 있으면 직접 표시, 없으면 placeholder */}
|
||||
{person.name && !person.id.startsWith('temp-') ? (
|
||||
<span>{person.department} / {person.position} / {person.name}</span>
|
||||
) : (
|
||||
<SelectValue placeholder="부서명 / 직책명 / 이름 ▼" />
|
||||
)}
|
||||
<SelectValue placeholder="부서명 / 직책명 / 이름 ▼">
|
||||
{person.name && !person.id.startsWith('temp-')
|
||||
? `${person.department || ''} / ${person.position || ''} / ${person.name}`
|
||||
: null}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{employees.map((employee) => (
|
||||
<SelectItem key={employee.id} value={employee.id}>
|
||||
{employee.department} / {employee.position} / {employee.name}
|
||||
{employee.department || ''} / {employee.position || ''} / {employee.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
'use server';
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type {
|
||||
ExpenseEstimateItem,
|
||||
ApprovalPerson,
|
||||
@@ -74,21 +75,6 @@ interface ApprovalCreateResponse {
|
||||
// 헬퍼 함수
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* API 헤더 생성
|
||||
*/
|
||||
async function getApiHeaders(): Promise<HeadersInit> {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('access_token')?.value;
|
||||
|
||||
return {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
'X-API-KEY': process.env.API_KEY || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 비용견적서 API 데이터 → 프론트엔드 데이터 변환
|
||||
*/
|
||||
@@ -141,6 +127,8 @@ function transformEmployee(employee: EmployeeApiData): ApprovalPerson {
|
||||
* 파일 업로드
|
||||
* @param files 업로드할 파일 배열
|
||||
* @returns 업로드된 파일 정보 배열
|
||||
*
|
||||
* NOTE: 파일 업로드는 multipart/form-data가 필요하므로 serverFetch 대신 직접 fetch 사용
|
||||
*/
|
||||
export async function uploadFiles(files: File[]): Promise<{
|
||||
success: boolean;
|
||||
@@ -210,7 +198,6 @@ export async function getExpenseEstimateItems(yearMonth?: string): Promise<{
|
||||
finalDifference: number;
|
||||
} | null> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (yearMonth) {
|
||||
@@ -219,12 +206,15 @@ export async function getExpenseEstimateItems(yearMonth?: string): Promise<{
|
||||
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/reports/expense-estimate?${searchParams.toString()}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (error || !response) {
|
||||
console.error('[DocumentCreateActions] GET expense-estimate error:', error?.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[DocumentCreateActions] GET expense-estimate error:', response.status);
|
||||
return null;
|
||||
@@ -254,7 +244,6 @@ export async function getExpenseEstimateItems(yearMonth?: string): Promise<{
|
||||
*/
|
||||
export async function getEmployees(search?: string): Promise<ApprovalPerson[]> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('per_page', '100');
|
||||
if (search) {
|
||||
@@ -263,12 +252,15 @@ export async function getEmployees(search?: string): Promise<ApprovalPerson[]> {
|
||||
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees?${searchParams.toString()}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (error || !response) {
|
||||
console.error('[DocumentCreateActions] GET employees error:', error?.message);
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[DocumentCreateActions] GET employees error:', response.status);
|
||||
return [];
|
||||
@@ -296,8 +288,6 @@ export async function createApproval(formData: DocumentFormData): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
|
||||
// 새 첨부파일 업로드
|
||||
const newFiles = formData.proposalData?.attachments
|
||||
|| formData.expenseReportData?.attachments
|
||||
@@ -332,15 +322,21 @@ export async function createApproval(formData: DocumentFormData): Promise<{
|
||||
content: getDocumentContent(formData, uploadedFiles),
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(requestBody),
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
return {
|
||||
success: false,
|
||||
error: error?.message || '문서 저장에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
const result: ApiResponse<ApprovalCreateResponse> = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
@@ -374,17 +370,21 @@ export async function submitApproval(id: number): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
|
||||
const response = await fetch(
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${id}/submit`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({}),
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
return {
|
||||
success: false,
|
||||
error: error?.message || '문서 상신에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
@@ -450,17 +450,17 @@ export async function getApprovalById(id: number): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
|
||||
const response = await fetch(
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${id}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
return { success: false, error: error?.message || '문서 조회에 실패했습니다.' };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return { success: false, error: '문서를 찾을 수 없습니다.' };
|
||||
@@ -476,9 +476,9 @@ export async function getApprovalById(id: number): Promise<{
|
||||
|
||||
// API 응답을 프론트엔드 형식으로 변환
|
||||
const apiData = result.data;
|
||||
const formData = transformApiToFormData(apiData);
|
||||
const formDataResult = transformApiToFormData(apiData);
|
||||
|
||||
return { success: true, data: formData };
|
||||
return { success: true, data: formDataResult };
|
||||
} catch (error) {
|
||||
console.error('[DocumentCreateActions] getApprovalById error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
@@ -494,8 +494,6 @@ export async function updateApproval(id: number, formData: DocumentFormData): Pr
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
|
||||
// 새 첨부파일 업로드
|
||||
const newFiles = formData.proposalData?.attachments
|
||||
|| formData.expenseReportData?.attachments
|
||||
@@ -528,15 +526,21 @@ export async function updateApproval(id: number, formData: DocumentFormData): Pr
|
||||
content: getDocumentContent(formData, uploadedFiles),
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${id}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
body: JSON.stringify(requestBody),
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
return {
|
||||
success: false,
|
||||
error: error?.message || '문서 수정에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
@@ -601,16 +605,20 @@ export async function deleteApproval(id: number): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
|
||||
const response = await fetch(
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${id}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
return {
|
||||
success: false,
|
||||
error: error?.message || '문서 삭제에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
|
||||
Reference in New Issue
Block a user