Files
sam-react-prod/src/components/approval/DraftBox/actions.ts
byeongcheolryu 0d539628f3 chore(WEB): actions.ts 에러 핸들링 및 CEO 대시보드 개선
- 전체 모듈 actions.ts redirect 에러 핸들링 추가
- CEODashboard DetailModal 추가
- MonthlyExpenseSection 개선
- fetch-wrapper redirect 에러 처리
- redirect-error 유틸 추가

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-08 18:41:15 +09:00

460 lines
12 KiB
TypeScript

/**
* 기안함 서버 액션
*
* API Endpoints:
* - GET /api/v1/approvals/drafts - 기안함 목록 조회
* - GET /api/v1/approvals/drafts/summary - 기안함 현황 카드
* - GET /api/v1/approvals/{id} - 결재 문서 상세
* - DELETE /api/v1/approvals/{id} - 결재 문서 삭제 (임시저장만)
* - POST /api/v1/approvals/{id}/submit - 결재 상신
* - POST /api/v1/approvals/{id}/cancel - 결재 회수
*/
'use server';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { serverFetch } from '@/lib/api/fetch-wrapper';
import type { DraftRecord, DocumentStatus, Approver } 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;
}
interface DraftsSummary {
total: number;
draft: number;
pending: number;
approved: number;
rejected: number;
}
// API 응답의 결재 문서 타입
interface ApprovalApiData {
id: number;
document_number: string;
title: string;
status: string;
form?: {
id: number;
name: string;
code: string;
category: string;
};
drafter?: {
id: number;
name: string;
};
steps?: ApprovalStepApiData[];
content?: Record<string, unknown>;
created_at: string;
updated_at: string;
}
interface ApprovalStepApiData {
id: number;
step_order: number;
step_type: string;
approver_id: number;
approver?: {
id: number;
name: string;
tenant_profile?: {
position_key?: string;
department?: { id: number; name: string };
};
};
status: string;
processed_at?: string;
comment?: string;
}
// ============================================
// 헬퍼 함수
// ============================================
/**
* API 상태 → 프론트엔드 상태 변환
*/
function mapApiStatus(apiStatus: string): DocumentStatus {
const statusMap: Record<string, DocumentStatus> = {
'draft': 'draft',
'pending': 'pending',
'in_progress': 'inProgress',
'approved': 'approved',
'rejected': 'rejected',
};
return statusMap[apiStatus] || 'draft';
}
/**
* 결재자 상태 변환
*/
function mapApproverStatus(stepStatus: string): Approver['status'] {
const statusMap: Record<string, Approver['status']> = {
'pending': 'pending',
'approved': 'approved',
'rejected': 'rejected',
};
return statusMap[stepStatus] || 'none';
}
/**
* 직책 코드 → 한글 변환
*/
function getPositionLabel(positionKey: string | null | undefined): string {
if (!positionKey) return '';
const labels: Record<string, string> = {
'EXECUTIVE': '임원',
'DIRECTOR': '부장',
'MANAGER': '과장',
'SENIOR': '대리',
'STAFF': '사원',
'INTERN': '인턴',
};
return labels[positionKey] ?? positionKey;
}
/**
* API 데이터 → 프론트엔드 데이터 변환
*/
function transformApiToFrontend(data: ApprovalApiData): DraftRecord {
// approval 타입 결재자만 필터링 (reference 제외)
const approvers: Approver[] = (data.steps || [])
.filter((step) => step.step_type === 'approval')
.map((step) => ({
id: String(step.approver_id),
name: step.approver?.name || '',
position: getPositionLabel(step.approver?.tenant_profile?.position_key),
department: step.approver?.tenant_profile?.department?.name || '',
status: mapApproverStatus(step.status),
approvedAt: step.processed_at,
}));
// drafter의 tenant_profile에서 직책/부서 추출
const drafterProfile = (data.drafter as { tenant_profile?: { position_key?: string; department?: { name: string } } })?.tenant_profile;
return {
id: String(data.id),
documentNo: data.document_number,
documentType: data.form?.name || '',
documentTypeCode: data.form?.code || 'proposal',
title: data.title,
draftDate: data.created_at.split('T')[0],
drafter: data.drafter?.name || '',
drafterPosition: getPositionLabel(drafterProfile?.position_key),
drafterDepartment: drafterProfile?.department?.name || '',
approvers,
status: mapApiStatus(data.status),
content: data.content,
createdAt: data.created_at,
updatedAt: data.updated_at,
};
}
// ============================================
// API 함수
// ============================================
/**
* 기안함 목록 조회
*/
export async function getDrafts(params?: {
page?: number;
per_page?: number;
search?: string;
status?: string;
sort_by?: string;
sort_dir?: 'asc' | 'desc';
}): Promise<{ data: DraftRecord[]; total: number; lastPage: number; __authError?: boolean }> {
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?.status && params.status !== 'all') {
// 프론트엔드 상태 → API 상태
const statusMap: Record<string, string> = {
'draft': 'draft',
'pending': 'pending',
'inProgress': 'in_progress',
'approved': 'approved',
'rejected': 'rejected',
};
searchParams.set('status', statusMap[params.status] || params.status);
}
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/drafts?${searchParams.toString()}`;
const { response, error } = await serverFetch(url, { method: 'GET' });
if (error?.__authError) {
return { data: [], total: 0, lastPage: 1, __authError: true };
}
if (!response) {
console.error('[DraftBoxActions] GET drafts error:', error?.message);
return { data: [], total: 0, lastPage: 1 };
}
const result: ApiResponse<PaginatedResponse<ApprovalApiData>> = await response.json();
if (!response.ok || !result.success || !result.data?.data) {
console.warn('[DraftBoxActions] 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) {
if (isNextRedirectError(error)) throw error;
console.error('[DraftBoxActions] getDrafts error:', error);
return { data: [], total: 0, lastPage: 1 };
}
}
/**
* 기안함 현황 카드 (통계)
*/
export async function getDraftsSummary(): Promise<DraftsSummary | null> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/drafts/summary`,
{ method: 'GET' }
);
if (error?.__authError || !response) {
console.error('[DraftBoxActions] GET summary error:', error?.message);
return null;
}
const result: ApiResponse<DraftsSummary> = await response.json();
if (!response.ok || !result.success || !result.data) {
return null;
}
return result.data;
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[DraftBoxActions] getDraftsSummary error:', error);
return null;
}
}
/**
* 결재 문서 상세 조회
*/
export async function getDraftById(id: string): Promise<DraftRecord | null> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${id}`,
{ method: 'GET' }
);
if (error?.__authError || !response) {
console.error('[DraftBoxActions] GET draft error:', error?.message);
return null;
}
const result: ApiResponse<ApprovalApiData> = await response.json();
if (!response.ok || !result.success || !result.data) {
return null;
}
return transformApiToFrontend(result.data);
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[DraftBoxActions] getDraftById error:', error);
return null;
}
}
/**
* 결재 문서 삭제 (임시저장 상태만)
*/
export async function deleteDraft(id: string): Promise<{ success: boolean; error?: string; __authError?: boolean }> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${id}`,
{ method: 'DELETE' }
);
if (error?.__authError) {
return { success: false, __authError: true };
}
if (!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) {
if (isNextRedirectError(error)) throw error;
console.error('[DraftBoxActions] deleteDraft error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
}
/**
* 결재 문서 일괄 삭제
*/
export async function deleteDrafts(ids: string[]): Promise<{ success: boolean; failedIds?: string[]; error?: string }> {
const failedIds: string[] = [];
for (const id of ids) {
const result = await deleteDraft(id);
if (!result.success) {
failedIds.push(id);
}
}
if (failedIds.length > 0) {
return {
success: false,
failedIds,
error: `${failedIds.length}건의 삭제에 실패했습니다.`,
};
}
return { success: true };
}
/**
* 결재 상신
*/
export async function submitDraft(id: string): Promise<{ success: boolean; error?: string; __authError?: boolean }> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${id}/submit`,
{
method: 'POST',
body: JSON.stringify({}),
}
);
if (error?.__authError) {
return { success: false, __authError: true };
}
if (!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) {
if (isNextRedirectError(error)) throw error;
console.error('[DraftBoxActions] submitDraft error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
}
/**
* 결재 문서 일괄 상신
*/
export async function submitDrafts(ids: string[]): Promise<{ success: boolean; failedIds?: string[]; error?: string }> {
const failedIds: string[] = [];
for (const id of ids) {
const result = await submitDraft(id);
if (!result.success) {
failedIds.push(id);
}
}
if (failedIds.length > 0) {
return {
success: false,
failedIds,
error: `${failedIds.length}건의 상신에 실패했습니다.`,
};
}
return { success: true };
}
/**
* 결재 회수 (기안자만)
*/
export async function cancelDraft(id: string): Promise<{ success: boolean; error?: string; __authError?: boolean }> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${id}/cancel`,
{
method: 'POST',
body: JSON.stringify({}),
}
);
if (error?.__authError) {
return { success: false, __authError: true };
}
if (!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) {
if (isNextRedirectError(error)) throw error;
console.error('[DraftBoxActions] cancelDraft error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
}