- FCM 공통 모듈 생성 (src/lib/actions/fcm.ts) - sendFcmNotification: 기본 FCM 발송 함수 - sendApprovalNotification: 결재 알림 프리셋 - sendWorkOrderNotification: 작업지시 알림 프리셋 - sendNoticeNotification: 공지사항 알림 프리셋 - 기안함 페이지에 '문서완료' 버튼 추가 - Bell 아이콘 + FCM 발송 기능 - 발송 결과 토스트 메시지 표시
461 lines
12 KiB
TypeScript
461 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: '서버 오류가 발생했습니다.',
|
|
};
|
|
}
|
|
}
|