refactor(WEB): 직급/직책 관리 Server Actions 전환
- 클라이언트 직접 API 호출 → Server Actions 방식으로 변경 - RankManagement/actions.ts 신규 생성 - TitleManagement/actions.ts 신규 생성 - API_KEY 환경변수를 서버에서만 사용하도록 변경 (보안 강화) - 기존 lib/api/positions.ts 삭제
This commit is contained in:
269
src/components/settings/TitleManagement/actions.ts
Normal file
269
src/components/settings/TitleManagement/actions.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
'use server';
|
||||
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { Title } from './types';
|
||||
|
||||
// ===== API 응답 타입 =====
|
||||
interface PositionApiData {
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
type: 'rank' | 'title';
|
||||
name: string;
|
||||
sort_order: number;
|
||||
is_active: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
// ===== 데이터 변환: API → Frontend =====
|
||||
function transformApiToFrontend(apiData: PositionApiData): Title {
|
||||
return {
|
||||
id: apiData.id,
|
||||
name: apiData.name,
|
||||
order: apiData.sort_order,
|
||||
isActive: apiData.is_active,
|
||||
createdAt: apiData.created_at,
|
||||
updatedAt: apiData.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 직책 목록 조회 =====
|
||||
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) {
|
||||
console.error('[getTitles] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 직책 생성 =====
|
||||
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) {
|
||||
console.error('[createTitle] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 직책 수정 =====
|
||||
export async function updateTitle(
|
||||
id: number,
|
||||
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/${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) {
|
||||
console.error('[updateTitle] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 직책 삭제 =====
|
||||
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) {
|
||||
console.error('[deleteTitle] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 직책 순서 변경 =====
|
||||
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) {
|
||||
console.error('[reorderTitles] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
@@ -21,27 +21,12 @@ import {
|
||||
import { toast } from 'sonner';
|
||||
import type { Title } from './types';
|
||||
import {
|
||||
fetchTitles,
|
||||
getTitles,
|
||||
createTitle,
|
||||
updatePosition,
|
||||
deletePosition,
|
||||
reorderPositions,
|
||||
type Position,
|
||||
} from '@/lib/api/positions';
|
||||
|
||||
/**
|
||||
* Position → Title 변환
|
||||
*/
|
||||
function positionToTitle(position: Position): Title {
|
||||
return {
|
||||
id: position.id,
|
||||
name: position.name,
|
||||
order: position.sort_order,
|
||||
isActive: position.is_active,
|
||||
createdAt: position.created_at,
|
||||
updatedAt: position.updated_at,
|
||||
};
|
||||
}
|
||||
updateTitle,
|
||||
deleteTitle,
|
||||
reorderTitles,
|
||||
} from './actions';
|
||||
|
||||
export function TitleManagement() {
|
||||
// 직책 데이터
|
||||
@@ -68,8 +53,12 @@ export function TitleManagement() {
|
||||
const loadTitles = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const positions = await fetchTitles();
|
||||
setTitles(positions.map(positionToTitle));
|
||||
const result = await getTitles();
|
||||
if (result.success && result.data) {
|
||||
setTitles(result.data);
|
||||
} else {
|
||||
toast.error(result.error || '직책 목록을 불러오는데 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('직책 목록 조회 실패:', error);
|
||||
toast.error('직책 목록을 불러오는데 실패했습니다.');
|
||||
@@ -89,10 +78,14 @@ export function TitleManagement() {
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
const newPosition = await createTitle({ name: newTitleName.trim() });
|
||||
setTitles(prev => [...prev, positionToTitle(newPosition)]);
|
||||
setNewTitleName('');
|
||||
toast.success('직책이 추가되었습니다.');
|
||||
const result = await createTitle({ name: newTitleName.trim() });
|
||||
if (result.success && result.data) {
|
||||
setTitles(prev => [...prev, result.data!]);
|
||||
setNewTitleName('');
|
||||
toast.success('직책이 추가되었습니다.');
|
||||
} else {
|
||||
toast.error(result.error || '직책 추가에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('직책 추가 실패:', error);
|
||||
toast.error('직책 추가에 실패했습니다.');
|
||||
@@ -120,9 +113,13 @@ export function TitleManagement() {
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
await deletePosition(titleToDelete.id);
|
||||
setTitles(prev => prev.filter(t => t.id !== titleToDelete.id));
|
||||
toast.success('직책이 삭제되었습니다.');
|
||||
const result = await deleteTitle(titleToDelete.id);
|
||||
if (result.success) {
|
||||
setTitles(prev => prev.filter(t => t.id !== titleToDelete.id));
|
||||
toast.success('직책이 삭제되었습니다.');
|
||||
} else {
|
||||
toast.error(result.error || '직책 삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('직책 삭제 실패:', error);
|
||||
toast.error('직책 삭제에 실패했습니다.');
|
||||
@@ -138,11 +135,15 @@ export function TitleManagement() {
|
||||
if (dialogMode === 'edit' && selectedTitle) {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
await updatePosition(selectedTitle.id, { name });
|
||||
setTitles(prev => prev.map(t =>
|
||||
t.id === selectedTitle.id ? { ...t, name } : t
|
||||
));
|
||||
toast.success('직책이 수정되었습니다.');
|
||||
const result = await updateTitle(selectedTitle.id, { name });
|
||||
if (result.success) {
|
||||
setTitles(prev => prev.map(t =>
|
||||
t.id === selectedTitle.id ? { ...t, name } : t
|
||||
));
|
||||
toast.success('직책이 수정되었습니다.');
|
||||
} else {
|
||||
toast.error(result.error || '직책 수정에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('직책 수정 실패:', error);
|
||||
toast.error('직책 수정에 실패했습니다.');
|
||||
@@ -171,8 +172,13 @@ export function TitleManagement() {
|
||||
id: title.id,
|
||||
sort_order: idx + 1,
|
||||
}));
|
||||
await reorderPositions(items);
|
||||
toast.success('순서가 변경되었습니다.');
|
||||
const result = await reorderTitles(items);
|
||||
if (result.success) {
|
||||
toast.success('순서가 변경되었습니다.');
|
||||
} else {
|
||||
toast.error(result.error || '순서 변경에 실패했습니다.');
|
||||
loadTitles();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('순서 변경 실패:', error);
|
||||
toast.error('순서 변경에 실패했습니다.');
|
||||
|
||||
Reference in New Issue
Block a user