diff --git a/src/components/hr/EmployeeManagement/EmployeeForm.tsx b/src/components/hr/EmployeeManagement/EmployeeForm.tsx index 4a82835d..eb202dd1 100644 --- a/src/components/hr/EmployeeManagement/EmployeeForm.tsx +++ b/src/components/hr/EmployeeManagement/EmployeeForm.tsx @@ -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([]); + const [titles, setTitles] = useState([]); + const [departments, setDepartments] = useState([]); // 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); diff --git a/src/components/hr/EmployeeManagement/actions.ts b/src/components/hr/EmployeeManagement/actions.ts index 97ad49d3..b6c806f3 100644 --- a/src/components/hr/EmployeeManagement/actions.ts +++ b/src/components/hr/EmployeeManagement/actions.ts @@ -346,10 +346,110 @@ export async function getEmployeeStats(): Promise { + 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 = 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 { + 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; diff --git a/src/components/hr/EmployeeManagement/utils.ts b/src/components/hr/EmployeeManagement/utils.ts index ff2e260c..ebfdf77d 100644 --- a/src/components/hr/EmployeeManagement/utils.ts +++ b/src/components/hr/EmployeeManagement/utils.ts @@ -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