From 5011bac5960c52cd6dd4cdc8cfd313c949e33e23 Mon Sep 17 00:00:00 2001 From: kent Date: Tue, 30 Dec 2025 21:31:30 +0900 Subject: [PATCH] =?UTF-8?q?feat(WEB):=20=EC=B6=9C=ED=87=B4=EA=B7=BC=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=B6=80?= =?UTF-8?q?=EC=84=9C=20=ED=8A=B8=EB=A6=AC=20=EA=B5=AC=EC=A1=B0=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MultiSelectCombobox에 depth 옵션 추가 (계층 들여쓰기) - AttendanceSettings actions.ts: serverFetch 패턴 적용 및 트리 API 사용 - getDepartments(): /departments/tree API 호출 후 평탄화 - 부서 선택 시 계층 구조(depth)에 따른 들여쓰기 표시 --- .../AttendanceSettingsManagement/actions.ts | 137 ++++++++++++++---- .../AttendanceSettingsManagement/index.tsx | 3 +- src/components/ui/multi-select-combobox.tsx | 6 +- 3 files changed, 112 insertions(+), 34 deletions(-) diff --git a/src/components/settings/AttendanceSettingsManagement/actions.ts b/src/components/settings/AttendanceSettingsManagement/actions.ts index 3ef45697..78abc6a0 100644 --- a/src/components/settings/AttendanceSettingsManagement/actions.ts +++ b/src/components/settings/AttendanceSettingsManagement/actions.ts @@ -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 { + success: boolean; + data?: T; + error?: string; +} + // ===== 데이터 변환 ===== /** @@ -61,30 +85,26 @@ function transformToApi(data: Partial): Record { +export async function getAttendanceSetting(): Promise> { 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 -): Promise<{ - success: boolean; - data?: AttendanceSettingFormData; - error?: string; -}> { +): Promise> { 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> { + 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: '부서 목록을 불러오는데 실패했습니다.', + }; + } } \ No newline at end of file diff --git a/src/components/settings/AttendanceSettingsManagement/index.tsx b/src/components/settings/AttendanceSettingsManagement/index.tsx index 1b99fc01..e77625d8 100644 --- a/src/components/settings/AttendanceSettingsManagement/index.tsx +++ b/src/components/settings/AttendanceSettingsManagement/index.tsx @@ -150,10 +150,11 @@ export function AttendanceSettingsManagement() { } }; - // 부서 옵션 변환 + // 부서 옵션 변환 (depth 포함) const departmentOptions = departments.map(dept => ({ value: dept.id, label: dept.name, + depth: dept.depth, })); // 선택된 부서 표시 텍스트 diff --git a/src/components/ui/multi-select-combobox.tsx b/src/components/ui/multi-select-combobox.tsx index 33fb6564..7712243a 100644 --- a/src/components/ui/multi-select-combobox.tsx +++ b/src/components/ui/multi-select-combobox.tsx @@ -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 }} > - {option.label} + {option.label} ))}