refactor(WEB): 직급/직책 관리 Server Actions 전환

- 클라이언트 직접 API 호출 → Server Actions 방식으로 변경
- RankManagement/actions.ts 신규 생성
- TitleManagement/actions.ts 신규 생성
- API_KEY 환경변수를 서버에서만 사용하도록 변경 (보안 강화)
- 기존 lib/api/positions.ts 삭제
This commit is contained in:
2025-12-30 21:04:14 +09:00
parent 5ab1354bcc
commit 258c8e4179
5 changed files with 623 additions and 369 deletions

View 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: '서버 오류가 발생했습니다.' };
}
}

View File

@@ -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('순서 변경에 실패했습니다.');