feat(WEB): 출퇴근 설정 페이지 부서 트리 구조 연동

- MultiSelectCombobox에 depth 옵션 추가 (계층 들여쓰기)
- AttendanceSettings actions.ts: serverFetch 패턴 적용 및 트리 API 사용
- getDepartments(): /departments/tree API 호출 후 평탄화
- 부서 선택 시 계층 구조(depth)에 따른 들여쓰기 표시
This commit is contained in:
2025-12-30 21:31:30 +09:00
parent 258c8e4179
commit 5011bac596
3 changed files with 112 additions and 34 deletions

View File

@@ -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: '부서 목록을 불러오는데 실패했습니다.',
};
}
}

View File

@@ -150,10 +150,11 @@ export function AttendanceSettingsManagement() {
}
};
// 부서 옵션 변환
// 부서 옵션 변환 (depth 포함)
const departmentOptions = departments.map(dept => ({
value: dept.id,
label: dept.name,
depth: dept.depth,
}));
// 선택된 부서 표시 텍스트

View File

@@ -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>