feat(WEB): 직원 관리 폼 및 API 연동 개선
- EmployeeForm: 직원 등록/수정 폼 기능 강화 - 프로필 이미지 업로드 기능 추가 - 직급/직책/부서 선택 API 연동 - 유효성 검사 및 에러 처리 개선
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user