Files
sam-react-prod/src/components/hr/CardManagement/actions.ts
김보곤 f4c0df3579 fix: [card] 카드 수정 시 데이터 유실 문제 해결
- || undefined → || null 변환으로 nullable 필드 정상 전송
- 숫자 필드(total_limit, used_amount, remaining_limit) 0 값 전송 보장
- assigned_user_id 항상 전송 (사용자 할당 해제 가능)
- paymentDay 타입 변환 수정 (number → string, Select 매칭)
2026-02-21 00:47:05 +09:00

294 lines
9.9 KiB
TypeScript

'use server';
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import { buildApiUrl } from '@/lib/api/query-params';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import type { Card, CardFormData, CardStatus, CardStats, CardListFilter } from './types';
// API 응답 타입
interface TenantProfile {
id: number;
tenant_id: number;
user_id: number;
department_id: number | null;
position_key: string | null;
job_title_key: string | null;
department?: { id: number; name: string } | null;
}
interface CardApiData {
id: number;
tenant_id: number;
card_company: string;
card_number_last4: string;
expiry_date: string;
card_name: string;
status: 'active' | 'inactive';
assigned_user_id: number | null;
assigned_user?: {
id: number;
name: string;
email?: string;
tenant_profiles?: TenantProfile[];
} | null;
created_at: string;
updated_at: string;
}
interface CardPaginationData {
data: CardApiData[];
current_page: number;
last_page: number;
per_page: number;
total: number;
}
// 상태 매핑: API → Frontend
function mapApiStatusToFrontend(apiStatus: 'active' | 'inactive'): CardStatus {
return apiStatus === 'active' ? 'active' : 'suspended';
}
// 상태 매핑: Frontend → API
function mapFrontendStatusToApi(frontendStatus: CardStatus): 'active' | 'inactive' {
return frontendStatus === 'active' ? 'active' : 'inactive';
}
// API → Frontend 변환
function transformApiToFrontend(apiData: CardApiData): Card {
const profile = apiData.assigned_user?.tenant_profiles?.[0];
const department = profile?.department;
const raw = apiData as CardApiData & Record<string, unknown>;
return {
id: String(apiData.id),
cardCompany: apiData.card_company as Card['cardCompany'],
cardType: (raw.card_type as string) || '',
cardNumber: `****-****-****-${apiData.card_number_last4}`,
cardName: apiData.card_name,
alias: (raw.alias as string) || '',
expiryDate: apiData.expiry_date ? apiData.expiry_date.replace('/', '') : '',
csv: (raw.csv as string) || '',
paymentDay: raw.payment_day != null ? String(raw.payment_day) : '',
pinPrefix: '**',
totalLimit: Number(raw.total_limit) || 0,
usedAmount: Number(raw.used_amount) || 0,
remainingLimit: Number(raw.remaining_limit) || 0,
status: mapApiStatusToFrontend(apiData.status),
isManual: (raw.is_manual as boolean) ?? true,
user: apiData.assigned_user ? {
id: String(apiData.assigned_user.id),
departmentId: department ? String(department.id) : '',
departmentName: department?.name || '',
employeeId: String(apiData.assigned_user.id),
employeeName: apiData.assigned_user.name,
positionId: profile?.position_key || '',
positionName: profile?.position_key || '',
} : undefined,
memo: (raw.memo as string) || '',
createdAt: apiData.created_at,
updatedAt: apiData.updated_at,
};
}
// Frontend → API 변환
function transformFrontendToApi(data: CardFormData): Record<string, unknown> {
// 유효기간: 4자리면 MM/YY 포맷으로 변환, 비어있으면 전송하지 않음
const formattedExpiry = data.expiryDate.length === 4
? `${data.expiryDate.slice(0, 2)}/${data.expiryDate.slice(2)}`
: data.expiryDate;
const apiData: Record<string, unknown> = {
card_company: data.cardCompany,
card_type: data.cardType || null,
card_name: data.cardName,
alias: data.alias || null,
csv: data.csv || null,
payment_day: data.paymentDay ? parseInt(data.paymentDay, 10) : null,
total_limit: data.totalLimit,
used_amount: data.usedAmount,
remaining_limit: data.remainingLimit,
status: mapFrontendStatusToApi(data.status),
memo: data.memo || null,
assigned_user_id: data.userId ? parseInt(data.userId, 10) : null,
};
// 유효기간: 값이 있을 때만 전송 (비어있으면 기존 값 유지)
if (formattedExpiry) {
apiData.expiry_date = formattedExpiry;
}
// 카드번호: 마스킹(*)이 포함되면 전송하지 않음 (기존 값 유지)
const cardNumberDigits = data.cardNumber.replace(/-/g, '');
if (cardNumberDigits && !cardNumberDigits.includes('*')) {
apiData.card_number = cardNumberDigits;
}
// 비밀번호: 변경된 경우만 전송
if (data.pinPrefix && data.pinPrefix !== '**') {
apiData.card_password = data.pinPrefix;
}
return apiData;
}
// ===== 카드 목록 조회 =====
export async function getCards(params?: Partial<CardListFilter> & { per_page?: number }): Promise<{
success: boolean; data?: Card[]; pagination?: { total: number; currentPage: number; lastPage: number }; error?: string;
}> {
const result = await executeServerAction<CardPaginationData>({
url: buildApiUrl('/api/v1/cards', {
search: params?.search,
status: params?.status && params.status !== 'all' ? mapFrontendStatusToApi(params.status as CardStatus) : undefined,
card_company: params?.cardCompany && params.cardCompany !== 'all' ? params.cardCompany : undefined,
start_date: params?.startDate,
end_date: params?.endDate,
page: params?.page,
per_page: params?.per_page,
}),
errorMessage: '카드 목록을 불러오는데 실패했습니다.',
});
if (!result.success || !result.data) {
return { success: false, error: result.error };
}
return {
success: true,
data: result.data.data.map(transformApiToFrontend),
pagination: {
total: result.data.total,
currentPage: result.data.current_page,
lastPage: result.data.last_page,
},
};
}
// ===== 카드 통계 조회 =====
export async function getCardStats(params?: {
startDate?: string; endDate?: string;
}): Promise<ActionResult<CardStats>> {
return executeServerAction({
url: buildApiUrl('/api/v1/cards/stats', {
start_date: params?.startDate,
end_date: params?.endDate,
}),
transform: (data: {
total_count?: number; upcoming_payment?: number; total_limit?: number; remaining_limit?: number;
}) => ({
totalCount: data.total_count ?? 0,
upcomingPayment: data.upcoming_payment ?? 0,
totalLimit: data.total_limit ?? 0,
remainingLimit: data.remaining_limit ?? 0,
}),
errorMessage: '카드 통계 조회에 실패했습니다.',
});
}
// ===== 품의서 작성 URL 조회 =====
export async function getApprovalFormUrl(cardId: string): Promise<ActionResult<{ url: string }>> {
return executeServerAction({
url: buildApiUrl(`/api/v1/cards/${cardId}/approval-form-url`),
transform: (resp: { url: string }) => ({ url: resp.url }),
errorMessage: '품의서 작성 페이지 URL 조회에 실패했습니다.',
});
}
// ===== 카드 상세 조회 =====
export async function getCard(id: string): Promise<ActionResult<Card>> {
return executeServerAction({
url: buildApiUrl(`/api/v1/cards/${id}`),
transform: (data: CardApiData) => transformApiToFrontend(data),
errorMessage: '카드 정보를 불러오는데 실패했습니다.',
});
}
// ===== 카드 등록 =====
export async function createCard(data: CardFormData): Promise<ActionResult<Card>> {
return executeServerAction({
url: buildApiUrl('/api/v1/cards'),
method: 'POST',
body: transformFrontendToApi(data),
transform: (d: CardApiData) => transformApiToFrontend(d),
errorMessage: '카드 등록에 실패했습니다.',
});
}
// ===== 카드 수정 =====
export async function updateCard(id: string, data: CardFormData): Promise<ActionResult<Card>> {
return executeServerAction({
url: buildApiUrl(`/api/v1/cards/${id}`),
method: 'PUT',
body: transformFrontendToApi(data),
transform: (d: CardApiData) => transformApiToFrontend(d),
errorMessage: '카드 수정에 실패했습니다.',
});
}
// ===== 카드 삭제 =====
export async function deleteCard(id: string): Promise<ActionResult> {
return executeServerAction({
url: buildApiUrl(`/api/v1/cards/${id}`),
method: 'DELETE',
errorMessage: '카드 삭제에 실패했습니다.',
});
}
// ===== 카드 일괄 삭제 =====
export async function deleteCards(ids: string[]): Promise<{ success: boolean; error?: string }> {
try {
const results = await Promise.all(ids.map(id => deleteCard(id)));
const failed = results.filter(r => !r.success);
if (failed.length > 0) {
return { success: false, error: `${failed.length}개의 카드 삭제에 실패했습니다.` };
}
return { success: true };
} catch (error) {
if (isNextRedirectError(error)) throw error;
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 카드 상태 토글 =====
export async function toggleCardStatus(id: string): Promise<ActionResult<Card>> {
return executeServerAction({
url: buildApiUrl(`/api/v1/cards/${id}/toggle`),
method: 'PATCH',
transform: (data: CardApiData) => transformApiToFrontend(data),
errorMessage: '상태 변경에 실패했습니다.',
});
}
// ===== 활성 직원 목록 조회 (카드 할당용) =====
export async function getActiveEmployees(): Promise<{ success: boolean; data?: Array<{ id: string; label: string }>; error?: string }> {
interface EmployeePaginationData {
data: Array<{
id: number;
user_id: number;
user?: { id: number; name: string };
department?: { name: string };
position_key?: string;
}>;
}
const result = await executeServerAction<EmployeePaginationData>({
url: buildApiUrl('/api/v1/employees', {
status: 'active',
per_page: 50,
}),
errorMessage: '직원 목록을 불러오는데 실패했습니다.',
});
if (!result.success || !result.data) {
return { success: false, error: result.error };
}
const employees = result.data.data.map(emp => ({
id: String(emp.user?.id || emp.user_id),
label: `${emp.department?.name || ''} / ${emp.user?.name || ''} / ${emp.position_key || ''}`.replace(/^ \/ | \/ $/g, '').replace(/ \/ $/g, ''),
}));
return { success: true, data: employees };
}