feat(WEB): 직원 관리 폼 및 API 연동 개선

- EmployeeForm: 직원 등록/수정 폼 기능 강화
- 프로필 이미지 업로드 기능 추가
- 직급/직책/부서 선택 API 연동
- 유효성 검사 및 에러 처리 개선
This commit is contained in:
2025-12-30 17:21:00 +09:00
parent 2443c0dc63
commit 68babd54be
3 changed files with 190 additions and 9 deletions

View File

@@ -1,8 +1,9 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useDaumPostcode } from '@/hooks/useDaumPostcode';
import { useRouter, useParams } from 'next/navigation';
import { toast } from 'sonner';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -34,7 +35,8 @@ import {
EMPLOYEE_STATUS_LABELS,
DEFAULT_FIELD_SETTINGS,
} from './types';
import { uploadProfileImage } from './actions';
import { getPositions, getDepartments, type PositionItem, type DepartmentItem } from './actions';
import { getProfileImageUrl } from './utils';
interface EmployeeFormProps {
mode: 'create' | 'edit' | 'view';
@@ -121,6 +123,10 @@ export function EmployeeForm({
: mode === 'edit'
? '사원 정보를 수정합니다'
: '사원 정보를 확인합니다';
// 직급/직책/부서 목록
const [ranks, setRanks] = useState<PositionItem[]>([]);
const [titles, setTitles] = useState<PositionItem[]>([]);
const [departments, setDepartments] = useState<DepartmentItem[]>([]);
// localStorage에서 항목 설정 로드
useEffect(() => {
@@ -134,6 +140,21 @@ export function EmployeeForm({
}
}, []);
// 직급/직책/부서 목록 로드
useEffect(() => {
const loadData = async () => {
const [rankList, titleList, deptList] = await Promise.all([
getPositions('rank'),
getPositions('title'),
getDepartments(),
]);
setRanks(rankList);
setTitles(titleList);
setDepartments(deptList);
};
loadData();
}, []);
// 항목 설정 저장
const handleSaveFieldSettings = (newSettings: FieldSettings) => {
setFieldSettings(newSettings);

View File

@@ -346,10 +346,110 @@ export async function getEmployeeStats(): Promise<EmployeeStats | null | { __aut
}
}
// ============================================
// 직급/직책 조회 (positions)
// ============================================
export interface PositionItem {
id: number;
key: string;
name: string;
type: 'rank' | 'title';
sort_order: number;
is_active: boolean;
}
/**
* 프로필 이미지 업로드
* 직급/직책 목록 조회
* @param type 'rank' (직급) | 'title' (직책) | undefined (전체)
*/
export async function uploadProfileImage(file: File): Promise<{
export async function getPositions(type?: 'rank' | 'title'): Promise<PositionItem[]> {
try {
const headers = await getApiHeaders();
const searchParams = new URLSearchParams();
if (type) {
searchParams.set('type', type);
}
const url = `${process.env.API_URL}/api/v1/positions?${searchParams.toString()}`;
const response = await fetch(url, {
method: 'GET',
headers,
cache: 'no-store',
});
if (!response.ok) {
console.error('[EmployeeActions] GET positions error:', response.status);
return [];
}
const result: ApiResponse<PositionItem[]> = await response.json();
if (!result.success || !result.data) {
return [];
}
return result.data;
} catch (error) {
console.error('[EmployeeActions] getPositions error:', error);
return [];
}
}
// ============================================
// 부서 조회 (departments)
// ============================================
export interface DepartmentItem {
id: number;
name: string;
code: string | null;
parent_id: number | null;
is_active: boolean;
}
/**
* 부서 목록 조회
*/
export async function getDepartments(): Promise<DepartmentItem[]> {
try {
const headers = await getApiHeaders();
const response = await fetch(
`${process.env.API_URL}/api/v1/departments`,
{
method: 'GET',
headers,
cache: 'no-store',
}
);
if (!response.ok) {
console.error('[EmployeeActions] GET departments error:', response.status);
return [];
}
const result = await response.json();
if (!result.success || !result.data) {
return [];
}
// 페이지네이션 응답 또는 배열 직접 반환 모두 처리
const departments = Array.isArray(result.data) ? result.data : result.data.data || [];
return departments;
} catch (error) {
console.error('[EmployeeActions] getDepartments error:', error);
return [];
}
}
// ============================================
// 파일 업로드
// ============================================
export async function uploadProfileImage(formData: FormData): Promise<{
success: boolean;
data?: { url: string; path: string };
error?: string;

View File

@@ -18,6 +18,64 @@ import type {
UserAccountStatus,
} from './types';
// ============================================
// 프로필 이미지 URL 변환
// ============================================
const API_URL = process.env.NEXT_PUBLIC_API_URL || process.env.API_URL || '';
/**
* 프로필 이미지 경로를 전체 URL로 변환
* - 이미 전체 URL이면 그대로 반환
* - base64 data URL이면 그대로 반환
* - 상대 경로면 API URL + /storage/tenants/ 붙여서 반환
*/
export function getProfileImageUrl(path: string | null | undefined): string | undefined {
if (!path) return undefined;
// base64 data URL인 경우 (미리보기)
if (path.startsWith('data:')) {
return path;
}
// 이미 전체 URL인 경우
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
}
// 상대 경로인 경우 API URL과 결합 (tenants 디렉토리 사용)
return `${API_URL}/storage/tenants/${path}`;
}
/**
* 전체 URL에서 상대 경로 추출 (API 저장용)
* - 상대 경로면 그대로 반환
* - 전체 URL이면 상대 경로 추출
* - base64 data URL이면 빈 문자열 반환 (저장하지 않음)
*/
export function extractRelativePath(path: string | null | undefined): string | null {
if (!path) return null;
// base64 data URL인 경우 (미리보기) - 저장하지 않음
if (path.startsWith('data:')) {
return null;
}
// 전체 URL인 경우 상대 경로 추출
if (path.startsWith('http://') || path.startsWith('https://')) {
// /storage/tenants/ 이후의 경로 추출
const match = path.match(/\/storage\/tenants\/(.+)$/);
if (match) {
return match[1];
}
// 매칭 실패 시 null 반환
return null;
}
// 이미 상대 경로인 경우 그대로 반환
return path;
}
// ============================================
// API 응답 타입 (TenantUserProfile)
// ============================================
@@ -29,6 +87,8 @@ export interface EmployeeApiData {
department_id: number | null;
position_key: string | null;
job_title_key: string | null;
position_label: string | null; // 직급 한글명 (positions 테이블에서 조회)
job_title_label: string | null; // 직책 한글명 (positions 테이블에서 조회)
work_location_key: string | null;
employment_type_key: string | null;
employee_status: string;
@@ -102,8 +162,8 @@ export function transformApiToFrontend(api: EmployeeApiData): Employee {
id: String(api.id),
departmentId: String(api.department_id),
departmentName: api.department.name,
positionId: api.position_key || '',
positionName: api.job_title_key || api.position_key || '',
positionId: api.job_title_key || '',
positionName: api.job_title_label || api.job_title_key || '', // 직책 한글명 사용
});
}
@@ -143,12 +203,12 @@ export function transformApiToFrontend(api: EmployeeApiData): Employee {
email: api.user?.email || undefined,
salary: extra.salary,
bankAccount,
profileImage: api.profile_photo_path || undefined,
profileImage: getProfileImageUrl(api.profile_photo_path),
gender: extra.gender as Gender | undefined,
address,
hireDate: extra.hire_date,
employmentType: mapEmploymentType(api.employment_type_key || extra.work_type),
rank: extra.rank,
rank: api.position_label || extra.rank, // 직급 한글명 사용 (position_label 우선)
status: mapEmployeeStatus(api.employee_status),
departmentPositions,
clockInLocation: extra.clock_in_location,
@@ -187,7 +247,7 @@ export function transformFrontendToApi(form: EmployeeFormData): Record<string, u
employment_type_key: form.employmentType || null,
employee_status: form.status,
display_name: form.name,
profile_photo_path: form.profileImage || null,
profile_photo_path: extractRelativePath(form.profileImage),
// 추가 정보 (json_extra)
employee_code: form.employeeCode || null,