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:
byeongcheolryu
2025-12-30 17:00:18 +09:00
parent 0e5307f7a3
commit d38b1242d7
82 changed files with 7434 additions and 4775 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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) {