Files
sam-react-prod/src/components/approval/DraftBox/actions.ts
유병철 437d5f6834 refactor(WEB): SearchableSelectionModal 공통화 및 actions lookup 통합
- SearchableSelectionModal<T> 제네릭 컴포넌트 추출 (organisms)
- 검색 모달 5개 리팩토링: SupplierSearch, QuotationSelect, SalesOrderSelect, OrderSelect, ItemSearch
- shared-lookups API 유틸 추가 (거래처/품목/수주 등 공통 조회)
- create-crud-service 확장 (lookup, search 메서드)
- actions.ts 20+개 파일 lookup 패턴 통일
- 공통 페이지 패턴 가이드 문서 추가
- CLAUDE.md Common Component Usage Rules 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 16:01:23 +09:00

260 lines
8.0 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 { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import type { PaginatedApiResponse } from '@/lib/api/types';
import type { DraftRecord, DocumentStatus, Approver } from './types';
// ============================================
// API 응답 타입 정의
// ============================================
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,
};
}
const API_URL = process.env.NEXT_PUBLIC_API_URL;
// ============================================
// 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 }> {
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') {
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 result = await executeServerAction<PaginatedApiResponse<ApprovalApiData>>({
url: `${API_URL}/api/v1/approvals/drafts?${searchParams.toString()}`,
errorMessage: '기안함 목록 조회에 실패했습니다.',
});
if (result.__authError) return { data: [], total: 0, lastPage: 1, __authError: true };
if (!result.success || !result.data?.data) return { data: [], total: 0, lastPage: 1 };
return {
data: result.data.data.map(transformApiToFrontend),
total: result.data.total,
lastPage: result.data.last_page,
};
}
export async function getDraftsSummary(): Promise<DraftsSummary | null> {
const result = await executeServerAction<DraftsSummary>({
url: `${API_URL}/api/v1/approvals/drafts/summary`,
errorMessage: '기안함 현황 조회에 실패했습니다.',
});
return result.success ? result.data || null : null;
}
export async function getDraftById(id: string): Promise<DraftRecord | null> {
const result = await executeServerAction({
url: `${API_URL}/api/v1/approvals/${id}`,
transform: (data: ApprovalApiData) => transformApiToFrontend(data),
errorMessage: '결재 문서 조회에 실패했습니다.',
});
return result.success ? result.data || null : null;
}
export async function deleteDraft(id: string): Promise<ActionResult> {
return executeServerAction({
url: `${API_URL}/api/v1/approvals/${id}`,
method: 'DELETE',
errorMessage: '결재 문서 삭제에 실패했습니다.',
});
}
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<ActionResult> {
return executeServerAction({
url: `${API_URL}/api/v1/approvals/${id}/submit`,
method: 'POST',
body: {},
errorMessage: '결재 상신에 실패했습니다.',
});
}
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<ActionResult> {
return executeServerAction({
url: `${API_URL}/api/v1/approvals/${id}/cancel`,
method: 'POST',
body: {},
errorMessage: '결재 회수에 실패했습니다.',
});
}