- 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>
338 lines
8.9 KiB
TypeScript
338 lines
8.9 KiB
TypeScript
/**
|
|
* 참조함 서버 액션
|
|
*
|
|
* API Endpoints:
|
|
* - GET /api/v1/approvals/reference - 참조함 목록 조회
|
|
* - POST /api/v1/approvals/{id}/read - 열람 처리
|
|
* - POST /api/v1/approvals/{id}/unread - 미열람 처리
|
|
*/
|
|
|
|
'use server';
|
|
|
|
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
|
import type { ReferenceRecord, ApprovalType, DocumentStatus } from './types';
|
|
|
|
// ============================================
|
|
// API 응답 타입 정의
|
|
// ============================================
|
|
|
|
interface ApiResponse<T> {
|
|
success: boolean;
|
|
data: T;
|
|
message: string;
|
|
}
|
|
|
|
interface PaginatedResponse<T> {
|
|
current_page: number;
|
|
data: T[];
|
|
total: number;
|
|
per_page: number;
|
|
last_page: number;
|
|
}
|
|
|
|
// API 응답의 참조 문서 타입
|
|
interface ReferenceApiData {
|
|
id: number;
|
|
document_number: string;
|
|
title: string;
|
|
status: string;
|
|
form?: {
|
|
id: number;
|
|
name: string;
|
|
code: string;
|
|
category: string;
|
|
};
|
|
drafter?: {
|
|
id: number;
|
|
name: string;
|
|
position?: string;
|
|
department?: { name: string };
|
|
};
|
|
steps?: ReferenceStepApiData[];
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
interface ReferenceStepApiData {
|
|
id: number;
|
|
step_order: number;
|
|
step_type: string;
|
|
approver_id: number;
|
|
approver?: {
|
|
id: number;
|
|
name: string;
|
|
position?: string;
|
|
department?: { name: string };
|
|
};
|
|
is_read: boolean;
|
|
read_at?: string;
|
|
}
|
|
|
|
// ============================================
|
|
// 헬퍼 함수
|
|
// ============================================
|
|
|
|
/**
|
|
* API 상태 → 프론트엔드 상태 변환
|
|
*/
|
|
function mapApiStatus(apiStatus: string): DocumentStatus {
|
|
const statusMap: Record<string, DocumentStatus> = {
|
|
'draft': 'pending',
|
|
'pending': 'pending',
|
|
'in_progress': 'pending',
|
|
'approved': 'approved',
|
|
'rejected': 'rejected',
|
|
};
|
|
return statusMap[apiStatus] || 'pending';
|
|
}
|
|
|
|
/**
|
|
* 양식 카테고리 → 결재 유형 변환
|
|
*/
|
|
function mapApprovalType(formCategory?: string): ApprovalType {
|
|
const typeMap: Record<string, ApprovalType> = {
|
|
'expense_report': 'expense_report',
|
|
'proposal': 'proposal',
|
|
'expense_estimate': 'expense_estimate',
|
|
};
|
|
return typeMap[formCategory || ''] || 'proposal';
|
|
}
|
|
|
|
/**
|
|
* API 데이터 → 프론트엔드 데이터 변환
|
|
*/
|
|
function transformApiToFrontend(data: ReferenceApiData): ReferenceRecord {
|
|
// 참조 단계에서 열람 상태 추출
|
|
const referenceStep = data.steps?.find(s => s.step_type === 'reference');
|
|
const isRead = referenceStep?.is_read ?? false;
|
|
const readAt = referenceStep?.read_at;
|
|
|
|
return {
|
|
id: String(data.id),
|
|
documentNo: data.document_number,
|
|
approvalType: mapApprovalType(data.form?.category),
|
|
title: data.title,
|
|
draftDate: data.created_at.replace('T', ' ').substring(0, 16),
|
|
drafter: data.drafter?.name || '',
|
|
drafterDepartment: data.drafter?.department?.name || '',
|
|
drafterPosition: data.drafter?.position || '',
|
|
documentStatus: mapApiStatus(data.status),
|
|
readStatus: isRead ? 'read' : 'unread',
|
|
readAt: readAt ? readAt.replace('T', ' ').substring(0, 16) : undefined,
|
|
createdAt: data.created_at,
|
|
updatedAt: data.updated_at,
|
|
};
|
|
}
|
|
|
|
// ============================================
|
|
// API 함수
|
|
// ============================================
|
|
|
|
/**
|
|
* 참조함 목록 조회
|
|
*/
|
|
export async function getReferences(params?: {
|
|
page?: number;
|
|
per_page?: number;
|
|
search?: string;
|
|
is_read?: boolean;
|
|
approval_type?: string;
|
|
sort_by?: string;
|
|
sort_dir?: 'asc' | 'desc';
|
|
}): Promise<{ data: ReferenceRecord[]; total: number; lastPage: number }> {
|
|
try {
|
|
const searchParams = new URLSearchParams();
|
|
|
|
if (params?.page) searchParams.set('page', String(params.page));
|
|
if (params?.per_page) searchParams.set('per_page', String(params.per_page));
|
|
if (params?.search) searchParams.set('search', params.search);
|
|
if (params?.is_read !== undefined) {
|
|
searchParams.set('is_read', params.is_read ? '1' : '0');
|
|
}
|
|
if (params?.approval_type && params.approval_type !== 'all') {
|
|
searchParams.set('approval_type', params.approval_type);
|
|
}
|
|
if (params?.sort_by) searchParams.set('sort_by', params.sort_by);
|
|
if (params?.sort_dir) searchParams.set('sort_dir', params.sort_dir);
|
|
|
|
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/reference?${searchParams.toString()}`;
|
|
|
|
const { response, error } = await serverFetch(url, {
|
|
method: 'GET',
|
|
});
|
|
|
|
// serverFetch handles 401 with redirect, so we just check for other errors
|
|
if (error || !response) {
|
|
console.error('[ReferenceBoxActions] GET reference error:', error?.message);
|
|
return { data: [], total: 0, lastPage: 1 };
|
|
}
|
|
|
|
if (!response.ok) {
|
|
console.error('[ReferenceBoxActions] GET reference error:', response.status);
|
|
return { data: [], total: 0, lastPage: 1 };
|
|
}
|
|
|
|
const result: ApiResponse<PaginatedResponse<ReferenceApiData>> = await response.json();
|
|
|
|
if (!result.success || !result.data?.data) {
|
|
console.warn('[ReferenceBoxActions] No data in response');
|
|
return { data: [], total: 0, lastPage: 1 };
|
|
}
|
|
|
|
return {
|
|
data: result.data.data.map(transformApiToFrontend),
|
|
total: result.data.total,
|
|
lastPage: result.data.last_page,
|
|
};
|
|
} catch (error) {
|
|
console.error('[ReferenceBoxActions] getReferences error:', error);
|
|
return { data: [], total: 0, lastPage: 1 };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 참조함 통계 (목록 데이터 기반)
|
|
*/
|
|
export async function getReferenceSummary(): Promise<{ all: number; read: number; unread: number } | null> {
|
|
try {
|
|
// 전체 데이터를 조회해서 통계 계산
|
|
const allResult = await getReferences({ per_page: 1 });
|
|
const readResult = await getReferences({ per_page: 1, is_read: true });
|
|
const unreadResult = await getReferences({ per_page: 1, is_read: false });
|
|
|
|
return {
|
|
all: allResult.total,
|
|
read: readResult.total,
|
|
unread: unreadResult.total,
|
|
};
|
|
} catch (error) {
|
|
console.error('[ReferenceBoxActions] getReferenceSummary error:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 열람 처리
|
|
*/
|
|
export async function markAsRead(id: string): Promise<{ success: boolean; error?: string }> {
|
|
try {
|
|
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${id}/read`;
|
|
|
|
const { response, error } = await serverFetch(url, {
|
|
method: 'POST',
|
|
body: JSON.stringify({}),
|
|
});
|
|
|
|
// serverFetch handles 401 with redirect
|
|
if (error || !response) {
|
|
return {
|
|
success: false,
|
|
error: error?.message || '열람 처리에 실패했습니다.',
|
|
};
|
|
}
|
|
|
|
const result = await response.json();
|
|
|
|
if (!response.ok || !result.success) {
|
|
return {
|
|
success: false,
|
|
error: result.message || '열람 처리에 실패했습니다.',
|
|
};
|
|
}
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error('[ReferenceBoxActions] markAsRead error:', error);
|
|
return {
|
|
success: false,
|
|
error: '서버 오류가 발생했습니다.',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 미열람 처리
|
|
*/
|
|
export async function markAsUnread(id: string): Promise<{ success: boolean; error?: string }> {
|
|
try {
|
|
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${id}/unread`;
|
|
|
|
const { response, error } = await serverFetch(url, {
|
|
method: 'POST',
|
|
body: JSON.stringify({}),
|
|
});
|
|
|
|
// serverFetch handles 401 with redirect
|
|
if (error || !response) {
|
|
return {
|
|
success: false,
|
|
error: error?.message || '미열람 처리에 실패했습니다.',
|
|
};
|
|
}
|
|
|
|
const result = await response.json();
|
|
|
|
if (!response.ok || !result.success) {
|
|
return {
|
|
success: false,
|
|
error: result.message || '미열람 처리에 실패했습니다.',
|
|
};
|
|
}
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error('[ReferenceBoxActions] markAsUnread error:', error);
|
|
return {
|
|
success: false,
|
|
error: '서버 오류가 발생했습니다.',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 일괄 열람 처리
|
|
*/
|
|
export async function markAsReadBulk(ids: string[]): Promise<{ success: boolean; failedIds?: string[]; error?: string }> {
|
|
const failedIds: string[] = [];
|
|
|
|
for (const id of ids) {
|
|
const result = await markAsRead(id);
|
|
if (!result.success) {
|
|
failedIds.push(id);
|
|
}
|
|
}
|
|
|
|
if (failedIds.length > 0) {
|
|
return {
|
|
success: false,
|
|
failedIds,
|
|
error: `${failedIds.length}건의 열람 처리에 실패했습니다.`,
|
|
};
|
|
}
|
|
|
|
return { success: true };
|
|
}
|
|
|
|
/**
|
|
* 일괄 미열람 처리
|
|
*/
|
|
export async function markAsUnreadBulk(ids: string[]): Promise<{ success: boolean; failedIds?: string[]; error?: string }> {
|
|
const failedIds: string[] = [];
|
|
|
|
for (const id of ids) {
|
|
const result = await markAsUnread(id);
|
|
if (!result.success) {
|
|
failedIds.push(id);
|
|
}
|
|
}
|
|
|
|
if (failedIds.length > 0) {
|
|
return {
|
|
success: false,
|
|
failedIds,
|
|
error: `${failedIds.length}건의 미열람 처리에 실패했습니다.`,
|
|
};
|
|
}
|
|
|
|
return { success: true };
|
|
} |