refactor(WEB): Server Action 공통화 및 보안 강화

- executeServerAction 공통 유틸 도입으로 actions.ts 대폭 간소화 (50+개 파일)
- sanitize 유틸 추가 (XSS 방지)
- middleware CSP 헤더 추가 및 Open Redirect 방지
- 프록시 라우트 로깅 개발환경 한정으로 변경
- 프로덕션 불필요 console.log 제거

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-02-09 16:14:06 +09:00
parent d014227e9c
commit 55e0791e16
85 changed files with 7211 additions and 17638 deletions

View File

@@ -1,10 +1,11 @@
'use server';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { serverFetch } from '@/lib/api/fetch-wrapper';
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import type { AccountInfo, TermsAgreement, MarketingConsent } from './types';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
/**
* 상대 경로를 절대 URL로 변환
* /storage/... 또는 1/temp/... → https://api.example.com/storage/tenants/...
@@ -34,243 +35,80 @@ export async function getAccountInfo(): Promise<{
error?: string;
__authError?: boolean;
}> {
try {
// 1. 사용자 기본 정보 조회
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/users/me`,
{
method: 'GET',
}
);
// 1. 사용자 기본 정보 조회
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const userResult = await executeServerAction<any>({
url: `${API_URL}/api/v1/users/me`,
errorMessage: '계정 정보를 불러올 수 없습니다.',
});
if (userResult.__authError) return { success: false, __authError: true };
if (!userResult.success || !userResult.data) return { success: false, error: userResult.error };
if (error) {
return {
success: false,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
const user = userResult.data;
if (!response) {
return {
success: false,
error: '계정 정보를 불러올 수 없습니다.',
};
}
const result = await response.json();
if (!response.ok || !result.success) {
return {
success: false,
error: result.message || '계정 정보 조회에 실패했습니다.',
};
}
const user = result.data;
// 2. 프로필 정보 조회 (프로필 이미지 포함)
let profileImage: string | undefined;
try {
const { response: profileResponse } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/profiles/me`,
{
method: 'GET',
}
);
if (profileResponse?.ok) {
const profileResult = await profileResponse.json();
if (profileResult.success && profileResult.data) {
// profile_photo_path 필드에서 이미지 경로 가져오기
profileImage = toAbsoluteUrl(profileResult.data.profile_photo_path);
}
}
} catch {
// 프로필 조회 실패해도 계속 진행
console.warn('[getAccountInfo] Failed to fetch profile image');
}
return {
success: true,
data: {
accountInfo: {
id: user.id?.toString() || '',
email: user.email || '',
profileImage, // 프로필 API에서 가져온 이미지
role: user.role?.name || user.role || '',
status: user.status || 'active',
isTenantMaster: user.is_tenant_master || false,
createdAt: user.created_at || '',
updatedAt: user.updated_at || '',
},
termsAgreements: user.terms_agreements || [],
marketingConsent: user.marketing_consent || {
email: { agreed: false },
sms: { agreed: false },
},
},
};
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[AccountInfoActions] getAccountInfo error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
// 2. 프로필 정보 조회 (프로필 이미지 포함 - 실패해도 계속 진행)
let profileImage: string | undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const profileResult = await executeServerAction<any>({
url: `${API_URL}/api/v1/profiles/me`,
errorMessage: '프로필 조회 실패',
});
if (profileResult.success && profileResult.data) {
profileImage = toAbsoluteUrl(profileResult.data.profile_photo_path);
}
return {
success: true,
data: {
accountInfo: {
id: user.id?.toString() || '',
email: user.email || '',
profileImage,
role: user.role?.name || user.role || '',
status: user.status || 'active',
isTenantMaster: user.is_tenant_master || false,
createdAt: user.created_at || '',
updatedAt: user.updated_at || '',
},
termsAgreements: user.terms_agreements || [],
marketingConsent: user.marketing_consent || {
email: { agreed: false },
sms: { agreed: false },
},
},
};
}
// ===== 계정 탈퇴 =====
export async function withdrawAccount(password: string): Promise<{
success: boolean;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/users/withdraw`,
{
method: 'POST',
body: JSON.stringify({ password }),
}
);
if (error) {
return {
success: false,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response) {
return {
success: false,
error: '계정 탈퇴에 실패했습니다.',
};
}
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('[AccountInfoActions] withdrawAccount error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
export async function withdrawAccount(password: string): Promise<ActionResult> {
return executeServerAction({
url: `${API_URL}/api/v1/users/withdraw`,
method: 'POST',
body: { password },
errorMessage: '계정 탈퇴에 실패했습니다.',
});
}
// ===== 테넌트 사용 중지 =====
export async function suspendTenant(): Promise<{
success: boolean;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/tenants/suspend`,
{
method: 'POST',
body: JSON.stringify({}),
}
);
if (error) {
return {
success: false,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response) {
return {
success: false,
error: '사용 중지에 실패했습니다.',
};
}
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('[AccountInfoActions] suspendTenant error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
export async function suspendTenant(): Promise<ActionResult> {
return executeServerAction({
url: `${API_URL}/api/v1/tenants/suspend`,
method: 'POST',
body: {},
errorMessage: '사용 중지에 실패했습니다.',
});
}
// ===== 약관 동의 수정 =====
export async function updateAgreements(
agreements: Array<{ type: string; agreed: boolean }>
): Promise<{
success: boolean;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/account/agreements`,
{
method: 'PUT',
body: JSON.stringify({ agreements }),
}
);
if (error) {
return {
success: false,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response) {
return {
success: false,
error: '약관 동의 수정에 실패했습니다.',
};
}
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('[AccountInfoActions] updateAgreements error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
): Promise<ActionResult> {
return executeServerAction({
url: `${API_URL}/api/v1/account/agreements`,
method: 'PUT',
body: { agreements },
errorMessage: '약관 동의 수정에 실패했습니다.',
});
}
// ===== 프로필 이미지 업로드 =====
@@ -280,113 +118,36 @@ export async function uploadProfileImage(formData: FormData): Promise<{
error?: string;
__authError?: boolean;
}> {
try {
console.log('[uploadProfileImage] Starting upload...');
// 1. 파일 업로드
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const uploadResult = await executeServerAction<any>({
url: `${API_URL}/api/v1/files/upload`,
method: 'POST',
body: formData,
errorMessage: '파일 업로드에 실패했습니다.',
});
if (uploadResult.__authError) return { success: false, __authError: true };
if (!uploadResult.success || !uploadResult.data) return { success: false, error: uploadResult.error };
// 1. 먼저 파일 업로드 (일반 파일 업로드 엔드포인트 사용)
const { response: uploadResponse, error: uploadError } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/files/upload`,
{
method: 'POST',
body: formData,
}
);
const uploadedPath = uploadResult.data.file_path || uploadResult.data.path || uploadResult.data.url;
if (!uploadedPath) return { success: false, error: '업로드된 파일 경로를 가져올 수 없습니다.' };
console.log('[uploadProfileImage] Upload response status:', uploadResponse?.status);
// 2. 프로필 업데이트 (업로드된 파일 경로로)
const updateResult = await executeServerAction({
url: `${API_URL}/api/v1/profiles/me`,
method: 'PATCH',
body: { profile_photo_path: uploadedPath },
errorMessage: '프로필 업데이트에 실패했습니다.',
});
if (updateResult.__authError) return { success: false, __authError: true };
if (!updateResult.success) return { success: false, error: updateResult.error };
if (uploadError) {
console.error('[uploadProfileImage] Upload error:', uploadError);
return {
success: false,
error: uploadError.message,
__authError: uploadError.code === 'UNAUTHORIZED',
};
}
const storagePath = uploadedPath.startsWith('/storage/')
? uploadedPath
: `/storage/tenants/${uploadedPath}`;
if (!uploadResponse) {
console.error('[uploadProfileImage] No upload response');
return {
success: false,
error: '파일 업로드에 실패했습니다.',
};
}
const uploadResult = await uploadResponse.json();
console.log('[uploadProfileImage] Upload result:', JSON.stringify(uploadResult, null, 2));
if (!uploadResponse.ok || !uploadResult.success) {
return {
success: false,
error: uploadResult.message || '파일 업로드에 실패했습니다.',
};
}
// 업로드된 파일 경로 추출 (API 응답: file_path 필드)
const uploadedPath = uploadResult.data?.file_path || uploadResult.data?.path || uploadResult.data?.url;
console.log('[uploadProfileImage] Uploaded path:', uploadedPath);
if (!uploadedPath) {
console.error('[uploadProfileImage] No file path in response. Full data:', uploadResult.data);
return {
success: false,
error: '업로드된 파일 경로를 가져올 수 없습니다.',
};
}
// 2. 프로필 업데이트 (업로드된 파일 경로로)
console.log('[uploadProfileImage] Updating profile with path:', uploadedPath);
const { response: updateResponse, error: updateError } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/profiles/me`,
{
method: 'PATCH',
body: JSON.stringify({ profile_photo_path: uploadedPath }),
}
);
if (updateError) {
console.error('[uploadProfileImage] Profile update error:', updateError);
return {
success: false,
error: updateError.message,
__authError: updateError.code === 'UNAUTHORIZED',
};
}
if (!updateResponse) {
console.error('[uploadProfileImage] No profile update response');
return {
success: false,
error: '프로필 업데이트에 실패했습니다.',
};
}
const updateResult = await updateResponse.json();
console.log('[uploadProfileImage] Profile update result:', JSON.stringify(updateResult, null, 2));
if (!updateResponse.ok || !updateResult.success) {
return {
success: false,
error: updateResult.message || '프로필 업데이트에 실패했습니다.',
};
}
// /storage/tenants/ 경로로 변환 (tenant disk 파일은 이 경로로 접근 가능)
const storagePath = uploadedPath.startsWith('/storage/')
? uploadedPath
: `/storage/tenants/${uploadedPath}`;
return {
success: true,
data: {
imageUrl: toAbsoluteUrl(storagePath) || '',
},
};
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[AccountInfoActions] uploadProfileImage error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
return {
success: true,
data: { imageUrl: toAbsoluteUrl(storagePath) || '' },
};
}

View File

@@ -1,11 +1,13 @@
'use server';
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { serverFetch } from '@/lib/api/fetch-wrapper';
import type { Account, AccountFormData, AccountStatus } from './types';
import { BANK_LABELS } from './types';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
// ===== API 응답 타입 =====
interface BankAccountApiData {
id: number;
@@ -21,34 +23,15 @@ interface BankAccountApiData {
updated_at?: string;
}
interface PaginationMeta {
current_page: number;
last_page: number;
per_page: number;
total: number;
}
interface PaginatedData {
current_page: number;
last_page: number;
per_page: number;
total: number;
interface BankAccountPaginatedResponse {
data: BankAccountApiData[];
current_page: number;
last_page: number;
per_page: number;
total: number;
}
interface ApiListResponse {
success: boolean;
message?: string;
data: PaginatedData;
}
interface ApiSingleResponse {
success: boolean;
message?: string;
data: BankAccountApiData;
}
// ===== 데이터 변환: API → Frontend =====
// ===== 데이터 변환 =====
function transformApiToFrontend(apiData: BankAccountApiData): Account {
return {
id: apiData.id,
@@ -65,7 +48,6 @@ function transformApiToFrontend(apiData: BankAccountApiData): Account {
};
}
// ===== 데이터 변환: Frontend → API =====
function transformFrontendToApi(data: Partial<AccountFormData>): Record<string, unknown> {
return {
bank_code: data.bankCode,
@@ -79,328 +61,91 @@ function transformFrontendToApi(data: Partial<AccountFormData>): Record<string,
// ===== 계좌 목록 조회 =====
export async function getBankAccounts(params?: {
page?: number;
perPage?: number;
search?: string;
page?: number; perPage?: number; search?: string;
}): Promise<{
success: boolean;
data?: Account[];
meta?: PaginationMeta;
error?: string;
__authError?: boolean;
success: boolean; data?: Account[]; meta?: { currentPage: number; lastPage: number; perPage: number; total: number };
error?: string; __authError?: boolean;
}> {
try {
const searchParams = new URLSearchParams();
const searchParams = new URLSearchParams();
if (params?.page) searchParams.set('page', params.page.toString());
if (params?.perPage) searchParams.set('per_page', params.perPage.toString());
if (params?.search) searchParams.set('search', params.search);
const queryString = searchParams.toString();
if (params?.page) searchParams.set('page', params.page.toString());
if (params?.perPage) searchParams.set('per_page', params.perPage.toString());
if (params?.search) searchParams.set('search', params.search);
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/bank-accounts?${searchParams.toString()}`;
const { response, error } = await serverFetch(url, {
method: 'GET',
cache: 'no-store',
});
if (error) {
return {
success: false,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response) {
return { success: false, error: '계좌 목록 조회에 실패했습니다.' };
}
const result: ApiListResponse = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '계좌 목록 조회에 실패했습니다.' };
}
const accounts = result.data.data.map(transformApiToFrontend);
const meta: PaginationMeta = {
current_page: result.data.current_page,
last_page: result.data.last_page,
per_page: result.data.per_page,
total: result.data.total,
};
return { success: true, data: accounts, meta };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[getBankAccounts] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
const result = await executeServerAction({
url: `${API_URL}/api/v1/bank-accounts${queryString ? `?${queryString}` : ''}`,
transform: (data: BankAccountPaginatedResponse) => ({
accounts: (data?.data || []).map(transformApiToFrontend),
meta: { currentPage: data?.current_page || 1, lastPage: data?.last_page || 1, perPage: data?.per_page || 20, total: data?.total || 0 },
}),
errorMessage: '계좌 목록 조회에 실패했습니다.',
});
return { success: result.success, data: result.data?.accounts, meta: result.data?.meta, error: result.error, __authError: result.__authError };
}
// ===== 계좌 상세 조회 =====
export async function getBankAccount(id: number): Promise<{
success: boolean;
data?: Account;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/bank-accounts/${id}`,
{
method: 'GET',
cache: 'no-store',
}
);
if (error) {
return {
success: false,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response) {
return { success: false, error: '계좌 조회에 실패했습니다.' };
}
const result: ApiSingleResponse = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '계좌 조회에 실패했습니다.' };
}
const account = transformApiToFrontend(result.data);
return { success: true, data: account };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[getBankAccount] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
export async function getBankAccount(id: number): Promise<ActionResult<Account>> {
return executeServerAction({
url: `${API_URL}/api/v1/bank-accounts/${id}`,
transform: (data: BankAccountApiData) => transformApiToFrontend(data),
errorMessage: '계좌 조회에 실패했습니다.',
});
}
// ===== 계좌 생성 =====
export async function createBankAccount(data: AccountFormData): Promise<{
success: boolean;
data?: Account;
error?: string;
__authError?: boolean;
}> {
try {
const apiData = transformFrontendToApi(data);
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/bank-accounts`,
{
method: 'POST',
body: JSON.stringify(apiData),
}
);
if (error) {
return {
success: false,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response) {
return { success: false, error: '계좌 등록에 실패했습니다.' };
}
const result: ApiSingleResponse = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '계좌 등록에 실패했습니다.' };
}
const account = transformApiToFrontend(result.data);
return { success: true, data: account };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[createBankAccount] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
export async function createBankAccount(data: AccountFormData): Promise<ActionResult<Account>> {
return executeServerAction({
url: `${API_URL}/api/v1/bank-accounts`,
method: 'POST',
body: transformFrontendToApi(data),
transform: (d: BankAccountApiData) => transformApiToFrontend(d),
errorMessage: '계좌 등록에 실패했습니다.',
});
}
// ===== 계좌 수정 =====
export async function updateBankAccount(
id: number,
data: Partial<AccountFormData>
): Promise<{
success: boolean;
data?: Account;
error?: string;
__authError?: boolean;
}> {
try {
const apiData = transformFrontendToApi(data);
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/bank-accounts/${id}`,
{
method: 'PUT',
body: JSON.stringify(apiData),
}
);
if (error) {
return {
success: false,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response) {
return { success: false, error: '계좌 수정에 실패했습니다.' };
}
const result: ApiSingleResponse = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '계좌 수정에 실패했습니다.' };
}
const account = transformApiToFrontend(result.data);
return { success: true, data: account };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[updateBankAccount] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
export async function updateBankAccount(id: number, data: Partial<AccountFormData>): Promise<ActionResult<Account>> {
return executeServerAction({
url: `${API_URL}/api/v1/bank-accounts/${id}`,
method: 'PUT',
body: transformFrontendToApi(data),
transform: (d: BankAccountApiData) => transformApiToFrontend(d),
errorMessage: '계좌 수정에 실패했습니다.',
});
}
// ===== 계좌 삭제 =====
export async function deleteBankAccount(id: number): Promise<{
success: boolean;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/bank-accounts/${id}`,
{
method: 'DELETE',
}
);
if (error) {
return {
success: false,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response) {
return { success: false, error: '계좌 삭제에 실패했습니다.' };
}
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('[deleteBankAccount] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
export async function deleteBankAccount(id: number): Promise<ActionResult> {
return executeServerAction({
url: `${API_URL}/api/v1/bank-accounts/${id}`,
method: 'DELETE',
errorMessage: '계좌 삭제에 실패했습니다.',
});
}
// ===== 계좌 상태 토글 =====
export async function toggleBankAccountStatus(id: number): Promise<{
success: boolean;
data?: Account;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/bank-accounts/${id}/toggle`,
{
method: 'PATCH',
}
);
if (error) {
return {
success: false,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response) {
return { success: false, error: '상태 변경에 실패했습니다.' };
}
const result: ApiSingleResponse = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '상태 변경에 실패했습니다.' };
}
const account = transformApiToFrontend(result.data);
return { success: true, data: account };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[toggleBankAccountStatus] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
export async function toggleBankAccountStatus(id: number): Promise<ActionResult<Account>> {
return executeServerAction({
url: `${API_URL}/api/v1/bank-accounts/${id}/toggle`,
method: 'PATCH',
transform: (data: BankAccountApiData) => transformApiToFrontend(data),
errorMessage: '상태 변경에 실패했습니다.',
});
}
// ===== 대표 계좌 설정 =====
export async function setPrimaryBankAccount(id: number): Promise<{
success: boolean;
data?: Account;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/bank-accounts/${id}/set-primary`,
{
method: 'PATCH',
}
);
if (error) {
return {
success: false,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response) {
return { success: false, error: '대표 계좌 설정에 실패했습니다.' };
}
const result: ApiSingleResponse = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '대표 계좌 설정에 실패했습니다.' };
}
const account = transformApiToFrontend(result.data);
return { success: true, data: account };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[setPrimaryBankAccount] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
export async function setPrimaryBankAccount(id: number): Promise<ActionResult<Account>> {
return executeServerAction({
url: `${API_URL}/api/v1/bank-accounts/${id}/set-primary`,
method: 'PATCH',
transform: (data: BankAccountApiData) => transformApiToFrontend(data),
errorMessage: '대표 계좌 설정에 실패했습니다.',
});
}
// ===== 다중 삭제 =====
export async function deleteBankAccounts(ids: number[]): Promise<{
success: boolean;
deletedCount?: number;
error?: string;
success: boolean; deletedCount?: number; error?: string;
}> {
try {
const results = await Promise.all(ids.map(id => deleteBankAccount(id)));
@@ -410,19 +155,12 @@ export async function deleteBankAccounts(ids: number[]): Promise<{
if (failedCount > 0 && successCount === 0) {
return { success: false, error: '계좌 삭제에 실패했습니다.' };
}
if (failedCount > 0) {
return {
success: true,
deletedCount: successCount,
error: `${failedCount}개의 계좌 삭제에 실패했습니다.`
};
return { success: true, deletedCount: successCount, error: `${failedCount}개의 계좌 삭제에 실패했습니다.` };
}
return { success: true, deletedCount: successCount };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[deleteBankAccounts] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}

View File

@@ -1,8 +1,7 @@
'use server';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { serverFetch } from '@/lib/api/fetch-wrapper';
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
@@ -47,13 +46,6 @@ interface ApiDepartment {
children?: ApiDepartment[];
}
// API 응답 공통 타입
interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
}
// ===== 데이터 변환 =====
/**
@@ -75,151 +67,64 @@ function transformFromApi(data: ApiAttendanceSetting): AttendanceSettingFormData
*/
function transformToApi(data: Partial<AttendanceSettingFormData>): Record<string, unknown> {
const apiData: Record<string, unknown> = {};
if (data.useGps !== undefined) apiData.use_gps = data.useGps;
if (data.useAuto !== undefined) apiData.use_auto = data.useAuto;
if (data.allowedRadius !== undefined) apiData.allowed_radius = data.allowedRadius;
if (data.hqAddress !== undefined) apiData.hq_address = data.hqAddress;
if (data.hqLatitude !== undefined) apiData.hq_latitude = data.hqLatitude;
if (data.hqLongitude !== undefined) apiData.hq_longitude = data.hqLongitude;
return apiData;
}
// ===== API 호출 =====
/**
* 출퇴근 설정 조회
*/
export async function getAttendanceSetting(): Promise<ApiResponse<AttendanceSettingFormData>> {
try {
const { response, error } = await serverFetch(`${API_URL}/api/v1/settings/attendance`, {
method: 'GET',
});
if (error) {
return { success: false, error: error.message };
}
if (!response) {
return { success: false, error: '출퇴근 설정 조회에 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '출퇴근 설정 조회 실패' };
}
return {
success: true,
data: transformFromApi(result.data),
};
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('getAttendanceSetting error:', error);
return {
success: false,
error: '출퇴근 설정을 불러오는데 실패했습니다.',
};
}
}
/**
* 출퇴근 설정 수정
*/
export async function updateAttendanceSetting(
data: Partial<AttendanceSettingFormData>
): Promise<ApiResponse<AttendanceSettingFormData>> {
try {
const { response, error } = await serverFetch(`${API_URL}/api/v1/settings/attendance`, {
method: 'PUT',
body: JSON.stringify(transformToApi(data)),
});
if (error) {
return { success: false, error: error.message };
}
if (!response) {
return { success: false, error: '출퇴근 설정 저장에 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '출퇴근 설정 저장 실패' };
}
return {
success: true,
data: transformFromApi(result.data),
};
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('updateAttendanceSetting error:', error);
return {
success: false,
error: '출퇴근 설정 저장에 실패했습니다.',
};
}
}
/**
* 트리 구조를 평탄화 (재귀)
*/
function flattenDepartmentTree(departments: ApiDepartment[], depth: number = 0): Department[] {
const result: Department[] = [];
for (const dept of departments) {
result.push({
id: String(dept.id),
name: dept.name,
depth,
});
result.push({ id: String(dept.id), name: dept.name, depth });
if (dept.children && dept.children.length > 0) {
result.push(...flattenDepartmentTree(dept.children, depth + 1));
}
}
return result;
}
// ===== API 호출 =====
/**
* 출퇴근 설정 조회
*/
export async function getAttendanceSetting(): Promise<ActionResult<AttendanceSettingFormData>> {
return executeServerAction({
url: `${API_URL}/api/v1/settings/attendance`,
transform: (data: ApiAttendanceSetting) => transformFromApi(data),
errorMessage: '출퇴근 설정을 불러오는데 실패했습니다.',
});
}
/**
* 출퇴근 설정 수정
*/
export async function updateAttendanceSetting(
data: Partial<AttendanceSettingFormData>
): Promise<ActionResult<AttendanceSettingFormData>> {
return executeServerAction({
url: `${API_URL}/api/v1/settings/attendance`,
method: 'PUT',
body: transformToApi(data),
transform: (data: ApiAttendanceSetting) => transformFromApi(data),
errorMessage: '출퇴근 설정 저장에 실패했습니다.',
});
}
/**
* 부서 목록 조회 (트리 구조)
*/
export async function getDepartments(): Promise<ApiResponse<Department[]>> {
try {
// 트리 API 사용
const { response, error } = await serverFetch(`${API_URL}/api/v1/departments/tree`, {
method: 'GET',
});
if (error) {
return { success: false, error: error.message };
}
if (!response) {
return { success: false, error: '부서 목록 조회에 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '부서 목록 조회 실패' };
}
// 트리를 평탄화하여 depth 포함된 배열로 변환
const departments = flattenDepartmentTree(result.data || []);
return { success: true, data: departments };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('getDepartments error:', error);
return {
success: false,
error: '부서 목록을 불러오는데 실패했습니다.',
};
}
export async function getDepartments(): Promise<ActionResult<Department[]>> {
return executeServerAction({
url: `${API_URL}/api/v1/departments/tree`,
transform: (data: ApiDepartment[]) => flattenDepartmentTree(data || []),
errorMessage: '부서 목록을 불러오는데 실패했습니다.',
});
}

View File

@@ -1,10 +1,11 @@
'use server';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { serverFetch } from '@/lib/api/fetch-wrapper';
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import type { CompanyFormData } from './types';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
// API 응답 타입
interface TenantApiData {
id: number;
@@ -35,138 +36,61 @@ interface TenantApiData {
updated_at?: string;
}
/**
* 테넌트 정보 조회
*/
export async function getCompanyInfo(): Promise<{
success: boolean;
data?: CompanyFormData & { tenantId: number };
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/tenants`,
{
method: 'GET',
cache: 'no-store',
}
);
if (error) {
return {
success: false,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response) {
return { success: false, error: '회사 정보 조회에 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '회사 정보 조회에 실패했습니다.' };
}
const apiData: TenantApiData = result.data;
const formData = transformApiToFrontend(apiData);
return { success: true, data: formData };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[getCompanyInfo] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
// ===== 테넌트 정보 조회 =====
export async function getCompanyInfo(): Promise<ActionResult<CompanyFormData & { tenantId: number }>> {
return executeServerAction({
url: `${API_URL}/api/v1/tenants`,
transform: (data: TenantApiData) => transformApiToFrontend(data),
errorMessage: '회사 정보 조회에 실패했습니다.',
});
}
/**
* 테넌트 정보 수정
*/
// ===== 테넌트 정보 수정 =====
export async function updateCompanyInfo(
tenantId: number,
data: Partial<CompanyFormData>
): Promise<{
success: boolean;
data?: CompanyFormData;
error?: string;
__authError?: boolean;
}> {
try {
const apiData = transformFrontendToApi(tenantId, data);
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/tenants`,
{
method: 'PUT',
body: JSON.stringify(apiData),
}
);
if (error) {
return {
success: false,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response) {
return { success: false, error: '회사 정보 수정에 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '회사 정보 수정에 실패했습니다.' };
}
const updatedData = transformApiToFrontend(result.data);
return { success: true, data: updatedData };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[updateCompanyInfo] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
): Promise<ActionResult<CompanyFormData & { tenantId: number }>> {
return executeServerAction({
url: `${API_URL}/api/v1/tenants`,
method: 'PUT',
body: transformFrontendToApi(tenantId, data),
transform: (d: TenantApiData) => transformApiToFrontend(d),
errorMessage: '회사 정보 수정에 실패했습니다.',
});
}
/**
* 상대 경로를 절대 URL로 변환
* /storage/... → https://api.example.com/storage/...
*/
// ===== 회사 로고 업로드 =====
export async function uploadCompanyLogo(formData: FormData): Promise<ActionResult<{ logoUrl: string }>> {
return executeServerAction({
url: `${API_URL}/api/v1/tenants/logo`,
method: 'POST',
body: formData,
transform: (data: { logo_url?: string }) => ({
logoUrl: toAbsoluteUrl(data?.logo_url) || '',
}),
errorMessage: '로고 업로드에 실패했습니다.',
});
}
// ===== 유틸리티 =====
function toAbsoluteUrl(path: string | undefined): string | undefined {
if (!path) return undefined;
// 이미 절대 URL이면 그대로 반환
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
}
// 상대 경로면 API URL 붙이기
if (path.startsWith('http://') || path.startsWith('https://')) return path;
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
return `${apiUrl}${path}`;
}
/**
* API 응답 → Frontend 변환
*
* 기본 필드: company_name, ceo_name, email, phone, business_num, address
* 확장 필드: options JSON에서 읽어옴
*/
function transformApiToFrontend(apiData: TenantApiData): CompanyFormData & { tenantId: number } {
const opts = apiData.options || {};
return {
// tenantId (API 응답의 id 필드)
tenantId: apiData.id,
// 기본 필드
companyName: apiData.company_name || '',
representativeName: apiData.ceo_name || '',
email: apiData.email || '',
managerPhone: apiData.phone || '',
businessNumber: apiData.business_num || '',
address: apiData.address || '',
// 로고 URL (상대 경로 → 절대 URL 변환)
companyLogo: toAbsoluteUrl(apiData.logo),
businessType: opts.business_type || '',
businessCategory: opts.business_category || '',
@@ -182,25 +106,12 @@ function transformApiToFrontend(apiData: TenantApiData): CompanyFormData & { ten
};
}
/**
* Frontend → API 변환
*
* 기본 필드: tenant_id, company_name, ceo_name, email, phone, business_num, address
* 확장 필드: options JSON에 저장 (businessType, businessCategory, taxInvoiceEmail 등)
*/
function transformFrontendToApi(
tenantId: number,
data: Partial<CompanyFormData>
): Record<string, unknown> {
// 로고 URL에서 상대 경로 추출 (API는 상대 경로 기대)
function transformFrontendToApi(tenantId: number, data: Partial<CompanyFormData>): Record<string, unknown> {
let logoPath: string | null = null;
if (data.companyLogo && typeof data.companyLogo === 'string') {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
logoPath = data.companyLogo.startsWith(apiUrl)
? data.companyLogo.replace(apiUrl, '')
: data.companyLogo;
logoPath = data.companyLogo.startsWith(apiUrl) ? data.companyLogo.replace(apiUrl, '') : data.companyLogo;
}
return {
tenant_id: tenantId,
company_name: data.companyName,
@@ -208,75 +119,14 @@ function transformFrontendToApi(
email: data.email,
phone: data.managerPhone,
business_num: data.businessNumber,
// 로고 (삭제 시 null)
logo: logoPath,
// address: 우편번호 + 주소 + 상세주소 결합
address: [data.zipCode, data.address, data.addressDetail]
.filter(Boolean)
.join(' '),
// 확장 필드 (options JSON)
address: [data.zipCode, data.address, data.addressDetail].filter(Boolean).join(' '),
options: {
business_type: data.businessType,
business_category: data.businessCategory,
zip_code: data.zipCode,
address_detail: data.addressDetail,
tax_invoice_email: data.taxInvoiceEmail,
manager_name: data.managerName,
payment_bank: data.paymentBank,
payment_account: data.paymentAccount,
payment_account_holder: data.paymentAccountHolder,
payment_day: data.paymentDay,
business_type: data.businessType, business_category: data.businessCategory,
zip_code: data.zipCode, address_detail: data.addressDetail,
tax_invoice_email: data.taxInvoiceEmail, manager_name: data.managerName,
payment_bank: data.paymentBank, payment_account: data.paymentAccount,
payment_account_holder: data.paymentAccountHolder, payment_day: data.paymentDay,
},
};
}
/**
* 회사 로고 업로드
* @param formData - FormData (클라이언트에서 생성, 'logo' 키로 파일 포함)
*/
export async function uploadCompanyLogo(formData: FormData): Promise<{
success: boolean;
data?: { logoUrl: string };
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/tenants/logo`,
{
method: 'POST',
body: formData,
// FormData는 Content-Type을 자동으로 설정하므로 headers 제거
}
);
if (error) {
return {
success: false,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response) {
return { success: false, error: '로고 업로드에 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '로고 업로드에 실패했습니다.' };
}
return {
success: true,
data: {
logoUrl: toAbsoluteUrl(result.data?.logo_url) || '',
},
};
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[uploadCompanyLogo] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}

View File

@@ -1,10 +1,11 @@
'use server';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { serverFetch } from '@/lib/api/fetch-wrapper';
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import type { LeavePolicySettings } from './types';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
// ===== API 응답 타입 =====
interface LeavePolicyApi {
id: number;
@@ -40,7 +41,6 @@ function transformLeavePolicy(data: LeavePolicyApi): LeavePolicySettings {
// ===== Frontend → API 변환 =====
function transformToApi(data: Partial<LeavePolicySettings>): Record<string, unknown> {
const result: Record<string, unknown> = {};
if (data.standardType !== undefined) result.standard_type = data.standardType;
if (data.fiscalStartMonth !== undefined) result.fiscal_start_month = data.fiscalStartMonth;
if (data.fiscalStartDay !== undefined) result.fiscal_start_day = data.fiscalStartDay;
@@ -50,119 +50,27 @@ function transformToApi(data: Partial<LeavePolicySettings>): Record<string, unkn
if (data.carryOverEnabled !== undefined) result.carry_over_enabled = data.carryOverEnabled;
if (data.carryOverMaxDays !== undefined) result.carry_over_max_days = data.carryOverMaxDays;
if (data.carryOverExpiryMonths !== undefined) result.carry_over_expiry_months = data.carryOverExpiryMonths;
return result;
}
// ===== 휴가 정책 조회 =====
export async function getLeavePolicy(): Promise<{
success: boolean;
data?: LeavePolicySettings;
error?: string;
__authError?: boolean;
}> {
try {
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/leave-policy`;
const { response, error } = await serverFetch(url, {
method: 'GET',
cache: 'no-store',
});
if (error) {
return {
success: false,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response || !response.ok) {
console.warn('[LeavePolicyActions] GET error:', response?.status);
return {
success: false,
error: `API 오류: ${response?.status}`,
};
}
const result = await response.json();
if (!result.success) {
return {
success: false,
error: result.message || '휴가 정책 조회에 실패했습니다.',
};
}
const transformedData = transformLeavePolicy(result.data);
return {
success: true,
data: transformedData,
};
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[LeavePolicyActions] getLeavePolicy error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
export async function getLeavePolicy(): Promise<ActionResult<LeavePolicySettings>> {
return executeServerAction({
url: `${API_URL}/api/v1/leave-policy`,
transform: (data: LeavePolicyApi) => transformLeavePolicy(data),
errorMessage: '휴가 정책 조회에 실패했습니다.',
});
}
// ===== 휴가 정책 업데이트 =====
export async function updateLeavePolicy(data: Partial<LeavePolicySettings>): Promise<{
success: boolean;
data?: LeavePolicySettings;
error?: string;
__authError?: boolean;
}> {
try {
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/leave-policy`;
const apiData = transformToApi(data);
const { response, error } = await serverFetch(url, {
method: 'PUT',
body: JSON.stringify(apiData),
});
if (error) {
return {
success: false,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response || !response.ok) {
console.warn('[LeavePolicyActions] PUT error:', response?.status);
return {
success: false,
error: `API 오류: ${response?.status}`,
};
}
const result = await response.json();
if (!result.success) {
return {
success: false,
error: result.message || '휴가 정책 저장에 실패했습니다.',
};
}
const transformedData = transformLeavePolicy(result.data);
return {
success: true,
data: transformedData,
};
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[LeavePolicyActions] updateLeavePolicy error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
export async function updateLeavePolicy(
data: Partial<LeavePolicySettings>
): Promise<ActionResult<LeavePolicySettings>> {
return executeServerAction({
url: `${API_URL}/api/v1/leave-policy`,
method: 'PUT',
body: transformToApi(data),
transform: (data: LeavePolicyApi) => transformLeavePolicy(data),
errorMessage: '휴가 정책 저장에 실패했습니다.',
});
}

View File

@@ -1,116 +1,40 @@
'use server';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { serverFetch } from '@/lib/api/fetch-wrapper';
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import type { NotificationSettings } from './types';
import { DEFAULT_NOTIFICATION_SETTINGS } from './types';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
// ===== 알림 설정 조회 =====
export async function getNotificationSettings(): Promise<{
success: boolean;
data: NotificationSettings;
error?: string;
__authError?: boolean;
success: boolean; data: NotificationSettings; error?: string; __authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/settings/notifications`,
{
method: 'GET',
cache: 'no-store',
}
);
const result = await executeServerAction({
url: `${API_URL}/api/v1/settings/notifications`,
transform: (data: Record<string, unknown>) => transformApiToFrontend(data),
errorMessage: '알림 설정 조회에 실패했습니다.',
});
if (error) {
return {
success: false,
data: DEFAULT_NOTIFICATION_SETTINGS,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response || !response.ok) {
console.warn('[NotificationActions] GET settings error:', response?.status);
return {
success: true,
data: DEFAULT_NOTIFICATION_SETTINGS,
};
}
const result = await response.json();
if (!result.success || !result.data) {
return {
success: true,
data: DEFAULT_NOTIFICATION_SETTINGS,
};
}
// API → Frontend 변환
return {
success: true,
data: transformApiToFrontend(result.data),
};
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[NotificationActions] getNotificationSettings error:', error);
return {
success: true,
data: DEFAULT_NOTIFICATION_SETTINGS,
};
// 인증 에러는 전파, 그 외 에러는 기본값 반환 (설정 미존재 시 정상 동작)
if (result.__authError) {
return { success: false, data: DEFAULT_NOTIFICATION_SETTINGS, error: result.error, __authError: true };
}
if (!result.success || !result.data) {
return { success: true, data: DEFAULT_NOTIFICATION_SETTINGS };
}
return { success: true, data: result.data };
}
// ===== 알림 설정 저장 =====
export async function saveNotificationSettings(
settings: NotificationSettings
): Promise<{ success: boolean; error?: string; __authError?: boolean }> {
try {
const apiData = transformFrontendToApi(settings);
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/settings/notifications`,
{
method: 'PUT',
body: JSON.stringify(apiData),
}
);
if (error) {
return {
success: false,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response) {
return {
success: false,
error: '알림 설정 저장에 실패했습니다.',
};
}
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('[NotificationActions] saveNotificationSettings error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
export async function saveNotificationSettings(settings: NotificationSettings): Promise<ActionResult> {
return executeServerAction({
url: `${API_URL}/api/v1/settings/notifications`,
method: 'PUT',
body: transformFrontendToApi(settings),
errorMessage: '알림 설정 저장에 실패했습니다.',
});
}
// ===== API → Frontend 변환 (기본값과 병합) =====

View File

@@ -1,246 +1,108 @@
'use server';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { serverFetch } from '@/lib/api/fetch-wrapper';
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import type { PaymentApiData, PaymentHistory } from './types';
import { transformApiToFrontend } from './utils';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
// ===== API 응답 타입 =====
interface PaymentPaginatedResponse {
data: PaymentApiData[];
current_page: number;
last_page: number;
per_page: number;
total: number;
}
interface PaymentStatementApiData {
statement_no: string;
issued_at: string;
payment: { id: number; amount: number; formatted_amount: string; payment_method: string; payment_method_label: string; transaction_id: string | null; status: string; status_label: string; paid_at: string | null; memo: string | null };
subscription: { id: number; started_at: string | null; ended_at: string | null; status: string; status_label: string };
plan: { id: number; name: string; code: string; price: number; billing_cycle: string; billing_cycle_label: string } | null;
customer: { tenant_id: number; company_name: string; business_number: string | null; representative: string | null; address: string | null; email: string | null; phone: string | null };
items: Array<{ description: string; quantity: number; unitPrice: number; amount: number }>;
subtotal: number;
tax: number;
total: number;
}
interface StatementData {
statementNo: string;
issuedAt: string;
payment: { id: number; amount: number; formattedAmount: string; paymentMethod: string; paymentMethodLabel: string; transactionId: string | null; status: string; statusLabel: string; paidAt: string | null; memo: string | null };
subscription: { id: number; startedAt: string | null; endedAt: string | null; status: string; statusLabel: string };
plan: { id: number; name: string; code: string; price: number; billingCycle: string; billingCycleLabel: string } | null;
customer: { tenantId: number; companyName: string; businessNumber: string | null; representative: string | null; address: string | null; email: string | null; phone: string | null };
items: Array<{ description: string; quantity: number; unitPrice: number; amount: number }>;
subtotal: number;
tax: number;
total: number;
}
interface FrontendPagination { currentPage: number; lastPage: number; perPage: number; total: number }
const DEFAULT_PAGINATION: FrontendPagination = { currentPage: 1, lastPage: 1, perPage: 20, total: 0 };
// ===== 결제 목록 조회 =====
export async function getPayments(params?: {
page?: number;
perPage?: number;
status?: string;
startDate?: string;
endDate?: string;
search?: string;
page?: number; perPage?: number; status?: string; startDate?: string; endDate?: string; search?: string;
}): Promise<{
success: boolean;
data: PaymentHistory[];
pagination: {
currentPage: number;
lastPage: number;
perPage: number;
total: number;
};
error?: string;
__authError?: boolean;
success: boolean; data: PaymentHistory[]; pagination: FrontendPagination;
error?: string; __authError?: boolean;
}> {
try {
// 쿼리 파라미터 생성
const searchParams = new URLSearchParams();
if (params?.page) searchParams.append('page', String(params.page));
if (params?.perPage) searchParams.append('per_page', String(params.perPage));
if (params?.status) searchParams.append('status', params.status);
if (params?.startDate) searchParams.append('start_date', params.startDate);
if (params?.endDate) searchParams.append('end_date', params.endDate);
if (params?.search) searchParams.append('search', params.search);
const searchParams = new URLSearchParams();
if (params?.page) searchParams.append('page', String(params.page));
if (params?.perPage) searchParams.append('per_page', String(params.perPage));
if (params?.status) searchParams.append('status', params.status);
if (params?.startDate) searchParams.append('start_date', params.startDate);
if (params?.endDate) searchParams.append('end_date', params.endDate);
if (params?.search) searchParams.append('search', params.search);
const queryString = searchParams.toString();
const queryString = searchParams.toString();
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/payments${queryString ? `?${queryString}` : ''}`;
const { response, error } = await serverFetch(url, {
method: 'GET',
cache: 'no-store',
});
if (error) {
return {
success: false,
data: [],
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response) {
return {
success: false,
data: [],
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
error: '결제 내역을 불러오는데 실패했습니다.',
};
}
const result = await response.json();
if (!response.ok || !result.success) {
return {
success: false,
data: [],
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
error: result.message || '결제 내역을 불러오는데 실패했습니다.',
};
}
const payments = result.data.data.map(transformApiToFrontend);
return {
success: true,
data: payments,
pagination: {
currentPage: result.data.current_page,
lastPage: result.data.last_page,
perPage: result.data.per_page,
total: result.data.total,
},
};
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[PaymentActions] getPayments error:', error);
return {
success: false,
data: [],
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
error: '서버 오류가 발생했습니다.',
};
}
const result = await executeServerAction({
url: `${API_URL}/api/v1/payments${queryString ? `?${queryString}` : ''}`,
transform: (data: PaymentPaginatedResponse) => ({
items: (data?.data || []).map(transformApiToFrontend),
pagination: { currentPage: data?.current_page || 1, lastPage: data?.last_page || 1, perPage: data?.per_page || 20, total: data?.total || 0 },
}),
errorMessage: '결제 내역을 불러오는데 실패했습니다.',
});
return { success: result.success, data: result.data?.items || [], pagination: result.data?.pagination || DEFAULT_PAGINATION, error: result.error, __authError: result.__authError };
}
// ===== 결제 명세서 조회 =====
export async function getPaymentStatement(id: string): Promise<{
success: boolean;
data?: {
statementNo: string;
issuedAt: string;
payment: {
id: number;
amount: number;
formattedAmount: string;
paymentMethod: string;
paymentMethodLabel: string;
transactionId: string | null;
status: string;
statusLabel: string;
paidAt: string | null;
memo: string | null;
};
subscription: {
id: number;
startedAt: string | null;
endedAt: string | null;
status: string;
statusLabel: string;
};
plan: {
id: number;
name: string;
code: string;
price: number;
billingCycle: string;
billingCycleLabel: string;
} | null;
customer: {
tenantId: number;
companyName: string;
businessNumber: string | null;
representative: string | null;
address: string | null;
email: string | null;
phone: string | null;
};
items: Array<{
description: string;
quantity: number;
unitPrice: number;
amount: number;
}>;
subtotal: number;
tax: number;
total: number;
};
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/payments/${id}/statement`,
{
method: 'GET',
cache: 'no-store',
}
);
if (error) {
return {
success: false,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response) {
return {
success: false,
error: '명세서를 불러오는데 실패했습니다.',
};
}
const result = await response.json();
if (!response.ok || !result.success) {
return {
success: false,
error: result.message || '명세서를 불러오는데 실패했습니다.',
};
}
// snake_case → camelCase 변환
const data = result.data;
return {
success: true,
data: {
statementNo: data.statement_no,
issuedAt: data.issued_at,
payment: {
id: data.payment.id,
amount: data.payment.amount,
formattedAmount: data.payment.formatted_amount,
paymentMethod: data.payment.payment_method,
paymentMethodLabel: data.payment.payment_method_label,
transactionId: data.payment.transaction_id,
status: data.payment.status,
statusLabel: data.payment.status_label,
paidAt: data.payment.paid_at,
memo: data.payment.memo,
},
subscription: {
id: data.subscription.id,
startedAt: data.subscription.started_at,
endedAt: data.subscription.ended_at,
status: data.subscription.status,
statusLabel: data.subscription.status_label,
},
plan: data.plan ? {
id: data.plan.id,
name: data.plan.name,
code: data.plan.code,
price: data.plan.price,
billingCycle: data.plan.billing_cycle,
billingCycleLabel: data.plan.billing_cycle_label,
} : null,
customer: {
tenantId: data.customer.tenant_id,
companyName: data.customer.company_name,
businessNumber: data.customer.business_number,
representative: data.customer.representative,
address: data.customer.address,
email: data.customer.email,
phone: data.customer.phone,
},
items: data.items,
subtotal: data.subtotal,
tax: data.tax,
total: data.total,
export async function getPaymentStatement(id: string): Promise<ActionResult<StatementData>> {
return executeServerAction({
url: `${API_URL}/api/v1/payments/${id}/statement`,
transform: (data: PaymentStatementApiData): StatementData => ({
statementNo: data.statement_no,
issuedAt: data.issued_at,
payment: {
id: data.payment.id, amount: data.payment.amount, formattedAmount: data.payment.formatted_amount,
paymentMethod: data.payment.payment_method, paymentMethodLabel: data.payment.payment_method_label,
transactionId: data.payment.transaction_id, status: data.payment.status, statusLabel: data.payment.status_label,
paidAt: data.payment.paid_at, memo: data.payment.memo,
},
};
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[PaymentActions] getPaymentStatement error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
subscription: {
id: data.subscription.id, startedAt: data.subscription.started_at, endedAt: data.subscription.ended_at,
status: data.subscription.status, statusLabel: data.subscription.status_label,
},
plan: data.plan ? {
id: data.plan.id, name: data.plan.name, code: data.plan.code, price: data.plan.price,
billingCycle: data.plan.billing_cycle, billingCycleLabel: data.plan.billing_cycle_label,
} : null,
customer: {
tenantId: data.customer.tenant_id, companyName: data.customer.company_name,
businessNumber: data.customer.business_number, representative: data.customer.representative,
address: data.customer.address, email: data.customer.email, phone: data.customer.phone,
},
items: data.items,
subtotal: data.subtotal,
tax: data.tax,
total: data.total,
}),
errorMessage: '명세서를 불러오는데 실패했습니다.',
});
}

View File

@@ -1,451 +1,139 @@
'use server';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import { revalidatePath } from 'next/cache';
import { serverFetch } from '@/lib/api/fetch-wrapper';
import type { Role, RoleStats, PermissionMatrix, MenuTreeItem, ApiResponse, PaginatedResponse } from './types';
import type { Role, RoleStats, PermissionMatrix, MenuTreeItem, PaginatedResponse } from './types';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
// ========== Role CRUD ==========
/**
* 역할 목록 조회
*/
export async function fetchRoles(params?: {
page?: number;
size?: number;
q?: string;
is_hidden?: boolean;
}): Promise<ApiResponse<PaginatedResponse<Role>>> {
try {
const searchParams = new URLSearchParams();
if (params?.page) searchParams.set('page', params.page.toString());
if (params?.size) searchParams.set('per_page', params.size.toString());
if (params?.q) searchParams.set('q', params.q);
if (params?.is_hidden !== undefined) searchParams.set('is_hidden', params.is_hidden.toString());
page?: number; size?: number; q?: string; is_hidden?: boolean;
}): Promise<ActionResult<PaginatedResponse<Role>>> {
const searchParams = new URLSearchParams();
if (params?.page) searchParams.set('page', params.page.toString());
if (params?.size) searchParams.set('per_page', params.size.toString());
if (params?.q) searchParams.set('q', params.q);
if (params?.is_hidden !== undefined) searchParams.set('is_hidden', params.is_hidden.toString());
const queryString = searchParams.toString();
const url = `${API_URL}/api/v1/roles?${searchParams.toString()}`;
const { response, error } = await serverFetch(url, { method: 'GET' });
if (error) {
return { success: false, error: error.message };
}
if (!response) {
return { success: false, error: '역할 목록 조회에 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '역할 목록 조회 실패' };
}
return { success: true, data: result.data };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('Failed to fetch roles:', error);
return { success: false, error: error instanceof Error ? error.message : '역할 목록 조회 실패' };
}
return executeServerAction<PaginatedResponse<Role>>({
url: `${API_URL}/api/v1/roles${queryString ? `?${queryString}` : ''}`,
errorMessage: '역할 목록 조회에 실패했습니다.',
});
}
/**
* 역할 상세 조회
*/
export async function fetchRole(id: number): Promise<ApiResponse<Role>> {
try {
const { response, error } = await serverFetch(`${API_URL}/api/v1/roles/${id}`, { method: 'GET' });
if (error) {
return { success: false, error: error.message };
}
if (!response) {
return { success: false, error: '역할 조회에 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '역할 조회 실패' };
}
return { success: true, data: result.data };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('Failed to fetch role:', error);
return { success: false, error: error instanceof Error ? error.message : '역할 조회 실패' };
}
export async function fetchRole(id: number): Promise<ActionResult<Role>> {
return executeServerAction<Role>({
url: `${API_URL}/api/v1/roles/${id}`,
errorMessage: '역할 조회에 실패했습니다.',
});
}
/**
* 역할 생성
*/
export async function createRole(data: {
name: string;
description?: string;
is_hidden?: boolean;
}): Promise<ApiResponse<Role>> {
try {
const { response, error } = await serverFetch(`${API_URL}/api/v1/roles`, {
method: 'POST',
body: JSON.stringify(data),
});
if (error) {
return { success: false, error: error.message };
}
if (!response) {
return { success: false, error: '역할 생성에 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '역할 생성 실패' };
}
revalidatePath('/settings/permissions');
return { success: true, data: result.data };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('Failed to create role:', error);
return { success: false, error: error instanceof Error ? error.message : '역할 생성 실패' };
}
name: string; description?: string; is_hidden?: boolean;
}): Promise<ActionResult<Role>> {
const result = await executeServerAction<Role>({
url: `${API_URL}/api/v1/roles`,
method: 'POST',
body: data,
errorMessage: '역할 생성에 실패했습니다.',
});
if (result.success) revalidatePath('/settings/permissions');
return result;
}
/**
* 역할 수정
*/
export async function updateRole(
id: number,
data: {
name?: string;
description?: string;
is_hidden?: boolean;
}
): Promise<ApiResponse<Role>> {
try {
const { response, error } = await serverFetch(`${API_URL}/api/v1/roles/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
if (error) {
return { success: false, error: error.message };
}
if (!response) {
return { success: false, error: '역할 수정에 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '역할 수정 실패' };
}
export async function updateRole(id: number, data: {
name?: string; description?: string; is_hidden?: boolean;
}): Promise<ActionResult<Role>> {
const result = await executeServerAction<Role>({
url: `${API_URL}/api/v1/roles/${id}`,
method: 'PATCH',
body: data,
errorMessage: '역할 수정에 실패했습니다.',
});
if (result.success) {
revalidatePath('/settings/permissions');
revalidatePath(`/settings/permissions/${id}`);
return { success: true, data: result.data };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('Failed to update role:', error);
return { success: false, error: error instanceof Error ? error.message : '역할 수정 실패' };
}
return result;
}
/**
* 역할 삭제
*/
export async function deleteRole(id: number): Promise<ApiResponse<void>> {
try {
const { response, error } = await serverFetch(`${API_URL}/api/v1/roles/${id}`, {
method: 'DELETE',
});
if (error) {
return { success: false, error: error.message };
}
if (!response) {
return { success: false, error: '역할 삭제에 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '역할 삭제 실패' };
}
revalidatePath('/settings/permissions');
return { success: true };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('Failed to delete role:', error);
return { success: false, error: error instanceof Error ? error.message : '역할 삭제 실패' };
}
export async function deleteRole(id: number): Promise<ActionResult> {
const result = await executeServerAction({
url: `${API_URL}/api/v1/roles/${id}`,
method: 'DELETE',
errorMessage: '역할 삭제에 실패했습니다.',
});
if (result.success) revalidatePath('/settings/permissions');
return result;
}
/**
* 역할 통계 조회
*/
export async function fetchRoleStats(): Promise<ApiResponse<RoleStats>> {
try {
const { response, error } = await serverFetch(`${API_URL}/api/v1/roles/stats`, { method: 'GET' });
if (error) {
return { success: false, error: error.message };
}
if (!response) {
return { success: false, error: '역할 통계 조회에 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '역할 통계 조회 실패' };
}
return { success: true, data: result.data };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('Failed to fetch role stats:', error);
return { success: false, error: error instanceof Error ? error.message : '역할 통계 조회 실패' };
}
export async function fetchRoleStats(): Promise<ActionResult<RoleStats>> {
return executeServerAction<RoleStats>({
url: `${API_URL}/api/v1/roles/stats`,
errorMessage: '역할 통계 조회에 실패했습니다.',
});
}
/**
* 활성 역할 목록 (드롭다운용)
*/
export async function fetchActiveRoles(): Promise<ApiResponse<Role[]>> {
try {
const { response, error } = await serverFetch(`${API_URL}/api/v1/roles/active`, { method: 'GET' });
if (error) {
return { success: false, error: error.message };
}
if (!response) {
return { success: false, error: '활성 역할 목록 조회에 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '활성 역할 목록 조회 실패' };
}
return { success: true, data: result.data };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('Failed to fetch active roles:', error);
return { success: false, error: error instanceof Error ? error.message : '활성 역할 목록 조회 실패' };
}
export async function fetchActiveRoles(): Promise<ActionResult<Role[]>> {
return executeServerAction<Role[]>({
url: `${API_URL}/api/v1/roles/active`,
errorMessage: '활성 역할 목록 조회에 실패했습니다.',
});
}
// ========== Permission Matrix ==========
/**
* 권한 매트릭스용 메뉴 트리 조회
*/
export async function fetchPermissionMenus(): Promise<ApiResponse<{
menus: MenuTreeItem[];
permission_types: string[];
export async function fetchPermissionMenus(): Promise<ActionResult<{
menus: MenuTreeItem[]; permission_types: string[];
}>> {
try {
const { response, error } = await serverFetch(`${API_URL}/api/v1/role-permissions/menus`, { method: 'GET' });
if (error) {
return { success: false, error: error.message };
}
if (!response) {
return { success: false, error: '메뉴 트리 조회에 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '메뉴 트리 조회 실패' };
}
return { success: true, data: result.data };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('Failed to fetch permission menus:', error);
return { success: false, error: error instanceof Error ? error.message : '메뉴 트리 조회 실패' };
}
return executeServerAction({
url: `${API_URL}/api/v1/role-permissions/menus`,
errorMessage: '메뉴 트리 조회에 실패했습니다.',
});
}
/**
* 역할의 권한 매트릭스 조회
*/
export async function fetchPermissionMatrix(roleId: number): Promise<ApiResponse<PermissionMatrix>> {
try {
const { response, error } = await serverFetch(`${API_URL}/api/v1/roles/${roleId}/permissions/matrix`, { method: 'GET' });
if (error) {
return { success: false, error: error.message };
}
if (!response) {
return { success: false, error: '권한 매트릭스 조회에 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '권한 매트릭스 조회 실패' };
}
return { success: true, data: result.data };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('Failed to fetch permission matrix:', error);
return { success: false, error: error instanceof Error ? error.message : '권한 매트릭스 조회 실패' };
}
export async function fetchPermissionMatrix(roleId: number): Promise<ActionResult<PermissionMatrix>> {
return executeServerAction<PermissionMatrix>({
url: `${API_URL}/api/v1/roles/${roleId}/permissions/matrix`,
errorMessage: '권한 매트릭스 조회에 실패했습니다.',
});
}
/**
* 특정 메뉴의 특정 권한 토글
*/
export async function togglePermission(
roleId: number,
menuId: number,
permissionType: string
): Promise<ApiResponse<{
granted: boolean;
propagated_to: number[];
export async function togglePermission(roleId: number, menuId: number, permissionType: string): Promise<ActionResult<{
granted: boolean; propagated_to: number[];
}>> {
try {
const { response, error } = await serverFetch(`${API_URL}/api/v1/roles/${roleId}/permissions/toggle`, {
method: 'POST',
body: JSON.stringify({
menu_id: menuId,
permission_type: permissionType,
}),
});
if (error) {
return { success: false, error: error.message };
}
if (!response) {
return { success: false, error: '권한 토글에 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '권한 토글 실패' };
}
revalidatePath(`/settings/permissions/${roleId}`);
return { success: true, data: result.data };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('Failed to toggle permission:', error);
return { success: false, error: error instanceof Error ? error.message : '권한 토글 실패' };
}
const result = await executeServerAction<{ granted: boolean; propagated_to: number[] }>({
url: `${API_URL}/api/v1/roles/${roleId}/permissions/toggle`,
method: 'POST',
body: { menu_id: menuId, permission_type: permissionType },
errorMessage: '권한 토글에 실패했습니다.',
});
if (result.success) revalidatePath(`/settings/permissions/${roleId}`);
return result;
}
/**
* 모든 권한 허용
*/
export async function allowAllPermissions(roleId: number): Promise<ApiResponse<{ count: number }>> {
try {
const { response, error } = await serverFetch(`${API_URL}/api/v1/roles/${roleId}/permissions/allow-all`, {
method: 'POST',
});
if (error) {
return { success: false, error: error.message };
}
if (!response) {
return { success: false, error: '전체 허용에 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '전체 허용 실패' };
}
revalidatePath(`/settings/permissions/${roleId}`);
return { success: true, data: result.data };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('Failed to allow all permissions:', error);
return { success: false, error: error instanceof Error ? error.message : '전체 허용 실패' };
}
async function rolePermissionAction(roleId: number, action: string, errorMessage: string): Promise<ActionResult<{ count: number }>> {
const result = await executeServerAction<{ count: number }>({
url: `${API_URL}/api/v1/roles/${roleId}/permissions/${action}`,
method: 'POST',
errorMessage,
});
if (result.success) revalidatePath(`/settings/permissions/${roleId}`);
return result;
}
/**
* 모든 권한 거부
*/
export async function denyAllPermissions(roleId: number): Promise<ApiResponse<{ count: number }>> {
try {
const { response, error } = await serverFetch(`${API_URL}/api/v1/roles/${roleId}/permissions/deny-all`, {
method: 'POST',
});
if (error) {
return { success: false, error: error.message };
}
if (!response) {
return { success: false, error: '전체 거부에 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '전체 거부 실패' };
}
revalidatePath(`/settings/permissions/${roleId}`);
return { success: true, data: result.data };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('Failed to deny all permissions:', error);
return { success: false, error: error instanceof Error ? error.message : '전체 거부 실패' };
}
export async function allowAllPermissions(roleId: number): Promise<ActionResult<{ count: number }>> {
return rolePermissionAction(roleId, 'allow-all', '전체 허용에 실패했습니다.');
}
/**
* 기본 권한으로 초기화 (view만 허용)
*/
export async function resetPermissions(roleId: number): Promise<ApiResponse<{ count: number }>> {
try {
const { response, error } = await serverFetch(`${API_URL}/api/v1/roles/${roleId}/permissions/reset`, {
method: 'POST',
});
export async function denyAllPermissions(roleId: number): Promise<ActionResult<{ count: number }>> {
return rolePermissionAction(roleId, 'deny-all', '전체 거부에 실패했습니다.');
}
if (error) {
return { success: false, error: error.message };
}
if (!response) {
return { success: false, error: '권한 초기화에 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '권한 초기화 실패' };
}
revalidatePath(`/settings/permissions/${roleId}`);
return { success: true, data: result.data };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('Failed to reset permissions:', error);
return { success: false, error: error instanceof Error ? error.message : '권한 초기화 실패' };
}
export async function resetPermissions(roleId: number): Promise<ActionResult<{ count: number }>> {
return rolePermissionAction(roleId, 'reset', '권한 초기화에 실패했습니다.');
}

View File

@@ -17,6 +17,7 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { type Popup } from './types';
import { sanitizeHTML } from '@/lib/sanitize';
interface PopupDetailProps {
popup: Popup;
@@ -91,7 +92,7 @@ export function PopupDetail({ popup, onEdit, onDelete }: PopupDetailProps) {
<dd className="text-sm mt-1">
<div
className="border border-gray-200 rounded-md p-4 bg-gray-50 min-h-[100px] prose prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: popup.content }}
dangerouslySetInnerHTML={{ __html: sanitizeHTML(popup.content) }}
/>
</dd>
</div>

View File

@@ -13,24 +13,16 @@
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { serverFetch } from '@/lib/api/fetch-wrapper';
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import type { Popup, PopupFormData } from './types';
import { transformApiToFrontend, transformFrontendToApi, type PopupApiData } from './utils';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
// ============================================
// API 응답 타입 정의
// ============================================
interface ApiResponse<T> {
success: boolean;
data: T;
message: string;
}
// ============================================
// API 함수
// ============================================
interface PaginatedResponse<T> {
current_page: number;
data: T[];
@@ -39,6 +31,10 @@ interface PaginatedResponse<T> {
last_page: number;
}
// ============================================
// API 함수
// ============================================
/**
* 팝업 목록 조회
*/
@@ -47,82 +43,31 @@ export async function getPopups(params?: {
size?: number;
status?: string;
}): Promise<Popup[]> {
try {
const searchParams = new URLSearchParams();
if (params?.page) searchParams.set('page', String(params.page));
if (params?.size) searchParams.set('size', String(params.size));
if (params?.status && params.status !== 'all') {
searchParams.set('status', params.status);
}
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/popups?${searchParams.toString()}`;
const { response, error } = await serverFetch(url, {
method: 'GET',
cache: 'no-store',
});
if (error || !response) {
console.error('[PopupActions] GET list error:', error?.message);
return [];
}
if (!response.ok) {
console.error('[PopupActions] GET list error:', response.status);
return [];
}
const result: ApiResponse<PaginatedResponse<PopupApiData>> = await response.json();
if (!result.success || !result.data?.data) {
console.warn('[PopupActions] No data in response');
return [];
}
return result.data.data.map(transformApiToFrontend);
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[PopupActions] getPopups error:', error);
return [];
const searchParams = new URLSearchParams();
if (params?.page) searchParams.set('page', String(params.page));
if (params?.size) searchParams.set('size', String(params.size));
if (params?.status && params.status !== 'all') {
searchParams.set('status', params.status);
}
const result = await executeServerAction({
url: `${API_URL}/api/v1/popups?${searchParams.toString()}`,
transform: (data: PaginatedResponse<PopupApiData>) => data.data.map(transformApiToFrontend),
errorMessage: '팝업 목록 조회에 실패했습니다.',
});
return result.data || [];
}
/**
* 팝업 상세 조회
*/
export async function getPopupById(id: string): Promise<Popup | null> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/popups/${id}`,
{
method: 'GET',
cache: 'no-store',
}
);
if (error || !response) {
console.error('[PopupActions] GET popup error:', error?.message);
return null;
}
if (!response.ok) {
console.error('[PopupActions] GET popup error:', response.status);
return null;
}
const result: ApiResponse<PopupApiData> = await response.json();
if (!result.success || !result.data) {
return null;
}
return transformApiToFrontend(result.data);
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[PopupActions] getPopupById error:', error);
return null;
}
const result = await executeServerAction({
url: `${API_URL}/api/v1/popups/${id}`,
transform: (data: PopupApiData) => transformApiToFrontend(data),
errorMessage: '팝업 조회에 실패했습니다.',
});
return result.data || null;
}
/**
@@ -130,57 +75,14 @@ export async function getPopupById(id: string): Promise<Popup | null> {
*/
export async function createPopup(
data: PopupFormData
): Promise<{ success: boolean; data?: Popup; error?: string; __authError?: boolean }> {
try {
const apiData = transformFrontendToApi(data);
console.log('[PopupActions] POST popup request:', apiData);
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/popups`,
{
method: 'POST',
body: JSON.stringify(apiData),
}
);
if (error) {
return {
success: false,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response) {
return {
success: false,
error: '팝업 등록에 실패했습니다.',
};
}
const result = await response.json();
console.log('[PopupActions] POST popup response:', result);
if (!response.ok || !result.success) {
return {
success: false,
error: result.message || '팝업 등록에 실패했습니다.',
};
}
return {
success: true,
data: transformApiToFrontend(result.data),
};
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[PopupActions] createPopup error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
): Promise<ActionResult<Popup>> {
return executeServerAction({
url: `${API_URL}/api/v1/popups`,
method: 'POST',
body: transformFrontendToApi(data),
transform: (data: PopupApiData) => transformApiToFrontend(data),
errorMessage: '팝업 등록에 실패했습니다.',
});
}
/**
@@ -189,105 +91,25 @@ export async function createPopup(
export async function updatePopup(
id: string,
data: PopupFormData
): Promise<{ success: boolean; data?: Popup; error?: string; __authError?: boolean }> {
try {
const apiData = transformFrontendToApi(data);
console.log('[PopupActions] PUT popup request:', apiData);
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/popups/${id}`,
{
method: 'PUT',
body: JSON.stringify(apiData),
}
);
if (error) {
return {
success: false,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response) {
return {
success: false,
error: '팝업 수정에 실패했습니다.',
};
}
const result = await response.json();
console.log('[PopupActions] PUT popup response:', result);
if (!response.ok || !result.success) {
return {
success: false,
error: result.message || '팝업 수정에 실패했습니다.',
};
}
return {
success: true,
data: transformApiToFrontend(result.data),
};
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[PopupActions] updatePopup error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
): Promise<ActionResult<Popup>> {
return executeServerAction({
url: `${API_URL}/api/v1/popups/${id}`,
method: 'PUT',
body: transformFrontendToApi(data),
transform: (data: PopupApiData) => transformApiToFrontend(data),
errorMessage: '팝업 수정에 실패했습니다.',
});
}
/**
* 팝업 삭제
*/
export async function deletePopup(id: string): Promise<{ success: boolean; error?: string; __authError?: boolean }> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/popups/${id}`,
{
method: 'DELETE',
}
);
if (error) {
return {
success: false,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response) {
return {
success: false,
error: '팝업 삭제에 실패했습니다.',
};
}
const result = await response.json();
console.log('[PopupActions] DELETE popup response:', result);
if (!response.ok || !result.success) {
return {
success: false,
error: result.message || '팝업 삭제에 실패했습니다.',
};
}
return { success: true };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[PopupActions] deletePopup error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
export async function deletePopup(id: string): Promise<ActionResult> {
return executeServerAction({
url: `${API_URL}/api/v1/popups/${id}`,
method: 'DELETE',
errorMessage: '팝업 삭제에 실패했습니다.',
});
}
/**
@@ -297,21 +119,13 @@ export async function deletePopups(ids: string[]): Promise<{ success: boolean; e
try {
const results = await Promise.all(ids.map(id => deletePopup(id)));
const failed = results.filter(r => !r.success);
if (failed.length > 0) {
return {
success: false,
error: `${failed.length}개 팝업 삭제에 실패했습니다.`,
};
return { success: false, error: `${failed.length}개 팝업 삭제에 실패했습니다.` };
}
return { success: true };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[PopupActions] deletePopups error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}

View File

@@ -8,6 +8,7 @@ import type { DetailConfig, FieldDefinition, SectionDefinition } from '@/compone
import type { Popup, PopupFormData, PopupTarget, PopupStatus } from './types';
import { RichTextEditor } from '@/components/board/RichTextEditor';
import { createElement } from 'react';
import { sanitizeHTML } from '@/lib/sanitize';
// ===== 대상 옵션 =====
const TARGET_OPTIONS = [
@@ -94,7 +95,7 @@ export const popupFields: FieldDefinition[] = [
// View 모드: HTML 렌더링
return createElement('div', {
className: 'border border-gray-200 rounded-md p-4 bg-gray-50 min-h-[100px] prose prose-sm max-w-none',
dangerouslySetInnerHTML: { __html: (value as string) || '' },
dangerouslySetInnerHTML: { __html: sanitizeHTML((value as string) || '') },
});
}
// Edit/Create 모드: RichTextEditor
@@ -110,7 +111,7 @@ export const popupFields: FieldDefinition[] = [
if (!value) return '-';
return createElement('div', {
className: 'border border-gray-200 rounded-md p-4 bg-gray-50 min-h-[100px] prose prose-sm max-w-none',
dangerouslySetInnerHTML: { __html: value as string },
dangerouslySetInnerHTML: { __html: sanitizeHTML(value as string) },
});
},
},

View File

@@ -1,10 +1,11 @@
'use server';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { serverFetch } from '@/lib/api/fetch-wrapper';
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import type { Rank } from './types';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
// ===== API 응답 타입 =====
interface PositionApiData {
id: number;
@@ -17,12 +18,6 @@ interface PositionApiData {
updated_at?: string;
}
interface ApiResponse<T> {
success: boolean;
message?: string;
data: T;
}
// ===== 데이터 변환: API → Frontend =====
function transformApiToFrontend(apiData: PositionApiData): Rank {
return {
@@ -39,55 +34,21 @@ function transformApiToFrontend(apiData: PositionApiData): Rank {
export async function getRanks(params?: {
is_active?: boolean;
q?: string;
}): Promise<{
success: boolean;
data?: Rank[];
error?: string;
__authError?: boolean;
}> {
try {
const searchParams = new URLSearchParams();
searchParams.set('type', 'rank');
if (params?.is_active !== undefined) {
searchParams.set('is_active', params.is_active.toString());
}
if (params?.q) {
searchParams.set('q', params.q);
}
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/positions?${searchParams.toString()}`;
const { response, error } = await serverFetch(url, {
method: 'GET',
cache: 'no-store',
});
if (error) {
return {
success: false,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response) {
return { success: false, error: '직급 목록 조회에 실패했습니다.' };
}
const result: ApiResponse<PositionApiData[]> = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '직급 목록 조회에 실패했습니다.' };
}
const ranks = result.data.map(transformApiToFrontend);
return { success: true, data: ranks };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[getRanks] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}): Promise<ActionResult<Rank[]>> {
const searchParams = new URLSearchParams();
searchParams.set('type', 'rank');
if (params?.is_active !== undefined) {
searchParams.set('is_active', params.is_active.toString());
}
if (params?.q) {
searchParams.set('q', params.q);
}
return executeServerAction({
url: `${API_URL}/api/v1/positions?${searchParams.toString()}`,
transform: (data: PositionApiData[]) => data.map(transformApiToFrontend),
errorMessage: '직급 목록 조회에 실패했습니다.',
});
}
// ===== 직급 생성 =====
@@ -95,51 +56,19 @@ export async function createRank(data: {
name: string;
sort_order?: number;
is_active?: boolean;
}): Promise<{
success: boolean;
data?: Rank;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/positions`,
{
method: 'POST',
body: JSON.stringify({
type: 'rank',
name: data.name,
sort_order: data.sort_order,
is_active: data.is_active ?? true,
}),
}
);
if (error) {
return {
success: false,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response) {
return { success: false, error: '직급 생성에 실패했습니다.' };
}
const result: ApiResponse<PositionApiData> = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '직급 생성에 실패했습니다.' };
}
const rank = transformApiToFrontend(result.data);
return { success: true, data: rank };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[createRank] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}): Promise<ActionResult<Rank>> {
return executeServerAction({
url: `${API_URL}/api/v1/positions`,
method: 'POST',
body: {
type: 'rank',
name: data.name,
sort_order: data.sort_order,
is_active: data.is_active ?? true,
},
transform: transformApiToFrontend,
errorMessage: '직급 생성에 실패했습니다.',
});
}
// ===== 직급 수정 =====
@@ -150,127 +79,33 @@ export async function updateRank(
sort_order?: number;
is_active?: boolean;
}
): Promise<{
success: boolean;
data?: Rank;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/positions/${id}`,
{
method: 'PUT',
body: JSON.stringify(data),
}
);
if (error) {
return {
success: false,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response) {
return { success: false, error: '직급 수정에 실패했습니다.' };
}
const result: ApiResponse<PositionApiData> = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '직급 수정에 실패했습니다.' };
}
const rank = transformApiToFrontend(result.data);
return { success: true, data: rank };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[updateRank] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
): Promise<ActionResult<Rank>> {
return executeServerAction({
url: `${API_URL}/api/v1/positions/${id}`,
method: 'PUT',
body: data,
transform: transformApiToFrontend,
errorMessage: '직급 수정에 실패했습니다.',
});
}
// ===== 직급 삭제 =====
export async function deleteRank(id: number): Promise<{
success: boolean;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/positions/${id}`,
{
method: 'DELETE',
}
);
if (error) {
return {
success: false,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response) {
return { success: false, error: '직급 삭제에 실패했습니다.' };
}
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('[deleteRank] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
export async function deleteRank(id: number): Promise<ActionResult> {
return executeServerAction({
url: `${API_URL}/api/v1/positions/${id}`,
method: 'DELETE',
errorMessage: '직급 삭제에 실패했습니다.',
});
}
// ===== 직급 순서 변경 =====
export async function reorderRanks(
items: { id: number; sort_order: number }[]
): Promise<{
success: boolean;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/positions/reorder`,
{
method: 'PUT',
body: JSON.stringify({ items }),
}
);
if (error) {
return {
success: false,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response) {
return { success: false, error: '순서 변경에 실패했습니다.' };
}
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('[reorderRanks] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
): Promise<ActionResult> {
return executeServerAction({
url: `${API_URL}/api/v1/positions/reorder`,
method: 'PUT',
body: { items },
errorMessage: '순서 변경에 실패했습니다.',
});
}

View File

@@ -2,236 +2,52 @@
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { serverFetch } from '@/lib/api/fetch-wrapper';
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import type { SubscriptionApiData, UsageApiData, SubscriptionInfo } from './types';
import { transformApiToFrontend } from './utils';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
// ===== 현재 활성 구독 조회 =====
export async function getCurrentSubscription(): Promise<{
success: boolean;
data: SubscriptionApiData | null;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/subscriptions/current`,
{
method: 'GET',
cache: 'no-store',
}
);
if (error) {
return {
success: false,
data: null,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response) {
return {
success: false,
data: null,
error: '구독 정보를 불러오는데 실패했습니다.',
};
}
const result = await response.json();
if (!response.ok || !result.success) {
return {
success: false,
data: null,
error: result.message || '구독 정보를 불러오는데 실패했습니다.',
};
}
return {
success: true,
data: result.data,
};
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[SubscriptionActions] getCurrentSubscription error:', error);
return {
success: false,
data: null,
error: '서버 오류가 발생했습니다.',
};
}
export async function getCurrentSubscription(): Promise<ActionResult<SubscriptionApiData>> {
return executeServerAction({
url: `${API_URL}/api/v1/subscriptions/current`,
errorMessage: '구독 정보를 불러오는데 실패했습니다.',
});
}
// ===== 사용량 조회 =====
export async function getUsage(): Promise<{
success: boolean;
data: UsageApiData | null;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/subscriptions/usage`,
{
method: 'GET',
cache: 'no-store',
}
);
if (error) {
return {
success: false,
data: null,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response) {
return {
success: false,
data: null,
error: '사용량 정보를 불러오는데 실패했습니다.',
};
}
const result = await response.json();
if (!response.ok || !result.success) {
return {
success: false,
data: null,
error: result.message || '사용량 정보를 불러오는데 실패했습니다.',
};
}
return {
success: true,
data: result.data,
};
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[SubscriptionActions] getUsage error:', error);
return {
success: false,
data: null,
error: '서버 오류가 발생했습니다.',
};
}
export async function getUsage(): Promise<ActionResult<UsageApiData>> {
return executeServerAction({
url: `${API_URL}/api/v1/subscriptions/usage`,
errorMessage: '사용량 정보를 불러오는데 실패했습니다.',
});
}
// ===== 구독 취소 =====
export async function cancelSubscription(
id: number,
reason?: string
): Promise<{
success: boolean;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/subscriptions/${id}/cancel`,
{
method: 'POST',
body: JSON.stringify({ reason }),
}
);
if (error) {
return {
success: false,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response) {
return {
success: false,
error: '구독 취소에 실패했습니다.',
};
}
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('[SubscriptionActions] cancelSubscription error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
): Promise<ActionResult> {
return executeServerAction({
url: `${API_URL}/api/v1/subscriptions/${id}/cancel`,
method: 'POST',
body: { reason },
errorMessage: '구독 취소에 실패했습니다.',
});
}
// ===== 데이터 내보내기 요청 =====
export async function requestDataExport(
exportType: string = 'all'
): Promise<{
success: boolean;
data?: { id: number; status: string };
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/subscriptions/export`,
{
method: 'POST',
body: JSON.stringify({ export_type: exportType }),
}
);
if (error) {
return {
success: false,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response) {
return {
success: false,
error: '내보내기 요청에 실패했습니다.',
};
}
const result = await response.json();
if (!response.ok || !result.success) {
return {
success: false,
error: result.message || '내보내기 요청에 실패했습니다.',
};
}
return {
success: true,
data: {
id: result.data.id,
status: result.data.status,
},
};
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[SubscriptionActions] requestDataExport error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
): Promise<ActionResult<{ id: number; status: string }>> {
return executeServerAction({
url: `${API_URL}/api/v1/subscriptions/export`,
method: 'POST',
body: { export_type: exportType },
transform: (data: { id: number; status: string }) => ({ id: data.id, status: data.status }),
errorMessage: '내보내기 요청에 실패했습니다.',
});
}
// ===== 통합 데이터 조회 (현재 구독 + 사용량) =====
@@ -255,21 +71,14 @@ export async function getSubscriptionData(): Promise<{
}
const data = transformApiToFrontend(
subscriptionResult.data,
usageResult.data
subscriptionResult.data ?? null,
usageResult.data ?? null
);
return {
success: true,
data,
};
return { success: true, data };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[SubscriptionActions] getSubscriptionData error:', error);
return {
success: false,
data: null,
error: '서버 오류가 발생했습니다.',
};
return { success: false, data: null, error: '서버 오류가 발생했습니다.' };
}
}

View File

@@ -1,10 +1,11 @@
'use server';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { serverFetch } from '@/lib/api/fetch-wrapper';
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import type { Title } from './types';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
// ===== API 응답 타입 =====
interface PositionApiData {
id: number;
@@ -17,12 +18,6 @@ interface PositionApiData {
updated_at?: string;
}
interface ApiResponse<T> {
success: boolean;
message?: string;
data: T;
}
// ===== 데이터 변환: API → Frontend =====
function transformApiToFrontend(apiData: PositionApiData): Title {
return {
@@ -39,55 +34,21 @@ function transformApiToFrontend(apiData: PositionApiData): Title {
export async function getTitles(params?: {
is_active?: boolean;
q?: string;
}): Promise<{
success: boolean;
data?: Title[];
error?: string;
__authError?: boolean;
}> {
try {
const searchParams = new URLSearchParams();
searchParams.set('type', 'title');
if (params?.is_active !== undefined) {
searchParams.set('is_active', params.is_active.toString());
}
if (params?.q) {
searchParams.set('q', params.q);
}
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/positions?${searchParams.toString()}`;
const { response, error } = await serverFetch(url, {
method: 'GET',
cache: 'no-store',
});
if (error) {
return {
success: false,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response) {
return { success: false, error: '직책 목록 조회에 실패했습니다.' };
}
const result: ApiResponse<PositionApiData[]> = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '직책 목록 조회에 실패했습니다.' };
}
const titles = result.data.map(transformApiToFrontend);
return { success: true, data: titles };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[getTitles] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}): Promise<ActionResult<Title[]>> {
const searchParams = new URLSearchParams();
searchParams.set('type', 'title');
if (params?.is_active !== undefined) {
searchParams.set('is_active', params.is_active.toString());
}
if (params?.q) {
searchParams.set('q', params.q);
}
return executeServerAction({
url: `${API_URL}/api/v1/positions?${searchParams.toString()}`,
transform: (data: PositionApiData[]) => data.map(transformApiToFrontend),
errorMessage: '직책 목록 조회에 실패했습니다.',
});
}
// ===== 직책 생성 =====
@@ -95,51 +56,19 @@ export async function createTitle(data: {
name: string;
sort_order?: number;
is_active?: boolean;
}): Promise<{
success: boolean;
data?: Title;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/positions`,
{
method: 'POST',
body: JSON.stringify({
type: 'title',
name: data.name,
sort_order: data.sort_order,
is_active: data.is_active ?? true,
}),
}
);
if (error) {
return {
success: false,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response) {
return { success: false, error: '직책 생성에 실패했습니다.' };
}
const result: ApiResponse<PositionApiData> = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '직책 생성에 실패했습니다.' };
}
const title = transformApiToFrontend(result.data);
return { success: true, data: title };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[createTitle] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}): Promise<ActionResult<Title>> {
return executeServerAction({
url: `${API_URL}/api/v1/positions`,
method: 'POST',
body: {
type: 'title',
name: data.name,
sort_order: data.sort_order,
is_active: data.is_active ?? true,
},
transform: transformApiToFrontend,
errorMessage: '직책 생성에 실패했습니다.',
});
}
// ===== 직책 수정 =====
@@ -150,127 +79,33 @@ export async function updateTitle(
sort_order?: number;
is_active?: boolean;
}
): Promise<{
success: boolean;
data?: Title;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/positions/${id}`,
{
method: 'PUT',
body: JSON.stringify(data),
}
);
if (error) {
return {
success: false,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response) {
return { success: false, error: '직책 수정에 실패했습니다.' };
}
const result: ApiResponse<PositionApiData> = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '직책 수정에 실패했습니다.' };
}
const title = transformApiToFrontend(result.data);
return { success: true, data: title };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[updateTitle] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
): Promise<ActionResult<Title>> {
return executeServerAction({
url: `${API_URL}/api/v1/positions/${id}`,
method: 'PUT',
body: data,
transform: transformApiToFrontend,
errorMessage: '직책 수정에 실패했습니다.',
});
}
// ===== 직책 삭제 =====
export async function deleteTitle(id: number): Promise<{
success: boolean;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/positions/${id}`,
{
method: 'DELETE',
}
);
if (error) {
return {
success: false,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response) {
return { success: false, error: '직책 삭제에 실패했습니다.' };
}
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('[deleteTitle] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
export async function deleteTitle(id: number): Promise<ActionResult> {
return executeServerAction({
url: `${API_URL}/api/v1/positions/${id}`,
method: 'DELETE',
errorMessage: '직책 삭제에 실패했습니다.',
});
}
// ===== 직책 순서 변경 =====
export async function reorderTitles(
items: { id: number; sort_order: number }[]
): Promise<{
success: boolean;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/positions/reorder`,
{
method: 'PUT',
body: JSON.stringify({ items }),
}
);
if (error) {
return {
success: false,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response) {
return { success: false, error: '순서 변경에 실패했습니다.' };
}
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('[reorderTitles] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
): Promise<ActionResult> {
return executeServerAction({
url: `${API_URL}/api/v1/positions/reorder`,
method: 'PUT',
body: { items },
errorMessage: '순서 변경에 실패했습니다.',
});
}

View File

@@ -1,8 +1,7 @@
'use server';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { serverFetch } from '@/lib/api/fetch-wrapper';
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://sam.kr:8080';
@@ -49,7 +48,7 @@ function transformFromApi(data: ApiWorkSetting): WorkSettingFormData {
return {
workType: data.work_type,
workDays: data.work_days || ['mon', 'tue', 'wed', 'thu', 'fri'],
workStartTime: data.start_time?.substring(0, 5) || '09:00', // HH:mm:ss → HH:mm
workStartTime: data.start_time?.substring(0, 5) || '09:00',
workEndTime: data.end_time?.substring(0, 5) || '18:00',
weeklyWorkHours: data.standard_hours,
weeklyOvertimeHours: data.overtime_hours,
@@ -65,10 +64,9 @@ function transformFromApi(data: ApiWorkSetting): WorkSettingFormData {
*/
function transformToApi(data: Partial<WorkSettingFormData>): Record<string, unknown> {
const apiData: Record<string, unknown> = {};
if (data.workType !== undefined) apiData.work_type = data.workType;
if (data.workDays !== undefined) apiData.work_days = data.workDays;
if (data.workStartTime !== undefined) apiData.start_time = `${data.workStartTime}:00`; // HH:mm → HH:mm:ss
if (data.workStartTime !== undefined) apiData.start_time = `${data.workStartTime}:00`;
if (data.workEndTime !== undefined) apiData.end_time = `${data.workEndTime}:00`;
if (data.weeklyWorkHours !== undefined) apiData.standard_hours = data.weeklyWorkHours;
if (data.weeklyOvertimeHours !== undefined) apiData.overtime_hours = data.weeklyOvertimeHours;
@@ -76,7 +74,6 @@ function transformToApi(data: Partial<WorkSettingFormData>): Record<string, unkn
if (data.breakMinutes !== undefined) apiData.break_minutes = data.breakMinutes;
if (data.breakStartTime !== undefined) apiData.break_start = `${data.breakStartTime}:00`;
if (data.breakEndTime !== undefined) apiData.break_end = `${data.breakEndTime}:00`;
return apiData;
}
@@ -85,48 +82,12 @@ function transformToApi(data: Partial<WorkSettingFormData>): Record<string, unkn
/**
* 근무 설정 조회
*/
export async function getWorkSetting(): Promise<{
success: boolean;
data?: WorkSettingFormData;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(`${API_BASE_URL}/api/v1/settings/work`, {
method: 'GET',
cache: 'no-store',
});
if (error) {
return {
success: false,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response || !response.ok) {
const errorData = await response?.json().catch(() => ({}));
return {
success: false,
error: errorData?.message || `API 오류: ${response?.status}`,
};
}
const result = await response.json();
return {
success: true,
data: transformFromApi(result.data),
};
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('getWorkSetting error:', error);
return {
success: false,
error: '근무 설정을 불러오는데 실패했습니다.',
};
}
export async function getWorkSetting(): Promise<ActionResult<WorkSettingFormData>> {
return executeServerAction({
url: `${API_BASE_URL}/api/v1/settings/work`,
transform: (data: ApiWorkSetting) => transformFromApi(data),
errorMessage: '근무 설정을 불러오는데 실패했습니다.',
});
}
/**
@@ -134,46 +95,12 @@ export async function getWorkSetting(): Promise<{
*/
export async function updateWorkSetting(
data: Partial<WorkSettingFormData>
): Promise<{
success: boolean;
data?: WorkSettingFormData;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(`${API_BASE_URL}/api/v1/settings/work`, {
method: 'PUT',
body: JSON.stringify(transformToApi(data)),
});
if (error) {
return {
success: false,
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response || !response.ok) {
const errorData = await response?.json().catch(() => ({}));
return {
success: false,
error: errorData?.message || `API 오류: ${response?.status}`,
};
}
const result = await response.json();
return {
success: true,
data: transformFromApi(result.data),
};
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('updateWorkSetting error:', error);
return {
success: false,
error: '근무 설정 저장에 실패했습니다.',
};
}
): Promise<ActionResult<WorkSettingFormData>> {
return executeServerAction({
url: `${API_BASE_URL}/api/v1/settings/work`,
method: 'PUT',
body: transformToApi(data),
transform: (data: ApiWorkSetting) => transformFromApi(data),
errorMessage: '근무 설정 저장에 실패했습니다.',
});
}