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/RankManagement/actions.ts
Normal file
269
src/components/settings/RankManagement/actions.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
'use server';
|
||||
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { Rank } 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): Rank {
|
||||
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 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) {
|
||||
console.error('[getRanks] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 직급 생성 =====
|
||||
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) {
|
||||
console.error('[createRank] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 직급 수정 =====
|
||||
export async function updateRank(
|
||||
id: number,
|
||||
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/${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) {
|
||||
console.error('[updateRank] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 직급 삭제 =====
|
||||
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) {
|
||||
console.error('[deleteRank] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 직급 순서 변경 =====
|
||||
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) {
|
||||
console.error('[reorderRanks] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
@@ -21,27 +21,12 @@ import {
|
||||
import { toast } from 'sonner';
|
||||
import type { Rank } from './types';
|
||||
import {
|
||||
fetchRanks,
|
||||
getRanks,
|
||||
createRank,
|
||||
updatePosition,
|
||||
deletePosition,
|
||||
reorderPositions,
|
||||
type Position,
|
||||
} from '@/lib/api/positions';
|
||||
|
||||
/**
|
||||
* Position → Rank 변환
|
||||
*/
|
||||
function positionToRank(position: Position): Rank {
|
||||
return {
|
||||
id: position.id,
|
||||
name: position.name,
|
||||
order: position.sort_order,
|
||||
isActive: position.is_active,
|
||||
createdAt: position.created_at,
|
||||
updatedAt: position.updated_at,
|
||||
};
|
||||
}
|
||||
updateRank,
|
||||
deleteRank,
|
||||
reorderRanks,
|
||||
} from './actions';
|
||||
|
||||
export function RankManagement() {
|
||||
// 직급 데이터
|
||||
@@ -68,8 +53,12 @@ export function RankManagement() {
|
||||
const loadRanks = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const positions = await fetchRanks();
|
||||
setRanks(positions.map(positionToRank));
|
||||
const result = await getRanks();
|
||||
if (result.success && result.data) {
|
||||
setRanks(result.data);
|
||||
} else {
|
||||
toast.error(result.error || '직급 목록을 불러오는데 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('직급 목록 조회 실패:', error);
|
||||
toast.error('직급 목록을 불러오는데 실패했습니다.');
|
||||
@@ -89,10 +78,14 @@ export function RankManagement() {
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
const newPosition = await createRank({ name: newRankName.trim() });
|
||||
setRanks(prev => [...prev, positionToRank(newPosition)]);
|
||||
setNewRankName('');
|
||||
toast.success('직급이 추가되었습니다.');
|
||||
const result = await createRank({ name: newRankName.trim() });
|
||||
if (result.success && result.data) {
|
||||
setRanks(prev => [...prev, result.data!]);
|
||||
setNewRankName('');
|
||||
toast.success('직급이 추가되었습니다.');
|
||||
} else {
|
||||
toast.error(result.error || '직급 추가에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('직급 추가 실패:', error);
|
||||
toast.error('직급 추가에 실패했습니다.');
|
||||
@@ -120,9 +113,13 @@ export function RankManagement() {
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
await deletePosition(rankToDelete.id);
|
||||
setRanks(prev => prev.filter(r => r.id !== rankToDelete.id));
|
||||
toast.success('직급이 삭제되었습니다.');
|
||||
const result = await deleteRank(rankToDelete.id);
|
||||
if (result.success) {
|
||||
setRanks(prev => prev.filter(r => r.id !== rankToDelete.id));
|
||||
toast.success('직급이 삭제되었습니다.');
|
||||
} else {
|
||||
toast.error(result.error || '직급 삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('직급 삭제 실패:', error);
|
||||
toast.error('직급 삭제에 실패했습니다.');
|
||||
@@ -138,11 +135,15 @@ export function RankManagement() {
|
||||
if (dialogMode === 'edit' && selectedRank) {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
await updatePosition(selectedRank.id, { name });
|
||||
setRanks(prev => prev.map(r =>
|
||||
r.id === selectedRank.id ? { ...r, name } : r
|
||||
));
|
||||
toast.success('직급이 수정되었습니다.');
|
||||
const result = await updateRank(selectedRank.id, { name });
|
||||
if (result.success) {
|
||||
setRanks(prev => prev.map(r =>
|
||||
r.id === selectedRank.id ? { ...r, name } : r
|
||||
));
|
||||
toast.success('직급이 수정되었습니다.');
|
||||
} else {
|
||||
toast.error(result.error || '직급 수정에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('직급 수정 실패:', error);
|
||||
toast.error('직급 수정에 실패했습니다.');
|
||||
@@ -171,8 +172,13 @@ export function RankManagement() {
|
||||
id: rank.id,
|
||||
sort_order: idx + 1,
|
||||
}));
|
||||
await reorderPositions(items);
|
||||
toast.success('순서가 변경되었습니다.');
|
||||
const result = await reorderRanks(items);
|
||||
if (result.success) {
|
||||
toast.success('순서가 변경되었습니다.');
|
||||
} else {
|
||||
toast.error(result.error || '순서 변경에 실패했습니다.');
|
||||
loadRanks();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('순서 변경 실패:', error);
|
||||
toast.error('순서 변경에 실패했습니다.');
|
||||
@@ -358,4 +364,4 @@ export function RankManagement() {
|
||||
</AlertDialog>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
}
|
||||
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('순서 변경에 실패했습니다.');
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
/**
|
||||
* 직급/직책 통합 API 클라이언트
|
||||
*
|
||||
* Laravel 백엔드 positions 테이블과 통신
|
||||
* type: 'rank' = 직급, 'title' = 직책
|
||||
*/
|
||||
|
||||
// ===== 타입 정의 =====
|
||||
|
||||
export type PositionType = 'rank' | 'title';
|
||||
|
||||
export interface Position {
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
type: PositionType;
|
||||
name: string;
|
||||
sort_order: number;
|
||||
is_active: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface PositionCreateRequest {
|
||||
type: PositionType;
|
||||
name: string;
|
||||
sort_order?: number;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface PositionUpdateRequest {
|
||||
name?: string;
|
||||
sort_order?: number;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface PositionReorderItem {
|
||||
id: number;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface PositionListParams {
|
||||
type?: PositionType;
|
||||
is_active?: boolean;
|
||||
q?: string;
|
||||
per_page?: number;
|
||||
page?: number;
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
// ===== 환경 변수 =====
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://api.sam.kr';
|
||||
|
||||
// ===== 유틸리티 함수 =====
|
||||
|
||||
/**
|
||||
* 인증 토큰 가져오기
|
||||
*/
|
||||
function getAuthToken(): string | null {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem('auth_token');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* API Key 가져오기
|
||||
*/
|
||||
function getApiKey(): string {
|
||||
return process.env.NEXT_PUBLIC_API_KEY || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch 옵션 생성
|
||||
*/
|
||||
function createFetchOptions(options: RequestInit = {}): RequestInit {
|
||||
const token = getAuthToken();
|
||||
const apiKey = getApiKey();
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
|
||||
// API Key 추가
|
||||
if (apiKey) {
|
||||
headers['X-API-KEY'] = apiKey;
|
||||
}
|
||||
|
||||
// Bearer 토큰 추가
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// Merge existing headers
|
||||
if (options.headers && typeof options.headers === 'object' && !Array.isArray(options.headers)) {
|
||||
Object.assign(headers, options.headers);
|
||||
}
|
||||
|
||||
return {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* API 에러 처리
|
||||
*/
|
||||
async function handleApiResponse<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({
|
||||
message: 'API 요청 실패',
|
||||
}));
|
||||
|
||||
throw new Error(error.message || `HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data;
|
||||
}
|
||||
|
||||
// ===== Position CRUD API =====
|
||||
|
||||
/**
|
||||
* 직급/직책 목록 조회
|
||||
*
|
||||
* @param params - 필터 파라미터
|
||||
* @example
|
||||
* // 직급만 조회
|
||||
* const ranks = await fetchPositions({ type: 'rank' });
|
||||
* // 직책만 조회
|
||||
* const titles = await fetchPositions({ type: 'title' });
|
||||
*/
|
||||
export async function fetchPositions(
|
||||
params?: PositionListParams
|
||||
): Promise<Position[]> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
queryParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const url = `${API_URL}/v1/positions${queryParams.toString() ? `?${queryParams}` : ''}`;
|
||||
|
||||
const response = await fetch(url, createFetchOptions());
|
||||
const result = await handleApiResponse<ApiResponse<Position[]>>(response);
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 직급/직책 단건 조회
|
||||
*
|
||||
* @param id - Position ID
|
||||
*/
|
||||
export async function fetchPosition(id: number): Promise<Position> {
|
||||
const response = await fetch(
|
||||
`${API_URL}/v1/positions/${id}`,
|
||||
createFetchOptions()
|
||||
);
|
||||
|
||||
const result = await handleApiResponse<ApiResponse<Position>>(response);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 직급/직책 생성
|
||||
*
|
||||
* @param data - 생성 데이터
|
||||
* @example
|
||||
* const newRank = await createPosition({
|
||||
* type: 'rank',
|
||||
* name: '차장',
|
||||
* });
|
||||
*/
|
||||
export async function createPosition(
|
||||
data: PositionCreateRequest
|
||||
): Promise<Position> {
|
||||
const response = await fetch(
|
||||
`${API_URL}/v1/positions`,
|
||||
createFetchOptions({
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
);
|
||||
|
||||
const result = await handleApiResponse<ApiResponse<Position>>(response);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 직급/직책 수정
|
||||
*
|
||||
* @param id - Position ID
|
||||
* @param data - 수정 데이터
|
||||
*/
|
||||
export async function updatePosition(
|
||||
id: number,
|
||||
data: PositionUpdateRequest
|
||||
): Promise<Position> {
|
||||
const response = await fetch(
|
||||
`${API_URL}/v1/positions/${id}`,
|
||||
createFetchOptions({
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
);
|
||||
|
||||
const result = await handleApiResponse<ApiResponse<Position>>(response);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 직급/직책 삭제
|
||||
*
|
||||
* @param id - Position ID
|
||||
*/
|
||||
export async function deletePosition(id: number): Promise<void> {
|
||||
const response = await fetch(
|
||||
`${API_URL}/v1/positions/${id}`,
|
||||
createFetchOptions({
|
||||
method: 'DELETE',
|
||||
})
|
||||
);
|
||||
|
||||
await handleApiResponse<ApiResponse<{ id: number; deleted_at: string }>>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 직급/직책 순서 일괄 변경
|
||||
*
|
||||
* @param items - 정렬할 아이템 목록
|
||||
* @example
|
||||
* await reorderPositions([
|
||||
* { id: 1, sort_order: 1 },
|
||||
* { id: 2, sort_order: 2 },
|
||||
* ]);
|
||||
*/
|
||||
export async function reorderPositions(
|
||||
items: PositionReorderItem[]
|
||||
): Promise<{ success: boolean; updated: number }> {
|
||||
const response = await fetch(
|
||||
`${API_URL}/v1/positions/reorder`,
|
||||
createFetchOptions({
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ items }),
|
||||
})
|
||||
);
|
||||
|
||||
const result = await handleApiResponse<ApiResponse<{ success: boolean; updated: number }>>(response);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
// ===== 헬퍼 함수 =====
|
||||
|
||||
/**
|
||||
* 직급 목록 조회 (헬퍼)
|
||||
*/
|
||||
export async function fetchRanks(params?: Omit<PositionListParams, 'type'>): Promise<Position[]> {
|
||||
return fetchPositions({ ...params, type: 'rank' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 직책 목록 조회 (헬퍼)
|
||||
*/
|
||||
export async function fetchTitles(params?: Omit<PositionListParams, 'type'>): Promise<Position[]> {
|
||||
return fetchPositions({ ...params, type: 'title' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 직급 생성 (헬퍼)
|
||||
*/
|
||||
export async function createRank(
|
||||
data: Omit<PositionCreateRequest, 'type'>
|
||||
): Promise<Position> {
|
||||
return createPosition({ ...data, type: 'rank' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 직책 생성 (헬퍼)
|
||||
*/
|
||||
export async function createTitle(
|
||||
data: Omit<PositionCreateRequest, 'type'>
|
||||
): Promise<Position> {
|
||||
return createPosition({ ...data, type: 'title' });
|
||||
}
|
||||
Reference in New Issue
Block a user