fix(WEB): E2E 테스트 버그 수정 (Phase 1-3)
Phase 1 (Critical): - 매출관리 계정과목 일괄변경 함수 추가 (bulkUpdateAccountCode) - 근태관리 사유 등록 페이지 생성 (/hr/documents/new) Phase 2 (High): - 근태 등록 서버 에러 수정 (json_details 유효성 검증) - 직원 등록 서버 에러 수정 (snake_case 필드명 변환) - 근태관리 엑셀 다운로드 구현 (exportAttendanceExcel) Phase 3 (Medium): - 급여관리 엑셀 다운로드 구현 (exportSalaryExcel) - 급여관리 지급항목 인라인 수정 기능 구현
This commit is contained in:
270
src/app/[locale]/(protected)/hr/documents/new/page.tsx
Normal file
270
src/app/[locale]/(protected)/hr/documents/new/page.tsx
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
/**
|
||||||
|
* 사유 문서 등록 페이지 (HR Document Creation)
|
||||||
|
*
|
||||||
|
* 근태관리에서 사유 등록 시 이동하는 페이지
|
||||||
|
* - 출장신청 (businessTripRequest)
|
||||||
|
* - 휴가신청 (vacationRequest)
|
||||||
|
* - 외근신청 (fieldWorkRequest)
|
||||||
|
* - 연장근무신청 (overtimeRequest)
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
|
import { useState, useEffect, useMemo, Suspense } from 'react';
|
||||||
|
import { FileText, ArrowLeft, Calendar, User, Clock, MapPin, FileCheck } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { ko } from 'date-fns/locale';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
// 문서 유형 라벨
|
||||||
|
const DOCUMENT_TYPE_LABELS: Record<string, string> = {
|
||||||
|
businessTripRequest: '출장신청',
|
||||||
|
vacationRequest: '휴가신청',
|
||||||
|
fieldWorkRequest: '외근신청',
|
||||||
|
overtimeRequest: '연장근무신청',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 문서 유형 아이콘
|
||||||
|
const DOCUMENT_TYPE_ICONS: Record<string, React.ElementType> = {
|
||||||
|
businessTripRequest: MapPin,
|
||||||
|
vacationRequest: Calendar,
|
||||||
|
fieldWorkRequest: MapPin,
|
||||||
|
overtimeRequest: Clock,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 문서 유형 설명
|
||||||
|
const DOCUMENT_TYPE_DESCRIPTIONS: Record<string, string> = {
|
||||||
|
businessTripRequest: '업무상 출장이 필요한 경우 작성합니다',
|
||||||
|
vacationRequest: '연차, 병가 등 휴가 신청 시 작성합니다',
|
||||||
|
fieldWorkRequest: '사업장 외 근무가 필요한 경우 작성합니다',
|
||||||
|
overtimeRequest: '정규 근무시간 외 연장근무 시 작성합니다',
|
||||||
|
};
|
||||||
|
|
||||||
|
function DocumentNewContent() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const documentType = searchParams.get('type') || 'businessTripRequest';
|
||||||
|
|
||||||
|
// 폼 상태
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
title: '',
|
||||||
|
startDate: format(new Date(), 'yyyy-MM-dd'),
|
||||||
|
endDate: format(new Date(), 'yyyy-MM-dd'),
|
||||||
|
startTime: '09:00',
|
||||||
|
endTime: '18:00',
|
||||||
|
destination: '',
|
||||||
|
purpose: '',
|
||||||
|
content: '',
|
||||||
|
});
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// 문서 유형 정보
|
||||||
|
const typeInfo = useMemo(() => ({
|
||||||
|
label: DOCUMENT_TYPE_LABELS[documentType] || '문서 등록',
|
||||||
|
description: DOCUMENT_TYPE_DESCRIPTIONS[documentType] || '문서를 작성합니다',
|
||||||
|
Icon: DOCUMENT_TYPE_ICONS[documentType] || FileText,
|
||||||
|
}), [documentType]);
|
||||||
|
|
||||||
|
// 입력 핸들러
|
||||||
|
const handleChange = (field: string, value: string) => {
|
||||||
|
setFormData(prev => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 제출 핸들러
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
// TODO: 백엔드 API 구현 필요
|
||||||
|
// const result = await createHrDocument({
|
||||||
|
// type: documentType,
|
||||||
|
// ...formData,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// 임시: 성공 메시지 표시 후 이전 페이지로 이동
|
||||||
|
toast.success(`${typeInfo.label}이 등록되었습니다`);
|
||||||
|
router.back();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Document creation error:', error);
|
||||||
|
toast.error('문서 등록에 실패했습니다');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 취소 핸들러
|
||||||
|
const handleCancel = () => {
|
||||||
|
router.back();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto py-6 max-w-2xl">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="mb-4"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
근태관리로 돌아가기
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||||
|
<typeInfo.Icon className="w-6 h-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">{typeInfo.label}</h1>
|
||||||
|
<p className="text-muted-foreground">{typeInfo.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 폼 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<FileCheck className="w-5 h-5" />
|
||||||
|
신청 정보
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
필수 정보를 입력해주세요
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* 제목 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="title">제목</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
placeholder={`${typeInfo.label} 제목을 입력하세요`}
|
||||||
|
value={formData.title}
|
||||||
|
onChange={(e) => handleChange('title', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 날짜 (출장/휴가/외근) */}
|
||||||
|
{['businessTripRequest', 'vacationRequest', 'fieldWorkRequest'].includes(documentType) && (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="startDate">시작일</Label>
|
||||||
|
<Input
|
||||||
|
id="startDate"
|
||||||
|
type="date"
|
||||||
|
value={formData.startDate}
|
||||||
|
onChange={(e) => handleChange('startDate', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="endDate">종료일</Label>
|
||||||
|
<Input
|
||||||
|
id="endDate"
|
||||||
|
type="date"
|
||||||
|
value={formData.endDate}
|
||||||
|
onChange={(e) => handleChange('endDate', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 시간 (연장근무) */}
|
||||||
|
{documentType === 'overtimeRequest' && (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="startTime">시작 시간</Label>
|
||||||
|
<Input
|
||||||
|
id="startTime"
|
||||||
|
type="time"
|
||||||
|
value={formData.startTime}
|
||||||
|
onChange={(e) => handleChange('startTime', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="endTime">종료 시간</Label>
|
||||||
|
<Input
|
||||||
|
id="endTime"
|
||||||
|
type="time"
|
||||||
|
value={formData.endTime}
|
||||||
|
onChange={(e) => handleChange('endTime', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 목적지/장소 (출장/외근) */}
|
||||||
|
{['businessTripRequest', 'fieldWorkRequest'].includes(documentType) && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="destination">
|
||||||
|
{documentType === 'businessTripRequest' ? '출장지' : '외근 장소'}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="destination"
|
||||||
|
placeholder="장소를 입력하세요"
|
||||||
|
value={formData.destination}
|
||||||
|
onChange={(e) => handleChange('destination', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 목적/사유 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="purpose">
|
||||||
|
{documentType === 'vacationRequest' ? '휴가 사유' : '목적'}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="purpose"
|
||||||
|
placeholder={documentType === 'vacationRequest' ? '휴가 사유를 입력하세요' : '목적을 입력하세요'}
|
||||||
|
value={formData.purpose}
|
||||||
|
onChange={(e) => handleChange('purpose', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상세 내용 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="content">상세 내용</Label>
|
||||||
|
<Textarea
|
||||||
|
id="content"
|
||||||
|
placeholder="상세 내용을 입력하세요"
|
||||||
|
rows={4}
|
||||||
|
value={formData.content}
|
||||||
|
onChange={(e) => handleChange('content', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 버튼 */}
|
||||||
|
<div className="flex justify-end gap-3 mt-6">
|
||||||
|
<Button variant="outline" onClick={handleCancel}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? '등록 중...' : '등록'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DocumentNewPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<ContentLoadingSpinner text="문서 양식을 불러오는 중..." />}>
|
||||||
|
<DocumentNewContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,42 @@ import { EmployeeForm } from '@/components/hr/EmployeeManagement/EmployeeForm';
|
|||||||
import { createEmployee } from '@/components/hr/EmployeeManagement/actions';
|
import { createEmployee } from '@/components/hr/EmployeeManagement/actions';
|
||||||
import type { EmployeeFormData } from '@/components/hr/EmployeeManagement/types';
|
import type { EmployeeFormData } from '@/components/hr/EmployeeManagement/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server Action 결과에서 상세 에러 메시지 생성
|
||||||
|
* - status: HTTP 상태 코드
|
||||||
|
* - error: 기본 에러 메시지
|
||||||
|
* - errors: validation errors (필드별 에러 배열)
|
||||||
|
*/
|
||||||
|
function formatErrorMessage(result: {
|
||||||
|
error?: string;
|
||||||
|
errors?: Record<string, string[]>;
|
||||||
|
status?: number;
|
||||||
|
}): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
// 상태 코드 추가
|
||||||
|
if (result.status) {
|
||||||
|
parts.push(`[${result.status}]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기본 메시지 추가
|
||||||
|
if (result.error) {
|
||||||
|
parts.push(result.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// validation errors가 있으면 모든 에러 추가
|
||||||
|
if (result.errors) {
|
||||||
|
const errorMessages = Object.entries(result.errors)
|
||||||
|
.map(([field, messages]) => `${field}: ${messages[0]}`)
|
||||||
|
.join(', ');
|
||||||
|
if (errorMessages) {
|
||||||
|
parts.push(`(${errorMessages})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(' ') || '알 수 없는 오류가 발생했습니다.';
|
||||||
|
}
|
||||||
|
|
||||||
export default function EmployeeNewPage() {
|
export default function EmployeeNewPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -18,8 +54,9 @@ export default function EmployeeNewPage() {
|
|||||||
toast.success('사원이 등록되었습니다.');
|
toast.success('사원이 등록되었습니다.');
|
||||||
router.push(`/${locale}/hr/employee-management`);
|
router.push(`/${locale}/hr/employee-management`);
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error || '사원 등록에 실패했습니다.');
|
const errorMessage = formatErrorMessage(result);
|
||||||
console.error('[EmployeeNewPage] Create failed:', result.error);
|
toast.error(errorMessage);
|
||||||
|
console.warn('[EmployeeNewPage] Create failed:', result);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('서버 오류가 발생했습니다.');
|
toast.error('서버 오류가 발생했습니다.');
|
||||||
|
|||||||
@@ -311,4 +311,56 @@ export async function getSalesSummary(params?: {
|
|||||||
draftCount: summary.draft_count || 0,
|
draftCount: summary.draft_count || 0,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 계정과목 일괄 수정 =====
|
||||||
|
export async function bulkUpdateSalesAccountCode(
|
||||||
|
ids: number[],
|
||||||
|
accountCode: string
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
updatedCount?: number;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/sales/bulk-update-account`;
|
||||||
|
|
||||||
|
const { response, error } = await serverFetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ ids, account_code: accountCode }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response?.ok) {
|
||||||
|
console.warn('[SalesActions] PUT bulk-update-account error:', response?.status);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `API 오류: ${response?.status}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: result.message || '계정과목 수정에 실패했습니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
updatedCount: result.data?.updated_count || 0,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (isNextRedirectError(error)) throw error;
|
||||||
|
console.error('[SalesActions] bulkUpdateSalesAccountCode error:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '서버 오류가 발생했습니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -9,12 +9,14 @@
|
|||||||
* - DELETE /api/v1/attendances/{id} - 삭제
|
* - DELETE /api/v1/attendances/{id} - 삭제
|
||||||
* - POST /api/v1/attendances/bulk-delete - 일괄 삭제
|
* - POST /api/v1/attendances/bulk-delete - 일괄 삭제
|
||||||
* - GET /api/v1/attendances/monthly-stats - 월간 통계
|
* - GET /api/v1/attendances/monthly-stats - 월간 통계
|
||||||
|
* - GET /api/v1/attendances/export - 엑셀 내보내기
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
|
|
||||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||||
import type {
|
import type {
|
||||||
AttendanceRecord,
|
AttendanceRecord,
|
||||||
@@ -442,3 +444,77 @@ export async function getMonthlyStats(params: {
|
|||||||
totalOvertimeMinutes: result.data.total_overtime_minutes,
|
totalOvertimeMinutes: result.data.total_overtime_minutes,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 엑셀 내보내기
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 근태 엑셀 내보내기
|
||||||
|
*/
|
||||||
|
export async function exportAttendanceExcel(params?: {
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
user_id?: string;
|
||||||
|
status?: string;
|
||||||
|
department_id?: string;
|
||||||
|
}): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: Blob;
|
||||||
|
filename?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get('access_token')?.value;
|
||||||
|
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
'Accept': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'Authorization': token ? `Bearer ${token}` : '',
|
||||||
|
'X-API-KEY': process.env.API_KEY || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (params?.date_from) searchParams.set('date_from', params.date_from);
|
||||||
|
if (params?.date_to) searchParams.set('date_to', params.date_to);
|
||||||
|
if (params?.user_id) searchParams.set('user_id', params.user_id);
|
||||||
|
if (params?.status && params.status !== 'all') {
|
||||||
|
searchParams.set('status', params.status);
|
||||||
|
}
|
||||||
|
if (params?.department_id) searchParams.set('department_id', params.department_id);
|
||||||
|
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
const url = `${API_URL}/v1/attendances/export${queryString ? `?${queryString}` : ''}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn('[AttendanceActions] GET export error:', response.status);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `API 오류: ${response.status}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const contentDisposition = response.headers.get('Content-Disposition');
|
||||||
|
const filenameMatch = contentDisposition?.match(/filename="?(.+)"?/);
|
||||||
|
const filename = filenameMatch?.[1] || `근태현황_${params?.date_from || 'all'}.xlsx`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: blob,
|
||||||
|
filename,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (isNextRedirectError(error)) throw error;
|
||||||
|
console.error('[AttendanceActions] exportAttendanceExcel error:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '서버 오류가 발생했습니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -143,7 +143,14 @@ export async function getEmployeeById(id: string): Promise<Employee | null | { _
|
|||||||
*/
|
*/
|
||||||
export async function createEmployee(
|
export async function createEmployee(
|
||||||
data: EmployeeFormData
|
data: EmployeeFormData
|
||||||
): Promise<{ success: boolean; data?: Employee; error?: string; __authError?: boolean }> {
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: Employee;
|
||||||
|
error?: string;
|
||||||
|
errors?: Record<string, string[]>;
|
||||||
|
status?: number;
|
||||||
|
__authError?: boolean;
|
||||||
|
}> {
|
||||||
try {
|
try {
|
||||||
const apiData = transformFrontendToApi(data);
|
const apiData = transformFrontendToApi(data);
|
||||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees`;
|
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees`;
|
||||||
@@ -171,6 +178,8 @@ export async function createEmployee(
|
|||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: result.message || '직원 등록에 실패했습니다.',
|
error: result.message || '직원 등록에 실패했습니다.',
|
||||||
|
errors: result.error?.details, // validation errors: error.details 구조
|
||||||
|
status: result.error?.code || response.status,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -18,7 +19,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Plus, Save } from 'lucide-react';
|
import { Save, Pencil, X } from 'lucide-react';
|
||||||
import type { SalaryDetail, PaymentStatus } from './types';
|
import type { SalaryDetail, PaymentStatus } from './types';
|
||||||
import {
|
import {
|
||||||
PAYMENT_STATUS_LABELS,
|
PAYMENT_STATUS_LABELS,
|
||||||
@@ -26,12 +27,19 @@ import {
|
|||||||
formatCurrency,
|
formatCurrency,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
|
interface AllowanceEdits {
|
||||||
|
positionAllowance: number;
|
||||||
|
overtimeAllowance: number;
|
||||||
|
mealAllowance: number;
|
||||||
|
transportAllowance: number;
|
||||||
|
otherAllowance: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface SalaryDetailDialogProps {
|
interface SalaryDetailDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
salaryDetail: SalaryDetail | null;
|
salaryDetail: SalaryDetail | null;
|
||||||
onSave?: (updatedDetail: SalaryDetail) => void;
|
onSave?: (updatedDetail: SalaryDetail, allowanceDetails?: Record<string, number>) => void;
|
||||||
onAddPaymentItem?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SalaryDetailDialog({
|
export function SalaryDetailDialog({
|
||||||
@@ -39,40 +47,114 @@ export function SalaryDetailDialog({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
salaryDetail,
|
salaryDetail,
|
||||||
onSave,
|
onSave,
|
||||||
onAddPaymentItem,
|
|
||||||
}: SalaryDetailDialogProps) {
|
}: SalaryDetailDialogProps) {
|
||||||
const [editedStatus, setEditedStatus] = useState<PaymentStatus>('scheduled');
|
const [editedStatus, setEditedStatus] = useState<PaymentStatus>('scheduled');
|
||||||
const [hasChanges, setHasChanges] = useState(false);
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
|
const [isEditingAllowances, setIsEditingAllowances] = useState(false);
|
||||||
|
const [editedAllowances, setEditedAllowances] = useState<AllowanceEdits>({
|
||||||
|
positionAllowance: 0,
|
||||||
|
overtimeAllowance: 0,
|
||||||
|
mealAllowance: 0,
|
||||||
|
transportAllowance: 0,
|
||||||
|
otherAllowance: 0,
|
||||||
|
});
|
||||||
|
|
||||||
// 다이얼로그가 열릴 때 상태 초기화
|
// 다이얼로그가 열릴 때 상태 초기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (salaryDetail) {
|
if (salaryDetail) {
|
||||||
setEditedStatus(salaryDetail.status);
|
setEditedStatus(salaryDetail.status);
|
||||||
|
setEditedAllowances({
|
||||||
|
positionAllowance: salaryDetail.allowances.positionAllowance,
|
||||||
|
overtimeAllowance: salaryDetail.allowances.overtimeAllowance,
|
||||||
|
mealAllowance: salaryDetail.allowances.mealAllowance,
|
||||||
|
transportAllowance: salaryDetail.allowances.transportAllowance,
|
||||||
|
otherAllowance: salaryDetail.allowances.otherAllowance,
|
||||||
|
});
|
||||||
setHasChanges(false);
|
setHasChanges(false);
|
||||||
|
setIsEditingAllowances(false);
|
||||||
}
|
}
|
||||||
}, [salaryDetail]);
|
}, [salaryDetail]);
|
||||||
|
|
||||||
|
// 변경 사항 확인
|
||||||
|
const checkForChanges = useCallback(() => {
|
||||||
|
if (!salaryDetail) return false;
|
||||||
|
|
||||||
|
const statusChanged = editedStatus !== salaryDetail.status;
|
||||||
|
const allowancesChanged =
|
||||||
|
editedAllowances.positionAllowance !== salaryDetail.allowances.positionAllowance ||
|
||||||
|
editedAllowances.overtimeAllowance !== salaryDetail.allowances.overtimeAllowance ||
|
||||||
|
editedAllowances.mealAllowance !== salaryDetail.allowances.mealAllowance ||
|
||||||
|
editedAllowances.transportAllowance !== salaryDetail.allowances.transportAllowance ||
|
||||||
|
editedAllowances.otherAllowance !== salaryDetail.allowances.otherAllowance;
|
||||||
|
|
||||||
|
return statusChanged || allowancesChanged;
|
||||||
|
}, [salaryDetail, editedStatus, editedAllowances]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHasChanges(checkForChanges());
|
||||||
|
}, [checkForChanges]);
|
||||||
|
|
||||||
if (!salaryDetail) return null;
|
if (!salaryDetail) return null;
|
||||||
|
|
||||||
const handleStatusChange = (newStatus: PaymentStatus) => {
|
const handleStatusChange = (newStatus: PaymentStatus) => {
|
||||||
setEditedStatus(newStatus);
|
setEditedStatus(newStatus);
|
||||||
setHasChanges(newStatus !== salaryDetail.status);
|
};
|
||||||
|
|
||||||
|
const handleAllowanceChange = (field: keyof AllowanceEdits, value: string) => {
|
||||||
|
const numValue = parseInt(value.replace(/,/g, ''), 10) || 0;
|
||||||
|
setEditedAllowances(prev => ({
|
||||||
|
...prev,
|
||||||
|
[field]: numValue,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 수당 합계 계산
|
||||||
|
const calculateTotalAllowance = () => {
|
||||||
|
return Object.values(editedAllowances).reduce((sum, val) => sum + val, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 실지급액 계산
|
||||||
|
const calculateNetPayment = () => {
|
||||||
|
return salaryDetail.baseSalary + calculateTotalAllowance() - salaryDetail.totalDeduction;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
if (onSave && salaryDetail) {
|
if (onSave && salaryDetail) {
|
||||||
onSave({
|
// allowance_details를 백엔드 형식으로 변환
|
||||||
|
const allowanceDetails = {
|
||||||
|
position_allowance: editedAllowances.positionAllowance,
|
||||||
|
overtime_allowance: editedAllowances.overtimeAllowance,
|
||||||
|
meal_allowance: editedAllowances.mealAllowance,
|
||||||
|
transport_allowance: editedAllowances.transportAllowance,
|
||||||
|
other_allowance: editedAllowances.otherAllowance,
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedDetail: SalaryDetail = {
|
||||||
...salaryDetail,
|
...salaryDetail,
|
||||||
status: editedStatus,
|
status: editedStatus,
|
||||||
});
|
allowances: editedAllowances,
|
||||||
|
totalAllowance: calculateTotalAllowance(),
|
||||||
|
netPayment: calculateNetPayment(),
|
||||||
|
};
|
||||||
|
|
||||||
|
onSave(updatedDetail, allowanceDetails);
|
||||||
}
|
}
|
||||||
setHasChanges(false);
|
setHasChanges(false);
|
||||||
|
setIsEditingAllowances(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddPaymentItem = () => {
|
const handleToggleEdit = () => {
|
||||||
if (onAddPaymentItem) {
|
if (isEditingAllowances) {
|
||||||
onAddPaymentItem();
|
// 편집 취소 - 원래 값으로 복원
|
||||||
|
setEditedAllowances({
|
||||||
|
positionAllowance: salaryDetail.allowances.positionAllowance,
|
||||||
|
overtimeAllowance: salaryDetail.allowances.overtimeAllowance,
|
||||||
|
mealAllowance: salaryDetail.allowances.mealAllowance,
|
||||||
|
transportAllowance: salaryDetail.allowances.transportAllowance,
|
||||||
|
otherAllowance: salaryDetail.allowances.otherAllowance,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
setIsEditingAllowances(!isEditingAllowances);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -154,45 +236,114 @@ export function SalaryDetailDialog({
|
|||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="font-semibold text-blue-600">수당 내역</h3>
|
<h3 className="font-semibold text-blue-600">수당 내역</h3>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant={isEditingAllowances ? "default" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleAddPaymentItem}
|
onClick={handleToggleEdit}
|
||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
>
|
>
|
||||||
<Plus className="h-3 w-3 mr-1" />
|
{isEditingAllowances ? (
|
||||||
지급항목 추가
|
<>
|
||||||
|
<X className="h-3 w-3 mr-1" />
|
||||||
|
편집 취소
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Pencil className="h-3 w-3 mr-1" />
|
||||||
|
지급항목 수정
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-muted-foreground">본봉</span>
|
<span className="text-muted-foreground">본봉</span>
|
||||||
<span className="font-medium">{formatCurrency(salaryDetail.baseSalary)}원</span>
|
<span className="font-medium">{formatCurrency(salaryDetail.baseSalary)}원</span>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-muted-foreground">직책수당</span>
|
<span className="text-muted-foreground">직책수당</span>
|
||||||
<span>{formatCurrency(salaryDetail.allowances.positionAllowance)}원</span>
|
{isEditingAllowances ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={editedAllowances.positionAllowance}
|
||||||
|
onChange={(e) => handleAllowanceChange('positionAllowance', e.target.value)}
|
||||||
|
className="w-28 h-7 text-right text-sm"
|
||||||
|
/>
|
||||||
|
<span className="text-xs">원</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span>{formatCurrency(editedAllowances.positionAllowance)}원</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-muted-foreground">초과근무수당</span>
|
<span className="text-muted-foreground">초과근무수당</span>
|
||||||
<span>{formatCurrency(salaryDetail.allowances.overtimeAllowance)}원</span>
|
{isEditingAllowances ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={editedAllowances.overtimeAllowance}
|
||||||
|
onChange={(e) => handleAllowanceChange('overtimeAllowance', e.target.value)}
|
||||||
|
className="w-28 h-7 text-right text-sm"
|
||||||
|
/>
|
||||||
|
<span className="text-xs">원</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span>{formatCurrency(editedAllowances.overtimeAllowance)}원</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-muted-foreground">식대</span>
|
<span className="text-muted-foreground">식대</span>
|
||||||
<span>{formatCurrency(salaryDetail.allowances.mealAllowance)}원</span>
|
{isEditingAllowances ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={editedAllowances.mealAllowance}
|
||||||
|
onChange={(e) => handleAllowanceChange('mealAllowance', e.target.value)}
|
||||||
|
className="w-28 h-7 text-right text-sm"
|
||||||
|
/>
|
||||||
|
<span className="text-xs">원</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span>{formatCurrency(editedAllowances.mealAllowance)}원</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-muted-foreground">교통비</span>
|
<span className="text-muted-foreground">교통비</span>
|
||||||
<span>{formatCurrency(salaryDetail.allowances.transportAllowance)}원</span>
|
{isEditingAllowances ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={editedAllowances.transportAllowance}
|
||||||
|
onChange={(e) => handleAllowanceChange('transportAllowance', e.target.value)}
|
||||||
|
className="w-28 h-7 text-right text-sm"
|
||||||
|
/>
|
||||||
|
<span className="text-xs">원</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span>{formatCurrency(editedAllowances.transportAllowance)}원</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-muted-foreground">기타수당</span>
|
<span className="text-muted-foreground">기타수당</span>
|
||||||
<span>{formatCurrency(salaryDetail.allowances.otherAllowance)}원</span>
|
{isEditingAllowances ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={editedAllowances.otherAllowance}
|
||||||
|
onChange={(e) => handleAllowanceChange('otherAllowance', e.target.value)}
|
||||||
|
className="w-28 h-7 text-right text-sm"
|
||||||
|
/>
|
||||||
|
<span className="text-xs">원</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span>{formatCurrency(editedAllowances.otherAllowance)}원</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="flex justify-between font-semibold text-blue-600">
|
<div className="flex justify-between font-semibold text-blue-600">
|
||||||
<span>수당 합계</span>
|
<span>수당 합계</span>
|
||||||
<span>{formatCurrency(salaryDetail.totalAllowance)}원</span>
|
<span>{formatCurrency(calculateTotalAllowance())}원</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -245,7 +396,7 @@ export function SalaryDetailDialog({
|
|||||||
<div>
|
<div>
|
||||||
<span className="text-sm text-muted-foreground block">급여 총액</span>
|
<span className="text-sm text-muted-foreground block">급여 총액</span>
|
||||||
<span className="text-lg font-semibold text-blue-600">
|
<span className="text-lg font-semibold text-blue-600">
|
||||||
{formatCurrency(salaryDetail.baseSalary + salaryDetail.totalAllowance)}원
|
{formatCurrency(salaryDetail.baseSalary + calculateTotalAllowance())}원
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -257,7 +408,7 @@ export function SalaryDetailDialog({
|
|||||||
<div>
|
<div>
|
||||||
<span className="text-sm text-muted-foreground block">실지급액</span>
|
<span className="text-sm text-muted-foreground block">실지급액</span>
|
||||||
<span className="text-xl font-bold text-primary">
|
<span className="text-xl font-bold text-primary">
|
||||||
{formatCurrency(salaryDetail.netPayment)}원
|
{formatCurrency(calculateNetPayment())}원
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
|
|
||||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||||
import type { SalaryRecord, SalaryDetail, PaymentStatus } from './types';
|
import type { SalaryRecord, SalaryDetail, PaymentStatus } from './types';
|
||||||
|
|
||||||
@@ -294,6 +295,40 @@ export async function bulkUpdateSalaryStatus(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 급여 수정 (수당/공제 항목 포함)
|
||||||
|
*/
|
||||||
|
export async function updateSalary(
|
||||||
|
id: string,
|
||||||
|
data: {
|
||||||
|
base_salary?: number;
|
||||||
|
allowance_details?: Record<string, number>;
|
||||||
|
deduction_details?: Record<string, number>;
|
||||||
|
status?: PaymentStatus;
|
||||||
|
payment_date?: string;
|
||||||
|
}
|
||||||
|
): Promise<{ success: boolean; data?: SalaryDetail; error?: string }> {
|
||||||
|
const { response, error } = await serverFetch(`${API_URL}/v1/salaries/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error || !response) {
|
||||||
|
return { success: false, error: error?.message || '급여 수정에 실패했습니다.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: SalaryResponse = await response.json();
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return { success: false, error: result.message || '급여 수정에 실패했습니다.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: transformApiToDetail(result.data),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 급여 통계 조회
|
* 급여 통계 조회
|
||||||
*/
|
*/
|
||||||
@@ -351,4 +386,76 @@ export async function getSalaryStatistics(params?: {
|
|||||||
completedCount: result.data.completed_count,
|
completedCount: result.data.completed_count,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 급여 엑셀 내보내기
|
||||||
|
*/
|
||||||
|
export async function exportSalaryExcel(params?: {
|
||||||
|
year?: number;
|
||||||
|
month?: number;
|
||||||
|
status?: string;
|
||||||
|
employee_id?: number;
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
}): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: Blob;
|
||||||
|
filename?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get('access_token')?.value;
|
||||||
|
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
'Accept': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'Authorization': token ? `Bearer ${token}` : '',
|
||||||
|
'X-API-KEY': process.env.API_KEY || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (params?.year) searchParams.set('year', String(params.year));
|
||||||
|
if (params?.month) searchParams.set('month', String(params.month));
|
||||||
|
if (params?.status && params.status !== 'all') {
|
||||||
|
searchParams.set('status', params.status);
|
||||||
|
}
|
||||||
|
if (params?.employee_id) searchParams.set('employee_id', String(params.employee_id));
|
||||||
|
if (params?.start_date) searchParams.set('start_date', params.start_date);
|
||||||
|
if (params?.end_date) searchParams.set('end_date', params.end_date);
|
||||||
|
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
const url = `${API_URL}/v1/salaries/export${queryString ? `?${queryString}` : ''}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn('[SalaryActions] GET export error:', response.status);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `API 오류: ${response.status}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const contentDisposition = response.headers.get('Content-Disposition');
|
||||||
|
const filenameMatch = contentDisposition?.match(/filename="?(.+)"?/);
|
||||||
|
const filename = filenameMatch?.[1] || `급여명세_${params?.year || 'all'}_${params?.month || 'all'}.xlsx`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: blob,
|
||||||
|
filename,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (isNextRedirectError(error)) throw error;
|
||||||
|
console.error('[SalaryActions] exportSalaryExcel error:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '서버 오류가 발생했습니다.',
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
getSalary,
|
getSalary,
|
||||||
bulkUpdateSalaryStatus,
|
bulkUpdateSalaryStatus,
|
||||||
updateSalaryStatus,
|
updateSalaryStatus,
|
||||||
|
updateSalary,
|
||||||
} from './actions';
|
} from './actions';
|
||||||
import type {
|
import type {
|
||||||
SalaryRecord,
|
SalaryRecord,
|
||||||
@@ -201,18 +202,37 @@ export function SalaryManagement() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ===== 급여 상세 저장 핸들러 =====
|
// ===== 급여 상세 저장 핸들러 =====
|
||||||
const handleSaveDetail = useCallback(async (updatedDetail: SalaryDetail) => {
|
const handleSaveDetail = useCallback(async (
|
||||||
|
updatedDetail: SalaryDetail,
|
||||||
|
allowanceDetails?: Record<string, number>
|
||||||
|
) => {
|
||||||
if (!selectedSalaryId) return;
|
if (!selectedSalaryId) return;
|
||||||
|
|
||||||
setIsActionLoading(true);
|
setIsActionLoading(true);
|
||||||
try {
|
try {
|
||||||
const result = await updateSalaryStatus(selectedSalaryId, updatedDetail.status);
|
// 수당 정보가 변경된 경우 updateSalary API 호출
|
||||||
if (result.success) {
|
if (allowanceDetails) {
|
||||||
toast.success('급여 정보가 저장되었습니다.');
|
const result = await updateSalary(selectedSalaryId, {
|
||||||
setDetailDialogOpen(false);
|
allowance_details: allowanceDetails,
|
||||||
await loadSalaries();
|
status: updatedDetail.status,
|
||||||
|
});
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('급여 정보가 저장되었습니다.');
|
||||||
|
setDetailDialogOpen(false);
|
||||||
|
await loadSalaries();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '저장에 실패했습니다.');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error || '저장에 실패했습니다.');
|
// 상태만 변경된 경우 기존 API 호출
|
||||||
|
const result = await updateSalaryStatus(selectedSalaryId, updatedDetail.status);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('급여 정보가 저장되었습니다.');
|
||||||
|
setDetailDialogOpen(false);
|
||||||
|
await loadSalaries();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '저장에 실패했습니다.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isNextRedirectError(error)) throw error;
|
if (isNextRedirectError(error)) throw error;
|
||||||
@@ -223,12 +243,6 @@ export function SalaryManagement() {
|
|||||||
}
|
}
|
||||||
}, [selectedSalaryId, loadSalaries]);
|
}, [selectedSalaryId, loadSalaries]);
|
||||||
|
|
||||||
// ===== 지급항목 추가 핸들러 =====
|
|
||||||
const handleAddPaymentItem = useCallback(() => {
|
|
||||||
// TODO: 지급항목 추가 다이얼로그 또는 로직 구현
|
|
||||||
toast.info('지급항목 추가 기능은 준비 중입니다.');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ===== 탭 (단일 탭) =====
|
// ===== 탭 (단일 탭) =====
|
||||||
const [activeTab, setActiveTab] = useState('all');
|
const [activeTab, setActiveTab] = useState('all');
|
||||||
const tabs: TabOption[] = useMemo(() => [
|
const tabs: TabOption[] = useMemo(() => [
|
||||||
@@ -522,7 +536,6 @@ export function SalaryManagement() {
|
|||||||
onOpenChange={setDetailDialogOpen}
|
onOpenChange={setDetailDialogOpen}
|
||||||
salaryDetail={selectedSalaryDetail}
|
salaryDetail={selectedSalaryDetail}
|
||||||
onSave={handleSaveDetail}
|
onSave={handleSaveDetail}
|
||||||
onAddPaymentItem={handleAddPaymentItem}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
107
src/lib/utils/export.ts
Normal file
107
src/lib/utils/export.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* 엑셀 내보내기 유틸리티
|
||||||
|
*
|
||||||
|
* 여러 모듈에서 공통으로 사용:
|
||||||
|
* - 근태관리 (AttendanceManagement)
|
||||||
|
* - 급여관리 (SalaryManagement)
|
||||||
|
* - 기타 데이터 내보내기가 필요한 모듈
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 엑셀 파일 다운로드 처리
|
||||||
|
*
|
||||||
|
* API 응답(Blob)을 받아 파일 다운로드를 트리거합니다.
|
||||||
|
*
|
||||||
|
* @param response - fetch Response 객체 (Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet)
|
||||||
|
* @param filename - 저장할 파일명 (확장자 포함)
|
||||||
|
*/
|
||||||
|
export async function downloadExcelFromResponse(
|
||||||
|
response: Response,
|
||||||
|
filename: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`엑셀 다운로드 실패: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
downloadBlob(blob, filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blob을 파일로 다운로드
|
||||||
|
*
|
||||||
|
* @param blob - 다운로드할 Blob 데이터
|
||||||
|
* @param filename - 저장할 파일명
|
||||||
|
*/
|
||||||
|
export function downloadBlob(blob: Blob, filename: string): void {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 날짜/시간을 포함한 파일명 생성
|
||||||
|
*
|
||||||
|
* @param prefix - 파일명 접두사 (예: '근태현황', '급여명세')
|
||||||
|
* @param extension - 파일 확장자 (기본값: 'xlsx')
|
||||||
|
* @returns 생성된 파일명 (예: '근태현황_20250115_143052.xlsx')
|
||||||
|
*/
|
||||||
|
export function generateExportFilename(
|
||||||
|
prefix: string,
|
||||||
|
extension: string = 'xlsx'
|
||||||
|
): string {
|
||||||
|
const now = new Date();
|
||||||
|
const dateStr = now.toISOString().slice(0, 10).replace(/-/g, '');
|
||||||
|
const timeStr = now.toTimeString().slice(0, 8).replace(/:/g, '');
|
||||||
|
return `${prefix}_${dateStr}_${timeStr}.${extension}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 엑셀 내보내기 API 호출을 위한 fetch 옵션 생성
|
||||||
|
*
|
||||||
|
* @param token - 인증 토큰
|
||||||
|
* @param params - 쿼리 파라미터 (선택)
|
||||||
|
* @returns fetch 옵션 객체
|
||||||
|
*/
|
||||||
|
export function createExportFetchOptions(
|
||||||
|
token: string,
|
||||||
|
params?: Record<string, string | number | boolean | undefined>
|
||||||
|
): RequestInit {
|
||||||
|
return {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
|
||||||
|
},
|
||||||
|
cache: 'no-store',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 쿼리 파라미터 문자열 생성
|
||||||
|
*
|
||||||
|
* @param params - 쿼리 파라미터 객체
|
||||||
|
* @returns URL 쿼리 문자열 (예: '?year=2025&month=1')
|
||||||
|
*/
|
||||||
|
export function buildExportQueryString(
|
||||||
|
params?: Record<string, string | number | boolean | undefined>
|
||||||
|
): string {
|
||||||
|
if (!params) return '';
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
|
searchParams.set(key, String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
return queryString ? `?${queryString}` : '';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user