670 lines
16 KiB
Markdown
670 lines
16 KiB
Markdown
|
|
# HR API - React 동기화 계획
|
||
|
|
|
||
|
|
> **작성일**: 2025-12-09
|
||
|
|
> **수정일**: 2025-12-09
|
||
|
|
> **목적**: API와 React 프론트엔드 간 데이터 타입 동기화
|
||
|
|
> **원칙**: **API snake_case 유지** - React에서 camelCase 변환 처리
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 📋 작업 요약
|
||
|
|
|
||
|
|
| 영역 | 작업 | 수정 필요 |
|
||
|
|
|------|------|----------|
|
||
|
|
| **Employee API** | 기존 snake_case 유지 | ❌ 불필요 |
|
||
|
|
| **Attendance API** | 기존 snake_case 유지 | ❌ 불필요 |
|
||
|
|
| **Department Tree API** | 기존 snake_case 유지 | ❌ 불필요 |
|
||
|
|
| **React 프론트엔드** | 변환 유틸리티 적용 | ✅ 프론트엔드 |
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
# Part 1: 백엔드 (API) - 변경 없음
|
||
|
|
|
||
|
|
## 설계 원칙
|
||
|
|
|
||
|
|
- **API 응답은 snake_case 유지** (Laravel 표준)
|
||
|
|
- **json_extra, json_details 구조 그대로 유지**
|
||
|
|
- **기존 API 클라이언트 호환성 보장**
|
||
|
|
|
||
|
|
## 현재 API 응답 구조
|
||
|
|
|
||
|
|
### Employee API 응답
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"id": 1,
|
||
|
|
"tenant_id": 1,
|
||
|
|
"user_id": 10,
|
||
|
|
"department_id": 5,
|
||
|
|
"position_key": "DEVELOPER",
|
||
|
|
"employment_type_key": "REGULAR",
|
||
|
|
"employee_status": "active",
|
||
|
|
"profile_photo_path": null,
|
||
|
|
"json_extra": {
|
||
|
|
"employee_code": "EMP001",
|
||
|
|
"resident_number": "******-*******",
|
||
|
|
"gender": "male",
|
||
|
|
"address": "서울시 강남구",
|
||
|
|
"salary": 50000000,
|
||
|
|
"hire_date": "2023-01-15",
|
||
|
|
"rank": "대리",
|
||
|
|
"bank_account": {
|
||
|
|
"bank": "국민",
|
||
|
|
"account": "123-456-789",
|
||
|
|
"holder": "홍길동"
|
||
|
|
}
|
||
|
|
},
|
||
|
|
"user": {
|
||
|
|
"id": 10,
|
||
|
|
"name": "홍길동",
|
||
|
|
"email": "hong@example.com",
|
||
|
|
"phone": "010-1234-5678",
|
||
|
|
"is_active": true
|
||
|
|
},
|
||
|
|
"department": {
|
||
|
|
"id": 5,
|
||
|
|
"name": "개발팀"
|
||
|
|
},
|
||
|
|
"created_at": "2023-01-15T09:00:00.000000Z",
|
||
|
|
"updated_at": "2024-12-09T10:30:00.000000Z"
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Attendance API 응답
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"id": 1,
|
||
|
|
"tenant_id": 1,
|
||
|
|
"user_id": 10,
|
||
|
|
"base_date": "2024-12-09",
|
||
|
|
"status": "onTime",
|
||
|
|
"json_details": {
|
||
|
|
"check_in": "09:00:00",
|
||
|
|
"check_out": "18:00:00",
|
||
|
|
"work_minutes": 540,
|
||
|
|
"overtime_minutes": 60,
|
||
|
|
"late_minutes": 0,
|
||
|
|
"gps_data": {
|
||
|
|
"check_in": { "lat": 37.5665, "lng": 126.9780 }
|
||
|
|
}
|
||
|
|
},
|
||
|
|
"remarks": null,
|
||
|
|
"user": {
|
||
|
|
"id": 10,
|
||
|
|
"name": "홍길동",
|
||
|
|
"email": "hong@example.com"
|
||
|
|
},
|
||
|
|
"created_at": "2024-12-09T09:00:00.000000Z",
|
||
|
|
"updated_at": "2024-12-09T18:00:00.000000Z"
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Department Tree API 응답
|
||
|
|
|
||
|
|
```json
|
||
|
|
[
|
||
|
|
{
|
||
|
|
"id": 1,
|
||
|
|
"tenant_id": 1,
|
||
|
|
"parent_id": null,
|
||
|
|
"code": "DEV",
|
||
|
|
"name": "개발본부",
|
||
|
|
"description": "개발 조직",
|
||
|
|
"is_active": true,
|
||
|
|
"sort_order": 1,
|
||
|
|
"children": [
|
||
|
|
{
|
||
|
|
"id": 2,
|
||
|
|
"tenant_id": 1,
|
||
|
|
"parent_id": 1,
|
||
|
|
"code": "DEV-FE",
|
||
|
|
"name": "프론트엔드팀",
|
||
|
|
"is_active": true,
|
||
|
|
"sort_order": 1,
|
||
|
|
"children": []
|
||
|
|
}
|
||
|
|
]
|
||
|
|
}
|
||
|
|
]
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
# Part 2: 프론트엔드 (React) 수정사항
|
||
|
|
|
||
|
|
## 변환 전략
|
||
|
|
|
||
|
|
React 프론트엔드에서 API 응답을 받아 내부 타입으로 변환합니다.
|
||
|
|
|
||
|
|
### 변환 유틸리티 위치
|
||
|
|
|
||
|
|
```
|
||
|
|
react/src/lib/
|
||
|
|
├── api/
|
||
|
|
│ └── transformers/
|
||
|
|
│ ├── employee.ts # Employee 변환
|
||
|
|
│ ├── attendance.ts # Attendance 변환
|
||
|
|
│ ├── department.ts # Department 변환
|
||
|
|
│ └── index.ts # 공통 유틸리티
|
||
|
|
```
|
||
|
|
|
||
|
|
## 1. 공통 변환 유틸리티
|
||
|
|
|
||
|
|
**파일**: `react/src/lib/api/transformers/index.ts`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
/**
|
||
|
|
* snake_case → camelCase 변환
|
||
|
|
*/
|
||
|
|
export function toCamelCase(str: string): string {
|
||
|
|
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 객체 키를 camelCase로 변환 (재귀)
|
||
|
|
*/
|
||
|
|
export function transformKeys<T>(obj: unknown): T {
|
||
|
|
if (obj === null || obj === undefined) {
|
||
|
|
return obj as T;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (Array.isArray(obj)) {
|
||
|
|
return obj.map(item => transformKeys(item)) as T;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (typeof obj === 'object') {
|
||
|
|
const result: Record<string, unknown> = {};
|
||
|
|
for (const [key, value] of Object.entries(obj)) {
|
||
|
|
const camelKey = toCamelCase(key);
|
||
|
|
result[camelKey] = transformKeys(value);
|
||
|
|
}
|
||
|
|
return result as T;
|
||
|
|
}
|
||
|
|
|
||
|
|
return obj as T;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* ISO 문자열을 Date로 변환
|
||
|
|
*/
|
||
|
|
export function parseDate(dateStr: string | null): Date | null {
|
||
|
|
if (!dateStr) return null;
|
||
|
|
return new Date(dateStr);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## 2. Employee 변환
|
||
|
|
|
||
|
|
**파일**: `react/src/lib/api/transformers/employee.ts`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
import { transformKeys } from './index';
|
||
|
|
import type { Employee, EmployeeApiResponse } from '@/types/hr';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* API 응답 → React Employee 타입 변환
|
||
|
|
*/
|
||
|
|
export function transformEmployee(data: EmployeeApiResponse): Employee {
|
||
|
|
const base = transformKeys<Record<string, unknown>>(data);
|
||
|
|
const jsonExtra = data.json_extra ?? {};
|
||
|
|
|
||
|
|
return {
|
||
|
|
id: String(data.id),
|
||
|
|
name: data.user?.name ?? '',
|
||
|
|
email: data.user?.email ?? '',
|
||
|
|
phone: data.user?.phone ?? null,
|
||
|
|
residentNumber: jsonExtra.resident_number ?? null,
|
||
|
|
salary: jsonExtra.salary ?? null,
|
||
|
|
profileImage: data.profile_photo_path ?? null,
|
||
|
|
employeeCode: jsonExtra.employee_code ?? null,
|
||
|
|
gender: jsonExtra.gender ?? null,
|
||
|
|
address: transformAddress(jsonExtra.address),
|
||
|
|
bankAccount: transformBankAccount(jsonExtra.bank_account),
|
||
|
|
hireDate: jsonExtra.hire_date ?? null,
|
||
|
|
employmentType: mapEmploymentType(data.employment_type_key),
|
||
|
|
rank: jsonExtra.rank ?? null,
|
||
|
|
status: data.employee_status ?? 'active',
|
||
|
|
departmentPositions: buildDepartmentPositions(data),
|
||
|
|
userInfo: buildUserInfo(data),
|
||
|
|
createdAt: data.created_at ?? null,
|
||
|
|
updatedAt: data.updated_at ?? null,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function transformAddress(address: unknown): Employee['address'] {
|
||
|
|
if (!address) return null;
|
||
|
|
|
||
|
|
if (typeof address === 'string') {
|
||
|
|
return {
|
||
|
|
zipCode: '',
|
||
|
|
address1: address,
|
||
|
|
address2: '',
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
if (typeof address === 'object') {
|
||
|
|
const addr = address as Record<string, string>;
|
||
|
|
return {
|
||
|
|
zipCode: addr.zip_code ?? addr.zipCode ?? '',
|
||
|
|
address1: addr.address1 ?? addr.address_1 ?? '',
|
||
|
|
address2: addr.address2 ?? addr.address_2 ?? '',
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function transformBankAccount(bankAccount: unknown): Employee['bankAccount'] {
|
||
|
|
if (!bankAccount || typeof bankAccount !== 'object') return null;
|
||
|
|
|
||
|
|
const ba = bankAccount as Record<string, string>;
|
||
|
|
return {
|
||
|
|
bankName: ba.bank ?? ba.bankName ?? '',
|
||
|
|
accountNumber: ba.account ?? ba.accountNumber ?? '',
|
||
|
|
accountHolder: ba.holder ?? ba.accountHolder ?? '',
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function mapEmploymentType(key: string | null): string | null {
|
||
|
|
if (!key) return null;
|
||
|
|
|
||
|
|
const map: Record<string, string> = {
|
||
|
|
REGULAR: 'regular',
|
||
|
|
CONTRACT: 'contract',
|
||
|
|
PARTTIME: 'parttime',
|
||
|
|
INTERN: 'intern',
|
||
|
|
};
|
||
|
|
|
||
|
|
return map[key] ?? key.toLowerCase();
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildDepartmentPositions(data: EmployeeApiResponse): Employee['departmentPositions'] {
|
||
|
|
if (!data.department_id) return [];
|
||
|
|
|
||
|
|
return [{
|
||
|
|
id: String(data.id),
|
||
|
|
departmentId: String(data.department_id),
|
||
|
|
departmentName: data.department?.name ?? '',
|
||
|
|
positionId: data.position_key ?? '',
|
||
|
|
positionName: data.position_key ?? '',
|
||
|
|
}];
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildUserInfo(data: EmployeeApiResponse): Employee['userInfo'] {
|
||
|
|
if (!data.user) return null;
|
||
|
|
|
||
|
|
return {
|
||
|
|
userId: data.user.user_id ?? data.user.email,
|
||
|
|
role: 'user', // TODO: 실제 역할 정보
|
||
|
|
accountStatus: data.user.is_active ? 'active' : 'inactive',
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Employee 목록 변환
|
||
|
|
*/
|
||
|
|
export function transformEmployeeList(data: EmployeeApiResponse[]): Employee[] {
|
||
|
|
return data.map(transformEmployee);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## 3. Attendance 변환
|
||
|
|
|
||
|
|
**파일**: `react/src/lib/api/transformers/attendance.ts`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
import type { Attendance, AttendanceApiResponse } from '@/types/hr';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* API 응답 → React Attendance 타입 변환
|
||
|
|
*/
|
||
|
|
export function transformAttendance(data: AttendanceApiResponse): Attendance {
|
||
|
|
const jsonDetails = data.json_details ?? {};
|
||
|
|
|
||
|
|
return {
|
||
|
|
id: String(data.id),
|
||
|
|
employeeId: String(data.user_id),
|
||
|
|
employeeName: data.user?.name ?? '',
|
||
|
|
department: '', // TODO: user.tenantProfile.department.name
|
||
|
|
position: '', // TODO: user.tenantProfile.position_key
|
||
|
|
rank: '', // TODO: user.tenantProfile.json_extra.rank
|
||
|
|
baseDate: data.base_date,
|
||
|
|
checkIn: jsonDetails.check_in ?? null,
|
||
|
|
checkOut: jsonDetails.check_out ?? null,
|
||
|
|
breakTime: jsonDetails.break_time ?? null,
|
||
|
|
overtimeHours: formatOvertimeHours(jsonDetails.overtime_minutes),
|
||
|
|
reason: buildReason(data),
|
||
|
|
status: data.status,
|
||
|
|
createdAt: data.created_at ?? null,
|
||
|
|
updatedAt: data.updated_at ?? null,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatOvertimeHours(minutes: number | undefined): string | null {
|
||
|
|
if (minutes === undefined || minutes === null) return null;
|
||
|
|
|
||
|
|
const hours = Math.floor(minutes / 60);
|
||
|
|
const mins = minutes % 60;
|
||
|
|
|
||
|
|
return mins > 0 ? `${hours}시간 ${mins}분` : `${hours}시간`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildReason(data: AttendanceApiResponse): Attendance['reason'] {
|
||
|
|
if (!data.remarks) return null;
|
||
|
|
|
||
|
|
const typeMap: Record<string, string> = {
|
||
|
|
vacation: 'vacationRequest',
|
||
|
|
businessTrip: 'businessTripRequest',
|
||
|
|
fieldWork: 'fieldWorkRequest',
|
||
|
|
overtime: 'overtimeRequest',
|
||
|
|
};
|
||
|
|
|
||
|
|
return {
|
||
|
|
type: typeMap[data.status] ?? 'vacationRequest',
|
||
|
|
label: data.remarks,
|
||
|
|
documentId: null,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Attendance 목록 변환
|
||
|
|
*/
|
||
|
|
export function transformAttendanceList(data: AttendanceApiResponse[]): Attendance[] {
|
||
|
|
return data.map(transformAttendance);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## 4. Department Tree 변환
|
||
|
|
|
||
|
|
**파일**: `react/src/lib/api/transformers/department.ts`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
import type { DepartmentNode, DepartmentApiResponse } from '@/types/hr';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* API 응답 → React Department 타입 변환 (재귀)
|
||
|
|
*/
|
||
|
|
export function transformDepartmentTree(
|
||
|
|
data: DepartmentApiResponse[],
|
||
|
|
depth: number = 0
|
||
|
|
): DepartmentNode[] {
|
||
|
|
return data.map(dept => ({
|
||
|
|
id: dept.id,
|
||
|
|
name: dept.name,
|
||
|
|
parentId: dept.parent_id,
|
||
|
|
depth: depth,
|
||
|
|
children: dept.children
|
||
|
|
? transformDepartmentTree(dept.children, depth + 1)
|
||
|
|
: [],
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## 5. API 호출 래퍼
|
||
|
|
|
||
|
|
**파일**: `react/src/lib/api/hr.ts`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
import { apiClient } from '@/lib/api/client';
|
||
|
|
import {
|
||
|
|
transformEmployee,
|
||
|
|
transformEmployeeList
|
||
|
|
} from './transformers/employee';
|
||
|
|
import {
|
||
|
|
transformAttendance,
|
||
|
|
transformAttendanceList
|
||
|
|
} from './transformers/attendance';
|
||
|
|
import { transformDepartmentTree } from './transformers/department';
|
||
|
|
|
||
|
|
// Employee API
|
||
|
|
export async function getEmployees(params?: Record<string, unknown>) {
|
||
|
|
const response = await apiClient.get('/v1/employees', { params });
|
||
|
|
return transformEmployeeList(response.data.data);
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function getEmployee(id: string) {
|
||
|
|
const response = await apiClient.get(`/v1/employees/${id}`);
|
||
|
|
return transformEmployee(response.data.data);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Attendance API
|
||
|
|
export async function getAttendances(params?: Record<string, unknown>) {
|
||
|
|
const response = await apiClient.get('/v1/attendances', { params });
|
||
|
|
return transformAttendanceList(response.data.data);
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function getAttendance(id: string) {
|
||
|
|
const response = await apiClient.get(`/v1/attendances/${id}`);
|
||
|
|
return transformAttendance(response.data.data);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Department API
|
||
|
|
export async function getDepartmentTree(params?: Record<string, unknown>) {
|
||
|
|
const response = await apiClient.get('/v1/departments/tree', { params });
|
||
|
|
return transformDepartmentTree(response.data.data);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
# Part 3: React 타입 정의
|
||
|
|
|
||
|
|
**파일**: `react/src/types/hr.ts`
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// ============================================================
|
||
|
|
// React 내부 타입 (camelCase)
|
||
|
|
// ============================================================
|
||
|
|
|
||
|
|
export interface Employee {
|
||
|
|
id: string;
|
||
|
|
name: string;
|
||
|
|
email: string;
|
||
|
|
phone: string | null;
|
||
|
|
residentNumber: string | null;
|
||
|
|
salary: number | null;
|
||
|
|
profileImage: string | null;
|
||
|
|
employeeCode: string | null;
|
||
|
|
gender: 'male' | 'female' | null;
|
||
|
|
address: {
|
||
|
|
zipCode: string;
|
||
|
|
address1: string;
|
||
|
|
address2: string;
|
||
|
|
} | null;
|
||
|
|
bankAccount: {
|
||
|
|
bankName: string;
|
||
|
|
accountNumber: string;
|
||
|
|
accountHolder: string;
|
||
|
|
} | null;
|
||
|
|
hireDate: string | null;
|
||
|
|
employmentType: 'regular' | 'contract' | 'parttime' | 'intern' | null;
|
||
|
|
rank: string | null;
|
||
|
|
status: 'active' | 'leave' | 'resigned';
|
||
|
|
departmentPositions: {
|
||
|
|
id: string;
|
||
|
|
departmentId: string;
|
||
|
|
departmentName: string;
|
||
|
|
positionId: string;
|
||
|
|
positionName: string;
|
||
|
|
}[];
|
||
|
|
userInfo: {
|
||
|
|
userId: string;
|
||
|
|
role: string;
|
||
|
|
accountStatus: 'active' | 'inactive';
|
||
|
|
} | null;
|
||
|
|
createdAt: string | null;
|
||
|
|
updatedAt: string | null;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface Attendance {
|
||
|
|
id: string;
|
||
|
|
employeeId: string;
|
||
|
|
employeeName: string;
|
||
|
|
department: string;
|
||
|
|
position: string;
|
||
|
|
rank: string;
|
||
|
|
baseDate: string;
|
||
|
|
checkIn: string | null;
|
||
|
|
checkOut: string | null;
|
||
|
|
breakTime: string | null;
|
||
|
|
overtimeHours: string | null;
|
||
|
|
reason: {
|
||
|
|
type: 'vacationRequest' | 'businessTripRequest' | 'fieldWorkRequest' | 'overtimeRequest';
|
||
|
|
label: string;
|
||
|
|
documentId: string | null;
|
||
|
|
} | null;
|
||
|
|
status: string;
|
||
|
|
createdAt: string | null;
|
||
|
|
updatedAt: string | null;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface DepartmentNode {
|
||
|
|
id: number;
|
||
|
|
name: string;
|
||
|
|
parentId: number | null;
|
||
|
|
depth: number;
|
||
|
|
children: DepartmentNode[];
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================
|
||
|
|
// API 응답 타입 (snake_case)
|
||
|
|
// ============================================================
|
||
|
|
|
||
|
|
export interface EmployeeApiResponse {
|
||
|
|
id: number;
|
||
|
|
tenant_id: number;
|
||
|
|
user_id: number;
|
||
|
|
department_id: number | null;
|
||
|
|
position_key: string | null;
|
||
|
|
employment_type_key: string | null;
|
||
|
|
employee_status: string;
|
||
|
|
profile_photo_path: string | null;
|
||
|
|
json_extra: Record<string, unknown> | null;
|
||
|
|
user: {
|
||
|
|
id: number;
|
||
|
|
user_id?: string;
|
||
|
|
name: string;
|
||
|
|
email: string;
|
||
|
|
phone: string | null;
|
||
|
|
is_active: boolean;
|
||
|
|
} | null;
|
||
|
|
department: {
|
||
|
|
id: number;
|
||
|
|
name: string;
|
||
|
|
} | null;
|
||
|
|
created_at: string | null;
|
||
|
|
updated_at: string | null;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface AttendanceApiResponse {
|
||
|
|
id: number;
|
||
|
|
tenant_id: number;
|
||
|
|
user_id: number;
|
||
|
|
base_date: string;
|
||
|
|
status: string;
|
||
|
|
json_details: Record<string, unknown> | null;
|
||
|
|
remarks: string | null;
|
||
|
|
user: {
|
||
|
|
id: number;
|
||
|
|
name: string;
|
||
|
|
email: string;
|
||
|
|
} | null;
|
||
|
|
created_at: string | null;
|
||
|
|
updated_at: string | null;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface DepartmentApiResponse {
|
||
|
|
id: number;
|
||
|
|
tenant_id: number;
|
||
|
|
parent_id: number | null;
|
||
|
|
code: string | null;
|
||
|
|
name: string;
|
||
|
|
description: string | null;
|
||
|
|
is_active: boolean;
|
||
|
|
sort_order: number;
|
||
|
|
children: DepartmentApiResponse[] | null;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
# Part 4: 작업 체크리스트
|
||
|
|
|
||
|
|
## 프론트엔드 작업
|
||
|
|
|
||
|
|
- [ ] `react/src/lib/api/transformers/index.ts` - 공통 변환 유틸리티
|
||
|
|
- [ ] `react/src/lib/api/transformers/employee.ts` - Employee 변환
|
||
|
|
- [ ] `react/src/lib/api/transformers/attendance.ts` - Attendance 변환
|
||
|
|
- [ ] `react/src/lib/api/transformers/department.ts` - Department 변환
|
||
|
|
- [ ] `react/src/lib/api/hr.ts` - API 호출 래퍼
|
||
|
|
- [ ] `react/src/types/hr.ts` - 타입 정의 (내부용 + API 응답용)
|
||
|
|
- [ ] 기존 API 호출 코드를 래퍼 함수로 교체
|
||
|
|
|
||
|
|
## 백엔드 작업
|
||
|
|
|
||
|
|
- [x] ~~Resource 클래스 생성~~ → **취소** (기존 응답 유지)
|
||
|
|
- [x] ~~Controller 수정~~ → **취소**
|
||
|
|
- [x] ~~Service 수정~~ → **취소**
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
# Part 5: 장단점 비교
|
||
|
|
|
||
|
|
## 현재 접근법 (React 변환)
|
||
|
|
|
||
|
|
**장점:**
|
||
|
|
- API 하위 호환성 유지 (기존 클라이언트 영향 없음)
|
||
|
|
- Laravel 표준 컨벤션 유지 (snake_case)
|
||
|
|
- 백엔드 변경 불필요
|
||
|
|
|
||
|
|
**단점:**
|
||
|
|
- React에서 변환 로직 필요
|
||
|
|
- 타입 이중 정의 (API 타입 + 내부 타입)
|
||
|
|
|
||
|
|
## 대안 접근법 (API camelCase 변환)
|
||
|
|
|
||
|
|
**장점:**
|
||
|
|
- React에서 변환 불필요
|
||
|
|
- 프론트엔드 코드 단순화
|
||
|
|
|
||
|
|
**단점:**
|
||
|
|
- 기존 API 클라이언트 호환성 깨짐
|
||
|
|
- Laravel 표준과 불일치
|
||
|
|
- Resource 클래스 추가 유지보수
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
# Part 6: 참고 사항
|
||
|
|
|
||
|
|
## 변환 시점
|
||
|
|
|
||
|
|
1. **API 호출 직후**: `transformXxx()` 함수로 즉시 변환
|
||
|
|
2. **React Query/SWR 사용 시**: fetcher 함수 내에서 변환
|
||
|
|
3. **Zustand/Redux 사용 시**: store에 저장 전 변환
|
||
|
|
|
||
|
|
## 성능 고려
|
||
|
|
|
||
|
|
- 대량 데이터 변환 시 Web Worker 고려
|
||
|
|
- 변환 결과 캐싱 (React Query의 staleTime 활용)
|
||
|
|
- 필요한 필드만 변환하는 최적화 가능
|
||
|
|
|
||
|
|
## 테스트 전략
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 변환 함수 단위 테스트
|
||
|
|
describe('transformEmployee', () => {
|
||
|
|
it('should transform snake_case to camelCase', () => {
|
||
|
|
const apiResponse = { employee_status: 'active' };
|
||
|
|
const result = transformEmployee(apiResponse);
|
||
|
|
expect(result.status).toBe('active');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should handle null json_extra', () => {
|
||
|
|
const apiResponse = { json_extra: null };
|
||
|
|
const result = transformEmployee(apiResponse);
|
||
|
|
expect(result.address).toBeNull();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
```
|