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 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() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
@@ -18,8 +54,9 @@ export default function EmployeeNewPage() {
|
||||
toast.success('사원이 등록되었습니다.');
|
||||
router.push(`/${locale}/hr/employee-management`);
|
||||
} else {
|
||||
toast.error(result.error || '사원 등록에 실패했습니다.');
|
||||
console.error('[EmployeeNewPage] Create failed:', result.error);
|
||||
const errorMessage = formatErrorMessage(result);
|
||||
toast.error(errorMessage);
|
||||
console.warn('[EmployeeNewPage] Create failed:', result);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('서버 오류가 발생했습니다.');
|
||||
|
||||
@@ -311,4 +311,56 @@ export async function getSalesSummary(params?: {
|
||||
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} - 삭제
|
||||
* - POST /api/v1/attendances/bulk-delete - 일괄 삭제
|
||||
* - GET /api/v1/attendances/monthly-stats - 월간 통계
|
||||
* - GET /api/v1/attendances/export - 엑셀 내보내기
|
||||
*/
|
||||
|
||||
'use server';
|
||||
|
||||
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { cookies } from 'next/headers';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type {
|
||||
AttendanceRecord,
|
||||
@@ -442,3 +444,77 @@ export async function getMonthlyStats(params: {
|
||||
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(
|
||||
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 {
|
||||
const apiData = transformFrontendToApi(data);
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees`;
|
||||
@@ -171,6 +178,8 @@ export async function createEmployee(
|
||||
return {
|
||||
success: false,
|
||||
error: result.message || '직원 등록에 실패했습니다.',
|
||||
errors: result.error?.details, // validation errors: error.details 구조
|
||||
status: result.error?.code || response.status,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Select,
|
||||
@@ -18,7 +19,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Plus, Save } from 'lucide-react';
|
||||
import { Save, Pencil, X } from 'lucide-react';
|
||||
import type { SalaryDetail, PaymentStatus } from './types';
|
||||
import {
|
||||
PAYMENT_STATUS_LABELS,
|
||||
@@ -26,12 +27,19 @@ import {
|
||||
formatCurrency,
|
||||
} from './types';
|
||||
|
||||
interface AllowanceEdits {
|
||||
positionAllowance: number;
|
||||
overtimeAllowance: number;
|
||||
mealAllowance: number;
|
||||
transportAllowance: number;
|
||||
otherAllowance: number;
|
||||
}
|
||||
|
||||
interface SalaryDetailDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
salaryDetail: SalaryDetail | null;
|
||||
onSave?: (updatedDetail: SalaryDetail) => void;
|
||||
onAddPaymentItem?: () => void;
|
||||
onSave?: (updatedDetail: SalaryDetail, allowanceDetails?: Record<string, number>) => void;
|
||||
}
|
||||
|
||||
export function SalaryDetailDialog({
|
||||
@@ -39,40 +47,114 @@ export function SalaryDetailDialog({
|
||||
onOpenChange,
|
||||
salaryDetail,
|
||||
onSave,
|
||||
onAddPaymentItem,
|
||||
}: SalaryDetailDialogProps) {
|
||||
const [editedStatus, setEditedStatus] = useState<PaymentStatus>('scheduled');
|
||||
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(() => {
|
||||
if (salaryDetail) {
|
||||
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);
|
||||
setIsEditingAllowances(false);
|
||||
}
|
||||
}, [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;
|
||||
|
||||
const handleStatusChange = (newStatus: PaymentStatus) => {
|
||||
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 = () => {
|
||||
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,
|
||||
status: editedStatus,
|
||||
});
|
||||
allowances: editedAllowances,
|
||||
totalAllowance: calculateTotalAllowance(),
|
||||
netPayment: calculateNetPayment(),
|
||||
};
|
||||
|
||||
onSave(updatedDetail, allowanceDetails);
|
||||
}
|
||||
setHasChanges(false);
|
||||
setIsEditingAllowances(false);
|
||||
};
|
||||
|
||||
const handleAddPaymentItem = () => {
|
||||
if (onAddPaymentItem) {
|
||||
onAddPaymentItem();
|
||||
const handleToggleEdit = () => {
|
||||
if (isEditingAllowances) {
|
||||
// 편집 취소 - 원래 값으로 복원
|
||||
setEditedAllowances({
|
||||
positionAllowance: salaryDetail.allowances.positionAllowance,
|
||||
overtimeAllowance: salaryDetail.allowances.overtimeAllowance,
|
||||
mealAllowance: salaryDetail.allowances.mealAllowance,
|
||||
transportAllowance: salaryDetail.allowances.transportAllowance,
|
||||
otherAllowance: salaryDetail.allowances.otherAllowance,
|
||||
});
|
||||
}
|
||||
setIsEditingAllowances(!isEditingAllowances);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -154,45 +236,114 @@ export function SalaryDetailDialog({
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-semibold text-blue-600">수당 내역</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
variant={isEditingAllowances ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={handleAddPaymentItem}
|
||||
onClick={handleToggleEdit}
|
||||
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>
|
||||
</div>
|
||||
<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="font-medium">{formatCurrency(salaryDetail.baseSalary)}원</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex justify-between">
|
||||
<div className="flex justify-between items-center">
|
||||
<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 className="flex justify-between">
|
||||
<div className="flex justify-between items-center">
|
||||
<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 className="flex justify-between">
|
||||
<div className="flex justify-between items-center">
|
||||
<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 className="flex justify-between">
|
||||
<div className="flex justify-between items-center">
|
||||
<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 className="flex justify-between">
|
||||
<div className="flex justify-between items-center">
|
||||
<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>
|
||||
<Separator />
|
||||
<div className="flex justify-between font-semibold text-blue-600">
|
||||
<span>수당 합계</span>
|
||||
<span>{formatCurrency(salaryDetail.totalAllowance)}원</span>
|
||||
<span>{formatCurrency(calculateTotalAllowance())}원</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -245,7 +396,7 @@ export function SalaryDetailDialog({
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground block">급여 총액</span>
|
||||
<span className="text-lg font-semibold text-blue-600">
|
||||
{formatCurrency(salaryDetail.baseSalary + salaryDetail.totalAllowance)}원
|
||||
{formatCurrency(salaryDetail.baseSalary + calculateTotalAllowance())}원
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
@@ -257,7 +408,7 @@ export function SalaryDetailDialog({
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground block">실지급액</span>
|
||||
<span className="text-xl font-bold text-primary">
|
||||
{formatCurrency(salaryDetail.netPayment)}원
|
||||
{formatCurrency(calculateNetPayment())}원
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { cookies } from 'next/headers';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 급여 엑셀 내보내기
|
||||
*/
|
||||
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,
|
||||
bulkUpdateSalaryStatus,
|
||||
updateSalaryStatus,
|
||||
updateSalary,
|
||||
} from './actions';
|
||||
import type {
|
||||
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;
|
||||
|
||||
setIsActionLoading(true);
|
||||
try {
|
||||
const result = await updateSalaryStatus(selectedSalaryId, updatedDetail.status);
|
||||
if (result.success) {
|
||||
toast.success('급여 정보가 저장되었습니다.');
|
||||
setDetailDialogOpen(false);
|
||||
await loadSalaries();
|
||||
// 수당 정보가 변경된 경우 updateSalary API 호출
|
||||
if (allowanceDetails) {
|
||||
const result = await updateSalary(selectedSalaryId, {
|
||||
allowance_details: allowanceDetails,
|
||||
status: updatedDetail.status,
|
||||
});
|
||||
if (result.success) {
|
||||
toast.success('급여 정보가 저장되었습니다.');
|
||||
setDetailDialogOpen(false);
|
||||
await loadSalaries();
|
||||
} else {
|
||||
toast.error(result.error || '저장에 실패했습니다.');
|
||||
}
|
||||
} 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) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
@@ -223,12 +243,6 @@ export function SalaryManagement() {
|
||||
}
|
||||
}, [selectedSalaryId, loadSalaries]);
|
||||
|
||||
// ===== 지급항목 추가 핸들러 =====
|
||||
const handleAddPaymentItem = useCallback(() => {
|
||||
// TODO: 지급항목 추가 다이얼로그 또는 로직 구현
|
||||
toast.info('지급항목 추가 기능은 준비 중입니다.');
|
||||
}, []);
|
||||
|
||||
// ===== 탭 (단일 탭) =====
|
||||
const [activeTab, setActiveTab] = useState('all');
|
||||
const tabs: TabOption[] = useMemo(() => [
|
||||
@@ -522,7 +536,6 @@ export function SalaryManagement() {
|
||||
onOpenChange={setDetailDialogOpen}
|
||||
salaryDetail={selectedSalaryDetail}
|
||||
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