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:
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user