feat(WEB): 출퇴근 설정 페이지 부서 트리 구조 연동
- MultiSelectCombobox에 depth 옵션 추가 (계층 들여쓰기) - AttendanceSettings actions.ts: serverFetch 패턴 적용 및 트리 API 사용 - getDepartments(): /departments/tree API 호출 후 평탄화 - 부서 선택 시 계층 구조(depth)에 따른 들여쓰기 표시
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
|
||||
// ===== 타입 정의 =====
|
||||
|
||||
// API 응답 타입
|
||||
@@ -26,6 +28,28 @@ export interface AttendanceSettingFormData {
|
||||
hqLongitude: number | null;
|
||||
}
|
||||
|
||||
// 부서 타입 (트리 구조 지원)
|
||||
export interface Department {
|
||||
id: string;
|
||||
name: string;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
// API 부서 응답 타입
|
||||
interface ApiDepartment {
|
||||
id: number;
|
||||
name: string;
|
||||
parent_id: number | null;
|
||||
children?: ApiDepartment[];
|
||||
}
|
||||
|
||||
// API 응답 공통 타입
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ===== 데이터 변환 =====
|
||||
|
||||
/**
|
||||
@@ -61,30 +85,26 @@ function transformToApi(data: Partial<AttendanceSettingFormData>): Record<string
|
||||
/**
|
||||
* 출퇴근 설정 조회
|
||||
*/
|
||||
export async function getAttendanceSetting(): Promise<{
|
||||
success: boolean;
|
||||
data?: AttendanceSettingFormData;
|
||||
error?: string;
|
||||
}> {
|
||||
export async function getAttendanceSetting(): Promise<ApiResponse<AttendanceSettingFormData>> {
|
||||
try {
|
||||
const headers = await getAuthHeaders();
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/api/v1/settings/attendance`, {
|
||||
const { response, error } = await serverFetch(`${API_URL}/api/v1/settings/attendance`, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.message || `API 오류: ${response.status}`,
|
||||
};
|
||||
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),
|
||||
@@ -103,30 +123,27 @@ export async function getAttendanceSetting(): Promise<{
|
||||
*/
|
||||
export async function updateAttendanceSetting(
|
||||
data: Partial<AttendanceSettingFormData>
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: AttendanceSettingFormData;
|
||||
error?: string;
|
||||
}> {
|
||||
): Promise<ApiResponse<AttendanceSettingFormData>> {
|
||||
try {
|
||||
const headers = await getAuthHeaders();
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/api/v1/settings/attendance`, {
|
||||
const { response, error } = await serverFetch(`${API_URL}/api/v1/settings/attendance`, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify(transformToApi(data)),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.message || `API 오류: ${response.status}`,
|
||||
};
|
||||
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),
|
||||
@@ -138,4 +155,62 @@ export async function updateAttendanceSetting(
|
||||
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,
|
||||
});
|
||||
|
||||
if (dept.children && dept.children.length > 0) {
|
||||
result.push(...flattenDepartmentTree(dept.children, depth + 1));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서 목록 조회 (트리 구조)
|
||||
*/
|
||||
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) {
|
||||
console.error('getDepartments error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '부서 목록을 불러오는데 실패했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -150,10 +150,11 @@ export function AttendanceSettingsManagement() {
|
||||
}
|
||||
};
|
||||
|
||||
// 부서 옵션 변환
|
||||
// 부서 옵션 변환 (depth 포함)
|
||||
const departmentOptions = departments.map(dept => ({
|
||||
value: dept.id,
|
||||
label: dept.name,
|
||||
depth: dept.depth,
|
||||
}));
|
||||
|
||||
// 선택된 부서 표시 텍스트
|
||||
|
||||
@@ -17,6 +17,7 @@ import { cn } from './utils';
|
||||
export interface MultiSelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
depth?: number; // 트리 구조 들여쓰기용
|
||||
}
|
||||
|
||||
interface MultiSelectComboboxProps {
|
||||
@@ -107,14 +108,15 @@ export function MultiSelectCombobox({
|
||||
value={option.label}
|
||||
onSelect={() => handleSelect(option.value)}
|
||||
className="cursor-pointer"
|
||||
style={{ paddingLeft: option.depth ? `${(option.depth * 16) + 8}px` : undefined }}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
'mr-2 h-4 w-4 shrink-0',
|
||||
value.includes(option.value) ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
{option.label}
|
||||
<span className="truncate">{option.label}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
|
||||
Reference in New Issue
Block a user