- || undefined → || null 변환으로 nullable 필드 정상 전송 - 숫자 필드(total_limit, used_amount, remaining_limit) 0 값 전송 보장 - assigned_user_id 항상 전송 (사용자 할당 해제 가능) - paymentDay 타입 변환 수정 (number → string, Select 매칭)
294 lines
9.9 KiB
TypeScript
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 };
|
|
}
|