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:
2026-01-15 18:36:10 +09:00
parent dc0ab88fb9
commit e998cfa2f8
9 changed files with 868 additions and 46 deletions

View 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>
);
}

View File

@@ -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('서버 오류가 발생했습니다.');

View File

@@ -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: '서버 오류가 발생했습니다.',
};
}
}

View File

@@ -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: '서버 오류가 발생했습니다.',
};
}
}

View File

@@ -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,
};
}

View File

@@ -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>

View File

@@ -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: '서버 오류가 발생했습니다.',
};
}
}

View File

@@ -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
View 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}` : '';
}