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

@@ -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}
/>
</>
);