feat: 단가관리 페이지 마이그레이션 및 HR 관리 기능 추가
## 단가관리 (Pricing Management) - 단가 목록 페이지 (IntegratedListTemplateV2 공통 템플릿 적용) - 단가 등록/수정 폼 (원가/마진 자동 계산) - 이력 조회, 수정 이력, 최종 확정 다이얼로그 - 판매관리 > 단가관리 네비게이션 메뉴 추가 ## HR 관리 (Human Resources) - 사원관리 (목록, 등록, 수정, 상세, CSV 업로드) - 부서관리 (트리 구조) - 근태관리 (기본 구조) ## 품목관리 개선 - Radix UI Select controlled mode 버그 수정 (key prop 적용) - DynamicItemForm 파일 업로드 지원 - 수정 페이지 데이터 로딩 개선 ## 문서화 - 단가관리 마이그레이션 체크리스트 - HR 관리 구현 체크리스트 - Radix UI Select 버그 수정 가이드 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
337
src/components/hr/AttendanceManagement/AttendanceInfoDialog.tsx
Normal file
337
src/components/hr/AttendanceManagement/AttendanceInfoDialog.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { CalendarIcon } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { ko } from 'date-fns/locale';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type {
|
||||
AttendanceInfoDialogProps,
|
||||
AttendanceFormData,
|
||||
} from './types';
|
||||
import {
|
||||
HOUR_OPTIONS,
|
||||
MINUTE_OPTIONS,
|
||||
OVERTIME_HOUR_OPTIONS,
|
||||
} from './types';
|
||||
|
||||
const initialFormData: AttendanceFormData = {
|
||||
employeeId: '',
|
||||
baseDate: format(new Date(), 'yyyy-MM-dd'),
|
||||
checkInHour: '9',
|
||||
checkInMinute: '0',
|
||||
checkOutHour: '18',
|
||||
checkOutMinute: '0',
|
||||
nightOvertimeHours: '0',
|
||||
nightOvertimeMinutes: '0',
|
||||
weekendOvertimeHours: '0',
|
||||
weekendOvertimeMinutes: '0',
|
||||
};
|
||||
|
||||
export function AttendanceInfoDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
mode,
|
||||
attendance,
|
||||
employees,
|
||||
onSave,
|
||||
}: AttendanceInfoDialogProps) {
|
||||
const [formData, setFormData] = useState<AttendanceFormData>(initialFormData);
|
||||
const [selectedDate, setSelectedDate] = useState<Date | undefined>(new Date());
|
||||
|
||||
// 모드별 타이틀
|
||||
const title = mode === 'create' ? '근태 정보' : '근태 정보';
|
||||
|
||||
// 데이터 초기화
|
||||
useEffect(() => {
|
||||
if (open && attendance && mode === 'edit') {
|
||||
const [checkInHour, checkInMinute] = (attendance.checkIn || '09:00').split(':');
|
||||
const [checkOutHour, checkOutMinute] = (attendance.checkOut || '18:00').split(':');
|
||||
|
||||
setFormData({
|
||||
employeeId: attendance.employeeId,
|
||||
baseDate: attendance.baseDate,
|
||||
checkInHour: checkInHour || '9',
|
||||
checkInMinute: checkInMinute === '30' ? '30' : '0',
|
||||
checkOutHour: checkOutHour || '18',
|
||||
checkOutMinute: checkOutMinute === '30' ? '30' : '0',
|
||||
nightOvertimeHours: '0',
|
||||
nightOvertimeMinutes: '0',
|
||||
weekendOvertimeHours: '0',
|
||||
weekendOvertimeMinutes: '0',
|
||||
});
|
||||
setSelectedDate(new Date(attendance.baseDate));
|
||||
} else if (open && mode === 'create') {
|
||||
setFormData(initialFormData);
|
||||
setSelectedDate(new Date());
|
||||
}
|
||||
}, [open, attendance, mode]);
|
||||
|
||||
// 입력 변경 핸들러
|
||||
const handleChange = (field: keyof AttendanceFormData, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
// 날짜 변경 핸들러
|
||||
const handleDateChange = (date: Date | undefined) => {
|
||||
setSelectedDate(date);
|
||||
if (date) {
|
||||
setFormData(prev => ({ ...prev, baseDate: format(date, 'yyyy-MM-dd') }));
|
||||
}
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSubmit = () => {
|
||||
onSave(formData);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
// 선택된 사원 정보
|
||||
const selectedEmployee = employees.find(e => e.id === formData.employeeId);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* 대상 선택 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium min-w-[80px]">대상</Label>
|
||||
<Select
|
||||
value={formData.employeeId}
|
||||
onValueChange={(value) => handleChange('employeeId', value)}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="선택 ▼" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{employees.map((employee) => (
|
||||
<SelectItem key={employee.id} value={employee.id}>
|
||||
{employee.department} / {employee.rank} / {employee.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 기준일 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium min-w-[80px]">기준일</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'w-[200px] justify-start text-left font-normal',
|
||||
!selectedDate && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{selectedDate ? format(selectedDate, 'yyyy-MM-dd') : '날짜 선택'}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="end">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
onSelect={handleDateChange}
|
||||
locale={ko}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* 출근 시간 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium min-w-[80px]">출근 시간</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={formData.checkInHour}
|
||||
onValueChange={(value) => handleChange('checkInHour', value)}
|
||||
>
|
||||
<SelectTrigger className="w-[90px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{HOUR_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={formData.checkInMinute}
|
||||
onValueChange={(value) => handleChange('checkInMinute', value)}
|
||||
>
|
||||
<SelectTrigger className="w-[90px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MINUTE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 퇴근 시간 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium min-w-[80px]">퇴근 시간</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={formData.checkOutHour}
|
||||
onValueChange={(value) => handleChange('checkOutHour', value)}
|
||||
>
|
||||
<SelectTrigger className="w-[90px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{HOUR_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={formData.checkOutMinute}
|
||||
onValueChange={(value) => handleChange('checkOutMinute', value)}
|
||||
>
|
||||
<SelectTrigger className="w-[90px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MINUTE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 야간 연장 시간 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium min-w-[80px]">야간 연장 시간</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={formData.nightOvertimeHours}
|
||||
onValueChange={(value) => handleChange('nightOvertimeHours', value)}
|
||||
>
|
||||
<SelectTrigger className="w-[90px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OVERTIME_HOUR_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={formData.nightOvertimeMinutes}
|
||||
onValueChange={(value) => handleChange('nightOvertimeMinutes', value)}
|
||||
>
|
||||
<SelectTrigger className="w-[90px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MINUTE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 주말 연장 시간 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium min-w-[80px]">주말 연장 시간</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={formData.weekendOvertimeHours}
|
||||
onValueChange={(value) => handleChange('weekendOvertimeHours', value)}
|
||||
>
|
||||
<SelectTrigger className="w-[90px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OVERTIME_HOUR_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={formData.weekendOvertimeMinutes}
|
||||
onValueChange={(value) => handleChange('weekendOvertimeMinutes', value)}
|
||||
>
|
||||
<SelectTrigger className="w-[90px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MINUTE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="bg-gray-900 text-white hover:bg-gray-800"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
className="bg-gray-900 text-white hover:bg-gray-800"
|
||||
>
|
||||
저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
174
src/components/hr/AttendanceManagement/ReasonInfoDialog.tsx
Normal file
174
src/components/hr/AttendanceManagement/ReasonInfoDialog.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { CalendarIcon } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { ko } from 'date-fns/locale';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type {
|
||||
ReasonInfoDialogProps,
|
||||
ReasonFormData,
|
||||
ReasonType,
|
||||
} from './types';
|
||||
import { REASON_TYPE_LABELS } from './types';
|
||||
|
||||
const initialFormData: ReasonFormData = {
|
||||
employeeId: '',
|
||||
baseDate: format(new Date(), 'yyyy-MM-dd'),
|
||||
reasonType: '',
|
||||
};
|
||||
|
||||
export function ReasonInfoDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
employees,
|
||||
onSubmit,
|
||||
}: ReasonInfoDialogProps) {
|
||||
const [formData, setFormData] = useState<ReasonFormData>(initialFormData);
|
||||
const [selectedDate, setSelectedDate] = useState<Date | undefined>(new Date());
|
||||
|
||||
// 데이터 초기화
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setFormData(initialFormData);
|
||||
setSelectedDate(new Date());
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// 입력 변경 핸들러
|
||||
const handleChange = (field: keyof ReasonFormData, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
// 날짜 변경 핸들러
|
||||
const handleDateChange = (date: Date | undefined) => {
|
||||
setSelectedDate(date);
|
||||
if (date) {
|
||||
setFormData(prev => ({ ...prev, baseDate: format(date, 'yyyy-MM-dd') }));
|
||||
}
|
||||
};
|
||||
|
||||
// 등록 (문서 작성 화면으로 이동)
|
||||
const handleSubmit = () => {
|
||||
onSubmit(formData);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>사유 정보</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* 대상 선택 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium min-w-[80px]">대상</Label>
|
||||
<Select
|
||||
value={formData.employeeId}
|
||||
onValueChange={(value) => handleChange('employeeId', value)}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="선택 ▼" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{employees.map((employee) => (
|
||||
<SelectItem key={employee.id} value={employee.id}>
|
||||
{employee.department} / {employee.rank} / {employee.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 기준일 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium min-w-[80px]">기준일</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'w-[200px] justify-start text-left font-normal',
|
||||
!selectedDate && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{selectedDate ? format(selectedDate, 'yyyy-MM-dd') : '날짜 선택'}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="end">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
onSelect={handleDateChange}
|
||||
locale={ko}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* 유형 선택 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium min-w-[80px]">유형</Label>
|
||||
<Select
|
||||
value={formData.reasonType}
|
||||
onValueChange={(value) => handleChange('reasonType', value)}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="선택 ▼" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(REASON_TYPE_LABELS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="bg-gray-900 text-white hover:bg-gray-800"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
className="bg-gray-900 text-white hover:bg-gray-800"
|
||||
>
|
||||
등록
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
535
src/components/hr/AttendanceManagement/index.tsx
Normal file
535
src/components/hr/AttendanceManagement/index.tsx
Normal file
@@ -0,0 +1,535 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Clock,
|
||||
UserCheck,
|
||||
AlertCircle,
|
||||
Calendar,
|
||||
Download,
|
||||
Plus,
|
||||
FileText,
|
||||
Edit,
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { TableRow, TableCell } from '@/components/ui/table';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Calendar as CalendarComponent } from '@/components/ui/calendar';
|
||||
import { CalendarIcon } from 'lucide-react';
|
||||
import { format, addDays } from 'date-fns';
|
||||
import { ko } from 'date-fns/locale';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
IntegratedListTemplateV2,
|
||||
type TableColumn,
|
||||
type StatCard,
|
||||
type TabOption,
|
||||
} from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
|
||||
import { AttendanceInfoDialog } from './AttendanceInfoDialog';
|
||||
import { ReasonInfoDialog } from './ReasonInfoDialog';
|
||||
import type {
|
||||
AttendanceRecord,
|
||||
AttendanceStatus,
|
||||
SortOption,
|
||||
AttendanceFormData,
|
||||
ReasonFormData,
|
||||
} from './types';
|
||||
import {
|
||||
ATTENDANCE_STATUS_LABELS,
|
||||
ATTENDANCE_STATUS_COLORS,
|
||||
SORT_OPTIONS,
|
||||
REASON_TYPE_LABELS,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Mock 데이터 - 실제 API 연동 전 테스트용
|
||||
*/
|
||||
const mockEmployees = [
|
||||
{ id: '1', name: '이름', department: '부서명', position: '부서장팀장', rank: '부장' },
|
||||
{ id: '2', name: '이름', department: '부서명', position: '팀장', rank: '부장' },
|
||||
{ id: '3', name: '이름', department: '부서명', position: '부서장팀장', rank: '부장' },
|
||||
{ id: '4', name: '이름', department: '부서명', position: '팀장', rank: '부장' },
|
||||
{ id: '5', name: '이름', department: '부서명', position: '부서장팀장', rank: '부장' },
|
||||
{ id: '6', name: '이름', department: '부서명', position: '팀장', rank: '부장' },
|
||||
{ id: '7', name: '이름', department: '부서명', position: '부서장팀장', rank: '부장' },
|
||||
];
|
||||
|
||||
// Mock 근태 기록 생성
|
||||
const generateMockAttendanceRecords = (): AttendanceRecord[] => {
|
||||
const records: AttendanceRecord[] = [];
|
||||
const statuses: AttendanceStatus[] = ['onTime', 'onTime', 'onTime', 'late', 'absent', 'vacation', 'onTime'];
|
||||
const reasons = [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{ type: 'businessTripRequest' as const, label: '출장기안', documentId: 'doc1' },
|
||||
{ type: 'fieldWorkRequest' as const, label: '외근승인', documentId: 'doc2' },
|
||||
null,
|
||||
];
|
||||
|
||||
mockEmployees.forEach((employee, index) => {
|
||||
records.push({
|
||||
id: String(index + 1),
|
||||
employeeId: employee.id,
|
||||
employeeName: employee.name,
|
||||
department: employee.department,
|
||||
position: employee.position,
|
||||
rank: employee.rank,
|
||||
baseDate: '2025-09-03',
|
||||
checkIn: index === 4 ? null : '08:40',
|
||||
checkOut: index === 4 ? null : '21:40',
|
||||
breakTime: '1시간',
|
||||
overtimeHours: index % 2 === 0 ? '3시간 30분' : '1시간',
|
||||
reason: reasons[index],
|
||||
status: statuses[index],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
return records;
|
||||
};
|
||||
|
||||
export function AttendanceManagement() {
|
||||
const router = useRouter();
|
||||
|
||||
// 근태 데이터 상태
|
||||
const [attendanceRecords, setAttendanceRecords] = useState<AttendanceRecord[]>(generateMockAttendanceRecords);
|
||||
|
||||
// 검색 및 필터 상태
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<string>('all');
|
||||
const [sortOption, setSortOption] = useState<SortOption>('rank');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// 날짜 범위 상태
|
||||
const [dateRange, setDateRange] = useState<{ from: Date; to: Date }>({
|
||||
from: new Date('2025-09-01'),
|
||||
to: new Date('2025-09-03'),
|
||||
});
|
||||
|
||||
// 다이얼로그 상태
|
||||
const [attendanceDialogOpen, setAttendanceDialogOpen] = useState(false);
|
||||
const [attendanceDialogMode, setAttendanceDialogMode] = useState<'create' | 'edit'>('create');
|
||||
const [selectedAttendance, setSelectedAttendance] = useState<AttendanceRecord | null>(null);
|
||||
const [reasonDialogOpen, setReasonDialogOpen] = useState(false);
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredRecords = useMemo(() => {
|
||||
let filtered = attendanceRecords;
|
||||
|
||||
// 탭(상태) 필터
|
||||
if (activeTab !== 'all') {
|
||||
filtered = filtered.filter(r => r.status === activeTab);
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if (searchValue) {
|
||||
const search = searchValue.toLowerCase();
|
||||
filtered = filtered.filter(r =>
|
||||
r.employeeName.toLowerCase().includes(search) ||
|
||||
r.department.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
// 정렬
|
||||
filtered = [...filtered].sort((a, b) => {
|
||||
switch (sortOption) {
|
||||
case 'rank':
|
||||
return a.rank.localeCompare(b.rank, 'ko');
|
||||
case 'deptAsc':
|
||||
return a.department.localeCompare(b.department, 'ko');
|
||||
case 'deptDesc':
|
||||
return b.department.localeCompare(a.department, 'ko');
|
||||
case 'nameAsc':
|
||||
return a.employeeName.localeCompare(b.employeeName, 'ko');
|
||||
case 'nameDesc':
|
||||
return b.employeeName.localeCompare(a.employeeName, 'ko');
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [attendanceRecords, activeTab, searchValue, sortOption]);
|
||||
|
||||
// 페이지네이션된 데이터
|
||||
const paginatedData = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
return filteredRecords.slice(startIndex, startIndex + itemsPerPage);
|
||||
}, [filteredRecords, currentPage, itemsPerPage]);
|
||||
|
||||
// 통계 계산
|
||||
const stats = useMemo(() => {
|
||||
const onTimeCount = attendanceRecords.filter(r => r.status === 'onTime').length;
|
||||
const lateCount = attendanceRecords.filter(r => r.status === 'late').length;
|
||||
const absentCount = attendanceRecords.filter(r => r.status === 'absent').length;
|
||||
const vacationCount = attendanceRecords.filter(r => r.status === 'vacation').length;
|
||||
|
||||
return { onTimeCount, lateCount, absentCount, vacationCount };
|
||||
}, [attendanceRecords]);
|
||||
|
||||
// StatCards 데이터
|
||||
const statCards: StatCard[] = useMemo(() => [
|
||||
{
|
||||
label: '정시 출근',
|
||||
value: `${stats.onTimeCount}명`,
|
||||
icon: UserCheck,
|
||||
iconColor: 'text-green-500',
|
||||
},
|
||||
{
|
||||
label: '지각',
|
||||
value: `${stats.lateCount}명`,
|
||||
icon: Clock,
|
||||
iconColor: 'text-yellow-500',
|
||||
},
|
||||
{
|
||||
label: '결근',
|
||||
value: `${stats.absentCount}명`,
|
||||
icon: AlertCircle,
|
||||
iconColor: 'text-red-500',
|
||||
},
|
||||
{
|
||||
label: '휴가',
|
||||
value: `${stats.vacationCount}명`,
|
||||
icon: Calendar,
|
||||
iconColor: 'text-blue-500',
|
||||
},
|
||||
], [stats]);
|
||||
|
||||
// 탭 옵션
|
||||
const tabs: TabOption[] = useMemo(() => [
|
||||
{ value: 'all', label: '전체', count: attendanceRecords.length, color: 'gray' },
|
||||
{ value: 'onTime', label: '정시 출근', count: stats.onTimeCount, color: 'green' },
|
||||
{ value: 'late', label: '지각', count: stats.lateCount, color: 'yellow' },
|
||||
{ value: 'absent', label: '결근', count: stats.absentCount, color: 'red' },
|
||||
{ value: 'vacation', label: '휴가', count: stats.vacationCount, color: 'blue' },
|
||||
{ value: 'businessTrip', label: '출장', count: attendanceRecords.filter(r => r.status === 'businessTrip').length, color: 'purple' },
|
||||
{ value: 'fieldWork', label: '외근', count: attendanceRecords.filter(r => r.status === 'fieldWork').length, color: 'orange' },
|
||||
{ value: 'overtime', label: '연장근무', count: attendanceRecords.filter(r => r.status === 'overtime').length, color: 'indigo' },
|
||||
], [attendanceRecords.length, stats]);
|
||||
|
||||
// 테이블 컬럼 정의
|
||||
const tableColumns: TableColumn[] = useMemo(() => [
|
||||
{ key: 'department', label: '부서', className: 'min-w-[80px]' },
|
||||
{ key: 'position', label: '직책', className: 'min-w-[100px]' },
|
||||
{ key: 'name', label: '이름', className: 'min-w-[60px]' },
|
||||
{ key: 'rank', label: '직급', className: 'min-w-[60px]' },
|
||||
{ key: 'baseDate', label: '기준일', className: 'min-w-[100px]' },
|
||||
{ key: 'checkIn', label: '출근', className: 'min-w-[60px]' },
|
||||
{ key: 'checkOut', label: '퇴근', className: 'min-w-[60px]' },
|
||||
{ key: 'breakTime', label: '휴게', className: 'min-w-[60px]' },
|
||||
{ key: 'overtime', label: '연장근무', className: 'min-w-[80px]' },
|
||||
{ key: 'reason', label: '사유', className: 'min-w-[80px]' },
|
||||
{ key: 'actions', label: '작업', className: 'w-[60px] text-center' },
|
||||
], []);
|
||||
|
||||
// 체크박스 토글
|
||||
const toggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 전체 선택/해제
|
||||
const toggleSelectAll = useCallback(() => {
|
||||
if (selectedItems.size === paginatedData.length && paginatedData.length > 0) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
const allIds = new Set(paginatedData.map((item) => item.id));
|
||||
setSelectedItems(allIds);
|
||||
}
|
||||
}, [selectedItems.size, paginatedData]);
|
||||
|
||||
// 핸들러
|
||||
const handleAddAttendance = useCallback(() => {
|
||||
setAttendanceDialogMode('create');
|
||||
setSelectedAttendance(null);
|
||||
setAttendanceDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleAddReason = useCallback(() => {
|
||||
setReasonDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleEditAttendance = useCallback((record: AttendanceRecord) => {
|
||||
setAttendanceDialogMode('edit');
|
||||
setSelectedAttendance(record);
|
||||
setAttendanceDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleSaveAttendance = useCallback((data: AttendanceFormData) => {
|
||||
console.log('Save attendance:', data);
|
||||
// TODO: API 연동
|
||||
setAttendanceDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleSubmitReason = useCallback((data: ReasonFormData) => {
|
||||
console.log('Submit reason:', data);
|
||||
// TODO: 문서 작성 화면으로 이동
|
||||
router.push(`/ko/hr/documents/new?type=${data.reasonType}`);
|
||||
}, [router]);
|
||||
|
||||
const handleExcelDownload = useCallback(() => {
|
||||
console.log('Excel download');
|
||||
// TODO: 엑셀 다운로드 기능 구현
|
||||
}, []);
|
||||
|
||||
const handleReasonClick = useCallback((record: AttendanceRecord) => {
|
||||
if (record.reason?.documentId) {
|
||||
router.push(`/ko/hr/documents/${record.reason.documentId}`);
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = useCallback((item: AttendanceRecord, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(item.id);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className="hover:bg-muted/50"
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleSelection(item.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{item.department}</TableCell>
|
||||
<TableCell>{item.position}</TableCell>
|
||||
<TableCell>{item.employeeName}</TableCell>
|
||||
<TableCell>{item.rank}</TableCell>
|
||||
<TableCell>
|
||||
{item.baseDate ? format(new Date(item.baseDate), 'yyyy-MM-dd (E)', { locale: ko }) : '-'}
|
||||
</TableCell>
|
||||
<TableCell>{item.checkIn || '-'}</TableCell>
|
||||
<TableCell>{item.checkOut || '-'}</TableCell>
|
||||
<TableCell>{item.breakTime || '-'}</TableCell>
|
||||
<TableCell>{item.overtimeHours || '-'}</TableCell>
|
||||
<TableCell>
|
||||
{item.reason ? (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="p-0 h-auto text-blue-600 hover:text-blue-800"
|
||||
onClick={() => handleReasonClick(item)}
|
||||
>
|
||||
{item.reason.label}
|
||||
</Button>
|
||||
) : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditAttendance(item)}
|
||||
title="수정"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}, [selectedItems, toggleSelection, handleEditAttendance, handleReasonClick]);
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = useCallback((
|
||||
item: AttendanceRecord,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
isSelected: boolean,
|
||||
onToggle: () => void
|
||||
) => {
|
||||
return (
|
||||
<ListMobileCard
|
||||
id={item.id}
|
||||
title={item.employeeName}
|
||||
headerBadges={
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{item.department}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{item.rank}
|
||||
</Badge>
|
||||
</div>
|
||||
}
|
||||
statusBadge={
|
||||
<Badge className={ATTENDANCE_STATUS_COLORS[item.status]}>
|
||||
{ATTENDANCE_STATUS_LABELS[item.status]}
|
||||
</Badge>
|
||||
}
|
||||
isSelected={isSelected}
|
||||
onToggleSelection={onToggle}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="직책" value={item.position} />
|
||||
<InfoField
|
||||
label="기준일"
|
||||
value={item.baseDate ? format(new Date(item.baseDate), 'yyyy-MM-dd') : '-'}
|
||||
/>
|
||||
<InfoField label="출근" value={item.checkIn || '-'} />
|
||||
<InfoField label="퇴근" value={item.checkOut || '-'} />
|
||||
<InfoField label="휴게" value={item.breakTime || '-'} />
|
||||
<InfoField label="연장근무" value={item.overtimeHours || '-'} />
|
||||
{item.reason && (
|
||||
<InfoField label="사유" value={item.reason.label} />
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
variant="default"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11"
|
||||
onClick={(e) => { e.stopPropagation(); handleEditAttendance(item); }}
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}, [handleEditAttendance]);
|
||||
|
||||
// 헤더 액션 (날짜 범위 + 버튼들)
|
||||
const headerActions = (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* 날짜 범위 선택 */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="gap-2">
|
||||
<CalendarIcon className="h-4 w-4" />
|
||||
{format(dateRange.from, 'yyyy-MM-dd')} ~ {format(dateRange.to, 'yyyy-MM-dd')}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="end">
|
||||
<CalendarComponent
|
||||
mode="range"
|
||||
selected={{ from: dateRange.from, to: dateRange.to }}
|
||||
onSelect={(range) => {
|
||||
if (range?.from && range?.to) {
|
||||
setDateRange({ from: range.from, to: range.to });
|
||||
}
|
||||
}}
|
||||
locale={ko}
|
||||
numberOfMonths={2}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Button variant="outline" onClick={handleExcelDownload}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
엑셀 다운로드
|
||||
</Button>
|
||||
<Button onClick={handleAddAttendance}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
근태 등록
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 검색 옆 추가 필터 (사유 등록 버튼 + 정렬 셀렉트)
|
||||
const extraFilters = (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button variant="outline" onClick={handleAddReason}>
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
사유 등록
|
||||
</Button>
|
||||
<Select value={sortOption} onValueChange={(value) => setSortOption(value as SortOption)}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(SORT_OPTIONS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 페이지네이션 설정
|
||||
const totalPages = Math.ceil(filteredRecords.length / itemsPerPage);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedListTemplateV2<AttendanceRecord>
|
||||
title="근태관리"
|
||||
description="직원 출퇴근 및 근태 정보를 관리합니다"
|
||||
icon={Clock}
|
||||
headerActions={headerActions}
|
||||
stats={statCards}
|
||||
searchValue={searchValue}
|
||||
onSearchChange={setSearchValue}
|
||||
searchPlaceholder="이름, 부서 검색..."
|
||||
extraFilters={extraFilters}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
tableColumns={tableColumns}
|
||||
data={paginatedData}
|
||||
totalCount={filteredRecords.length}
|
||||
allData={filteredRecords}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={toggleSelection}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
getItemId={(item) => item.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: filteredRecords.length,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 근태 정보 다이얼로그 */}
|
||||
<AttendanceInfoDialog
|
||||
open={attendanceDialogOpen}
|
||||
onOpenChange={setAttendanceDialogOpen}
|
||||
mode={attendanceDialogMode}
|
||||
attendance={selectedAttendance}
|
||||
employees={mockEmployees}
|
||||
onSave={handleSaveAttendance}
|
||||
/>
|
||||
|
||||
{/* 사유 정보 다이얼로그 */}
|
||||
<ReasonInfoDialog
|
||||
open={reasonDialogOpen}
|
||||
onOpenChange={setReasonDialogOpen}
|
||||
employees={mockEmployees}
|
||||
onSubmit={handleSubmitReason}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
128
src/components/hr/AttendanceManagement/types.ts
Normal file
128
src/components/hr/AttendanceManagement/types.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 근태관리 (AttendanceManagement) 타입 정의
|
||||
*/
|
||||
|
||||
// 근태 상태 타입
|
||||
export type AttendanceStatus = 'onTime' | 'late' | 'absent' | 'vacation' | 'businessTrip' | 'fieldWork' | 'overtime';
|
||||
|
||||
// 근태 상태 라벨
|
||||
export const ATTENDANCE_STATUS_LABELS: Record<AttendanceStatus, string> = {
|
||||
onTime: '정시 출근',
|
||||
late: '지각',
|
||||
absent: '결근',
|
||||
vacation: '휴가',
|
||||
businessTrip: '출장',
|
||||
fieldWork: '외근',
|
||||
overtime: '연장근무',
|
||||
};
|
||||
|
||||
// 근태 상태 색상
|
||||
export const ATTENDANCE_STATUS_COLORS: Record<AttendanceStatus, string> = {
|
||||
onTime: 'bg-green-100 text-green-700',
|
||||
late: 'bg-yellow-100 text-yellow-700',
|
||||
absent: 'bg-red-100 text-red-700',
|
||||
vacation: 'bg-blue-100 text-blue-700',
|
||||
businessTrip: 'bg-purple-100 text-purple-700',
|
||||
fieldWork: 'bg-orange-100 text-orange-700',
|
||||
overtime: 'bg-indigo-100 text-indigo-700',
|
||||
};
|
||||
|
||||
// 정렬 옵션
|
||||
export type SortOption = 'rank' | 'deptAsc' | 'deptDesc' | 'nameAsc' | 'nameDesc';
|
||||
|
||||
export const SORT_OPTIONS: Record<SortOption, string> = {
|
||||
rank: '직급순',
|
||||
deptAsc: '부서 오름차순',
|
||||
deptDesc: '부서 내림차순',
|
||||
nameAsc: '이름 오름차순',
|
||||
nameDesc: '이름 내림차순',
|
||||
};
|
||||
|
||||
// 사유 유형 (문서 유형)
|
||||
export type ReasonType = 'businessTripRequest' | 'vacationRequest' | 'fieldWorkRequest' | 'overtimeRequest';
|
||||
|
||||
export const REASON_TYPE_LABELS: Record<ReasonType, string> = {
|
||||
businessTripRequest: '출장신청서',
|
||||
vacationRequest: '휴가신청서',
|
||||
fieldWorkRequest: '외근신청서',
|
||||
overtimeRequest: '연장근무신청서',
|
||||
};
|
||||
|
||||
// 근태 기록 인터페이스
|
||||
export interface AttendanceRecord {
|
||||
id: string;
|
||||
employeeId: string;
|
||||
employeeName: string;
|
||||
department: string;
|
||||
position: string;
|
||||
rank: string;
|
||||
baseDate: string;
|
||||
checkIn: string | null;
|
||||
checkOut: string | null;
|
||||
breakTime: string | null;
|
||||
overtimeHours: string | null;
|
||||
reason: {
|
||||
type: ReasonType;
|
||||
label: string;
|
||||
documentId?: string;
|
||||
} | null;
|
||||
status: AttendanceStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// 근태 등록/수정 폼 데이터
|
||||
export interface AttendanceFormData {
|
||||
employeeId: string;
|
||||
baseDate: string;
|
||||
checkInHour: string;
|
||||
checkInMinute: string;
|
||||
checkOutHour: string;
|
||||
checkOutMinute: string;
|
||||
nightOvertimeHours: string;
|
||||
nightOvertimeMinutes: string;
|
||||
weekendOvertimeHours: string;
|
||||
weekendOvertimeMinutes: string;
|
||||
}
|
||||
|
||||
// 사유 등록 폼 데이터
|
||||
export interface ReasonFormData {
|
||||
employeeId: string;
|
||||
baseDate: string;
|
||||
reasonType: ReasonType | '';
|
||||
}
|
||||
|
||||
// 근태 정보 다이얼로그 Props
|
||||
export interface AttendanceInfoDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
mode: 'create' | 'edit';
|
||||
attendance?: AttendanceRecord | null;
|
||||
employees: { id: string; name: string; department: string; position: string; rank: string }[];
|
||||
onSave: (data: AttendanceFormData) => void;
|
||||
}
|
||||
|
||||
// 사유 정보 다이얼로그 Props
|
||||
export interface ReasonInfoDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
employees: { id: string; name: string; department: string; position: string; rank: string }[];
|
||||
onSubmit: (data: ReasonFormData) => void;
|
||||
}
|
||||
|
||||
// 시간 옵션 생성 헬퍼
|
||||
export const HOUR_OPTIONS = Array.from({ length: 24 }, (_, i) => ({
|
||||
value: String(i),
|
||||
label: `${i}시`,
|
||||
}));
|
||||
|
||||
export const MINUTE_OPTIONS = [
|
||||
{ value: '0', label: '0분' },
|
||||
{ value: '30', label: '30분' },
|
||||
];
|
||||
|
||||
// 연장 시간 옵션 (0-12시간)
|
||||
export const OVERTIME_HOUR_OPTIONS = Array.from({ length: 13 }, (_, i) => ({
|
||||
value: String(i),
|
||||
label: `${i}시간`,
|
||||
}));
|
||||
92
src/components/hr/DepartmentManagement/DepartmentDialog.tsx
Normal file
92
src/components/hr/DepartmentManagement/DepartmentDialog.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import type { DepartmentDialogProps } from './types';
|
||||
|
||||
/**
|
||||
* 부서 추가/수정 다이얼로그
|
||||
*/
|
||||
export function DepartmentDialog({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
mode,
|
||||
parentDepartment,
|
||||
department,
|
||||
onSubmit
|
||||
}: DepartmentDialogProps) {
|
||||
const [name, setName] = useState('');
|
||||
|
||||
// 다이얼로그 열릴 때 초기값 설정
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (mode === 'edit' && department) {
|
||||
setName(department.name);
|
||||
} else {
|
||||
setName('');
|
||||
}
|
||||
}
|
||||
}, [isOpen, mode, department]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (name.trim()) {
|
||||
onSubmit(name.trim());
|
||||
setName('');
|
||||
}
|
||||
};
|
||||
|
||||
const title = mode === 'add' ? '부서 추가' : '부서 수정';
|
||||
const submitText = mode === 'add' ? '등록' : '수정';
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-4 py-4">
|
||||
{/* 부모 부서 표시 (추가 모드일 때) */}
|
||||
{mode === 'add' && parentDepartment && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
상위 부서: <span className="font-medium">{parentDepartment.name}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 부서명 입력 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="department-name">부서명</Label>
|
||||
<Input
|
||||
id="department-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="부서명을 입력하세요"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit" disabled={!name.trim()}>
|
||||
{submitText}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
18
src/components/hr/DepartmentManagement/DepartmentStats.tsx
Normal file
18
src/components/hr/DepartmentManagement/DepartmentStats.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import type { DepartmentStatsProps } from './types';
|
||||
|
||||
/**
|
||||
* 전체 부서 카운트 카드
|
||||
*/
|
||||
export function DepartmentStats({ totalCount }: DepartmentStatsProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<div className="text-sm text-muted-foreground">전체 부서</div>
|
||||
<div className="text-3xl font-bold">{totalCount}개</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
53
src/components/hr/DepartmentManagement/DepartmentToolbar.tsx
Normal file
53
src/components/hr/DepartmentManagement/DepartmentToolbar.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Search, Plus, Trash2 } from 'lucide-react';
|
||||
import type { DepartmentToolbarProps } from './types';
|
||||
|
||||
/**
|
||||
* 검색 + 추가/삭제 버튼 툴바
|
||||
*/
|
||||
export function DepartmentToolbar({
|
||||
totalCount,
|
||||
selectedCount,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
onAdd,
|
||||
onDelete
|
||||
}: DepartmentToolbarProps) {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
|
||||
{/* 검색창 */}
|
||||
<div className="relative w-full sm:w-80">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="부서명 검색"
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 선택 카운트 + 버튼 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
총 {totalCount}건 {selectedCount > 0 && `| ${selectedCount}건 선택`}
|
||||
</span>
|
||||
<Button size="sm" onClick={onAdd}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={onDelete}
|
||||
disabled={selectedCount === 0}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
src/components/hr/DepartmentManagement/DepartmentTree.tsx
Normal file
70
src/components/hr/DepartmentManagement/DepartmentTree.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { DepartmentTreeItem } from './DepartmentTreeItem';
|
||||
import type { DepartmentTreeProps } from './types';
|
||||
import { getAllDepartmentIds } from './types';
|
||||
|
||||
/**
|
||||
* 트리 구조 테이블 컨테이너
|
||||
*/
|
||||
export function DepartmentTree({
|
||||
departments,
|
||||
expandedIds,
|
||||
selectedIds,
|
||||
onToggleExpand,
|
||||
onToggleSelect,
|
||||
onToggleSelectAll,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete
|
||||
}: DepartmentTreeProps) {
|
||||
const allIds = getAllDepartmentIds(departments);
|
||||
const isAllSelected = allIds.length > 0 && selectedIds.size === allIds.length;
|
||||
const isIndeterminate = selectedIds.size > 0 && selectedIds.size < allIds.length;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{/* 테이블 헤더 */}
|
||||
<div className="flex items-center px-4 py-3 border-b bg-muted/50">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<Checkbox
|
||||
checked={isIndeterminate ? 'indeterminate' : isAllSelected}
|
||||
onCheckedChange={onToggleSelectAll}
|
||||
aria-label="전체 선택"
|
||||
/>
|
||||
<span className="font-medium text-sm">부서명</span>
|
||||
</div>
|
||||
<div className="w-24 text-right font-medium text-sm">작업</div>
|
||||
</div>
|
||||
|
||||
{/* 트리 아이템 목록 */}
|
||||
<div className="divide-y">
|
||||
{departments.map(department => (
|
||||
<DepartmentTreeItem
|
||||
key={department.id}
|
||||
department={department}
|
||||
depth={0}
|
||||
expandedIds={expandedIds}
|
||||
selectedIds={selectedIds}
|
||||
onToggleExpand={onToggleExpand}
|
||||
onToggleSelect={onToggleSelect}
|
||||
onAdd={onAdd}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 빈 상태 */}
|
||||
{departments.length === 0 && (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
등록된 부서가 없습니다
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
117
src/components/hr/DepartmentManagement/DepartmentTreeItem.tsx
Normal file
117
src/components/hr/DepartmentManagement/DepartmentTreeItem.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
'use client';
|
||||
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChevronRight, ChevronDown, Plus, Pencil, Trash2 } from 'lucide-react';
|
||||
import type { DepartmentTreeItemProps } from './types';
|
||||
|
||||
/**
|
||||
* 트리 행 (재귀 렌더링)
|
||||
* - 무제한 깊이 지원
|
||||
* - depth에 따른 동적 들여쓰기
|
||||
*/
|
||||
export function DepartmentTreeItem({
|
||||
department,
|
||||
depth,
|
||||
expandedIds,
|
||||
selectedIds,
|
||||
onToggleExpand,
|
||||
onToggleSelect,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete
|
||||
}: DepartmentTreeItemProps) {
|
||||
const hasChildren = department.children && department.children.length > 0;
|
||||
const isExpanded = expandedIds.has(department.id);
|
||||
const isSelected = selectedIds.has(department.id);
|
||||
|
||||
// 들여쓰기 계산 (depth * 24px)
|
||||
const paddingLeft = depth * 24;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 현재 행 */}
|
||||
<div
|
||||
className="group flex items-center px-4 py-3 hover:bg-muted/50 transition-colors"
|
||||
style={{ paddingLeft: `${paddingLeft + 16}px` }}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{/* 펼침/접힘 버튼 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-6 w-6 p-0 ${!hasChildren ? 'invisible' : ''}`}
|
||||
onClick={() => onToggleExpand(department.id)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* 체크박스 */}
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => onToggleSelect(department.id)}
|
||||
aria-label={`${department.name} 선택`}
|
||||
/>
|
||||
|
||||
{/* 부서명 */}
|
||||
<span className="truncate">{department.name}</span>
|
||||
</div>
|
||||
|
||||
{/* 작업 버튼 (호버 시 표시) */}
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => onAdd(department.id)}
|
||||
title="하위 부서 추가"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => onEdit(department)}
|
||||
title="수정"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
|
||||
onClick={() => onDelete(department)}
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 하위 부서 (재귀) */}
|
||||
{hasChildren && isExpanded && (
|
||||
<>
|
||||
{department.children!.map(child => (
|
||||
<DepartmentTreeItem
|
||||
key={child.id}
|
||||
department={child}
|
||||
depth={depth + 1}
|
||||
expandedIds={expandedIds}
|
||||
selectedIds={selectedIds}
|
||||
onToggleExpand={onToggleExpand}
|
||||
onToggleSelect={onToggleSelect}
|
||||
onAdd={onAdd}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
352
src/components/hr/DepartmentManagement/index.tsx
Normal file
352
src/components/hr/DepartmentManagement/index.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { Building2 } from 'lucide-react';
|
||||
import { DepartmentStats } from './DepartmentStats';
|
||||
import { DepartmentToolbar } from './DepartmentToolbar';
|
||||
import { DepartmentTree } from './DepartmentTree';
|
||||
import { DepartmentDialog } from './DepartmentDialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import type { Department } from './types';
|
||||
import { countAllDepartments, getAllDepartmentIds, findDepartmentById } from './types';
|
||||
|
||||
/**
|
||||
* 무제한 깊이 트리 구조 목업 데이터
|
||||
*/
|
||||
const mockDepartments: Department[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: '회사명',
|
||||
parentId: null,
|
||||
depth: 0,
|
||||
children: [
|
||||
{
|
||||
id: 2,
|
||||
name: '경영지원본부',
|
||||
parentId: 1,
|
||||
depth: 1,
|
||||
children: [
|
||||
{
|
||||
id: 4,
|
||||
name: '인사팀',
|
||||
parentId: 2,
|
||||
depth: 2,
|
||||
children: [
|
||||
{
|
||||
id: 7,
|
||||
name: '채용파트',
|
||||
parentId: 4,
|
||||
depth: 3,
|
||||
children: [
|
||||
{ id: 10, name: '신입채용셀', parentId: 7, depth: 4, children: [] },
|
||||
{ id: 11, name: '경력채용셀', parentId: 7, depth: 4, children: [] },
|
||||
]
|
||||
},
|
||||
{ id: 8, name: '교육파트', parentId: 4, depth: 3, children: [] },
|
||||
]
|
||||
},
|
||||
{ id: 5, name: '총무팀', parentId: 2, depth: 2, children: [] },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '개발본부',
|
||||
parentId: 1,
|
||||
depth: 1,
|
||||
children: [
|
||||
{ id: 6, name: '프론트엔드팀', parentId: 3, depth: 2, children: [] },
|
||||
{ id: 9, name: '백엔드팀', parentId: 3, depth: 2, children: [] },
|
||||
]
|
||||
},
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export function DepartmentManagement() {
|
||||
// 부서 데이터 상태
|
||||
const [departments, setDepartments] = useState<Department[]>(mockDepartments);
|
||||
|
||||
// 선택 상태
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||
|
||||
// 펼침 상태 (기본: 최상위만 펼침)
|
||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set([1]));
|
||||
|
||||
// 검색어
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// 다이얼로그 상태
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [dialogMode, setDialogMode] = useState<'add' | 'edit'>('add');
|
||||
const [selectedDepartment, setSelectedDepartment] = useState<Department | undefined>();
|
||||
const [parentDepartment, setParentDepartment] = useState<Department | undefined>();
|
||||
|
||||
// 삭제 확인 다이얼로그
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [departmentToDelete, setDepartmentToDelete] = useState<Department | null>(null);
|
||||
const [isBulkDelete, setIsBulkDelete] = useState(false);
|
||||
|
||||
// 전체 부서 수 계산
|
||||
const totalCount = useMemo(() => countAllDepartments(departments), [departments]);
|
||||
|
||||
// 모든 부서 ID
|
||||
const allIds = useMemo(() => getAllDepartmentIds(departments), [departments]);
|
||||
|
||||
// 펼침/접힘 토글
|
||||
const handleToggleExpand = (id: number) => {
|
||||
setExpandedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// 선택 토글
|
||||
const handleToggleSelect = (id: number) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// 전체 선택/해제
|
||||
const handleToggleSelectAll = () => {
|
||||
if (selectedIds.size === allIds.length) {
|
||||
setSelectedIds(new Set());
|
||||
} else {
|
||||
setSelectedIds(new Set(allIds));
|
||||
}
|
||||
};
|
||||
|
||||
// 부서 추가 (행 버튼)
|
||||
const handleAdd = (parentId: number) => {
|
||||
const parent = findDepartmentById(departments, parentId);
|
||||
setParentDepartment(parent || undefined);
|
||||
setSelectedDepartment(undefined);
|
||||
setDialogMode('add');
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
// 부서 추가 (상단 버튼 - 선택된 부서의 하위에 일괄 추가)
|
||||
const handleBulkAdd = () => {
|
||||
if (selectedIds.size === 0) {
|
||||
// 선택된 부서가 없으면 최상위에 추가
|
||||
setParentDepartment(undefined);
|
||||
} else {
|
||||
// 선택된 첫 번째 부서를 부모로 설정
|
||||
const firstSelectedId = Array.from(selectedIds)[0];
|
||||
const parent = findDepartmentById(departments, firstSelectedId);
|
||||
setParentDepartment(parent || undefined);
|
||||
}
|
||||
setSelectedDepartment(undefined);
|
||||
setDialogMode('add');
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
// 부서 수정
|
||||
const handleEdit = (department: Department) => {
|
||||
setSelectedDepartment(department);
|
||||
setParentDepartment(undefined);
|
||||
setDialogMode('edit');
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
// 부서 삭제 (단일)
|
||||
const handleDelete = (department: Department) => {
|
||||
setDepartmentToDelete(department);
|
||||
setIsBulkDelete(false);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// 부서 삭제 (일괄)
|
||||
const handleBulkDelete = () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
setDepartmentToDelete(null);
|
||||
setIsBulkDelete(true);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// 삭제 확인
|
||||
const confirmDelete = () => {
|
||||
if (isBulkDelete) {
|
||||
// 일괄 삭제 로직
|
||||
setDepartments(prev => deleteDepartmentsRecursive(prev, selectedIds));
|
||||
setSelectedIds(new Set());
|
||||
} else if (departmentToDelete) {
|
||||
// 단일 삭제 로직
|
||||
setDepartments(prev => deleteDepartmentsRecursive(prev, new Set([departmentToDelete.id])));
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(departmentToDelete.id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
setDeleteDialogOpen(false);
|
||||
setDepartmentToDelete(null);
|
||||
};
|
||||
|
||||
// 재귀적으로 부서 삭제
|
||||
const deleteDepartmentsRecursive = (depts: Department[], idsToDelete: Set<number>): Department[] => {
|
||||
return depts
|
||||
.filter(dept => !idsToDelete.has(dept.id))
|
||||
.map(dept => ({
|
||||
...dept,
|
||||
children: dept.children ? deleteDepartmentsRecursive(dept.children, idsToDelete) : []
|
||||
}));
|
||||
};
|
||||
|
||||
// 부서 추가/수정 제출
|
||||
const handleDialogSubmit = (name: string) => {
|
||||
if (dialogMode === 'add') {
|
||||
// 새 부서 추가
|
||||
const newId = Math.max(...allIds, 0) + 1;
|
||||
const newDept: Department = {
|
||||
id: newId,
|
||||
name,
|
||||
parentId: parentDepartment?.id || null,
|
||||
depth: parentDepartment ? parentDepartment.depth + 1 : 0,
|
||||
children: []
|
||||
};
|
||||
|
||||
if (parentDepartment) {
|
||||
// 부모 부서의 children에 추가
|
||||
setDepartments(prev => addChildDepartment(prev, parentDepartment.id, newDept));
|
||||
} else {
|
||||
// 최상위에 추가
|
||||
setDepartments(prev => [...prev, newDept]);
|
||||
}
|
||||
} else if (dialogMode === 'edit' && selectedDepartment) {
|
||||
// 부서 수정
|
||||
setDepartments(prev => updateDepartmentName(prev, selectedDepartment.id, name));
|
||||
}
|
||||
setDialogOpen(false);
|
||||
};
|
||||
|
||||
// 재귀적으로 자식 부서 추가
|
||||
const addChildDepartment = (depts: Department[], parentId: number, newDept: Department): Department[] => {
|
||||
return depts.map(dept => {
|
||||
if (dept.id === parentId) {
|
||||
return {
|
||||
...dept,
|
||||
children: [...(dept.children || []), newDept]
|
||||
};
|
||||
}
|
||||
if (dept.children) {
|
||||
return {
|
||||
...dept,
|
||||
children: addChildDepartment(dept.children, parentId, newDept)
|
||||
};
|
||||
}
|
||||
return dept;
|
||||
});
|
||||
};
|
||||
|
||||
// 재귀적으로 부서명 업데이트
|
||||
const updateDepartmentName = (depts: Department[], id: number, name: string): Department[] => {
|
||||
return depts.map(dept => {
|
||||
if (dept.id === id) {
|
||||
return { ...dept, name };
|
||||
}
|
||||
if (dept.children) {
|
||||
return {
|
||||
...dept,
|
||||
children: updateDepartmentName(dept.children, id, name)
|
||||
};
|
||||
}
|
||||
return dept;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title="부서관리"
|
||||
description="부서 정보를 관리합니다"
|
||||
icon={Building2}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 전체 부서 카운트 */}
|
||||
<DepartmentStats totalCount={totalCount} />
|
||||
|
||||
{/* 검색 + 추가/삭제 버튼 */}
|
||||
<DepartmentToolbar
|
||||
totalCount={totalCount}
|
||||
selectedCount={selectedIds.size}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
onAdd={handleBulkAdd}
|
||||
onDelete={handleBulkDelete}
|
||||
/>
|
||||
|
||||
{/* 트리 테이블 */}
|
||||
<DepartmentTree
|
||||
departments={departments}
|
||||
expandedIds={expandedIds}
|
||||
selectedIds={selectedIds}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
onToggleSelect={handleToggleSelect}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
onAdd={handleAdd}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 추가/수정 다이얼로그 */}
|
||||
<DepartmentDialog
|
||||
isOpen={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
mode={dialogMode}
|
||||
parentDepartment={parentDepartment}
|
||||
department={selectedDepartment}
|
||||
onSubmit={handleDialogSubmit}
|
||||
/>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>부서 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{isBulkDelete
|
||||
? `선택한 부서 ${selectedIds.size}개를 삭제하시겠습니까?`
|
||||
: `"${departmentToDelete?.name}" 부서를 삭제하시겠습니까?`
|
||||
}
|
||||
<br />
|
||||
<span className="text-destructive">
|
||||
삭제된 부서의 인원은 회사(기본) 인원으로 변경됩니다.
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
109
src/components/hr/DepartmentManagement/types.ts
Normal file
109
src/components/hr/DepartmentManagement/types.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 부서관리 타입 정의
|
||||
* @description 무제한 깊이 트리 구조 지원
|
||||
*/
|
||||
|
||||
/**
|
||||
* 부서 데이터 (무제한 깊이 재귀 구조)
|
||||
*/
|
||||
export interface Department {
|
||||
id: number;
|
||||
name: string;
|
||||
parentId: number | null;
|
||||
depth: number; // 깊이 (0: 최상위, 1, 2, 3, ... 무제한)
|
||||
children?: Department[]; // 하위 부서 (재귀 - 무제한 깊이)
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서 추가/수정 다이얼로그 Props
|
||||
*/
|
||||
export interface DepartmentDialogProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
mode: 'add' | 'edit';
|
||||
parentDepartment?: Department; // 추가 시 부모 부서
|
||||
department?: Department; // 수정 시 대상 부서
|
||||
onSubmit: (name: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 트리 아이템 Props (재귀 렌더링)
|
||||
*/
|
||||
export interface DepartmentTreeItemProps {
|
||||
department: Department;
|
||||
depth: number;
|
||||
expandedIds: Set<number>;
|
||||
selectedIds: Set<number>;
|
||||
onToggleExpand: (id: number) => void;
|
||||
onToggleSelect: (id: number) => void;
|
||||
onAdd: (parentId: number) => void;
|
||||
onEdit: (department: Department) => void;
|
||||
onDelete: (department: Department) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서 통계 Props
|
||||
*/
|
||||
export interface DepartmentStatsProps {
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 툴바 Props
|
||||
*/
|
||||
export interface DepartmentToolbarProps {
|
||||
totalCount: number;
|
||||
selectedCount: number;
|
||||
searchQuery: string;
|
||||
onSearchChange: (query: string) => void;
|
||||
onAdd: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 트리 컨테이너 Props
|
||||
*/
|
||||
export interface DepartmentTreeProps {
|
||||
departments: Department[];
|
||||
expandedIds: Set<number>;
|
||||
selectedIds: Set<number>;
|
||||
onToggleExpand: (id: number) => void;
|
||||
onToggleSelect: (id: number) => void;
|
||||
onToggleSelectAll: () => void;
|
||||
onAdd: (parentId: number) => void;
|
||||
onEdit: (department: Department) => void;
|
||||
onDelete: (department: Department) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 부서 수 계산 유틸리티 (재귀)
|
||||
*/
|
||||
export const countAllDepartments = (departments: Department[]): number => {
|
||||
return departments.reduce((count, dept) => {
|
||||
return count + 1 + (dept.children ? countAllDepartments(dept.children) : 0);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
/**
|
||||
* 모든 부서 ID 추출 유틸리티 (재귀 - 전체 선택용)
|
||||
*/
|
||||
export const getAllDepartmentIds = (departments: Department[]): number[] => {
|
||||
return departments.flatMap(dept => [
|
||||
dept.id,
|
||||
...(dept.children ? getAllDepartmentIds(dept.children) : [])
|
||||
]);
|
||||
};
|
||||
|
||||
/**
|
||||
* ID로 부서 찾기 (재귀)
|
||||
*/
|
||||
export const findDepartmentById = (departments: Department[], id: number): Department | null => {
|
||||
for (const dept of departments) {
|
||||
if (dept.id === id) return dept;
|
||||
if (dept.children) {
|
||||
const found = findDepartmentById(dept.children, id);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
277
src/components/hr/EmployeeManagement/CSVUploadDialog.tsx
Normal file
277
src/components/hr/EmployeeManagement/CSVUploadDialog.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Upload, FileSpreadsheet, AlertCircle, CheckCircle } from 'lucide-react';
|
||||
import type { Employee, CSVEmployeeRow, CSVValidationResult } from './types';
|
||||
|
||||
interface CSVUploadDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onUpload: (employees: Employee[]) => void;
|
||||
}
|
||||
|
||||
export function CSVUploadDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onUpload,
|
||||
}: CSVUploadDialogProps) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [validationResults, setValidationResults] = useState<CSVValidationResult[]>([]);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 파일 선택
|
||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = e.target.files?.[0];
|
||||
if (selectedFile && selectedFile.type === 'text/csv') {
|
||||
setFile(selectedFile);
|
||||
processCSV(selectedFile);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// CSV 파싱 및 유효성 검사
|
||||
const processCSV = async (csvFile: File) => {
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const text = await csvFile.text();
|
||||
const lines = text.split('\n').map(line => line.trim()).filter(line => line);
|
||||
|
||||
if (lines.length < 2) {
|
||||
setValidationResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 헤더 파싱
|
||||
const headers = lines[0].split(',').map(h => h.trim());
|
||||
|
||||
// 데이터 파싱 및 유효성 검사
|
||||
const results: CSVValidationResult[] = lines.slice(1).map((line, index) => {
|
||||
const values = line.split(',').map(v => v.trim());
|
||||
const data: CSVEmployeeRow = {
|
||||
name: values[headers.indexOf('이름')] || values[headers.indexOf('name')] || '',
|
||||
phone: values[headers.indexOf('휴대폰')] || values[headers.indexOf('phone')] || undefined,
|
||||
email: values[headers.indexOf('이메일')] || values[headers.indexOf('email')] || undefined,
|
||||
departmentName: values[headers.indexOf('부서')] || values[headers.indexOf('department')] || undefined,
|
||||
positionName: values[headers.indexOf('직책')] || values[headers.indexOf('position')] || undefined,
|
||||
hireDate: values[headers.indexOf('입사일')] || values[headers.indexOf('hireDate')] || undefined,
|
||||
status: values[headers.indexOf('상태')] || values[headers.indexOf('status')] || undefined,
|
||||
};
|
||||
|
||||
// 유효성 검사
|
||||
const errors: string[] = [];
|
||||
if (!data.name) {
|
||||
errors.push('이름은 필수입니다');
|
||||
}
|
||||
if (data.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
|
||||
errors.push('이메일 형식이 올바르지 않습니다');
|
||||
}
|
||||
if (data.phone && !/^\d{3}-\d{4}-\d{4}$/.test(data.phone)) {
|
||||
errors.push('휴대폰 형식이 올바르지 않습니다 (000-0000-0000)');
|
||||
}
|
||||
|
||||
return {
|
||||
row: index + 2, // 1-indexed, 헤더 제외
|
||||
data,
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
});
|
||||
|
||||
setValidationResults(results);
|
||||
} catch {
|
||||
console.error('CSV 파싱 오류');
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 업로드 실행
|
||||
const handleUpload = () => {
|
||||
const validRows = validationResults.filter(r => r.isValid);
|
||||
const employees: Employee[] = validRows.map((r, index) => ({
|
||||
id: String(Date.now() + index),
|
||||
name: r.data.name,
|
||||
phone: r.data.phone,
|
||||
email: r.data.email,
|
||||
status: (r.data.status === '재직' || r.data.status === 'active') ? 'active' :
|
||||
(r.data.status === '휴직' || r.data.status === 'leave') ? 'leave' :
|
||||
(r.data.status === '퇴직' || r.data.status === 'resigned') ? 'resigned' : 'active',
|
||||
hireDate: r.data.hireDate,
|
||||
departmentPositions: r.data.departmentName ? [{
|
||||
id: String(Date.now() + index),
|
||||
departmentId: '',
|
||||
departmentName: r.data.departmentName,
|
||||
positionId: '',
|
||||
positionName: r.data.positionName || '',
|
||||
}] : [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
onUpload(employees);
|
||||
handleReset();
|
||||
};
|
||||
|
||||
// 초기화
|
||||
const handleReset = () => {
|
||||
setFile(null);
|
||||
setValidationResults([]);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const validCount = validationResults.filter(r => r.isValid).length;
|
||||
const invalidCount = validationResults.filter(r => !r.isValid).length;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(open) => { if (!open) handleReset(); onOpenChange(open); }}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>CSV 일괄등록</DialogTitle>
|
||||
<DialogDescription>
|
||||
CSV 파일을 업로드하여 사원을 일괄 등록합니다
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* 파일 업로드 영역 */}
|
||||
{!file && (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="p-8">
|
||||
<div className="flex flex-col items-center justify-center space-y-4">
|
||||
<FileSpreadsheet className="w-12 h-12 text-muted-foreground" />
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
CSV 파일을 드래그하거나 클릭하여 업로드
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
필수 컬럼: 이름 | 선택 컬럼: 휴대폰, 이메일, 부서, 직책, 입사일, 상태
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
id="csv-upload"
|
||||
/>
|
||||
<Button variant="outline" asChild>
|
||||
<label htmlFor="csv-upload" className="cursor-pointer">
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
파일 선택
|
||||
</label>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 파일 정보 및 미리보기 */}
|
||||
{file && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileSpreadsheet className="w-5 h-5 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">{file.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
<span className="text-sm">유효: {validCount}건</span>
|
||||
</div>
|
||||
{invalidCount > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-red-500" />
|
||||
<span className="text-sm">오류: {invalidCount}건</span>
|
||||
</div>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={handleReset}>
|
||||
다시 선택
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 미리보기 테이블 */}
|
||||
{validationResults.length > 0 && (
|
||||
<div className="rounded-md border max-h-[400px] overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[60px]">행</TableHead>
|
||||
<TableHead className="w-[80px]">상태</TableHead>
|
||||
<TableHead>이름</TableHead>
|
||||
<TableHead>휴대폰</TableHead>
|
||||
<TableHead>이메일</TableHead>
|
||||
<TableHead>부서</TableHead>
|
||||
<TableHead>직책</TableHead>
|
||||
<TableHead>오류</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{validationResults.map((result) => (
|
||||
<TableRow
|
||||
key={result.row}
|
||||
className={result.isValid ? '' : 'bg-red-50'}
|
||||
>
|
||||
<TableCell className="font-medium">{result.row}</TableCell>
|
||||
<TableCell>
|
||||
{result.isValid ? (
|
||||
<Badge className="bg-green-100 text-green-800">유효</Badge>
|
||||
) : (
|
||||
<Badge className="bg-red-100 text-red-800">오류</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{result.data.name || '-'}</TableCell>
|
||||
<TableCell>{result.data.phone || '-'}</TableCell>
|
||||
<TableCell>{result.data.email || '-'}</TableCell>
|
||||
<TableCell>{result.data.departmentName || '-'}</TableCell>
|
||||
<TableCell>{result.data.positionName || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-red-600">
|
||||
{result.errors.join(', ')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={!file || validCount === 0 || isProcessing}
|
||||
>
|
||||
{validCount}건 등록
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
354
src/components/hr/EmployeeManagement/CSVUploadPage.tsx
Normal file
354
src/components/hr/EmployeeManagement/CSVUploadPage.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { X, Users, Download } from 'lucide-react';
|
||||
import type { Employee, CSVEmployeeRow, CSVValidationResult } from './types';
|
||||
|
||||
interface CSVUploadPageProps {
|
||||
onUpload: (employees: Employee[]) => void;
|
||||
}
|
||||
|
||||
export function CSVUploadPage({ onUpload }: CSVUploadPageProps) {
|
||||
const router = useRouter();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [validationResults, setValidationResults] = useState<CSVValidationResult[]>([]);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 파일 선택
|
||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = e.target.files?.[0];
|
||||
if (selectedFile) {
|
||||
setFile(selectedFile);
|
||||
setValidationResults([]);
|
||||
setSelectedRows(new Set());
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 파일 제거
|
||||
const handleRemoveFile = useCallback(() => {
|
||||
setFile(null);
|
||||
setValidationResults([]);
|
||||
setSelectedRows(new Set());
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 파일변환 (CSV 파싱)
|
||||
const handleConvert = async () => {
|
||||
if (!file) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const text = await file.text();
|
||||
const lines = text.split('\n').map(line => line.trim()).filter(line => line);
|
||||
|
||||
if (lines.length < 2) {
|
||||
setValidationResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 헤더 파싱
|
||||
const headers = lines[0].split(',').map(h => h.trim());
|
||||
|
||||
// 데이터 파싱 및 유효성 검사
|
||||
const results: CSVValidationResult[] = lines.slice(1).map((line, index) => {
|
||||
const values = line.split(',').map(v => v.trim());
|
||||
const data: CSVEmployeeRow = {
|
||||
name: values[headers.indexOf('이름')] || values[headers.indexOf('name')] || '',
|
||||
phone: values[headers.indexOf('휴대폰')] || values[headers.indexOf('phone')] || undefined,
|
||||
email: values[headers.indexOf('이메일')] || values[headers.indexOf('email')] || undefined,
|
||||
departmentName: values[headers.indexOf('부서')] || values[headers.indexOf('department')] || undefined,
|
||||
positionName: values[headers.indexOf('직책')] || values[headers.indexOf('position')] || undefined,
|
||||
hireDate: values[headers.indexOf('입사일')] || values[headers.indexOf('hireDate')] || undefined,
|
||||
status: values[headers.indexOf('상태')] || values[headers.indexOf('status')] || undefined,
|
||||
};
|
||||
|
||||
// 유효성 검사
|
||||
const errors: string[] = [];
|
||||
if (!data.name) {
|
||||
errors.push('이름은 필수입니다');
|
||||
}
|
||||
if (data.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
|
||||
errors.push('이메일 형식이 올바르지 않습니다');
|
||||
}
|
||||
if (data.phone && !/^\d{3}-\d{4}-\d{4}$/.test(data.phone)) {
|
||||
errors.push('휴대폰 형식이 올바르지 않습니다 (000-0000-0000)');
|
||||
}
|
||||
|
||||
return {
|
||||
row: index + 2,
|
||||
data,
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
});
|
||||
|
||||
setValidationResults(results);
|
||||
} catch {
|
||||
console.error('CSV 파싱 오류');
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 행 선택
|
||||
const handleSelectRow = useCallback((rowIndex: number, checked: boolean) => {
|
||||
setSelectedRows(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (checked) {
|
||||
newSet.add(rowIndex);
|
||||
} else {
|
||||
newSet.delete(rowIndex);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 전체 선택
|
||||
const handleSelectAll = useCallback((checked: boolean) => {
|
||||
if (checked) {
|
||||
const validIndices = validationResults
|
||||
.filter(r => r.isValid)
|
||||
.map((_, index) => index);
|
||||
setSelectedRows(new Set(validIndices));
|
||||
} else {
|
||||
setSelectedRows(new Set());
|
||||
}
|
||||
}, [validationResults]);
|
||||
|
||||
// 양식 다운로드
|
||||
const handleDownloadTemplate = () => {
|
||||
const headers = ['이름', '휴대폰', '이메일', '부서', '직책', '입사일', '상태'];
|
||||
const sampleData = ['홍길동', '010-1234-5678', 'hong@company.com', '개발팀', '팀원', '2024-01-01', '재직'];
|
||||
const csv = [headers.join(','), sampleData.join(',')].join('\n');
|
||||
|
||||
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = '사원등록_양식.csv';
|
||||
link.click();
|
||||
};
|
||||
|
||||
// 업로드 실행
|
||||
const handleUpload = () => {
|
||||
const selectedResults = validationResults.filter((_, index) => selectedRows.has(index));
|
||||
const employees: Employee[] = selectedResults.map((r, index) => ({
|
||||
id: String(Date.now() + index),
|
||||
name: r.data.name,
|
||||
phone: r.data.phone,
|
||||
email: r.data.email,
|
||||
status: (r.data.status === '재직' || r.data.status === 'active') ? 'active' :
|
||||
(r.data.status === '휴직' || r.data.status === 'leave') ? 'leave' :
|
||||
(r.data.status === '퇴직' || r.data.status === 'resigned') ? 'resigned' : 'active',
|
||||
hireDate: r.data.hireDate,
|
||||
departmentPositions: r.data.departmentName ? [{
|
||||
id: String(Date.now() + index),
|
||||
departmentId: '',
|
||||
departmentName: r.data.departmentName,
|
||||
positionId: '',
|
||||
positionName: r.data.positionName || '',
|
||||
}] : [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
onUpload(employees);
|
||||
router.push('/ko/hr/employee-management');
|
||||
};
|
||||
|
||||
const validCount = validationResults.filter(r => r.isValid).length;
|
||||
const isAllSelected = validCount > 0 && selectedRows.size === validCount;
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title="CSV 일괄 등록"
|
||||
description="CSV로 정보를 일괄 등록합니다"
|
||||
icon={Users}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 일괄 등록 카드 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">일괄 등록</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* CSV 파일 선택 영역 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">CSV 파일</span>
|
||||
<span className="text-xs text-muted-foreground">CSV 파일 50MB 이하 가능</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{/* 찾기 버튼 */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
id="csv-file-input"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="px-6"
|
||||
>
|
||||
찾기
|
||||
</Button>
|
||||
|
||||
{/* 파일명 표시 */}
|
||||
{file && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 border rounded">
|
||||
<span className="text-sm">{file.name}</span>
|
||||
<button
|
||||
onClick={handleRemoveFile}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 양식 다운로드 버튼 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDownloadTemplate}
|
||||
className="ml-auto gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
양식 다운로드
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 파일변환 버튼 */}
|
||||
<Button
|
||||
onClick={handleConvert}
|
||||
disabled={!file || isProcessing}
|
||||
className="w-full bg-black hover:bg-black/90 text-white"
|
||||
>
|
||||
파일변환
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 테이블 상단 정보 */}
|
||||
{validationResults.length > 0 && (
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm">
|
||||
총 <strong>{validationResults.length}</strong>건
|
||||
</span>
|
||||
{selectedRows.size > 0 && (
|
||||
<span className="text-sm">
|
||||
{selectedRows.size}건 선택
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 데이터 테이블 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px] text-center">
|
||||
<Checkbox
|
||||
checked={isAllSelected}
|
||||
onCheckedChange={handleSelectAll}
|
||||
disabled={validationResults.length === 0}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-[60px] text-center">번호</TableHead>
|
||||
<TableHead className="min-w-[80px]">이름</TableHead>
|
||||
<TableHead className="min-w-[120px]">휴대폰</TableHead>
|
||||
<TableHead className="min-w-[150px]">이메일</TableHead>
|
||||
<TableHead className="min-w-[100px]">부서</TableHead>
|
||||
<TableHead className="min-w-[100px]">직책</TableHead>
|
||||
<TableHead className="min-w-[100px]">입사일</TableHead>
|
||||
<TableHead className="min-w-[80px]">상태</TableHead>
|
||||
<TableHead className="min-w-[150px]">오류</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{validationResults.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} className="h-24 text-center text-muted-foreground">
|
||||
파일 선택 및 파일 변환이 필요합니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
validationResults.map((result, index) => (
|
||||
<TableRow
|
||||
key={result.row}
|
||||
className={!result.isValid ? 'bg-red-50 hover:bg-red-100' : 'hover:bg-muted/50'}
|
||||
>
|
||||
<TableCell className="text-center">
|
||||
<Checkbox
|
||||
checked={selectedRows.has(index)}
|
||||
onCheckedChange={(checked) => handleSelectRow(index, !!checked)}
|
||||
disabled={!result.isValid}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">
|
||||
{validationResults.length - index}
|
||||
</TableCell>
|
||||
<TableCell>{result.data.name || '-'}</TableCell>
|
||||
<TableCell>{result.data.phone || '-'}</TableCell>
|
||||
<TableCell>{result.data.email || '-'}</TableCell>
|
||||
<TableCell>{result.data.departmentName || '-'}</TableCell>
|
||||
<TableCell>{result.data.positionName || '-'}</TableCell>
|
||||
<TableCell>{result.data.hireDate || '-'}</TableCell>
|
||||
<TableCell>{result.data.status || '-'}</TableCell>
|
||||
<TableCell>
|
||||
{result.errors.length > 0 ? (
|
||||
<span className="text-sm text-red-600">
|
||||
{result.errors.join(', ')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-green-600">유효</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 등록 버튼 */}
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={selectedRows.size === 0 || isProcessing}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
{selectedRows.size > 0 ? `${selectedRows.size}건 등록` : '등록'}
|
||||
</Button>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
203
src/components/hr/EmployeeManagement/EmployeeDetail.tsx
Normal file
203
src/components/hr/EmployeeManagement/EmployeeDetail.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Users, ArrowLeft, Edit, Trash2 } from 'lucide-react';
|
||||
import type { Employee } from './types';
|
||||
import {
|
||||
EMPLOYEE_STATUS_LABELS,
|
||||
EMPLOYEE_STATUS_COLORS,
|
||||
EMPLOYMENT_TYPE_LABELS,
|
||||
GENDER_LABELS,
|
||||
USER_ROLE_LABELS,
|
||||
USER_ACCOUNT_STATUS_LABELS,
|
||||
} from './types';
|
||||
|
||||
interface EmployeeDetailProps {
|
||||
employee: Employee;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export function EmployeeDetail({ employee, onEdit, onDelete }: EmployeeDetailProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const handleBack = () => {
|
||||
router.push('/ko/hr/employee-management');
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title="사원 상세"
|
||||
description="사원 정보를 확인합니다"
|
||||
icon={Users}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base">기본 정보</CardTitle>
|
||||
<Badge className={EMPLOYEE_STATUS_COLORS[employee.status]}>
|
||||
{EMPLOYEE_STATUS_LABELS[employee.status]}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">이름</dt>
|
||||
<dd className="text-sm mt-1">{employee.name}</dd>
|
||||
</div>
|
||||
{employee.employeeCode && (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">사원코드</dt>
|
||||
<dd className="text-sm mt-1">{employee.employeeCode}</dd>
|
||||
</div>
|
||||
)}
|
||||
{employee.residentNumber && (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">주민등록번호</dt>
|
||||
<dd className="text-sm mt-1">{employee.residentNumber}</dd>
|
||||
</div>
|
||||
)}
|
||||
{employee.gender && (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">성별</dt>
|
||||
<dd className="text-sm mt-1">{GENDER_LABELS[employee.gender]}</dd>
|
||||
</div>
|
||||
)}
|
||||
{employee.phone && (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">휴대폰</dt>
|
||||
<dd className="text-sm mt-1">{employee.phone}</dd>
|
||||
</div>
|
||||
)}
|
||||
{employee.email && (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">이메일</dt>
|
||||
<dd className="text-sm mt-1">{employee.email}</dd>
|
||||
</div>
|
||||
)}
|
||||
{employee.salary && (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">연봉</dt>
|
||||
<dd className="text-sm mt-1">{employee.salary.toLocaleString()}원</dd>
|
||||
</div>
|
||||
)}
|
||||
{employee.bankAccount && (
|
||||
<div className="md:col-span-2">
|
||||
<dt className="text-sm font-medium text-muted-foreground">급여계좌</dt>
|
||||
<dd className="text-sm mt-1">
|
||||
{employee.bankAccount.bankName} {employee.bankAccount.accountNumber} ({employee.bankAccount.accountHolder})
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{employee.address && (
|
||||
<div className="md:col-span-2">
|
||||
<dt className="text-sm font-medium text-muted-foreground">주소</dt>
|
||||
<dd className="text-sm mt-1">
|
||||
({employee.address.zipCode}) {employee.address.address1} {employee.address.address2}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 인사 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">인사 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{employee.hireDate && (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">입사일</dt>
|
||||
<dd className="text-sm mt-1">{new Date(employee.hireDate).toLocaleDateString('ko-KR')}</dd>
|
||||
</div>
|
||||
)}
|
||||
{employee.employmentType && (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">고용형태</dt>
|
||||
<dd className="text-sm mt-1">{EMPLOYMENT_TYPE_LABELS[employee.employmentType]}</dd>
|
||||
</div>
|
||||
)}
|
||||
{employee.rank && (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">직급</dt>
|
||||
<dd className="text-sm mt-1">{employee.rank}</dd>
|
||||
</div>
|
||||
)}
|
||||
{employee.departmentPositions.length > 0 && (
|
||||
<div className="md:col-span-2">
|
||||
<dt className="text-sm font-medium text-muted-foreground">부서/직책</dt>
|
||||
<dd className="text-sm mt-1">
|
||||
<div className="space-y-1">
|
||||
{employee.departmentPositions.map((dp) => (
|
||||
<div key={dp.id} className="flex items-center gap-2">
|
||||
<span>{dp.departmentName}</span>
|
||||
<span className="text-muted-foreground">-</span>
|
||||
<span>{dp.positionName}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 사용자 정보 */}
|
||||
{employee.userInfo && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">사용자 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">아이디</dt>
|
||||
<dd className="text-sm mt-1">{employee.userInfo.userId}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">권한</dt>
|
||||
<dd className="text-sm mt-1">{USER_ROLE_LABELS[employee.userInfo.role]}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">계정상태</dt>
|
||||
<dd className="text-sm mt-1">{USER_ACCOUNT_STATUS_LABELS[employee.userInfo.accountStatus]}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
목록으로
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={onDelete} className="text-destructive hover:bg-destructive hover:text-destructive-foreground">
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={onEdit}>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
573
src/components/hr/EmployeeManagement/EmployeeDialog.tsx
Normal file
573
src/components/hr/EmployeeManagement/EmployeeDialog.tsx
Normal file
@@ -0,0 +1,573 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import type {
|
||||
EmployeeDialogProps,
|
||||
EmployeeFormData,
|
||||
DepartmentPosition,
|
||||
} from './types';
|
||||
import {
|
||||
EMPLOYMENT_TYPE_LABELS,
|
||||
GENDER_LABELS,
|
||||
USER_ROLE_LABELS,
|
||||
USER_ACCOUNT_STATUS_LABELS,
|
||||
EMPLOYEE_STATUS_LABELS,
|
||||
} from './types';
|
||||
|
||||
const initialFormData: EmployeeFormData = {
|
||||
name: '',
|
||||
residentNumber: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
salary: '',
|
||||
bankAccount: { bankName: '', accountNumber: '', accountHolder: '' },
|
||||
profileImage: '',
|
||||
employeeCode: '',
|
||||
gender: '',
|
||||
address: { zipCode: '', address1: '', address2: '' },
|
||||
hireDate: '',
|
||||
employmentType: '',
|
||||
rank: '',
|
||||
status: 'active',
|
||||
departmentPositions: [],
|
||||
hasUserAccount: false,
|
||||
userId: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
role: 'user',
|
||||
accountStatus: 'active',
|
||||
};
|
||||
|
||||
export function EmployeeDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
mode,
|
||||
employee,
|
||||
onSave,
|
||||
fieldSettings,
|
||||
}: EmployeeDialogProps) {
|
||||
const [formData, setFormData] = useState<EmployeeFormData>(initialFormData);
|
||||
|
||||
// 모드별 타이틀
|
||||
const title = {
|
||||
create: '사원 등록',
|
||||
edit: '사원 수정',
|
||||
view: '사원 상세',
|
||||
}[mode];
|
||||
|
||||
const isViewMode = mode === 'view';
|
||||
|
||||
// 데이터 초기화
|
||||
useEffect(() => {
|
||||
if (open && employee && mode !== 'create') {
|
||||
setFormData({
|
||||
name: employee.name,
|
||||
residentNumber: employee.residentNumber || '',
|
||||
phone: employee.phone || '',
|
||||
email: employee.email || '',
|
||||
salary: employee.salary?.toString() || '',
|
||||
bankAccount: employee.bankAccount || { bankName: '', accountNumber: '', accountHolder: '' },
|
||||
profileImage: employee.profileImage || '',
|
||||
employeeCode: employee.employeeCode || '',
|
||||
gender: employee.gender || '',
|
||||
address: employee.address || { zipCode: '', address1: '', address2: '' },
|
||||
hireDate: employee.hireDate || '',
|
||||
employmentType: employee.employmentType || '',
|
||||
rank: employee.rank || '',
|
||||
status: employee.status,
|
||||
departmentPositions: employee.departmentPositions || [],
|
||||
hasUserAccount: !!employee.userInfo,
|
||||
userId: employee.userInfo?.userId || '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
role: employee.userInfo?.role || 'user',
|
||||
accountStatus: employee.userInfo?.accountStatus || 'active',
|
||||
});
|
||||
} else if (open && mode === 'create') {
|
||||
setFormData(initialFormData);
|
||||
}
|
||||
}, [open, employee, mode]);
|
||||
|
||||
// 입력 변경 핸들러
|
||||
const handleChange = (field: keyof EmployeeFormData, value: unknown) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
// 부서/직책 추가
|
||||
const handleAddDepartmentPosition = () => {
|
||||
const newDP: DepartmentPosition = {
|
||||
id: String(Date.now()),
|
||||
departmentId: '',
|
||||
departmentName: '',
|
||||
positionId: '',
|
||||
positionName: '',
|
||||
};
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
departmentPositions: [...prev.departmentPositions, newDP],
|
||||
}));
|
||||
};
|
||||
|
||||
// 부서/직책 삭제
|
||||
const handleRemoveDepartmentPosition = (id: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
departmentPositions: prev.departmentPositions.filter(dp => dp.id !== id),
|
||||
}));
|
||||
};
|
||||
|
||||
// 부서/직책 변경
|
||||
const handleDepartmentPositionChange = (id: string, field: keyof DepartmentPosition, value: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
departmentPositions: prev.departmentPositions.map(dp =>
|
||||
dp.id === id ? { ...dp, [field]: value } : dp
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSubmit = () => {
|
||||
onSave(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{mode === 'create' ? '새로운 사원 정보를 입력합니다' : '사원 정보를 확인/수정합니다'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* 기본 사원정보 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold">기본 정보</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">이름 *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleChange('name', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
placeholder="이름을 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="residentNumber">주민등록번호</Label>
|
||||
<Input
|
||||
id="residentNumber"
|
||||
value={formData.residentNumber}
|
||||
onChange={(e) => handleChange('residentNumber', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
placeholder="000000-0000000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">휴대폰</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleChange('phone', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
placeholder="010-0000-0000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">이메일</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleChange('email', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
placeholder="email@company.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="salary">연봉</Label>
|
||||
<Input
|
||||
id="salary"
|
||||
type="number"
|
||||
value={formData.salary}
|
||||
onChange={(e) => handleChange('salary', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
placeholder="연봉 (원)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 급여 계좌 */}
|
||||
<div className="space-y-2">
|
||||
<Label>급여계좌</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Input
|
||||
value={formData.bankAccount.bankName}
|
||||
onChange={(e) => handleChange('bankAccount', { ...formData.bankAccount, bankName: e.target.value })}
|
||||
disabled={isViewMode}
|
||||
placeholder="은행명"
|
||||
/>
|
||||
<Input
|
||||
value={formData.bankAccount.accountNumber}
|
||||
onChange={(e) => handleChange('bankAccount', { ...formData.bankAccount, accountNumber: e.target.value })}
|
||||
disabled={isViewMode}
|
||||
placeholder="계좌번호"
|
||||
/>
|
||||
<Input
|
||||
value={formData.bankAccount.accountHolder}
|
||||
onChange={(e) => handleChange('bankAccount', { ...formData.bankAccount, accountHolder: e.target.value })}
|
||||
disabled={isViewMode}
|
||||
placeholder="예금주"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 선택적 필드 (설정에 따라 표시) */}
|
||||
{(fieldSettings.showEmployeeCode || fieldSettings.showGender || fieldSettings.showAddress) && (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold">추가 정보</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{fieldSettings.showEmployeeCode && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="employeeCode">사원코드</Label>
|
||||
<Input
|
||||
id="employeeCode"
|
||||
value={formData.employeeCode}
|
||||
onChange={(e) => handleChange('employeeCode', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
placeholder="자동생성 또는 직접입력"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fieldSettings.showGender && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="gender">성별</Label>
|
||||
<Select
|
||||
value={formData.gender}
|
||||
onValueChange={(value) => handleChange('gender', value)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="성별 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(GENDER_LABELS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{fieldSettings.showAddress && (
|
||||
<div className="space-y-2">
|
||||
<Label>주소</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={formData.address.zipCode}
|
||||
onChange={(e) => handleChange('address', { ...formData.address, zipCode: e.target.value })}
|
||||
disabled={isViewMode}
|
||||
placeholder="우편번호"
|
||||
className="w-32"
|
||||
/>
|
||||
<Button variant="outline" size="sm" disabled={isViewMode}>
|
||||
우편번호 찾기
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
value={formData.address.address1}
|
||||
onChange={(e) => handleChange('address', { ...formData.address, address1: e.target.value })}
|
||||
disabled={isViewMode}
|
||||
placeholder="기본주소"
|
||||
/>
|
||||
<Input
|
||||
value={formData.address.address2}
|
||||
onChange={(e) => handleChange('address', { ...formData.address, address2: e.target.value })}
|
||||
disabled={isViewMode}
|
||||
placeholder="상세주소"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 인사 정보 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold">인사 정보</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{fieldSettings.showHireDate && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hireDate">입사일</Label>
|
||||
<Input
|
||||
id="hireDate"
|
||||
type="date"
|
||||
value={formData.hireDate}
|
||||
onChange={(e) => handleChange('hireDate', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fieldSettings.showEmploymentType && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="employmentType">고용형태</Label>
|
||||
<Select
|
||||
value={formData.employmentType}
|
||||
onValueChange={(value) => handleChange('employmentType', value)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="고용형태 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(EMPLOYMENT_TYPE_LABELS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fieldSettings.showRank && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rank">직급</Label>
|
||||
<Input
|
||||
id="rank"
|
||||
value={formData.rank}
|
||||
onChange={(e) => handleChange('rank', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
placeholder="직급 입력"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fieldSettings.showStatus && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">상태</Label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(value) => handleChange('status', value)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="상태 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(EMPLOYEE_STATUS_LABELS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 부서/직책 (복수 가능) */}
|
||||
{(fieldSettings.showDepartment || fieldSettings.showPosition) && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>부서/직책</Label>
|
||||
{!isViewMode && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddDepartmentPosition}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{formData.departmentPositions.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">부서/직책을 추가해주세요</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{formData.departmentPositions.map((dp) => (
|
||||
<div key={dp.id} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={dp.departmentName}
|
||||
onChange={(e) => handleDepartmentPositionChange(dp.id, 'departmentName', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
placeholder="부서명"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
value={dp.positionName}
|
||||
onChange={(e) => handleDepartmentPositionChange(dp.id, 'positionName', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
placeholder="직책"
|
||||
className="flex-1"
|
||||
/>
|
||||
{!isViewMode && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveDepartmentPosition(dp.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 사용자 정보 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">사용자 정보</h3>
|
||||
{!isViewMode && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="hasUserAccount"
|
||||
checked={formData.hasUserAccount}
|
||||
onCheckedChange={(checked) => handleChange('hasUserAccount', checked)}
|
||||
/>
|
||||
<Label htmlFor="hasUserAccount" className="text-sm">
|
||||
사용자 계정 생성
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(formData.hasUserAccount || (isViewMode && employee?.userInfo)) && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="userId">아이디 *</Label>
|
||||
<Input
|
||||
id="userId"
|
||||
value={formData.userId}
|
||||
onChange={(e) => handleChange('userId', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
placeholder="사용자 아이디"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isViewMode && mode === 'create' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">비밀번호 *</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => handleChange('password', e.target.value)}
|
||||
placeholder="비밀번호"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">비밀번호 확인</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => handleChange('confirmPassword', e.target.value)}
|
||||
placeholder="비밀번호 확인"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role">권한</Label>
|
||||
<Select
|
||||
value={formData.role}
|
||||
onValueChange={(value) => handleChange('role', value)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="권한 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(USER_ROLE_LABELS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="accountStatus">계정상태</Label>
|
||||
<Select
|
||||
value={formData.accountStatus}
|
||||
onValueChange={(value) => handleChange('accountStatus', value)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="상태 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(USER_ACCOUNT_STATUS_LABELS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{isViewMode ? '닫기' : '취소'}
|
||||
</Button>
|
||||
{!isViewMode && (
|
||||
<Button onClick={handleSubmit}>
|
||||
{mode === 'create' ? '등록' : '저장'}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
628
src/components/hr/EmployeeManagement/EmployeeForm.tsx
Normal file
628
src/components/hr/EmployeeManagement/EmployeeForm.tsx
Normal file
@@ -0,0 +1,628 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Image from 'next/image';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Users, Plus, Trash2, ArrowLeft, Save, Camera, User } from 'lucide-react';
|
||||
import type {
|
||||
Employee,
|
||||
EmployeeFormData,
|
||||
DepartmentPosition,
|
||||
FieldSettings,
|
||||
} from './types';
|
||||
import {
|
||||
EMPLOYMENT_TYPE_LABELS,
|
||||
GENDER_LABELS,
|
||||
USER_ROLE_LABELS,
|
||||
USER_ACCOUNT_STATUS_LABELS,
|
||||
EMPLOYEE_STATUS_LABELS,
|
||||
DEFAULT_FIELD_SETTINGS,
|
||||
} from './types';
|
||||
|
||||
interface EmployeeFormProps {
|
||||
mode: 'create' | 'edit';
|
||||
employee?: Employee;
|
||||
onSave: (data: EmployeeFormData) => void;
|
||||
fieldSettings?: FieldSettings;
|
||||
}
|
||||
|
||||
const initialFormData: EmployeeFormData = {
|
||||
name: '',
|
||||
residentNumber: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
salary: '',
|
||||
bankAccount: { bankName: '', accountNumber: '', accountHolder: '' },
|
||||
profileImage: '',
|
||||
employeeCode: '',
|
||||
gender: '',
|
||||
address: { zipCode: '', address1: '', address2: '' },
|
||||
hireDate: '',
|
||||
employmentType: '',
|
||||
rank: '',
|
||||
status: 'active',
|
||||
departmentPositions: [],
|
||||
hasUserAccount: false,
|
||||
userId: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
role: 'user',
|
||||
accountStatus: 'active',
|
||||
};
|
||||
|
||||
export function EmployeeForm({
|
||||
mode,
|
||||
employee,
|
||||
onSave,
|
||||
fieldSettings = DEFAULT_FIELD_SETTINGS,
|
||||
}: EmployeeFormProps) {
|
||||
const router = useRouter();
|
||||
const [formData, setFormData] = useState<EmployeeFormData>(initialFormData);
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const title = mode === 'create' ? '사원 등록' : '사원 수정';
|
||||
|
||||
// 데이터 초기화
|
||||
useEffect(() => {
|
||||
if (employee && mode === 'edit') {
|
||||
setFormData({
|
||||
name: employee.name,
|
||||
residentNumber: employee.residentNumber || '',
|
||||
phone: employee.phone || '',
|
||||
email: employee.email || '',
|
||||
salary: employee.salary?.toString() || '',
|
||||
bankAccount: employee.bankAccount || { bankName: '', accountNumber: '', accountHolder: '' },
|
||||
profileImage: employee.profileImage || '',
|
||||
employeeCode: employee.employeeCode || '',
|
||||
gender: employee.gender || '',
|
||||
address: employee.address || { zipCode: '', address1: '', address2: '' },
|
||||
hireDate: employee.hireDate || '',
|
||||
employmentType: employee.employmentType || '',
|
||||
rank: employee.rank || '',
|
||||
status: employee.status,
|
||||
departmentPositions: employee.departmentPositions || [],
|
||||
hasUserAccount: !!employee.userInfo,
|
||||
userId: employee.userInfo?.userId || '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
role: employee.userInfo?.role || 'user',
|
||||
accountStatus: employee.userInfo?.accountStatus || 'active',
|
||||
});
|
||||
}
|
||||
}, [employee, mode]);
|
||||
|
||||
// 입력 변경 핸들러
|
||||
const handleChange = (field: keyof EmployeeFormData, value: unknown) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
// 부서/직책 추가
|
||||
const handleAddDepartmentPosition = () => {
|
||||
const newDP: DepartmentPosition = {
|
||||
id: String(Date.now()),
|
||||
departmentId: '',
|
||||
departmentName: '',
|
||||
positionId: '',
|
||||
positionName: '',
|
||||
};
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
departmentPositions: [...prev.departmentPositions, newDP],
|
||||
}));
|
||||
};
|
||||
|
||||
// 부서/직책 삭제
|
||||
const handleRemoveDepartmentPosition = (id: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
departmentPositions: prev.departmentPositions.filter(dp => dp.id !== id),
|
||||
}));
|
||||
};
|
||||
|
||||
// 부서/직책 변경
|
||||
const handleDepartmentPositionChange = (id: string, field: keyof DepartmentPosition, value: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
departmentPositions: prev.departmentPositions.map(dp =>
|
||||
dp.id === id ? { ...dp, [field]: value } : dp
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSave(formData);
|
||||
};
|
||||
|
||||
// 취소
|
||||
const handleCancel = () => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
// 프로필 이미지 업로드 핸들러
|
||||
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setPreviewImage(reader.result as string);
|
||||
handleChange('profileImage', reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
// 프로필 이미지 삭제 핸들러
|
||||
const handleRemoveImage = () => {
|
||||
setPreviewImage(null);
|
||||
handleChange('profileImage', '');
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title={title}
|
||||
description={mode === 'create' ? '새로운 사원 정보를 입력합니다' : '사원 정보를 수정합니다'}
|
||||
icon={Users}
|
||||
/>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* 사원 정보 - 프로필 사진 + 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader className="bg-black text-white rounded-t-lg">
|
||||
<CardTitle className="text-base font-medium">사원 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
{/* 프로필 사진 영역 */}
|
||||
{fieldSettings.showProfileImage && (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="relative w-32 h-32 rounded-full border-2 border-dashed border-gray-300 bg-gray-50 flex items-center justify-center overflow-hidden">
|
||||
{previewImage || formData.profileImage ? (
|
||||
<Image
|
||||
src={previewImage || formData.profileImage}
|
||||
alt="프로필 사진"
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
) : (
|
||||
<User className="w-12 h-12 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageUpload}
|
||||
className="hidden"
|
||||
id="profile-image-input"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Camera className="w-4 h-4 mr-1" />
|
||||
사진 등록
|
||||
</Button>
|
||||
{(previewImage || formData.profileImage) && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRemoveImage}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 기본 정보 필드들 */}
|
||||
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">이름 *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleChange('name', e.target.value)}
|
||||
placeholder="이름을 입력하세요"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="residentNumber">주민등록번호</Label>
|
||||
<Input
|
||||
id="residentNumber"
|
||||
value={formData.residentNumber}
|
||||
onChange={(e) => handleChange('residentNumber', e.target.value)}
|
||||
placeholder="000000-0000000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">휴대폰</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleChange('phone', e.target.value)}
|
||||
placeholder="010-0000-0000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">이메일</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleChange('email', e.target.value)}
|
||||
placeholder="email@company.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="salary">연봉</Label>
|
||||
<Input
|
||||
id="salary"
|
||||
type="number"
|
||||
value={formData.salary}
|
||||
onChange={(e) => handleChange('salary', e.target.value)}
|
||||
placeholder="연봉 (원)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 급여 계좌 */}
|
||||
<div className="space-y-2 mt-6">
|
||||
<Label>급여계좌</Label>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
|
||||
<Input
|
||||
value={formData.bankAccount.bankName}
|
||||
onChange={(e) => handleChange('bankAccount', { ...formData.bankAccount, bankName: e.target.value })}
|
||||
placeholder="은행명"
|
||||
/>
|
||||
<Input
|
||||
value={formData.bankAccount.accountNumber}
|
||||
onChange={(e) => handleChange('bankAccount', { ...formData.bankAccount, accountNumber: e.target.value })}
|
||||
placeholder="계좌번호"
|
||||
/>
|
||||
<Input
|
||||
value={formData.bankAccount.accountHolder}
|
||||
onChange={(e) => handleChange('bankAccount', { ...formData.bankAccount, accountHolder: e.target.value })}
|
||||
placeholder="예금주"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 선택 정보 (사원 상세) */}
|
||||
{(fieldSettings.showEmployeeCode || fieldSettings.showGender || fieldSettings.showAddress) && (
|
||||
<Card>
|
||||
<CardHeader className="bg-black text-white rounded-t-lg">
|
||||
<CardTitle className="text-base font-medium">선택 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{fieldSettings.showEmployeeCode && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="employeeCode">사원코드</Label>
|
||||
<Input
|
||||
id="employeeCode"
|
||||
value={formData.employeeCode}
|
||||
onChange={(e) => handleChange('employeeCode', e.target.value)}
|
||||
placeholder="자동생성 또는 직접입력"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fieldSettings.showGender && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="gender">성별</Label>
|
||||
<Select
|
||||
value={formData.gender}
|
||||
onValueChange={(value) => handleChange('gender', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="성별 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(GENDER_LABELS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{fieldSettings.showAddress && (
|
||||
<div className="space-y-2">
|
||||
<Label>주소</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={formData.address.zipCode}
|
||||
onChange={(e) => handleChange('address', { ...formData.address, zipCode: e.target.value })}
|
||||
placeholder="우편번호"
|
||||
className="w-32"
|
||||
/>
|
||||
<Button type="button" variant="outline" size="sm">
|
||||
우편번호 찾기
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
value={formData.address.address1}
|
||||
onChange={(e) => handleChange('address', { ...formData.address, address1: e.target.value })}
|
||||
placeholder="기본주소"
|
||||
/>
|
||||
<Input
|
||||
value={formData.address.address2}
|
||||
onChange={(e) => handleChange('address', { ...formData.address, address2: e.target.value })}
|
||||
placeholder="상세주소"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 인사 정보 */}
|
||||
<Card>
|
||||
<CardHeader className="bg-black text-white rounded-t-lg">
|
||||
<CardTitle className="text-base font-medium">인사 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{fieldSettings.showHireDate && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hireDate">입사일</Label>
|
||||
<Input
|
||||
id="hireDate"
|
||||
type="date"
|
||||
value={formData.hireDate}
|
||||
onChange={(e) => handleChange('hireDate', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fieldSettings.showEmploymentType && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="employmentType">고용형태</Label>
|
||||
<Select
|
||||
value={formData.employmentType}
|
||||
onValueChange={(value) => handleChange('employmentType', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="고용형태 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(EMPLOYMENT_TYPE_LABELS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fieldSettings.showRank && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rank">직급</Label>
|
||||
<Input
|
||||
id="rank"
|
||||
value={formData.rank}
|
||||
onChange={(e) => handleChange('rank', e.target.value)}
|
||||
placeholder="직급 입력"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fieldSettings.showStatus && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">상태</Label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(value) => handleChange('status', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="상태 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(EMPLOYEE_STATUS_LABELS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 부서/직책 */}
|
||||
{(fieldSettings.showDepartment || fieldSettings.showPosition) && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>부서/직책</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddDepartmentPosition}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{formData.departmentPositions.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center border rounded-md">
|
||||
부서/직책을 추가해주세요
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{formData.departmentPositions.map((dp) => (
|
||||
<div key={dp.id} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={dp.departmentName}
|
||||
onChange={(e) => handleDepartmentPositionChange(dp.id, 'departmentName', e.target.value)}
|
||||
placeholder="부서명"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
value={dp.positionName}
|
||||
onChange={(e) => handleDepartmentPositionChange(dp.id, 'positionName', e.target.value)}
|
||||
placeholder="직책"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveDepartmentPosition(dp.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 사용자 정보 */}
|
||||
<Card>
|
||||
<CardHeader className="bg-black text-white rounded-t-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-medium">사용자 정보</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="hasUserAccount"
|
||||
checked={formData.hasUserAccount}
|
||||
onCheckedChange={(checked) => handleChange('hasUserAccount', checked)}
|
||||
className="data-[state=checked]:bg-white data-[state=checked]:text-black"
|
||||
/>
|
||||
<Label htmlFor="hasUserAccount" className="text-sm font-normal text-white">
|
||||
사용자 계정 생성
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{formData.hasUserAccount && (
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="userId">아이디 *</Label>
|
||||
<Input
|
||||
id="userId"
|
||||
value={formData.userId}
|
||||
onChange={(e) => handleChange('userId', e.target.value)}
|
||||
placeholder="사용자 아이디"
|
||||
required={formData.hasUserAccount}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{mode === 'create' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">비밀번호 *</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => handleChange('password', e.target.value)}
|
||||
placeholder="비밀번호"
|
||||
required={formData.hasUserAccount}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">비밀번호 확인</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => handleChange('confirmPassword', e.target.value)}
|
||||
placeholder="비밀번호 확인"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role">권한</Label>
|
||||
<Select
|
||||
value={formData.role}
|
||||
onValueChange={(value) => handleChange('role', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="권한 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(USER_ROLE_LABELS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="accountStatus">계정상태</Label>
|
||||
<Select
|
||||
value={formData.accountStatus}
|
||||
onValueChange={(value) => handleChange('accountStatus', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="상태 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(USER_ACCOUNT_STATUS_LABELS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{mode === 'create' ? '등록' : '저장'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
82
src/components/hr/EmployeeManagement/EmployeeToolbar.tsx
Normal file
82
src/components/hr/EmployeeManagement/EmployeeToolbar.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Calendar, FileSpreadsheet, UserPlus, Mail, Settings } from 'lucide-react';
|
||||
|
||||
interface EmployeeToolbarProps {
|
||||
dateRange: { from?: Date; to?: Date };
|
||||
onDateRangeChange: (range: { from?: Date; to?: Date }) => void;
|
||||
onAddEmployee: () => void;
|
||||
onCSVUpload: () => void;
|
||||
onUserInvite: () => void;
|
||||
onFieldSettings: () => void;
|
||||
}
|
||||
|
||||
export function EmployeeToolbar({
|
||||
dateRange,
|
||||
onDateRangeChange,
|
||||
onAddEmployee,
|
||||
onCSVUpload,
|
||||
onUserInvite,
|
||||
onFieldSettings,
|
||||
}: EmployeeToolbarProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
{/* 날짜 필터 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">기간:</span>
|
||||
<Button variant="outline" size="sm">
|
||||
{dateRange.from && dateRange.to
|
||||
? `${dateRange.from.toLocaleDateString('ko-KR')} - ${dateRange.to.toLocaleDateString('ko-KR')}`
|
||||
: '전체 기간'
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼들 */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onFieldSettings}
|
||||
className="gap-1"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">필드 설정</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onCSVUpload}
|
||||
className="gap-1"
|
||||
>
|
||||
<FileSpreadsheet className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">CSV 일괄등록</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onUserInvite}
|
||||
className="gap-1"
|
||||
>
|
||||
<Mail className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">사용자 초대</span>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onAddEmployee}
|
||||
className="gap-1"
|
||||
>
|
||||
<UserPlus className="w-4 h-4" />
|
||||
<span>사원 등록</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
223
src/components/hr/EmployeeManagement/FieldSettingsDialog.tsx
Normal file
223
src/components/hr/EmployeeManagement/FieldSettingsDialog.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import type { FieldSettings } from './types';
|
||||
|
||||
interface FieldSettingsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
settings: FieldSettings;
|
||||
onSave: (settings: FieldSettings) => void;
|
||||
}
|
||||
|
||||
export function FieldSettingsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
settings,
|
||||
onSave,
|
||||
}: FieldSettingsDialogProps) {
|
||||
const [localSettings, setLocalSettings] = useState<FieldSettings>(settings);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setLocalSettings(settings);
|
||||
}
|
||||
}, [open, settings]);
|
||||
|
||||
const handleToggle = (key: keyof FieldSettings) => {
|
||||
setLocalSettings(prev => ({ ...prev, [key]: !prev[key] }));
|
||||
};
|
||||
|
||||
// 사원 상세 전체 토글
|
||||
const employeeDetailFields: (keyof FieldSettings)[] = [
|
||||
'showProfileImage', 'showEmployeeCode', 'showGender', 'showAddress'
|
||||
];
|
||||
const isAllEmployeeDetailOn = useMemo(() =>
|
||||
employeeDetailFields.every(key => localSettings[key]),
|
||||
[localSettings]
|
||||
);
|
||||
const handleToggleAllEmployeeDetail = (checked: boolean) => {
|
||||
setLocalSettings(prev => {
|
||||
const updated = { ...prev };
|
||||
employeeDetailFields.forEach(key => {
|
||||
updated[key] = checked;
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
// 인사 정보 전체 토글
|
||||
const hrInfoFields: (keyof FieldSettings)[] = [
|
||||
'showHireDate', 'showEmploymentType', 'showRank', 'showStatus', 'showDepartment', 'showPosition'
|
||||
];
|
||||
const isAllHrInfoOn = useMemo(() =>
|
||||
hrInfoFields.every(key => localSettings[key]),
|
||||
[localSettings]
|
||||
);
|
||||
const handleToggleAllHrInfo = (checked: boolean) => {
|
||||
setLocalSettings(prev => {
|
||||
const updated = { ...prev };
|
||||
hrInfoFields.forEach(key => {
|
||||
updated[key] = checked;
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(localSettings);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>항목 설정</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* 사원 상세 섹션 */}
|
||||
<Card className="p-4">
|
||||
<div className="space-y-3">
|
||||
{/* 전체 토글 */}
|
||||
<div className="flex items-center justify-between border-b pb-3">
|
||||
<Label className="font-semibold">사원 상세</Label>
|
||||
<Switch
|
||||
checked={isAllEmployeeDetailOn}
|
||||
onCheckedChange={handleToggleAllEmployeeDetail}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 개별 항목들 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="showProfileImage">프로필 사진</Label>
|
||||
<Switch
|
||||
id="showProfileImage"
|
||||
checked={localSettings.showProfileImage}
|
||||
onCheckedChange={() => handleToggle('showProfileImage')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="showEmployeeCode">사원코드</Label>
|
||||
<Switch
|
||||
id="showEmployeeCode"
|
||||
checked={localSettings.showEmployeeCode}
|
||||
onCheckedChange={() => handleToggle('showEmployeeCode')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="showGender">성별</Label>
|
||||
<Switch
|
||||
id="showGender"
|
||||
checked={localSettings.showGender}
|
||||
onCheckedChange={() => handleToggle('showGender')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="showAddress">주소</Label>
|
||||
<Switch
|
||||
id="showAddress"
|
||||
checked={localSettings.showAddress}
|
||||
onCheckedChange={() => handleToggle('showAddress')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 인사 정보 섹션 */}
|
||||
<Card className="p-4">
|
||||
<div className="space-y-3">
|
||||
{/* 전체 토글 */}
|
||||
<div className="flex items-center justify-between border-b pb-3">
|
||||
<Label className="font-semibold">인사 정보</Label>
|
||||
<Switch
|
||||
checked={isAllHrInfoOn}
|
||||
onCheckedChange={handleToggleAllHrInfo}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 개별 항목들 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="showHireDate">입사일</Label>
|
||||
<Switch
|
||||
id="showHireDate"
|
||||
checked={localSettings.showHireDate}
|
||||
onCheckedChange={() => handleToggle('showHireDate')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="showEmploymentType">고용 형태</Label>
|
||||
<Switch
|
||||
id="showEmploymentType"
|
||||
checked={localSettings.showEmploymentType}
|
||||
onCheckedChange={() => handleToggle('showEmploymentType')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="showRank">직급</Label>
|
||||
<Switch
|
||||
id="showRank"
|
||||
checked={localSettings.showRank}
|
||||
onCheckedChange={() => handleToggle('showRank')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="showStatus">상태</Label>
|
||||
<Switch
|
||||
id="showStatus"
|
||||
checked={localSettings.showStatus}
|
||||
onCheckedChange={() => handleToggle('showStatus')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="showDepartment">부서</Label>
|
||||
<Switch
|
||||
id="showDepartment"
|
||||
checked={localSettings.showDepartment}
|
||||
onCheckedChange={() => handleToggle('showDepartment')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="showPosition">직책</Label>
|
||||
<Switch
|
||||
id="showPosition"
|
||||
checked={localSettings.showPosition}
|
||||
onCheckedChange={() => handleToggle('showPosition')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex justify-center gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} className="w-24">
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="w-24 bg-blue-500 hover:bg-blue-600">
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
115
src/components/hr/EmployeeManagement/UserInviteDialog.tsx
Normal file
115
src/components/hr/EmployeeManagement/UserInviteDialog.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
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 type { UserRole } from './types';
|
||||
import { USER_ROLE_LABELS } from './types';
|
||||
|
||||
interface UserInviteDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onInvite: (data: { email: string; role: UserRole; message?: string }) => void;
|
||||
}
|
||||
|
||||
export function UserInviteDialog({ open, onOpenChange, onInvite }: UserInviteDialogProps) {
|
||||
const [email, setEmail] = useState('');
|
||||
const [role, setRole] = useState<UserRole>('user');
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!email) return;
|
||||
onInvite({ email, role, message: message || undefined });
|
||||
// Reset form
|
||||
setEmail('');
|
||||
setRole('user');
|
||||
setMessage('');
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEmail('');
|
||||
setRole('user');
|
||||
setMessage('');
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>사용자 초대</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* 이메일 주소 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-email">이메일 주소</Label>
|
||||
<Input
|
||||
id="invite-email"
|
||||
type="email"
|
||||
placeholder="이메일"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 권한 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-role">권한</Label>
|
||||
<Select value={role} onValueChange={(value) => setRole(value as UserRole)}>
|
||||
<SelectTrigger id="invite-role">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="admin">{USER_ROLE_LABELS.admin}</SelectItem>
|
||||
<SelectItem value="manager">{USER_ROLE_LABELS.manager}</SelectItem>
|
||||
<SelectItem value="user">{USER_ROLE_LABELS.user}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 초대 메시지 (선택) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-message">초대 메시지 (선택)</Label>
|
||||
<Textarea
|
||||
id="invite-message"
|
||||
placeholder="초대 메시지를 입력해주세요."
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!email}
|
||||
className="bg-black hover:bg-black/90 text-white"
|
||||
>
|
||||
초대
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
588
src/components/hr/EmployeeManagement/index.tsx
Normal file
588
src/components/hr/EmployeeManagement/index.tsx
Normal file
@@ -0,0 +1,588 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Users, Edit, Trash2, UserCheck, UserX, Clock, Calendar, Mail, Plus, Upload } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { TableRow, TableCell } from '@/components/ui/table';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
IntegratedListTemplateV2,
|
||||
type TabOption,
|
||||
type TableColumn,
|
||||
type StatCard,
|
||||
} from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
|
||||
import { FieldSettingsDialog } from './FieldSettingsDialog';
|
||||
import { UserInviteDialog } from './UserInviteDialog';
|
||||
import type {
|
||||
Employee,
|
||||
EmployeeStatus,
|
||||
FieldSettings,
|
||||
} from './types';
|
||||
import {
|
||||
EMPLOYEE_STATUS_LABELS,
|
||||
EMPLOYEE_STATUS_COLORS,
|
||||
DEFAULT_FIELD_SETTINGS,
|
||||
USER_ROLE_LABELS,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Mock 데이터 - 실제 API 연동 전 테스트용
|
||||
*/
|
||||
const mockEmployees: Employee[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: '김철수',
|
||||
employeeCode: 'abc123',
|
||||
phone: '010-1234-1234',
|
||||
email: 'abc@company.com',
|
||||
status: 'active',
|
||||
hireDate: '2025-09-11',
|
||||
departmentPositions: [
|
||||
{ id: '1', departmentId: 'd1', departmentName: '부서명', positionId: 'p1', positionName: '부서장팀장' }
|
||||
],
|
||||
rank: '부장',
|
||||
userInfo: { userId: 'abc', role: 'manager', accountStatus: 'active' },
|
||||
createdAt: '2020-03-15T00:00:00Z',
|
||||
updatedAt: '2024-01-15T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: '이영희',
|
||||
employeeCode: 'abc123',
|
||||
phone: '010-1234-1234',
|
||||
email: 'abc@company.com',
|
||||
status: 'leave',
|
||||
hireDate: '2025-09-11',
|
||||
departmentPositions: [
|
||||
{ id: '2', departmentId: 'd2', departmentName: '부서명', positionId: 'p2', positionName: '팀장' }
|
||||
],
|
||||
rank: '부장',
|
||||
userInfo: { userId: 'abc', role: 'manager', accountStatus: 'active' },
|
||||
createdAt: '2019-06-01T00:00:00Z',
|
||||
updatedAt: '2024-02-20T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: '박민수',
|
||||
employeeCode: 'abc123',
|
||||
phone: '010-1234-1234',
|
||||
email: 'abc@company.com',
|
||||
status: 'resigned',
|
||||
hireDate: '2025-09-11',
|
||||
departmentPositions: [
|
||||
{ id: '3', departmentId: 'd1', departmentName: '부서명', positionId: 'p2', positionName: '부서장팀장' }
|
||||
],
|
||||
rank: '부장',
|
||||
createdAt: '2021-01-10T00:00:00Z',
|
||||
updatedAt: '2024-03-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: '정수진',
|
||||
employeeCode: 'abc123',
|
||||
phone: '010-1234-1234',
|
||||
email: 'abc@company.com',
|
||||
status: 'active',
|
||||
hireDate: '2025-09-11',
|
||||
departmentPositions: [
|
||||
{ id: '4', departmentId: 'd3', departmentName: '부서명', positionId: 'p3', positionName: '팀장' }
|
||||
],
|
||||
rank: '부장',
|
||||
createdAt: '2018-09-20T00:00:00Z',
|
||||
updatedAt: '2024-01-30T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
// 추가 mock 데이터 생성 (55명 재직, 5명 휴직, 1명 퇴직)
|
||||
const generateMockEmployees = (): Employee[] => {
|
||||
const employees: Employee[] = [...mockEmployees];
|
||||
const departments = ['부서명'];
|
||||
const positions = ['팀장', '부서장팀장', '파트장'];
|
||||
const ranks = ['부장'];
|
||||
|
||||
for (let i = 5; i <= 61; i++) {
|
||||
const status: EmployeeStatus = i <= 55 ? 'active' : i <= 60 ? 'leave' : 'resigned';
|
||||
employees.push({
|
||||
id: String(i),
|
||||
name: `이름`,
|
||||
employeeCode: `abc123`,
|
||||
phone: `010-1234-1234`,
|
||||
email: `abc@company.com`,
|
||||
status,
|
||||
hireDate: `2025-09-11`,
|
||||
departmentPositions: [
|
||||
{
|
||||
id: String(i),
|
||||
departmentId: `d${Math.floor(1 + Math.random() * 5)}`,
|
||||
departmentName: departments[0],
|
||||
positionId: `p${Math.floor(1 + Math.random() * 3)}`,
|
||||
positionName: positions[Math.floor(Math.random() * positions.length)],
|
||||
}
|
||||
],
|
||||
rank: ranks[0],
|
||||
userInfo: Math.random() > 0.3 ? {
|
||||
userId: `abc`,
|
||||
role: 'user',
|
||||
accountStatus: 'active',
|
||||
} : undefined,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
return employees;
|
||||
};
|
||||
|
||||
export function EmployeeManagement() {
|
||||
const router = useRouter();
|
||||
|
||||
// 사원 데이터 상태
|
||||
const [employees, setEmployees] = useState<Employee[]>(generateMockEmployees);
|
||||
|
||||
// 검색 및 필터 상태
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<string>('all');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// 다이얼로그 상태
|
||||
const [fieldSettingsOpen, setFieldSettingsOpen] = useState(false);
|
||||
const [fieldSettings, setFieldSettings] = useState<FieldSettings>(DEFAULT_FIELD_SETTINGS);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [employeeToDelete, setEmployeeToDelete] = useState<Employee | null>(null);
|
||||
const [userInviteOpen, setUserInviteOpen] = useState(false);
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredEmployees = useMemo(() => {
|
||||
let filtered = employees;
|
||||
|
||||
// 탭 필터 (상태)
|
||||
if (activeTab !== 'all') {
|
||||
filtered = filtered.filter(e => e.status === activeTab);
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if (searchValue) {
|
||||
const search = searchValue.toLowerCase();
|
||||
filtered = filtered.filter(e =>
|
||||
e.name.toLowerCase().includes(search) ||
|
||||
e.employeeCode?.toLowerCase().includes(search) ||
|
||||
e.email?.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [employees, activeTab, searchValue]);
|
||||
|
||||
// 페이지네이션된 데이터
|
||||
const paginatedData = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
return filteredEmployees.slice(startIndex, startIndex + itemsPerPage);
|
||||
}, [filteredEmployees, currentPage, itemsPerPage]);
|
||||
|
||||
// 통계 계산
|
||||
const stats = useMemo(() => {
|
||||
const activeCount = employees.filter(e => e.status === 'active').length;
|
||||
const leaveCount = employees.filter(e => e.status === 'leave').length;
|
||||
const resignedCount = employees.filter(e => e.status === 'resigned').length;
|
||||
|
||||
const activeEmployees = employees.filter(e => e.status === 'active' && e.hireDate);
|
||||
const totalTenure = activeEmployees.reduce((sum, e) => {
|
||||
const hireDate = new Date(e.hireDate!);
|
||||
const today = new Date();
|
||||
const years = (today.getTime() - hireDate.getTime()) / (1000 * 60 * 60 * 24 * 365);
|
||||
return sum + years;
|
||||
}, 0);
|
||||
const averageTenure = activeEmployees.length > 0 ? totalTenure / activeEmployees.length : 0;
|
||||
|
||||
return { activeCount, leaveCount, resignedCount, averageTenure };
|
||||
}, [employees]);
|
||||
|
||||
// StatCards 데이터
|
||||
const statCards: StatCard[] = useMemo(() => [
|
||||
{
|
||||
label: '재직',
|
||||
value: `${stats.activeCount}명`,
|
||||
icon: UserCheck,
|
||||
iconColor: 'text-green-500',
|
||||
},
|
||||
{
|
||||
label: '휴직',
|
||||
value: `${stats.leaveCount}명`,
|
||||
icon: Clock,
|
||||
iconColor: 'text-yellow-500',
|
||||
},
|
||||
{
|
||||
label: '퇴직',
|
||||
value: `${stats.resignedCount}명`,
|
||||
icon: UserX,
|
||||
iconColor: 'text-gray-500',
|
||||
},
|
||||
{
|
||||
label: '평균근속년수',
|
||||
value: `${stats.averageTenure.toFixed(1)}년`,
|
||||
icon: Calendar,
|
||||
iconColor: 'text-blue-500',
|
||||
},
|
||||
], [stats]);
|
||||
|
||||
// 탭 옵션
|
||||
const tabs: TabOption[] = useMemo(() => [
|
||||
{ value: 'all', label: '전체', count: employees.length, color: 'gray' },
|
||||
{ value: 'active', label: '재직', count: stats.activeCount, color: 'green' },
|
||||
{ value: 'leave', label: '휴직', count: stats.leaveCount, color: 'yellow' },
|
||||
{ value: 'resigned', label: '퇴직', count: stats.resignedCount, color: 'gray' },
|
||||
], [employees.length, stats]);
|
||||
|
||||
// 테이블 컬럼 정의
|
||||
const tableColumns: TableColumn[] = useMemo(() => [
|
||||
{ key: 'rowNumber', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'employeeCode', label: '사원코드', className: 'min-w-[100px]' },
|
||||
{ key: 'department', label: '부서', className: 'min-w-[100px]' },
|
||||
{ key: 'position', label: '직책', className: 'min-w-[100px]' },
|
||||
{ key: 'name', label: '이름', className: 'min-w-[80px]' },
|
||||
{ key: 'rank', label: '직급', className: 'min-w-[80px]' },
|
||||
{ key: 'phone', label: '휴대폰', className: 'min-w-[120px]' },
|
||||
{ key: 'email', label: '이메일', className: 'min-w-[150px]' },
|
||||
{ key: 'hireDate', label: '입사일', className: 'min-w-[100px]' },
|
||||
{ key: 'status', label: '상태', className: 'min-w-[80px]' },
|
||||
{ key: 'userId', label: '사용자아이디', className: 'min-w-[100px]' },
|
||||
{ key: 'userRole', label: '권한', className: 'min-w-[80px]' },
|
||||
{ key: 'actions', label: '작업', className: 'w-[100px] text-right' },
|
||||
], []);
|
||||
|
||||
// 체크박스 토글
|
||||
const toggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 전체 선택/해제
|
||||
const toggleSelectAll = useCallback(() => {
|
||||
if (selectedItems.size === paginatedData.length && paginatedData.length > 0) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
const allIds = new Set(paginatedData.map((item) => item.id));
|
||||
setSelectedItems(allIds);
|
||||
}
|
||||
}, [selectedItems.size, paginatedData]);
|
||||
|
||||
// 일괄 삭제 핸들러
|
||||
const handleBulkDelete = useCallback(() => {
|
||||
const ids = Array.from(selectedItems);
|
||||
setEmployees(prev => prev.filter(emp => !ids.includes(emp.id)));
|
||||
setSelectedItems(new Set());
|
||||
}, [selectedItems]);
|
||||
|
||||
// 핸들러
|
||||
const handleAddEmployee = useCallback(() => {
|
||||
router.push('/ko/hr/employee-management/new');
|
||||
}, [router]);
|
||||
|
||||
const handleCSVUpload = useCallback(() => {
|
||||
router.push('/ko/hr/employee-management/csv-upload');
|
||||
}, [router]);
|
||||
|
||||
const handleDeleteEmployee = useCallback(() => {
|
||||
if (employeeToDelete) {
|
||||
setEmployees(prev => prev.filter(emp => emp.id !== employeeToDelete.id));
|
||||
setDeleteDialogOpen(false);
|
||||
setEmployeeToDelete(null);
|
||||
}
|
||||
}, [employeeToDelete]);
|
||||
|
||||
const handleRowClick = useCallback((row: Employee) => {
|
||||
router.push(`/ko/hr/employee-management/${row.id}`);
|
||||
}, [router]);
|
||||
|
||||
const handleEdit = useCallback((id: string) => {
|
||||
router.push(`/ko/hr/employee-management/${id}/edit`);
|
||||
}, [router]);
|
||||
|
||||
const openDeleteDialog = useCallback((employee: Employee) => {
|
||||
setEmployeeToDelete(employee);
|
||||
setDeleteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = useCallback((item: Employee, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(item.id);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className="hover:bg-muted/50 cursor-pointer"
|
||||
onClick={() => handleRowClick(item)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleSelection(item.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-center">
|
||||
{globalIndex}
|
||||
</TableCell>
|
||||
<TableCell>{item.employeeCode || '-'}</TableCell>
|
||||
<TableCell>
|
||||
{item.departmentPositions?.length > 0
|
||||
? item.departmentPositions.map(dp => dp.departmentName).join(', ')
|
||||
: '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.departmentPositions?.length > 0
|
||||
? item.departmentPositions.map(dp => dp.positionName).join(', ')
|
||||
: '-'}
|
||||
</TableCell>
|
||||
<TableCell>{item.name}</TableCell>
|
||||
<TableCell>{item.rank || '-'}</TableCell>
|
||||
<TableCell>{item.phone || '-'}</TableCell>
|
||||
<TableCell>{item.email || '-'}</TableCell>
|
||||
<TableCell>
|
||||
{item.hireDate ? new Date(item.hireDate).toLocaleDateString('ko-KR') : '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={EMPLOYEE_STATUS_COLORS[item.status]}>
|
||||
{EMPLOYEE_STATUS_LABELS[item.status]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{item.userInfo?.userId || '-'}</TableCell>
|
||||
<TableCell>
|
||||
{item.userInfo ? USER_ROLE_LABELS[item.userInfo.role] : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
|
||||
{isSelected && (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(item.id)}
|
||||
title="수정"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openDeleteDialog(item)}
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}, [selectedItems, toggleSelection, handleRowClick, handleEdit, openDeleteDialog]);
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = useCallback((
|
||||
item: Employee,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
isSelected: boolean,
|
||||
onToggle: () => void
|
||||
) => {
|
||||
return (
|
||||
<ListMobileCard
|
||||
id={item.id}
|
||||
title={item.name}
|
||||
headerBadges={
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
#{globalIndex}
|
||||
</Badge>
|
||||
<code className="text-xs bg-gray-100 px-2 py-1 rounded">
|
||||
{item.employeeCode}
|
||||
</code>
|
||||
</div>
|
||||
}
|
||||
statusBadge={
|
||||
<Badge className={EMPLOYEE_STATUS_COLORS[item.status]}>
|
||||
{EMPLOYEE_STATUS_LABELS[item.status]}
|
||||
</Badge>
|
||||
}
|
||||
isSelected={isSelected}
|
||||
onToggleSelection={onToggle}
|
||||
onCardClick={() => handleRowClick(item)}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField
|
||||
label="부서"
|
||||
value={item.departmentPositions?.length > 0
|
||||
? item.departmentPositions.map(dp => dp.departmentName).join(', ')
|
||||
: '-'}
|
||||
/>
|
||||
<InfoField
|
||||
label="직책"
|
||||
value={item.departmentPositions?.length > 0
|
||||
? item.departmentPositions.map(dp => dp.positionName).join(', ')
|
||||
: '-'}
|
||||
/>
|
||||
<InfoField label="직급" value={item.rank || '-'} />
|
||||
<InfoField label="휴대폰" value={item.phone || '-'} />
|
||||
<InfoField label="이메일" value={item.email || '-'} />
|
||||
<InfoField
|
||||
label="입사일"
|
||||
value={item.hireDate ? new Date(item.hireDate).toLocaleDateString('ko-KR') : '-'}
|
||||
/>
|
||||
{item.userInfo && (
|
||||
<>
|
||||
<InfoField label="사용자ID" value={item.userInfo.userId || '-'} />
|
||||
<InfoField label="권한" value={USER_ROLE_LABELS[item.userInfo.role]} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
isSelected ? (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
variant="default"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11"
|
||||
onClick={(e) => { e.stopPropagation(); handleEdit(item.id); }}
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-transparent"
|
||||
onClick={(e) => { e.stopPropagation(); openDeleteDialog(item); }}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}, [handleRowClick, handleEdit, openDeleteDialog]);
|
||||
|
||||
// 헤더 액션
|
||||
const headerActions = (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setUserInviteOpen(true)}
|
||||
>
|
||||
<Mail className="w-4 h-4 mr-2" />
|
||||
사용자 초대
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleCSVUpload}>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
CSV 일괄 등록
|
||||
</Button>
|
||||
<Button onClick={handleAddEmployee}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
사원 등록
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 페이지네이션 설정
|
||||
const totalPages = Math.ceil(filteredEmployees.length / itemsPerPage);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedListTemplateV2<Employee>
|
||||
title="사원관리"
|
||||
description="사원 정보를 관리합니다"
|
||||
icon={Users}
|
||||
headerActions={headerActions}
|
||||
stats={statCards}
|
||||
searchValue={searchValue}
|
||||
onSearchChange={setSearchValue}
|
||||
searchPlaceholder="이름, 사원코드, 이메일 검색..."
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
tableColumns={tableColumns}
|
||||
data={paginatedData}
|
||||
totalCount={filteredEmployees.length}
|
||||
allData={filteredEmployees}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={toggleSelection}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
onBulkDelete={handleBulkDelete}
|
||||
getItemId={(item) => item.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: filteredEmployees.length,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 필드 설정 다이얼로그 */}
|
||||
<FieldSettingsDialog
|
||||
open={fieldSettingsOpen}
|
||||
onOpenChange={setFieldSettingsOpen}
|
||||
settings={fieldSettings}
|
||||
onSave={setFieldSettings}
|
||||
/>
|
||||
|
||||
{/* 사용자 초대 다이얼로그 */}
|
||||
<UserInviteDialog
|
||||
open={userInviteOpen}
|
||||
onOpenChange={setUserInviteOpen}
|
||||
onInvite={(data) => {
|
||||
console.log('Invite user:', data);
|
||||
setUserInviteOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>사원 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
"{employeeToDelete?.name}" 사원을 삭제하시겠습니까?
|
||||
<br />
|
||||
<span className="text-destructive">
|
||||
삭제된 사원 정보는 복구할 수 없습니다.
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDeleteEmployee}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
266
src/components/hr/EmployeeManagement/types.ts
Normal file
266
src/components/hr/EmployeeManagement/types.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* Employee Management Types
|
||||
* 사원관리 타입 정의
|
||||
*/
|
||||
|
||||
// ===== 기본 Enum/상수 타입 =====
|
||||
|
||||
/** 사원 상태 */
|
||||
export type EmployeeStatus = 'active' | 'leave' | 'resigned';
|
||||
|
||||
/** 상태 라벨 매핑 */
|
||||
export const EMPLOYEE_STATUS_LABELS: Record<EmployeeStatus, string> = {
|
||||
active: '재직',
|
||||
leave: '휴직',
|
||||
resigned: '퇴직',
|
||||
};
|
||||
|
||||
/** 상태 뱃지 색상 */
|
||||
export const EMPLOYEE_STATUS_COLORS: Record<EmployeeStatus, string> = {
|
||||
active: 'bg-green-100 text-green-800',
|
||||
leave: 'bg-yellow-100 text-yellow-800',
|
||||
resigned: 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
|
||||
/** 고용 형태 */
|
||||
export type EmploymentType = 'regular' | 'contract' | 'parttime' | 'intern';
|
||||
|
||||
export const EMPLOYMENT_TYPE_LABELS: Record<EmploymentType, string> = {
|
||||
regular: '정규직',
|
||||
contract: '계약직',
|
||||
parttime: '파트타임',
|
||||
intern: '인턴',
|
||||
};
|
||||
|
||||
/** 성별 */
|
||||
export type Gender = 'male' | 'female';
|
||||
|
||||
export const GENDER_LABELS: Record<Gender, string> = {
|
||||
male: '남성',
|
||||
female: '여성',
|
||||
};
|
||||
|
||||
/** 사용자 권한 */
|
||||
export type UserRole = 'admin' | 'manager' | 'user';
|
||||
|
||||
export const USER_ROLE_LABELS: Record<UserRole, string> = {
|
||||
admin: '관리자',
|
||||
manager: '매니저',
|
||||
user: '일반 사용자',
|
||||
};
|
||||
|
||||
/** 사용자 계정 상태 */
|
||||
export type UserAccountStatus = 'active' | 'inactive' | 'pending';
|
||||
|
||||
export const USER_ACCOUNT_STATUS_LABELS: Record<UserAccountStatus, string> = {
|
||||
active: '활성',
|
||||
inactive: '비활성',
|
||||
pending: '대기',
|
||||
};
|
||||
|
||||
// ===== 부서/직책 관련 =====
|
||||
|
||||
/** 부서-직책 매핑 (한 사원이 여러 부서에 소속 가능) */
|
||||
export interface DepartmentPosition {
|
||||
id: string;
|
||||
departmentId: string;
|
||||
departmentName: string;
|
||||
positionId: string;
|
||||
positionName: string;
|
||||
}
|
||||
|
||||
// ===== 급여 계좌 정보 =====
|
||||
|
||||
export interface BankAccount {
|
||||
bankName: string;
|
||||
accountNumber: string;
|
||||
accountHolder: string;
|
||||
}
|
||||
|
||||
// ===== 주소 정보 =====
|
||||
|
||||
export interface Address {
|
||||
zipCode: string;
|
||||
address1: string; // 기본 주소
|
||||
address2: string; // 상세 주소
|
||||
}
|
||||
|
||||
// ===== 사용자 정보 =====
|
||||
|
||||
export interface UserInfo {
|
||||
userId: string;
|
||||
password?: string; // 등록/수정 시에만 사용
|
||||
role: UserRole;
|
||||
accountStatus: UserAccountStatus;
|
||||
}
|
||||
|
||||
// ===== 메인 Employee 인터페이스 =====
|
||||
|
||||
export interface Employee {
|
||||
id: string;
|
||||
|
||||
// 기본 정보 (필수)
|
||||
name: string;
|
||||
|
||||
// 기본 정보 (선택)
|
||||
residentNumber?: string; // 주민등록번호
|
||||
phone?: string;
|
||||
email?: string;
|
||||
salary?: number; // 연봉
|
||||
bankAccount?: BankAccount;
|
||||
|
||||
// 선택적 필드 (설정에 따라 표시)
|
||||
profileImage?: string;
|
||||
employeeCode?: string; // 사원코드
|
||||
gender?: Gender;
|
||||
address?: Address;
|
||||
|
||||
// 인사 정보
|
||||
hireDate?: string; // YYYY-MM-DD
|
||||
employmentType?: EmploymentType;
|
||||
rank?: string; // 직급 (예: 사원, 대리, 과장 등)
|
||||
status: EmployeeStatus;
|
||||
departmentPositions: DepartmentPosition[]; // 부서/직책 (복수 가능)
|
||||
|
||||
// 사용자 정보 (시스템 계정)
|
||||
userInfo?: UserInfo;
|
||||
|
||||
// 메타 정보
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ===== 폼 데이터 타입 =====
|
||||
|
||||
export interface EmployeeFormData {
|
||||
// 기본 정보
|
||||
name: string;
|
||||
residentNumber: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
salary: string; // 입력 시 문자열
|
||||
bankAccount: BankAccount;
|
||||
|
||||
// 선택적 필드
|
||||
profileImage: string;
|
||||
employeeCode: string;
|
||||
gender: Gender | '';
|
||||
address: Address;
|
||||
|
||||
// 인사 정보
|
||||
hireDate: string;
|
||||
employmentType: EmploymentType | '';
|
||||
rank: string;
|
||||
status: EmployeeStatus;
|
||||
departmentPositions: DepartmentPosition[];
|
||||
|
||||
// 사용자 정보
|
||||
hasUserAccount: boolean;
|
||||
userId: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
role: UserRole;
|
||||
accountStatus: UserAccountStatus;
|
||||
}
|
||||
|
||||
// ===== 필드 설정 타입 =====
|
||||
|
||||
export interface FieldSettings {
|
||||
// 사원 상세 필드
|
||||
showProfileImage: boolean;
|
||||
showEmployeeCode: boolean;
|
||||
showGender: boolean;
|
||||
showAddress: boolean;
|
||||
|
||||
// 인사 정보 필드
|
||||
showHireDate: boolean;
|
||||
showEmploymentType: boolean;
|
||||
showRank: boolean;
|
||||
showStatus: boolean;
|
||||
showDepartment: boolean;
|
||||
showPosition: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_FIELD_SETTINGS: FieldSettings = {
|
||||
showProfileImage: true,
|
||||
showEmployeeCode: true,
|
||||
showGender: true,
|
||||
showAddress: true,
|
||||
showHireDate: true,
|
||||
showEmploymentType: true,
|
||||
showRank: true,
|
||||
showStatus: true,
|
||||
showDepartment: true,
|
||||
showPosition: true,
|
||||
};
|
||||
|
||||
// ===== 필터/검색 타입 =====
|
||||
|
||||
export type EmployeeFilterType = 'all' | 'hasUserId' | 'noUserId';
|
||||
|
||||
export const EMPLOYEE_FILTER_OPTIONS: { value: EmployeeFilterType; label: string }[] = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'hasUserId', label: '사용자 아이디 보유' },
|
||||
{ value: 'noUserId', label: '사용자 아이디 미보유' },
|
||||
];
|
||||
|
||||
// ===== CSV 업로드 타입 =====
|
||||
|
||||
export interface CSVEmployeeRow {
|
||||
name: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
departmentName?: string;
|
||||
positionName?: string;
|
||||
hireDate?: string;
|
||||
status?: string;
|
||||
// 추가 필드들...
|
||||
}
|
||||
|
||||
export interface CSVValidationResult {
|
||||
row: number;
|
||||
data: CSVEmployeeRow;
|
||||
isValid: boolean;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
// ===== 통계 타입 =====
|
||||
|
||||
export interface EmployeeStats {
|
||||
activeCount: number;
|
||||
leaveCount: number;
|
||||
resignedCount: number;
|
||||
averageTenure: number; // 평균 근속년수
|
||||
}
|
||||
|
||||
// ===== 다이얼로그 타입 =====
|
||||
|
||||
export type DialogMode = 'create' | 'edit' | 'view';
|
||||
|
||||
export interface EmployeeDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
mode: DialogMode;
|
||||
employee?: Employee;
|
||||
onSave: (data: EmployeeFormData) => void;
|
||||
fieldSettings: FieldSettings;
|
||||
}
|
||||
|
||||
// ===== 정렬 옵션 =====
|
||||
|
||||
export type SortField = 'name' | 'employeeCode' | 'department' | 'hireDate' | 'status';
|
||||
export type SortDirection = 'asc' | 'desc';
|
||||
|
||||
export interface SortOption {
|
||||
field: SortField;
|
||||
direction: SortDirection;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const SORT_OPTIONS: SortOption[] = [
|
||||
{ field: 'name', direction: 'asc', label: '이름 (오름차순)' },
|
||||
{ field: 'name', direction: 'desc', label: '이름 (내림차순)' },
|
||||
{ field: 'hireDate', direction: 'desc', label: '입사일 (최신순)' },
|
||||
{ field: 'hireDate', direction: 'asc', label: '입사일 (오래된순)' },
|
||||
{ field: 'employeeCode', direction: 'asc', label: '사원코드 (오름차순)' },
|
||||
];
|
||||
@@ -80,6 +80,22 @@ export function DropdownField({
|
||||
// 옵션이 없으면 드롭다운을 disabled로 표시
|
||||
const hasOptions = options.length > 0;
|
||||
|
||||
// 디버깅: 단위 필드 값 추적
|
||||
if (isUnitField) {
|
||||
console.log('[DropdownField] 단위 필드 디버깅:', {
|
||||
fieldKey,
|
||||
fieldName: field.field_name,
|
||||
rawValue: value,
|
||||
stringValue,
|
||||
isUnitField,
|
||||
unitOptionsCount: unitOptions?.length || 0,
|
||||
unitOptions: unitOptions?.slice(0, 3), // 처음 3개만
|
||||
optionsCount: options.length,
|
||||
options: options.slice(0, 3), // 처음 3개만
|
||||
valueInOptions: options.some(o => o.value === stringValue),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor={fieldKey}>
|
||||
@@ -87,6 +103,7 @@ export function DropdownField({
|
||||
{field.is_required && <span className="text-red-500"> *</span>}
|
||||
</Label>
|
||||
<Select
|
||||
key={`${fieldKey}-${stringValue}`}
|
||||
value={stringValue}
|
||||
onValueChange={onChange}
|
||||
disabled={disabled || !hasOptions}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import type {
|
||||
DynamicFormData,
|
||||
DynamicFormErrors,
|
||||
@@ -19,26 +19,18 @@ import type {
|
||||
import type { ItemFieldResponse } from '@/types/item-master-api';
|
||||
|
||||
export function useDynamicFormState(
|
||||
initialData?: DynamicFormData
|
||||
_initialData?: DynamicFormData // 사용하지 않음 - 호환성을 위해 파라미터 유지
|
||||
): UseDynamicFormStateResult {
|
||||
const [formData, setFormData] = useState<DynamicFormData>(initialData || {});
|
||||
// 2025-12-05: 항상 빈 객체로 시작
|
||||
// Edit 모드 데이터는 DynamicItemForm에서 resetForm()으로 설정
|
||||
// 이렇게 해야 StrictMode 리마운트에서도 안전함
|
||||
const [formData, setFormData] = useState<DynamicFormData>({});
|
||||
const [errors, setErrors] = useState<DynamicFormErrors>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// 2025-12-04: Edit 모드에서 initialData가 비동기로 로드될 때 formData 동기화
|
||||
// useState의 초기값은 첫 렌더 시에만 사용되므로,
|
||||
// initialData가 나중에 변경되면 formData를 업데이트해야 함
|
||||
const isInitialDataLoaded = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// initialData가 있고, 아직 로드되지 않았을 때만 동기화
|
||||
// (사용자가 수정 중인 데이터를 덮어쓰지 않도록)
|
||||
if (initialData && Object.keys(initialData).length > 0 && !isInitialDataLoaded.current) {
|
||||
console.log('[useDynamicFormState] initialData 동기화:', initialData);
|
||||
setFormData(initialData);
|
||||
isInitialDataLoaded.current = true;
|
||||
}
|
||||
}, [initialData]);
|
||||
// 2025-12-05: initialData 동기화 useEffect 제거
|
||||
// 모든 초기 데이터는 resetForm()을 통해서만 설정
|
||||
// StrictMode에서 useState 초기값이 원본 데이터로 리셋되는 문제 해결
|
||||
|
||||
// 필드 값 설정
|
||||
const setFieldValue = useCallback((fieldKey: string, value: DynamicFieldValue) => {
|
||||
@@ -186,6 +178,7 @@ export function useDynamicFormState(
|
||||
|
||||
// 폼 초기화
|
||||
const resetForm = useCallback((newInitialData?: DynamicFormData) => {
|
||||
console.log('[useDynamicFormState] resetForm 호출됨:', newInitialData);
|
||||
setFormData(newInitialData || {});
|
||||
setErrors({});
|
||||
setIsSubmitting(false);
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Package, Save, X } from 'lucide-react';
|
||||
import { Package, Save, X, FileText, Trash2, Download } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -34,9 +34,10 @@ import {
|
||||
generateBendingItemCodeSimple,
|
||||
generatePurchasedItemCode,
|
||||
} from './utils/itemCodeGenerator';
|
||||
import type { DynamicItemFormProps, DynamicFormData, DynamicSection, DynamicFieldValue, BOMLine, BOMSearchState } from './types';
|
||||
import type { DynamicItemFormProps, DynamicFormData, DynamicSection, DynamicFieldValue, BOMLine, BOMSearchState, ItemSaveResult } from './types';
|
||||
import type { ItemType, BendingDetail } from '@/types/item';
|
||||
import type { ItemFieldResponse } from '@/types/item-master-api';
|
||||
import { uploadItemFile, deleteItemFile, ItemFileType } from '@/lib/api/items';
|
||||
|
||||
/**
|
||||
* 헤더 컴포넌트 - 기존 FormHeader와 동일한 디자인
|
||||
@@ -220,6 +221,7 @@ function DynamicSectionRenderer({
|
||||
export default function DynamicItemForm({
|
||||
mode,
|
||||
itemType: initialItemType,
|
||||
itemId: propItemId,
|
||||
initialData,
|
||||
onSubmit,
|
||||
}: DynamicItemFormProps) {
|
||||
@@ -260,6 +262,75 @@ export default function DynamicItemForm({
|
||||
const [specificationFile, setSpecificationFile] = useState<File | null>(null);
|
||||
const [certificationFile, setCertificationFile] = useState<File | null>(null);
|
||||
|
||||
// 기존 파일 URL 상태 (edit 모드에서 사용)
|
||||
const [existingBendingDiagram, setExistingBendingDiagram] = useState<string>('');
|
||||
const [existingSpecificationFile, setExistingSpecificationFile] = useState<string>('');
|
||||
const [existingSpecificationFileName, setExistingSpecificationFileName] = useState<string>('');
|
||||
const [existingCertificationFile, setExistingCertificationFile] = useState<string>('');
|
||||
const [existingCertificationFileName, setExistingCertificationFileName] = useState<string>('');
|
||||
const [isDeletingFile, setIsDeletingFile] = useState<string | null>(null);
|
||||
|
||||
// initialData에서 기존 파일 정보 로드 (edit 모드)
|
||||
useEffect(() => {
|
||||
if (mode === 'edit' && initialData) {
|
||||
if (initialData.bending_diagram) {
|
||||
setExistingBendingDiagram(initialData.bending_diagram as string);
|
||||
}
|
||||
if (initialData.specification_file) {
|
||||
setExistingSpecificationFile(initialData.specification_file as string);
|
||||
setExistingSpecificationFileName((initialData.specification_file_name as string) || '시방서');
|
||||
}
|
||||
if (initialData.certification_file) {
|
||||
setExistingCertificationFile(initialData.certification_file as string);
|
||||
setExistingCertificationFileName((initialData.certification_file_name as string) || '인정서');
|
||||
}
|
||||
}
|
||||
}, [mode, initialData]);
|
||||
|
||||
// Storage 경로를 전체 URL로 변환
|
||||
const getStorageUrl = (path: string | undefined): string | null => {
|
||||
if (!path) return null;
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||
return path;
|
||||
}
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
return `${apiUrl}/storage/${path}`;
|
||||
};
|
||||
|
||||
// 파일 삭제 핸들러
|
||||
const handleDeleteFile = async (fileType: ItemFileType) => {
|
||||
if (!propItemId) return;
|
||||
|
||||
const confirmMessage = fileType === 'bending_diagram' ? '전개도 이미지를' :
|
||||
fileType === 'specification' ? '시방서 파일을' : '인정서 파일을';
|
||||
|
||||
if (!confirm(`${confirmMessage} 삭제하시겠습니까?`)) return;
|
||||
|
||||
try {
|
||||
setIsDeletingFile(fileType);
|
||||
await deleteItemFile(propItemId, fileType);
|
||||
|
||||
// 상태 업데이트
|
||||
if (fileType === 'bending_diagram') {
|
||||
setExistingBendingDiagram('');
|
||||
setBendingDiagram('');
|
||||
} else if (fileType === 'specification') {
|
||||
setExistingSpecificationFile('');
|
||||
setExistingSpecificationFileName('');
|
||||
} else if (fileType === 'certification') {
|
||||
setExistingCertificationFile('');
|
||||
setExistingCertificationFileName('');
|
||||
}
|
||||
|
||||
alert('파일이 삭제되었습니다.');
|
||||
} catch (error) {
|
||||
console.error('[DynamicItemForm] 파일 삭제 실패:', error);
|
||||
alert('파일 삭제에 실패했습니다.');
|
||||
} finally {
|
||||
setIsDeletingFile(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 조건부 표시 관리
|
||||
const { shouldShowSection, shouldShowField } = useConditionalDisplay(structure, formData);
|
||||
|
||||
@@ -332,9 +403,18 @@ export default function DynamicItemForm({
|
||||
const [isEditDataMapped, setIsEditDataMapped] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'edit' || !structure || !initialData || isEditDataMapped) return;
|
||||
if (mode !== 'edit' || !structure || !initialData) return;
|
||||
|
||||
// console.log('[DynamicItemForm] Edit mode: mapping initialData to field_key format');
|
||||
// 이미 매핑된 데이터가 formData에 있으면 스킵 (98_unit 같은 field_key 형식)
|
||||
// StrictMode 리렌더에서도 안전하게 동작
|
||||
const hasFieldKeyData = Object.keys(formData).some(key => /^\d+_/.test(key));
|
||||
if (hasFieldKeyData) {
|
||||
console.log('[DynamicItemForm] Edit mode: 이미 field_key 형식 데이터 있음, 매핑 스킵');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[DynamicItemForm] Edit mode: mapping initialData to field_key format');
|
||||
console.log('[DynamicItemForm] initialData:', initialData);
|
||||
|
||||
// initialData의 간단한 키를 structure의 field_key로 매핑
|
||||
// 예: { item_name: '테스트' } → { '98_item_name': '테스트' }
|
||||
@@ -353,6 +433,17 @@ export default function DynamicItemForm({
|
||||
// structure에서 모든 필드의 field_key 수집
|
||||
const fieldKeyMap: Record<string, string> = {}; // 간단한 키 → field_key 매핑
|
||||
|
||||
// 영문 → 한글 필드명 별칭 (API 응답 키 → structure field_name 매핑)
|
||||
// API는 영문 키(unit, note)로 응답하지만, structure field_key는 한글(단위, 비고) 포함
|
||||
const fieldAliases: Record<string, string> = {
|
||||
'unit': '단위',
|
||||
'note': '비고',
|
||||
'remarks': '비고', // Material 모델은 remarks 사용
|
||||
'item_name': '품목명',
|
||||
'specification': '규격',
|
||||
'description': '설명',
|
||||
};
|
||||
|
||||
structure.sections.forEach((section) => {
|
||||
section.fields.forEach((f) => {
|
||||
const field = f.field;
|
||||
@@ -378,7 +469,7 @@ export default function DynamicItemForm({
|
||||
}
|
||||
});
|
||||
|
||||
// console.log('[DynamicItemForm] fieldKeyMap:', fieldKeyMap);
|
||||
console.log('[DynamicItemForm] fieldKeyMap:', fieldKeyMap);
|
||||
|
||||
// initialData를 field_key 형식으로 변환
|
||||
Object.entries(initialData).forEach(([key, value]) => {
|
||||
@@ -390,13 +481,41 @@ export default function DynamicItemForm({
|
||||
else if (fieldKeyMap[key]) {
|
||||
mappedData[fieldKeyMap[key]] = value;
|
||||
}
|
||||
// 영문 → 한글 별칭으로 시도 (API 응답 키 → structure field_name)
|
||||
else if (fieldAliases[key] && fieldKeyMap[fieldAliases[key]]) {
|
||||
mappedData[fieldKeyMap[fieldAliases[key]]] = value;
|
||||
console.log(`[DynamicItemForm] 별칭 매핑: ${key} → ${fieldAliases[key]} → ${fieldKeyMap[fieldAliases[key]]}`);
|
||||
}
|
||||
// 매핑 없는 경우 그대로 유지
|
||||
else {
|
||||
mappedData[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
// console.log('[DynamicItemForm] Mapped initialData:', mappedData);
|
||||
// 추가: 폼 구조의 모든 필드를 순회하면서, initialData에서 해당 값 직접 찾아서 설정
|
||||
// (fieldKeyMap에 매핑이 없는 경우를 위한 fallback)
|
||||
Object.entries(fieldKeyMap).forEach(([simpleName, fieldKey]) => {
|
||||
// 아직 매핑 안된 필드인데 initialData에 값이 있으면 설정
|
||||
if (mappedData[fieldKey] === undefined && initialData[simpleName] !== undefined) {
|
||||
mappedData[fieldKey] = initialData[simpleName];
|
||||
}
|
||||
});
|
||||
|
||||
// 추가: 영문 별칭을 역으로 검색하여 매핑 (한글 field_name → 영문 API 키)
|
||||
// 예: fieldKeyMap에 '단위'가 있고, initialData에 'unit'이 있으면 매핑
|
||||
Object.entries(fieldAliases).forEach(([englishKey, koreanKey]) => {
|
||||
const targetFieldKey = fieldKeyMap[koreanKey];
|
||||
if (targetFieldKey && mappedData[targetFieldKey] === undefined && initialData[englishKey] !== undefined) {
|
||||
mappedData[targetFieldKey] = initialData[englishKey];
|
||||
console.log(`[DynamicItemForm] 별칭 fallback 매핑: ${englishKey} → ${koreanKey} → ${targetFieldKey}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('========== [DynamicItemForm] Edit 모드 데이터 매핑 ==========');
|
||||
console.log('specification 관련 키:', Object.keys(mappedData).filter(k => k.includes('specification') || k.includes('규격')));
|
||||
console.log('is_active 관련 키:', Object.keys(mappedData).filter(k => k.includes('active') || k.includes('상태')));
|
||||
console.log('매핑된 데이터:', mappedData);
|
||||
console.log('==============================================================');
|
||||
|
||||
// 변환된 데이터로 폼 리셋
|
||||
resetForm(mappedData);
|
||||
@@ -1113,7 +1232,11 @@ export default function DynamicItemForm({
|
||||
};
|
||||
|
||||
// formData를 백엔드 필드명으로 변환
|
||||
// console.log('[DynamicItemForm] formData before conversion:', formData);
|
||||
console.log('========== [DynamicItemForm] 저장 시 formData ==========');
|
||||
console.log('specification 관련:', Object.entries(formData).filter(([k]) => k.includes('specification') || k.includes('규격')));
|
||||
console.log('is_active 관련:', Object.entries(formData).filter(([k]) => k.includes('active') || k.includes('상태')));
|
||||
console.log('전체 formData:', formData);
|
||||
console.log('=========================================================');
|
||||
const convertedData: Record<string, any> = {};
|
||||
Object.entries(formData).forEach(([key, value]) => {
|
||||
// "{id}_{fieldKey}" 형식 체크: 숫자로 시작하고 _가 있는 경우
|
||||
@@ -1131,6 +1254,7 @@ export default function DynamicItemForm({
|
||||
// "활성", true, "true", "1", 1 등을 true로, 나머지는 false로
|
||||
const isActive = value === true || value === 'true' || value === '1' ||
|
||||
value === 1 || value === '활성' || value === 'active';
|
||||
console.log(`[DynamicItemForm] is_active 변환: key=${key}, value=${value}(${typeof value}) → isActive=${isActive}`);
|
||||
convertedData[backendKey] = isActive;
|
||||
} else {
|
||||
convertedData[backendKey] = value;
|
||||
@@ -1142,13 +1266,18 @@ export default function DynamicItemForm({
|
||||
if (backendKey === 'is_active') {
|
||||
const isActive = value === true || value === 'true' || value === '1' ||
|
||||
value === 1 || value === '활성' || value === 'active';
|
||||
console.log(`[DynamicItemForm] is_active 변환 (non-field_key): key=${key}, value=${value}(${typeof value}) → isActive=${isActive}`);
|
||||
convertedData[backendKey] = isActive;
|
||||
} else {
|
||||
convertedData[backendKey] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
// console.log('[DynamicItemForm] convertedData after conversion:', convertedData);
|
||||
console.log('========== [DynamicItemForm] convertedData 결과 ==========');
|
||||
console.log('is_active:', convertedData.is_active);
|
||||
console.log('specification:', convertedData.spec || convertedData.specification);
|
||||
console.log('전체:', convertedData);
|
||||
console.log('===========================================================');
|
||||
|
||||
// 품목명 값 추출 (품목코드와 품목명 모두 필요)
|
||||
// 2025-12-04: 절곡 부품은 별도 품목명 필드(bendingFieldKeys.itemName) 사용
|
||||
@@ -1249,7 +1378,79 @@ export default function DynamicItemForm({
|
||||
// console.log('[DynamicItemForm] 제출 데이터:', submitData);
|
||||
|
||||
await handleSubmit(async () => {
|
||||
await onSubmit(submitData);
|
||||
// 품목 저장 (ID 반환)
|
||||
const result = await onSubmit(submitData);
|
||||
const itemId = result?.id;
|
||||
|
||||
// 파일 업로드 (품목 ID가 있을 때만)
|
||||
if (itemId) {
|
||||
const fileUploadErrors: string[] = [];
|
||||
|
||||
// PT (절곡/조립) 전개도 이미지 업로드
|
||||
if (selectedItemType === 'PT' && (isBendingPart || isAssemblyPart) && bendingDiagramFile) {
|
||||
try {
|
||||
console.log('[DynamicItemForm] 전개도 파일 업로드 시작:', bendingDiagramFile.name);
|
||||
await uploadItemFile(itemId, bendingDiagramFile, 'bending_diagram', {
|
||||
bendingDetails: bendingDetails.length > 0 ? bendingDetails.map(d => ({
|
||||
angle: d.angle || 0,
|
||||
length: d.width || 0,
|
||||
type: d.direction || '',
|
||||
})) : undefined,
|
||||
});
|
||||
console.log('[DynamicItemForm] 전개도 파일 업로드 성공');
|
||||
} catch (error) {
|
||||
console.error('[DynamicItemForm] 전개도 파일 업로드 실패:', error);
|
||||
fileUploadErrors.push('전개도 이미지');
|
||||
}
|
||||
}
|
||||
|
||||
// FG (제품) 시방서 업로드
|
||||
if (selectedItemType === 'FG' && specificationFile) {
|
||||
try {
|
||||
console.log('[DynamicItemForm] 시방서 파일 업로드 시작:', specificationFile.name);
|
||||
await uploadItemFile(itemId, specificationFile, 'specification');
|
||||
console.log('[DynamicItemForm] 시방서 파일 업로드 성공');
|
||||
} catch (error) {
|
||||
console.error('[DynamicItemForm] 시방서 파일 업로드 실패:', error);
|
||||
fileUploadErrors.push('시방서');
|
||||
}
|
||||
}
|
||||
|
||||
// FG (제품) 인정서 업로드
|
||||
if (selectedItemType === 'FG' && certificationFile) {
|
||||
try {
|
||||
console.log('[DynamicItemForm] 인정서 파일 업로드 시작:', certificationFile.name);
|
||||
// formData에서 인정서 관련 필드 추출
|
||||
const certNumber = Object.entries(formData).find(([key]) =>
|
||||
key.includes('certification_number') || key.includes('인정번호')
|
||||
)?.[1] as string | undefined;
|
||||
const certStartDate = Object.entries(formData).find(([key]) =>
|
||||
key.includes('certification_start') || key.includes('인정_유효기간_시작')
|
||||
)?.[1] as string | undefined;
|
||||
const certEndDate = Object.entries(formData).find(([key]) =>
|
||||
key.includes('certification_end') || key.includes('인정_유효기간_종료')
|
||||
)?.[1] as string | undefined;
|
||||
|
||||
await uploadItemFile(itemId, certificationFile, 'certification', {
|
||||
certificationNumber: certNumber,
|
||||
certificationStartDate: certStartDate,
|
||||
certificationEndDate: certEndDate,
|
||||
});
|
||||
console.log('[DynamicItemForm] 인정서 파일 업로드 성공');
|
||||
} catch (error) {
|
||||
console.error('[DynamicItemForm] 인정서 파일 업로드 실패:', error);
|
||||
fileUploadErrors.push('인정서');
|
||||
}
|
||||
}
|
||||
|
||||
// 파일 업로드 실패 경고 (품목은 저장됨)
|
||||
if (fileUploadErrors.length > 0) {
|
||||
console.warn('[DynamicItemForm] 일부 파일 업로드 실패:', fileUploadErrors.join(', '));
|
||||
// 품목은 저장되었으므로 경고만 표시하고 진행
|
||||
alert(`품목이 저장되었습니다.\n\n일부 파일 업로드에 실패했습니다: ${fileUploadErrors.join(', ')}\n수정 화면에서 다시 업로드해 주세요.`);
|
||||
}
|
||||
}
|
||||
|
||||
router.push('/items');
|
||||
router.refresh();
|
||||
});
|
||||
@@ -1484,10 +1685,36 @@ export default function DynamicItemForm({
|
||||
{/* FG(제품) 전용: 인정 유효기간 종료일 다음에 시방서/인정서 파일 업로드 */}
|
||||
{isCertEndDateField && selectedItemType === 'FG' && (
|
||||
<div className="mt-4 space-y-4">
|
||||
{/* 시방서 파일 업로드 */}
|
||||
{/* 시방서 파일 */}
|
||||
<div>
|
||||
<Label htmlFor="specification_file">시방서 (PDF)</Label>
|
||||
<div className="mt-1.5">
|
||||
<div className="mt-1.5 space-y-2">
|
||||
{/* 기존 파일 표시 (edit 모드) */}
|
||||
{mode === 'edit' && existingSpecificationFile && !specificationFile && (
|
||||
<div className="flex items-center gap-2 p-2 bg-gray-50 rounded border">
|
||||
<FileText className="h-4 w-4 text-blue-600" />
|
||||
<span className="text-sm flex-1 truncate">{existingSpecificationFileName}</span>
|
||||
<a
|
||||
href={getStorageUrl(existingSpecificationFile) || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteFile('specification')}
|
||||
disabled={isDeletingFile === 'specification' || isSubmitting}
|
||||
className="h-7 w-7 p-0 text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{/* 새 파일 업로드 */}
|
||||
<Input
|
||||
id="specification_file"
|
||||
type="file"
|
||||
@@ -1500,16 +1727,42 @@ export default function DynamicItemForm({
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
{specificationFile && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
선택된 파일: {specificationFile.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* 인정서 파일 업로드 */}
|
||||
{/* 인정서 파일 */}
|
||||
<div>
|
||||
<Label htmlFor="certification_file">인정서 (PDF)</Label>
|
||||
<div className="mt-1.5">
|
||||
<div className="mt-1.5 space-y-2">
|
||||
{/* 기존 파일 표시 (edit 모드) */}
|
||||
{mode === 'edit' && existingCertificationFile && !certificationFile && (
|
||||
<div className="flex items-center gap-2 p-2 bg-gray-50 rounded border">
|
||||
<FileText className="h-4 w-4 text-green-600" />
|
||||
<span className="text-sm flex-1 truncate">{existingCertificationFileName}</span>
|
||||
<a
|
||||
href={getStorageUrl(existingCertificationFile) || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm text-green-600 hover:text-green-800"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteFile('certification')}
|
||||
disabled={isDeletingFile === 'certification' || isSubmitting}
|
||||
className="h-7 w-7 p-0 text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{/* 새 파일 업로드 */}
|
||||
<Input
|
||||
id="certification_file"
|
||||
type="file"
|
||||
@@ -1522,7 +1775,7 @@ export default function DynamicItemForm({
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
{certificationFile && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
선택된 파일: {certificationFile.name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -134,14 +134,24 @@ export type DynamicFormErrors = Record<string, string>;
|
||||
// 컴포넌트 Props 타입
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 품목 저장 결과 (파일 업로드에 필요한 ID 포함)
|
||||
*/
|
||||
export interface ItemSaveResult {
|
||||
id: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* DynamicItemForm 메인 컴포넌트 Props
|
||||
*/
|
||||
export interface DynamicItemFormProps {
|
||||
mode: 'create' | 'edit';
|
||||
itemType: 'FG' | 'PT' | 'SM' | 'RM' | 'CS';
|
||||
itemType?: 'FG' | 'PT' | 'SM' | 'RM' | 'CS';
|
||||
itemId?: number; // edit 모드에서 파일 업로드에 사용
|
||||
initialData?: DynamicFormData;
|
||||
onSubmit: (data: DynamicFormData) => Promise<void>;
|
||||
/** 품목 저장 후 결과 반환 (create: id 필수, edit: id 선택) */
|
||||
onSubmit: (data: DynamicFormData) => Promise<ItemSaveResult | void>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { ArrowLeft, Edit, Package } from 'lucide-react';
|
||||
import { ArrowLeft, Edit, Package, FileImage, Download, FileText, Check, Calendar } from 'lucide-react';
|
||||
|
||||
interface ItemDetailClientProps {
|
||||
item: ItemMaster;
|
||||
@@ -60,6 +60,22 @@ function formatItemCodeForAssembly(item: ItemMaster): string {
|
||||
return item.itemCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage 경로를 전체 URL로 변환
|
||||
* - 이미 전체 URL인 경우 그대로 반환
|
||||
* - 상대 경로인 경우 API URL + /storage/ 붙여서 반환
|
||||
*/
|
||||
function getStorageUrl(path: string | undefined): string | null {
|
||||
if (!path) return null;
|
||||
// 이미 전체 URL인 경우
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||
return path;
|
||||
}
|
||||
// 상대 경로인 경우
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
return `${apiUrl}/storage/${path}`;
|
||||
}
|
||||
|
||||
export default function ItemDetailClient({ item }: ItemDetailClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
@@ -339,6 +355,186 @@ export default function ItemDetailClient({ item }: ItemDetailClientProps) {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 절곡품/조립품 전개도 정보 */}
|
||||
{item.itemType === 'PT' &&
|
||||
(item.partType === 'BENDING' || item.partType === 'ASSEMBLY') &&
|
||||
(item.bendingDiagram || (item.bendingDetails && item.bendingDetails.length > 0)) && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base md:text-lg">
|
||||
<FileImage className="h-4 w-4 md:h-5 md:w-5" />
|
||||
{item.partType === 'ASSEMBLY' ? '조립품 전개도 (바라시)' : '절곡품 전개도 (바라시)'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 md:space-y-6 pt-0">
|
||||
{/* 전개도 이미지 */}
|
||||
{item.bendingDiagram ? (
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs md:text-sm">전개도 이미지</Label>
|
||||
<div className="mt-2 p-2 md:p-4 border rounded-lg bg-gray-50">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getStorageUrl(item.bendingDiagram) || ''}
|
||||
alt="전개도"
|
||||
className="max-w-full h-auto max-h-64 md:max-h-96 mx-auto border rounded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-6 md:py-8 text-xs md:text-sm text-muted-foreground border rounded-lg bg-gray-50">
|
||||
등록된 전개도 이미지가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 전개도 상세 데이터 */}
|
||||
{item.bendingDetails && item.bendingDetails.length > 0 && (
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs md:text-sm">전개도 상세 데이터</Label>
|
||||
<div className="mt-2 overflow-x-auto bg-white rounded border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-100">
|
||||
<TableHead className="px-1 md:px-2 py-1 md:py-2 text-center">번호</TableHead>
|
||||
<TableHead className="px-1 md:px-2 py-1 md:py-2 text-center">입력</TableHead>
|
||||
<TableHead className="px-1 md:px-2 py-1 md:py-2 text-center">연신율</TableHead>
|
||||
<TableHead className="px-1 md:px-2 py-1 md:py-2 text-center">연신율계산후</TableHead>
|
||||
<TableHead className="px-1 md:px-2 py-1 md:py-2 text-center">합계</TableHead>
|
||||
<TableHead className="px-1 md:px-2 py-1 md:py-2 text-center">음영</TableHead>
|
||||
<TableHead className="px-1 md:px-2 py-1 md:py-2 text-center">A각</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{item.bendingDetails.map((detail, detailIndex) => {
|
||||
const calculated = detail.input + detail.elongation;
|
||||
let sum = 0;
|
||||
for (let i = 0; i <= detailIndex; i++) {
|
||||
const d = item.bendingDetails![i];
|
||||
sum += d.input + d.elongation;
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow key={detail.id} className={detail.shaded ? "bg-gray-200" : ""}>
|
||||
<TableCell className="px-1 md:px-2 py-1 text-center">{detail.no}</TableCell>
|
||||
<TableCell className="px-1 md:px-2 py-1 text-center">{detail.input}</TableCell>
|
||||
<TableCell className="px-1 md:px-2 py-1 text-center">{detail.elongation}</TableCell>
|
||||
<TableCell className="px-1 md:px-2 py-1 text-center bg-blue-50">{calculated}</TableCell>
|
||||
<TableCell className="px-1 md:px-2 py-1 text-center bg-green-50 font-medium">{sum}</TableCell>
|
||||
<TableCell className="px-1 md:px-2 py-1 text-center">
|
||||
{detail.shaded ? <Check className="h-3 w-3 md:h-4 md:w-4 inline" /> : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="px-1 md:px-2 py-1 text-center">{detail.aAngle || "-"}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="mt-2 p-2 md:p-3 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<p className="text-xs md:text-sm">
|
||||
<span className="font-medium">최종 전개 길이:</span>{" "}
|
||||
<span className="text-base md:text-lg font-bold text-blue-700">
|
||||
{item.bendingDetails.reduce((sum, d) => sum + d.input + d.elongation, 0)} mm
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 제품(FG) 인정 정보 및 첨부 파일 */}
|
||||
{item.itemType === 'FG' && (item.certificationNumber || item.specificationFile || item.certificationFile) && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base md:text-lg">
|
||||
<FileText className="h-4 w-4 md:h-5 md:w-5" />
|
||||
인정 정보 및 첨부 파일
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 md:space-y-6 pt-0">
|
||||
{/* 인정 정보 */}
|
||||
{item.certificationNumber && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 p-4 bg-gray-50 rounded-lg border">
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs md:text-sm">인정번호</Label>
|
||||
<p className="mt-1 font-medium">{item.certificationNumber}</p>
|
||||
</div>
|
||||
{item.certificationStartDate && (
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs md:text-sm flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
유효기간 시작
|
||||
</Label>
|
||||
<p className="mt-1">{new Date(item.certificationStartDate).toLocaleDateString('ko-KR')}</p>
|
||||
</div>
|
||||
)}
|
||||
{item.certificationEndDate && (
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs md:text-sm flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
유효기간 종료
|
||||
</Label>
|
||||
<p className="mt-1">{new Date(item.certificationEndDate).toLocaleDateString('ko-KR')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 첨부 파일 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 시방서 */}
|
||||
<div className="p-4 border rounded-lg">
|
||||
<Label className="text-muted-foreground text-xs md:text-sm">시방서</Label>
|
||||
{item.specificationFile ? (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-blue-600" />
|
||||
<span className="text-sm truncate flex-1">
|
||||
{item.specificationFileName || '시방서 파일'}
|
||||
</span>
|
||||
<a
|
||||
href={getStorageUrl(item.specificationFile) || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
다운로드
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-muted-foreground">등록된 시방서가 없습니다.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 인정서 */}
|
||||
<div className="p-4 border rounded-lg">
|
||||
<Label className="text-muted-foreground text-xs md:text-sm">인정서</Label>
|
||||
{item.certificationFile ? (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-green-600" />
|
||||
<span className="text-sm truncate flex-1">
|
||||
{item.certificationFileName || '인정서 파일'}
|
||||
</span>
|
||||
<a
|
||||
href={getStorageUrl(item.certificationFile) || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm text-green-600 hover:text-green-800"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
다운로드
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-muted-foreground">등록된 인정서가 없습니다.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* BOM 정보 - 절곡 부품은 제외 */}
|
||||
{(item.itemType === 'FG' || (item.itemType === 'PT' && item.partType !== 'BENDING')) && item.bom && item.bom.length > 0 && (
|
||||
<Card>
|
||||
|
||||
94
src/components/pricing/PricingFinalizeDialog.tsx
Normal file
94
src/components/pricing/PricingFinalizeDialog.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* 단가 최종 확정 다이얼로그
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Lock, CheckCircle2 } from 'lucide-react';
|
||||
|
||||
interface PricingFinalizeDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
itemName: string;
|
||||
purchasePrice?: number;
|
||||
salesPrice?: number;
|
||||
marginRate?: number;
|
||||
}
|
||||
|
||||
export function PricingFinalizeDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
itemName,
|
||||
purchasePrice,
|
||||
salesPrice,
|
||||
marginRate,
|
||||
}: PricingFinalizeDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Lock className="h-5 w-5 text-purple-600" />
|
||||
최종 확정
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
단가를 최종 확정하시겠습니까? 확정 후에는 수정할 수 없습니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4 space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">품목:</span>
|
||||
<span className="font-semibold">{itemName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">매입단가:</span>
|
||||
<span className="font-semibold">
|
||||
{purchasePrice?.toLocaleString() || '-'}원
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">판매단가:</span>
|
||||
<span className="font-semibold">
|
||||
{salesPrice?.toLocaleString() || '-'}원
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">마진율:</span>
|
||||
<span className="font-semibold">
|
||||
{marginRate?.toFixed(1) || '-'}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
className="bg-purple-600 hover:bg-purple-700"
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4 mr-2" />
|
||||
최종 확정
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default PricingFinalizeDialog;
|
||||
769
src/components/pricing/PricingFormClient.tsx
Normal file
769
src/components/pricing/PricingFormClient.tsx
Normal file
@@ -0,0 +1,769 @@
|
||||
/**
|
||||
* 단가 등록/수정 폼 클라이언트 컴포넌트
|
||||
*
|
||||
* 기능:
|
||||
* - 품목 정보 표시 (읽기전용)
|
||||
* - 단가 정보 입력
|
||||
* - 원가/마진 자동 계산
|
||||
* - 반올림 규칙 적용
|
||||
* - 수정 이력 관리
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
DollarSign,
|
||||
Package,
|
||||
ArrowLeft,
|
||||
Save,
|
||||
Calculator,
|
||||
TrendingUp,
|
||||
AlertCircle,
|
||||
History,
|
||||
CheckCircle2,
|
||||
Lock,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type {
|
||||
PricingData,
|
||||
PricingFormData,
|
||||
ItemInfo,
|
||||
RoundingRule,
|
||||
ItemType,
|
||||
} from './types';
|
||||
import {
|
||||
ITEM_TYPE_LABELS,
|
||||
UNIT_OPTIONS,
|
||||
ROUNDING_RULE_OPTIONS,
|
||||
ROUNDING_UNIT_OPTIONS,
|
||||
} from './types';
|
||||
|
||||
// 다이얼로그 컴포넌트들 (추후 분리)
|
||||
import { PricingHistoryDialog } from './PricingHistoryDialog';
|
||||
import { PricingRevisionDialog } from './PricingRevisionDialog';
|
||||
import { PricingFinalizeDialog } from './PricingFinalizeDialog';
|
||||
|
||||
interface PricingFormClientProps {
|
||||
mode: 'create' | 'edit';
|
||||
itemInfo?: ItemInfo;
|
||||
initialData?: PricingData;
|
||||
onSave?: (data: PricingData, isRevision?: boolean, revisionReason?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function PricingFormClient({
|
||||
mode,
|
||||
itemInfo,
|
||||
initialData,
|
||||
onSave,
|
||||
}: PricingFormClientProps) {
|
||||
const router = useRouter();
|
||||
const isEditMode = mode === 'edit';
|
||||
|
||||
// 품목 정보 (신규: itemInfo, 수정: initialData)
|
||||
const displayItemCode = initialData?.itemCode || itemInfo?.itemCode || '';
|
||||
const displayItemName = initialData?.itemName || itemInfo?.itemName || '';
|
||||
const displayItemType = initialData?.itemType || itemInfo?.itemType || '';
|
||||
const displaySpecification = initialData?.specification || itemInfo?.specification || '';
|
||||
const displayUnit = initialData?.unit || itemInfo?.unit || 'EA';
|
||||
|
||||
// 폼 상태
|
||||
const [effectiveDate, setEffectiveDate] = useState(
|
||||
initialData?.effectiveDate || new Date().toISOString().split('T')[0]
|
||||
);
|
||||
const [receiveDate, setReceiveDate] = useState(initialData?.receiveDate || '');
|
||||
const [author, setAuthor] = useState(initialData?.author || '');
|
||||
const [purchasePrice, setPurchasePrice] = useState(initialData?.purchasePrice || 0);
|
||||
const [processingCost, setProcessingCost] = useState(initialData?.processingCost || 0);
|
||||
const [loss, setLoss] = useState(initialData?.loss || 0);
|
||||
const [roundingRule, setRoundingRule] = useState<RoundingRule>(
|
||||
initialData?.roundingRule || 'round'
|
||||
);
|
||||
const [roundingUnit, setRoundingUnit] = useState(initialData?.roundingUnit || 1);
|
||||
const [marginRate, setMarginRate] = useState(initialData?.marginRate || 0);
|
||||
const [salesPrice, setSalesPrice] = useState(initialData?.salesPrice || 0);
|
||||
const [supplier, setSupplier] = useState(initialData?.supplier || '');
|
||||
const [note, setNote] = useState(initialData?.note || '');
|
||||
const [unit, setUnit] = useState(displayUnit);
|
||||
|
||||
// 에러 상태
|
||||
const [errors, setErrors] = useState<Record<string, boolean>>({});
|
||||
|
||||
// 다이얼로그 상태
|
||||
const [showHistoryDialog, setShowHistoryDialog] = useState(false);
|
||||
const [showRevisionDialog, setShowRevisionDialog] = useState(false);
|
||||
const [showFinalizeDialog, setShowFinalizeDialog] = useState(false);
|
||||
|
||||
// 로딩 상태
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// 반올림 적용 함수
|
||||
const applyRounding = useCallback(
|
||||
(value: number, rule: RoundingRule, unit: number): number => {
|
||||
if (unit <= 0) return Math.round(value);
|
||||
switch (rule) {
|
||||
case 'ceil':
|
||||
return Math.ceil(value / unit) * unit;
|
||||
case 'floor':
|
||||
return Math.floor(value / unit) * unit;
|
||||
default:
|
||||
return Math.round(value / unit) * unit;
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// LOSS 적용 원가 계산
|
||||
const costWithLoss = useMemo(() => {
|
||||
const basePrice = (purchasePrice || 0) + (processingCost || 0);
|
||||
const lossRate = (loss || 0) / 100;
|
||||
return Math.round(basePrice * (1 + lossRate));
|
||||
}, [purchasePrice, processingCost, loss]);
|
||||
|
||||
// 마진율 → 판매단가 계산
|
||||
const handleMarginRateChange = useCallback(
|
||||
(rate: number) => {
|
||||
setMarginRate(rate);
|
||||
if (costWithLoss > 0) {
|
||||
const calculatedPrice = costWithLoss * (1 + rate / 100);
|
||||
const roundedPrice = applyRounding(calculatedPrice, roundingRule, roundingUnit);
|
||||
setSalesPrice(Math.round(roundedPrice));
|
||||
} else {
|
||||
setSalesPrice(0);
|
||||
}
|
||||
},
|
||||
[costWithLoss, roundingRule, roundingUnit, applyRounding]
|
||||
);
|
||||
|
||||
// 판매단가 → 마진율 계산
|
||||
const handleSalesPriceChange = useCallback(
|
||||
(price: number) => {
|
||||
setSalesPrice(price);
|
||||
if (costWithLoss > 0) {
|
||||
const calculatedMarginRate = ((price - costWithLoss) / costWithLoss) * 100;
|
||||
setMarginRate(parseFloat(calculatedMarginRate.toFixed(1)));
|
||||
} else {
|
||||
setMarginRate(0);
|
||||
}
|
||||
},
|
||||
[costWithLoss]
|
||||
);
|
||||
|
||||
// 원가 변경 시 판매가 자동 재계산
|
||||
useEffect(() => {
|
||||
if (marginRate > 0 && (purchasePrice > 0 || processingCost > 0)) {
|
||||
const calculatedPrice = costWithLoss * (1 + marginRate / 100);
|
||||
const roundedPrice = applyRounding(calculatedPrice, roundingRule, roundingUnit);
|
||||
const finalPrice = Math.round(roundedPrice);
|
||||
if (finalPrice !== salesPrice) {
|
||||
setSalesPrice(finalPrice);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [purchasePrice, processingCost, loss, roundingRule, roundingUnit]);
|
||||
|
||||
// 마진 금액 계산
|
||||
const marginAmount = useMemo(() => {
|
||||
return salesPrice - costWithLoss;
|
||||
}, [salesPrice, costWithLoss]);
|
||||
|
||||
// 유효성 검사
|
||||
const validateForm = useCallback(() => {
|
||||
const newErrors: Record<string, boolean> = {};
|
||||
if (!effectiveDate) newErrors.effectiveDate = true;
|
||||
if (purchasePrice <= 0 && salesPrice <= 0) {
|
||||
newErrors.purchasePrice = true;
|
||||
newErrors.salesPrice = true;
|
||||
}
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
}, [effectiveDate, purchasePrice, salesPrice]);
|
||||
|
||||
// 저장 처리
|
||||
const handleSave = async (isRevision = false, revisionReason = '') => {
|
||||
if (!validateForm()) {
|
||||
toast.error('필수 항목을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 수정 모드이고 리비전 있으면 수정 이력 다이얼로그
|
||||
if (isEditMode && initialData && !isRevision &&
|
||||
(initialData.currentRevision > 0 || initialData.isFinal)) {
|
||||
setShowRevisionDialog(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
const pricingData: PricingData = {
|
||||
id: initialData?.id || `PR-${Date.now()}`,
|
||||
itemId: initialData?.itemId || itemInfo?.id || '',
|
||||
itemCode: displayItemCode,
|
||||
itemName: displayItemName,
|
||||
itemType: displayItemType,
|
||||
specification: displaySpecification,
|
||||
unit: unit,
|
||||
effectiveDate,
|
||||
receiveDate: receiveDate || undefined,
|
||||
author: author || undefined,
|
||||
purchasePrice: purchasePrice || undefined,
|
||||
processingCost: processingCost || undefined,
|
||||
loss: loss || undefined,
|
||||
roundingRule: roundingRule || undefined,
|
||||
roundingUnit: roundingUnit || undefined,
|
||||
marginRate: marginRate || undefined,
|
||||
salesPrice: salesPrice || undefined,
|
||||
supplier: supplier || undefined,
|
||||
note: note || undefined,
|
||||
currentRevision: isRevision
|
||||
? (initialData?.currentRevision || 0) + 1
|
||||
: initialData?.currentRevision || 0,
|
||||
isFinal: initialData?.isFinal || false,
|
||||
revisions: initialData?.revisions || [],
|
||||
status: isRevision ? 'active' : initialData?.status || 'draft',
|
||||
createdAt: initialData?.createdAt || new Date().toISOString(),
|
||||
createdBy: initialData?.createdBy || '관리자',
|
||||
updatedAt: new Date().toISOString(),
|
||||
updatedBy: '관리자',
|
||||
};
|
||||
|
||||
if (onSave) {
|
||||
await onSave(pricingData, isRevision, revisionReason);
|
||||
}
|
||||
|
||||
toast.success(isEditMode ? '단가가 수정되었습니다.' : '단가가 등록되었습니다.');
|
||||
router.push('/sales/pricing-management');
|
||||
} catch (error) {
|
||||
toast.error('저장 중 오류가 발생했습니다.');
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 최종 확정 처리
|
||||
const handleFinalize = async () => {
|
||||
if (!initialData) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const finalizedData: PricingData = {
|
||||
...initialData,
|
||||
effectiveDate,
|
||||
receiveDate: receiveDate || undefined,
|
||||
author: author || undefined,
|
||||
purchasePrice: purchasePrice || undefined,
|
||||
processingCost: processingCost || undefined,
|
||||
loss: loss || undefined,
|
||||
roundingRule: roundingRule || undefined,
|
||||
roundingUnit: roundingUnit || undefined,
|
||||
marginRate: marginRate || undefined,
|
||||
salesPrice: salesPrice || undefined,
|
||||
supplier: supplier || undefined,
|
||||
note: note || undefined,
|
||||
isFinal: true,
|
||||
finalizedDate: new Date().toISOString(),
|
||||
finalizedBy: '관리자',
|
||||
status: 'finalized',
|
||||
updatedAt: new Date().toISOString(),
|
||||
updatedBy: '관리자',
|
||||
};
|
||||
|
||||
if (onSave) {
|
||||
await onSave(finalizedData);
|
||||
}
|
||||
|
||||
toast.success('단가가 최종 확정되었습니다.');
|
||||
setShowFinalizeDialog(false);
|
||||
router.push('/sales/pricing-management');
|
||||
} catch (error) {
|
||||
toast.error('확정 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 max-w-5xl">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<DollarSign className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">
|
||||
단가 {isEditMode ? '수정' : '등록'}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isEditMode
|
||||
? '품목의 단가 정보를 수정합니다'
|
||||
: '새로운 품목의 단가 정보를 등록합니다'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 상태 표시 (수정 모드) */}
|
||||
{isEditMode && initialData && (
|
||||
<div className="mb-4 flex gap-2 justify-end">
|
||||
{initialData.isFinal && (
|
||||
<Badge className="bg-purple-600">
|
||||
<Lock className="h-3 w-3 mr-1" />
|
||||
최종 확정됨
|
||||
</Badge>
|
||||
)}
|
||||
{initialData.currentRevision > 0 && (
|
||||
<Badge variant="outline" className="bg-blue-50 text-blue-700">
|
||||
<History className="h-3 w-3 mr-1" />
|
||||
수정 {initialData.currentRevision}차
|
||||
</Badge>
|
||||
)}
|
||||
{initialData.status === 'active' && !initialData.isFinal && (
|
||||
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">
|
||||
활성
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 품목 정보 카드 */}
|
||||
<Card className="mb-6 border-2 border-blue-200">
|
||||
<CardHeader className="bg-blue-50">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Package className="h-5 w-5 text-blue-600" />
|
||||
품목 정보
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<Label className="text-sm text-muted-foreground">품목 코드</Label>
|
||||
<div className="mt-1 font-semibold">{displayItemCode}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm text-muted-foreground">품목명</Label>
|
||||
<div className="mt-1 font-semibold">{displayItemName}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm text-muted-foreground">품목 유형</Label>
|
||||
<div className="mt-1">
|
||||
<Badge variant="outline">
|
||||
{ITEM_TYPE_LABELS[displayItemType as ItemType] || displayItemType}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm text-muted-foreground">단위</Label>
|
||||
<div className="mt-1 font-semibold">{displayUnit}</div>
|
||||
</div>
|
||||
{displaySpecification && (
|
||||
<div className="col-span-2 md:col-span-4">
|
||||
<Label className="text-sm text-muted-foreground">규격</Label>
|
||||
<div className="mt-1">{displaySpecification}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 단가 정보 카드 */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<DollarSign className="h-5 w-5" />
|
||||
단가 정보
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* 적용일 */}
|
||||
<div>
|
||||
<Label>
|
||||
적용일 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={effectiveDate}
|
||||
onChange={(e) => {
|
||||
setEffectiveDate(e.target.value);
|
||||
setErrors((prev) => ({ ...prev, effectiveDate: false }));
|
||||
}}
|
||||
className={errors.effectiveDate ? 'border-red-500' : ''}
|
||||
/>
|
||||
{errors.effectiveDate && (
|
||||
<p className="text-sm text-red-500 mt-1 flex items-center gap-1">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
적용일을 선택해주세요
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 공급업체 및 기본 정보 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>공급업체</Label>
|
||||
<Input
|
||||
value={supplier}
|
||||
onChange={(e) => setSupplier(e.target.value)}
|
||||
placeholder="공급업체명을 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>입고일</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={receiveDate}
|
||||
onChange={(e) => setReceiveDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>작성자</Label>
|
||||
<Input
|
||||
value={author}
|
||||
onChange={(e) => setAuthor(e.target.value)}
|
||||
placeholder="작성자명을 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 입고가 및 단위 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>
|
||||
입고가 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="number"
|
||||
value={purchasePrice || ''}
|
||||
onChange={(e) => {
|
||||
setPurchasePrice(parseInt(e.target.value) || 0);
|
||||
setErrors((prev) => ({ ...prev, purchasePrice: false, salesPrice: false }));
|
||||
}}
|
||||
placeholder="0"
|
||||
className={errors.purchasePrice ? 'border-red-500 pr-12' : 'pr-12'}
|
||||
/>
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground">
|
||||
원
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>단위</Label>
|
||||
<Select value={unit} onValueChange={setUnit}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="단위 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{UNIT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
단가 적용 시 사용될 단위
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>LOSS (%)</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={loss || ''}
|
||||
onChange={(e) => setLoss(parseFloat(e.target.value) || 0)}
|
||||
placeholder="0"
|
||||
className="pr-12"
|
||||
/>
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground">
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
제조 과정에서 발생하는 손실율
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>가공비</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="number"
|
||||
value={processingCost || ''}
|
||||
onChange={(e) => setProcessingCost(parseInt(e.target.value) || 0)}
|
||||
placeholder="0"
|
||||
className="pr-12"
|
||||
/>
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground">
|
||||
원
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 원가 계산 섹션 */}
|
||||
{(purchasePrice > 0 || processingCost > 0) && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Calculator className="h-4 w-4 text-blue-600" />
|
||||
<Label className="text-blue-900">원가 계산</Label>
|
||||
</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">입고가:</span>
|
||||
<span>{(purchasePrice || 0).toLocaleString()}원</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">가공비:</span>
|
||||
<span>{(processingCost || 0).toLocaleString()}원</span>
|
||||
</div>
|
||||
<Separator className="my-2" />
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">소계:</span>
|
||||
<span>{((purchasePrice || 0) + (processingCost || 0)).toLocaleString()}원</span>
|
||||
</div>
|
||||
{loss > 0 && (
|
||||
<div className="flex justify-between text-orange-600">
|
||||
<span>LOSS ({loss}%):</span>
|
||||
<span>
|
||||
+{(((purchasePrice || 0) + (processingCost || 0)) * (loss / 100)).toLocaleString()}원
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Separator className="my-2" />
|
||||
<div className="flex justify-between font-semibold text-base">
|
||||
<span className="text-blue-900">LOSS 적용 원가:</span>
|
||||
<span className="text-blue-600">{costWithLoss.toLocaleString()}원</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 반올림 설정 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>반올림 규칙</Label>
|
||||
<Select
|
||||
value={roundingRule}
|
||||
onValueChange={(value) => setRoundingRule(value as RoundingRule)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ROUNDING_RULE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>반올림 단위</Label>
|
||||
<Select
|
||||
value={roundingUnit.toString()}
|
||||
onValueChange={(value) => setRoundingUnit(parseInt(value))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ROUNDING_UNIT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value.toString()}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
단가 계산 시 적용될 반올림 단위
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 마진율/판매단가 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>마진율 (%)</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={marginRate || ''}
|
||||
onChange={(e) => handleMarginRateChange(parseFloat(e.target.value) || 0)}
|
||||
placeholder="0"
|
||||
className="pr-12"
|
||||
/>
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground">
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
마진율을 입력하면 판매단가가 자동 계산됩니다
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>
|
||||
판매단가 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="number"
|
||||
value={salesPrice || ''}
|
||||
onChange={(e) => {
|
||||
handleSalesPriceChange(parseInt(e.target.value) || 0);
|
||||
setErrors((prev) => ({ ...prev, purchasePrice: false, salesPrice: false }));
|
||||
}}
|
||||
placeholder="0"
|
||||
className={errors.salesPrice ? 'border-red-500 pr-12' : 'pr-12'}
|
||||
/>
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground">
|
||||
원
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
판매단가를 직접 입력하면 마진율이 자동 계산됩니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(errors.purchasePrice || errors.salesPrice) && (
|
||||
<p className="text-sm text-red-500 flex items-center gap-1">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
입고가 또는 판매단가 중 최소 하나를 입력해주세요
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 마진 계산 섹션 */}
|
||||
{salesPrice > 0 && (purchasePrice > 0 || processingCost > 0) && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<TrendingUp className="h-4 w-4 text-green-600" />
|
||||
<Label className="text-green-900">마진 계산</Label>
|
||||
</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">LOSS 적용 원가:</span>
|
||||
<span>{costWithLoss.toLocaleString()}원</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">판매단가:</span>
|
||||
<span>{salesPrice.toLocaleString()}원</span>
|
||||
</div>
|
||||
<Separator className="my-2" />
|
||||
<div className="flex justify-between font-semibold text-base">
|
||||
<span className="text-green-900">마진:</span>
|
||||
<span className="text-green-600">
|
||||
{marginAmount.toLocaleString()}원 ({marginRate.toFixed(1)}%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 비고 */}
|
||||
<div>
|
||||
<Label>비고</Label>
|
||||
<Textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="비고사항을 입력하세요"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="flex gap-2 justify-between">
|
||||
<div className="flex gap-2">
|
||||
{isEditMode && initialData?.revisions && initialData.revisions.length > 0 && (
|
||||
<Button variant="outline" onClick={() => setShowHistoryDialog(true)}>
|
||||
<History className="h-4 w-4 mr-2" />
|
||||
이력 조회 ({initialData.currentRevision}차)
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push('/sales/pricing-management')}
|
||||
className="min-w-[100px]"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
취소
|
||||
</Button>
|
||||
{isEditMode && initialData && !initialData.isFinal && (
|
||||
<Button
|
||||
onClick={() => setShowFinalizeDialog(true)}
|
||||
className="min-w-[100px] bg-purple-600 hover:bg-purple-700"
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4 mr-2" />
|
||||
최종 확정
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => handleSave()}
|
||||
className="min-w-[100px] bg-blue-600 hover:bg-blue-700"
|
||||
disabled={initialData?.isFinal || isSaving}
|
||||
>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{initialData?.isFinal ? '확정됨' : '저장'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 다이얼로그들 */}
|
||||
<PricingHistoryDialog
|
||||
open={showHistoryDialog}
|
||||
onOpenChange={setShowHistoryDialog}
|
||||
pricingData={initialData}
|
||||
/>
|
||||
|
||||
<PricingRevisionDialog
|
||||
open={showRevisionDialog}
|
||||
onOpenChange={setShowRevisionDialog}
|
||||
onConfirm={(reason) => handleSave(true, reason)}
|
||||
/>
|
||||
|
||||
<PricingFinalizeDialog
|
||||
open={showFinalizeDialog}
|
||||
onOpenChange={setShowFinalizeDialog}
|
||||
onConfirm={handleFinalize}
|
||||
itemName={displayItemName}
|
||||
purchasePrice={purchasePrice}
|
||||
salesPrice={salesPrice}
|
||||
marginRate={marginRate}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PricingFormClient;
|
||||
168
src/components/pricing/PricingHistoryDialog.tsx
Normal file
168
src/components/pricing/PricingHistoryDialog.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* 단가 이력 조회 다이얼로그
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { History } from 'lucide-react';
|
||||
import type { PricingData } from './types';
|
||||
|
||||
interface PricingHistoryDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
pricingData?: PricingData | null;
|
||||
}
|
||||
|
||||
export function PricingHistoryDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
pricingData,
|
||||
}: PricingHistoryDialogProps) {
|
||||
if (!pricingData) return null;
|
||||
|
||||
const hasRevisions = pricingData.revisions && pricingData.revisions.length > 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<History className="h-5 w-5" />
|
||||
단가 수정 이력
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{pricingData.itemName} ({pricingData.itemCode})의 단가 변경 이력입니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{hasRevisions ? (
|
||||
<div className="space-y-4">
|
||||
{/* 현재 버전 */}
|
||||
<div className="border-2 border-blue-200 rounded-lg p-4 bg-blue-50">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className="bg-blue-600">현재 버전</Badge>
|
||||
<span className="font-semibold">
|
||||
수정 {pricingData.currentRevision}차
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{new Date(
|
||||
pricingData.updatedAt || pricingData.createdAt
|
||||
).toLocaleString('ko-KR')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-3 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">매입단가:</span>
|
||||
<div className="font-semibold">
|
||||
{pricingData.purchasePrice?.toLocaleString() || '-'}원
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">가공비:</span>
|
||||
<div className="font-semibold">
|
||||
{pricingData.processingCost?.toLocaleString() || '-'}원
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">판매단가:</span>
|
||||
<div className="font-semibold">
|
||||
{pricingData.salesPrice?.toLocaleString() || '-'}원
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">마진율:</span>
|
||||
<div className="font-semibold">
|
||||
{pricingData.marginRate?.toFixed(1) || '-'}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 이전 버전들 */}
|
||||
{[...pricingData.revisions!].reverse().map((revision) => (
|
||||
<div key={revision.revisionNumber} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">이전 버전</Badge>
|
||||
<span className="font-semibold">
|
||||
수정 {revision.revisionNumber}차
|
||||
</span>
|
||||
{revision.revisionReason && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({revision.revisionReason})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{new Date(revision.revisionDate).toLocaleString('ko-KR')}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
by {revision.revisionBy}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-3 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">매입단가:</span>
|
||||
<div>
|
||||
{revision.previousData.purchasePrice?.toLocaleString() || '-'}원
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">가공비:</span>
|
||||
<div>
|
||||
{revision.previousData.processingCost?.toLocaleString() || '-'}원
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">판매단가:</span>
|
||||
<div>
|
||||
{revision.previousData.salesPrice?.toLocaleString() || '-'}원
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">마진율:</span>
|
||||
<div>
|
||||
{revision.previousData.marginRate?.toFixed(1) || '-'}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 최초 버전 */}
|
||||
<div className="border rounded-lg p-4 bg-gray-50">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">최초 버전</Badge>
|
||||
<span className="font-semibold">초기 등록</span>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{new Date(pricingData.createdAt).toLocaleString('ko-KR')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
수정 이력이 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default PricingHistoryDialog;
|
||||
449
src/components/pricing/PricingListClient.tsx
Normal file
449
src/components/pricing/PricingListClient.tsx
Normal file
@@ -0,0 +1,449 @@
|
||||
/**
|
||||
* 단가 목록 클라이언트 컴포넌트
|
||||
*
|
||||
* IntegratedListTemplateV2 공통 템플릿 활용
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
DollarSign,
|
||||
Package,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Plus,
|
||||
Edit,
|
||||
History,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { TableRow, TableCell } from '@/components/ui/table';
|
||||
import {
|
||||
IntegratedListTemplateV2,
|
||||
type TabOption,
|
||||
type TableColumn,
|
||||
type StatCard,
|
||||
} from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
|
||||
import type { PricingListItem, ItemType } from './types';
|
||||
import { ITEM_TYPE_LABELS, ITEM_TYPE_COLORS } from './types';
|
||||
|
||||
interface PricingListClientProps {
|
||||
initialData: PricingListItem[];
|
||||
}
|
||||
|
||||
export function PricingListClient({
|
||||
initialData,
|
||||
}: PricingListClientProps) {
|
||||
const router = useRouter();
|
||||
const [data] = useState<PricingListItem[]>(initialData);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('all');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const pageSize = 20;
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredData = useMemo(() => {
|
||||
let result = [...data];
|
||||
|
||||
// 탭 필터
|
||||
if (activeTab !== 'all') {
|
||||
result = result.filter(item => item.itemType === activeTab);
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if (searchTerm) {
|
||||
const search = searchTerm.toLowerCase();
|
||||
result = result.filter(item =>
|
||||
item.itemCode.toLowerCase().includes(search) ||
|
||||
item.itemName.toLowerCase().includes(search) ||
|
||||
(item.specification?.toLowerCase().includes(search) ?? false)
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [data, activeTab, searchTerm]);
|
||||
|
||||
// 페이지네이션된 데이터
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
return filteredData.slice(start, start + pageSize);
|
||||
}, [filteredData, currentPage, pageSize]);
|
||||
|
||||
// 통계 계산
|
||||
const totalStats = useMemo(() => {
|
||||
const totalAll = data.length;
|
||||
const totalFG = data.filter(d => d.itemType === 'FG').length;
|
||||
const totalPT = data.filter(d => d.itemType === 'PT').length;
|
||||
const totalSM = data.filter(d => d.itemType === 'SM').length;
|
||||
const totalRM = data.filter(d => d.itemType === 'RM').length;
|
||||
const totalCS = data.filter(d => d.itemType === 'CS').length;
|
||||
const registered = data.filter(d => d.status !== 'not_registered').length;
|
||||
const notRegistered = totalAll - registered;
|
||||
const finalized = data.filter(d => d.isFinal).length;
|
||||
|
||||
return { totalAll, totalFG, totalPT, totalSM, totalRM, totalCS, registered, notRegistered, finalized };
|
||||
}, [data]);
|
||||
|
||||
// 금액 포맷팅
|
||||
const formatPrice = (price?: number) => {
|
||||
if (price === undefined || price === null) return '-';
|
||||
return `${price.toLocaleString()}원`;
|
||||
};
|
||||
|
||||
// 품목 유형 Badge 렌더링
|
||||
const renderItemTypeBadge = (type: string) => {
|
||||
const colors = ITEM_TYPE_COLORS[type as ItemType];
|
||||
const label = ITEM_TYPE_LABELS[type as ItemType] || type;
|
||||
|
||||
if (!colors) {
|
||||
return <Badge variant="outline">{label}</Badge>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`${colors.bg} ${colors.text} ${colors.border}`}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
// 상태 Badge 렌더링
|
||||
const renderStatusBadge = (item: PricingListItem) => {
|
||||
if (item.status === 'not_registered') {
|
||||
return <Badge variant="outline" className="bg-gray-50 text-gray-700">미등록</Badge>;
|
||||
}
|
||||
if (item.isFinal) {
|
||||
return <Badge variant="outline" className="bg-purple-50 text-purple-700 border-purple-200">확정</Badge>;
|
||||
}
|
||||
if (item.status === 'active') {
|
||||
return <Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">활성</Badge>;
|
||||
}
|
||||
if (item.status === 'inactive') {
|
||||
return <Badge variant="outline" className="bg-red-50 text-red-700 border-red-200">비활성</Badge>;
|
||||
}
|
||||
return <Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">초안</Badge>;
|
||||
};
|
||||
|
||||
// 마진율 Badge 렌더링
|
||||
const renderMarginBadge = (marginRate?: number) => {
|
||||
if (marginRate === undefined || marginRate === null || marginRate === 0) {
|
||||
return <span className="text-muted-foreground">-</span>;
|
||||
}
|
||||
|
||||
const colorClass =
|
||||
marginRate >= 30 ? 'bg-green-50 text-green-700 border-green-200' :
|
||||
marginRate >= 20 ? 'bg-blue-50 text-blue-700 border-blue-200' :
|
||||
marginRate >= 10 ? 'bg-orange-50 text-orange-700 border-orange-200' :
|
||||
'bg-red-50 text-red-700 border-red-200';
|
||||
|
||||
return (
|
||||
<Badge variant="outline" className={colorClass}>
|
||||
{marginRate.toFixed(1)}%
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
// 네비게이션 핸들러
|
||||
const handleRegister = (item: PricingListItem) => {
|
||||
router.push(`/sales/pricing-management/create?itemId=${item.itemId}&itemCode=${item.itemCode}`);
|
||||
};
|
||||
|
||||
const handleEdit = (item: PricingListItem) => {
|
||||
router.push(`/sales/pricing-management/${item.id}/edit`);
|
||||
};
|
||||
|
||||
const handleHistory = (item: PricingListItem) => {
|
||||
// TODO: 이력 다이얼로그 열기
|
||||
console.log('이력 조회:', item.id);
|
||||
};
|
||||
|
||||
// 체크박스 전체 선택/해제
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedItems.size === paginatedData.length && paginatedData.length > 0) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
const allIds = new Set(paginatedData.map((item) => item.id));
|
||||
setSelectedItems(allIds);
|
||||
}
|
||||
};
|
||||
|
||||
// 개별 체크박스 선택/해제
|
||||
const toggleSelection = (itemId: string) => {
|
||||
const newSelected = new Set(selectedItems);
|
||||
if (newSelected.has(itemId)) {
|
||||
newSelected.delete(itemId);
|
||||
} else {
|
||||
newSelected.add(itemId);
|
||||
}
|
||||
setSelectedItems(newSelected);
|
||||
};
|
||||
|
||||
// 탭 옵션
|
||||
const tabs: TabOption[] = [
|
||||
{ value: 'all', label: '전체', count: totalStats.totalAll, color: 'gray' },
|
||||
{ value: 'FG', label: '제품', count: totalStats.totalFG, color: 'purple' },
|
||||
{ value: 'PT', label: '부품', count: totalStats.totalPT, color: 'orange' },
|
||||
{ value: 'SM', label: '부자재', count: totalStats.totalSM, color: 'green' },
|
||||
{ value: 'RM', label: '원자재', count: totalStats.totalRM, color: 'blue' },
|
||||
{ value: 'CS', label: '소모품', count: totalStats.totalCS, color: 'gray' },
|
||||
];
|
||||
|
||||
// 통계 카드
|
||||
const stats: StatCard[] = [
|
||||
{ label: '전체 품목', value: totalStats.totalAll, icon: Package, iconColor: 'text-blue-600' },
|
||||
{ label: '단가 등록', value: totalStats.registered, icon: DollarSign, iconColor: 'text-green-600' },
|
||||
{ label: '미등록', value: totalStats.notRegistered, icon: AlertCircle, iconColor: 'text-orange-600' },
|
||||
{ label: '확정', value: totalStats.finalized, icon: CheckCircle2, iconColor: 'text-purple-600' },
|
||||
];
|
||||
|
||||
// 테이블 컬럼 정의
|
||||
const tableColumns: TableColumn[] = [
|
||||
{ key: 'rowNumber', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'itemType', label: '품목유형', className: 'min-w-[100px]' },
|
||||
{ key: 'itemCode', label: '품목코드', className: 'min-w-[120px]' },
|
||||
{ key: 'itemName', label: '품목명', className: 'min-w-[150px]' },
|
||||
{ key: 'specification', label: '규격', className: 'min-w-[100px]', hideOnMobile: true },
|
||||
{ key: 'unit', label: '단위', className: 'min-w-[60px]', hideOnMobile: true },
|
||||
{ key: 'purchasePrice', label: '매입단가', className: 'min-w-[100px] text-right', hideOnTablet: true },
|
||||
{ key: 'processingCost', label: '가공비', className: 'min-w-[80px] text-right', hideOnTablet: true },
|
||||
{ key: 'salesPrice', label: '판매단가', className: 'min-w-[100px] text-right' },
|
||||
{ key: 'marginRate', label: '마진율', className: 'min-w-[80px] text-right', hideOnMobile: true },
|
||||
{ key: 'effectiveDate', label: '적용일', className: 'min-w-[100px]', hideOnMobile: true },
|
||||
{ key: 'status', label: '상태', className: 'min-w-[80px]' },
|
||||
{ key: 'actions', label: '작업', className: 'w-[120px] text-right' },
|
||||
];
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = (item: PricingListItem, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(item.id);
|
||||
|
||||
return (
|
||||
<TableRow key={item.id} className="hover:bg-muted/50">
|
||||
<TableCell className="text-center">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleSelection(item.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-center">
|
||||
{globalIndex}
|
||||
</TableCell>
|
||||
<TableCell>{renderItemTypeBadge(item.itemType)}</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 px-2 py-1 rounded">
|
||||
{item.itemCode}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-medium truncate max-w-[200px] block">
|
||||
{item.itemName}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground hidden md:table-cell">
|
||||
{item.specification || '-'}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
<Badge variant="secondary">{item.unit || '-'}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono hidden lg:table-cell">
|
||||
{formatPrice(item.purchasePrice)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono hidden lg:table-cell">
|
||||
{formatPrice(item.processingCost)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono font-semibold">
|
||||
{formatPrice(item.salesPrice)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right hidden md:table-cell">
|
||||
{renderMarginBadge(item.marginRate)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
{item.effectiveDate
|
||||
? new Date(item.effectiveDate).toLocaleDateString('ko-KR')
|
||||
: '-'}
|
||||
</TableCell>
|
||||
<TableCell>{renderStatusBadge(item)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{item.status === 'not_registered' ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); handleRegister(item); }}
|
||||
title="단가 등록"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); handleEdit(item); }}
|
||||
title="수정"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
{item.currentRevision > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); handleHistory(item); }}
|
||||
title="이력"
|
||||
>
|
||||
<History className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = (
|
||||
item: PricingListItem,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
isSelected: boolean,
|
||||
onToggle: () => void
|
||||
) => {
|
||||
return (
|
||||
<ListMobileCard
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
title={item.itemName}
|
||||
headerBadges={
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<code className="text-xs bg-gray-100 px-2 py-1 rounded">
|
||||
{item.itemCode}
|
||||
</code>
|
||||
{renderItemTypeBadge(item.itemType)}
|
||||
</div>
|
||||
}
|
||||
statusBadge={renderStatusBadge(item)}
|
||||
isSelected={isSelected}
|
||||
onToggleSelection={onToggle}
|
||||
onCardClick={() => item.status !== 'not_registered' ? handleEdit(item) : handleRegister(item)}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
{item.specification && (
|
||||
<InfoField label="규격" value={item.specification} />
|
||||
)}
|
||||
{item.unit && (
|
||||
<InfoField label="단위" value={item.unit} />
|
||||
)}
|
||||
<InfoField label="판매단가" value={formatPrice(item.salesPrice)} />
|
||||
<InfoField
|
||||
label="마진율"
|
||||
value={item.marginRate ? `${item.marginRate.toFixed(1)}%` : '-'}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
isSelected ? (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{item.status === 'not_registered' ? (
|
||||
<Button
|
||||
variant="default"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11"
|
||||
onClick={(e) => { e.stopPropagation(); handleRegister(item); }}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
등록
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="default"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11"
|
||||
onClick={(e) => { e.stopPropagation(); handleEdit(item); }}
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
{item.currentRevision > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11"
|
||||
onClick={(e) => { e.stopPropagation(); handleHistory(item); }}
|
||||
>
|
||||
<History className="h-4 w-4 mr-2" />
|
||||
이력
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 헤더 액션
|
||||
const headerActions = (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
// TODO: API 연동 시 품목 마스터 동기화 로직 구현
|
||||
console.log('품목 마스터 동기화');
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
품목 마스터 동기화
|
||||
</Button>
|
||||
);
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(filteredData.length / pageSize);
|
||||
|
||||
return (
|
||||
<IntegratedListTemplateV2<PricingListItem>
|
||||
title="단가 관리"
|
||||
description="품목별 매입단가, 판매단가 및 마진을 관리합니다"
|
||||
icon={DollarSign}
|
||||
headerActions={headerActions}
|
||||
stats={stats}
|
||||
searchValue={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
searchPlaceholder="품목코드, 품목명, 규격 검색..."
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
tableColumns={tableColumns}
|
||||
data={paginatedData}
|
||||
totalCount={filteredData.length}
|
||||
allData={filteredData}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={toggleSelection}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
getItemId={(item) => item.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: filteredData.length,
|
||||
itemsPerPage: pageSize,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default PricingListClient;
|
||||
94
src/components/pricing/PricingRevisionDialog.tsx
Normal file
94
src/components/pricing/PricingRevisionDialog.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* 단가 수정 이력 생성 다이얼로그
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Edit2, Save } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface PricingRevisionDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: (reason: string) => void;
|
||||
}
|
||||
|
||||
export function PricingRevisionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: PricingRevisionDialogProps) {
|
||||
const [revisionReason, setRevisionReason] = useState('');
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!revisionReason.trim()) {
|
||||
toast.error('수정 사유를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
onConfirm(revisionReason);
|
||||
setRevisionReason('');
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setRevisionReason('');
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Edit2 className="h-5 w-5" />
|
||||
수정 이력 생성
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
단가 정보를 수정하시겠습니까? 수정 사유를 입력해주세요.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div>
|
||||
<Label>
|
||||
수정 사유 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
value={revisionReason}
|
||||
onChange={(e) => setRevisionReason(e.target.value)}
|
||||
placeholder="예: 공급업체 단가 인상으로 인한 조정"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
수정 이력 저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default PricingRevisionDialog;
|
||||
10
src/components/pricing/index.ts
Normal file
10
src/components/pricing/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 단가관리 컴포넌트 인덱스
|
||||
*/
|
||||
|
||||
export * from './types';
|
||||
export { PricingListClient } from './PricingListClient';
|
||||
export { PricingFormClient } from './PricingFormClient';
|
||||
export { PricingHistoryDialog } from './PricingHistoryDialog';
|
||||
export { PricingRevisionDialog } from './PricingRevisionDialog';
|
||||
export { PricingFinalizeDialog } from './PricingFinalizeDialog';
|
||||
182
src/components/pricing/types.ts
Normal file
182
src/components/pricing/types.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* 단가관리 타입 정의
|
||||
*/
|
||||
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
// ===== 단가 리비전 =====
|
||||
|
||||
/** 단가 수정 이력 */
|
||||
export interface PricingRevision {
|
||||
revisionNumber: number;
|
||||
revisionDate: string;
|
||||
revisionBy: string;
|
||||
revisionReason?: string;
|
||||
previousData: PricingData;
|
||||
}
|
||||
|
||||
// ===== 단가 데이터 =====
|
||||
|
||||
/** 단가 상태 */
|
||||
export type PricingStatus = 'draft' | 'active' | 'inactive' | 'finalized';
|
||||
|
||||
/** 반올림 규칙 */
|
||||
export type RoundingRule = 'round' | 'ceil' | 'floor';
|
||||
|
||||
/** 단가 데이터 인터페이스 */
|
||||
export interface PricingData {
|
||||
id: string;
|
||||
itemId: string;
|
||||
itemCode: string;
|
||||
itemName: string;
|
||||
itemType: string;
|
||||
specification?: string;
|
||||
unit: string;
|
||||
|
||||
// 단가 정보
|
||||
effectiveDate: string; // 적용일
|
||||
receiveDate?: string; // 입고일
|
||||
author?: string; // 작성자
|
||||
purchasePrice?: number; // 매입단가 (입고가)
|
||||
processingCost?: number; // 가공비
|
||||
loss?: number; // LOSS(%)
|
||||
roundingRule?: RoundingRule; // 반올림 규칙
|
||||
roundingUnit?: number; // 반올림 단위
|
||||
marginRate?: number; // 마진율(%)
|
||||
salesPrice?: number; // 판매단가
|
||||
supplier?: string; // 공급업체
|
||||
note?: string; // 비고
|
||||
|
||||
// 리비전 관리
|
||||
currentRevision: number;
|
||||
isFinal: boolean;
|
||||
revisions?: PricingRevision[];
|
||||
finalizedDate?: string;
|
||||
finalizedBy?: string;
|
||||
status: PricingStatus;
|
||||
|
||||
// 메타데이터
|
||||
createdAt: string;
|
||||
createdBy: string;
|
||||
updatedAt?: string;
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
// ===== 품목 정보 (등록 시 전달) =====
|
||||
|
||||
/** 품목 기본 정보 */
|
||||
export interface ItemInfo {
|
||||
id: string;
|
||||
itemCode: string;
|
||||
itemName: string;
|
||||
itemType: string;
|
||||
specification?: string;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
// ===== 폼 데이터 =====
|
||||
|
||||
/** 단가 폼 입력 데이터 */
|
||||
export interface PricingFormData {
|
||||
effectiveDate: string;
|
||||
receiveDate: string;
|
||||
author: string;
|
||||
purchasePrice: number;
|
||||
processingCost: number;
|
||||
loss: number;
|
||||
roundingRule: RoundingRule;
|
||||
roundingUnit: number;
|
||||
marginRate: number;
|
||||
salesPrice: number;
|
||||
supplier: string;
|
||||
note: string;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
// ===== 통계 =====
|
||||
|
||||
/** 단가 통계 */
|
||||
export interface PricingStats {
|
||||
totalItems: number;
|
||||
registeredCount: number;
|
||||
notRegisteredCount: number;
|
||||
finalizedCount: number;
|
||||
}
|
||||
|
||||
// ===== 목록 아이템 (테이블용) =====
|
||||
|
||||
/** 목록 표시용 데이터 */
|
||||
export interface PricingListItem {
|
||||
id: string;
|
||||
itemId: string;
|
||||
itemCode: string;
|
||||
itemName: string;
|
||||
itemType: string;
|
||||
specification?: string;
|
||||
unit: string;
|
||||
purchasePrice?: number;
|
||||
processingCost?: number;
|
||||
salesPrice?: number;
|
||||
marginRate?: number;
|
||||
effectiveDate?: string;
|
||||
status: PricingStatus | 'not_registered';
|
||||
currentRevision: number;
|
||||
isFinal: boolean;
|
||||
}
|
||||
|
||||
// ===== 유틸리티 타입 =====
|
||||
|
||||
/** 품목 유형 */
|
||||
export type ItemType = 'FG' | 'PT' | 'SM' | 'RM' | 'CS' | 'BENDING';
|
||||
|
||||
/** 품목 유형 라벨 맵 */
|
||||
export const ITEM_TYPE_LABELS: Record<ItemType, string> = {
|
||||
FG: '제품',
|
||||
PT: '부품',
|
||||
SM: '부자재',
|
||||
RM: '원자재',
|
||||
CS: '소모품',
|
||||
BENDING: '절곡물',
|
||||
};
|
||||
|
||||
/** 품목 유형 색상 맵 */
|
||||
export const ITEM_TYPE_COLORS: Record<ItemType, { bg: string; text: string; border: string }> = {
|
||||
FG: { bg: 'bg-purple-50', text: 'text-purple-700', border: 'border-purple-200' },
|
||||
PT: { bg: 'bg-orange-50', text: 'text-orange-700', border: 'border-orange-200' },
|
||||
SM: { bg: 'bg-cyan-50', text: 'text-cyan-700', border: 'border-cyan-200' },
|
||||
RM: { bg: 'bg-green-50', text: 'text-green-700', border: 'border-green-200' },
|
||||
CS: { bg: 'bg-gray-50', text: 'text-gray-700', border: 'border-gray-200' },
|
||||
BENDING: { bg: 'bg-blue-50', text: 'text-blue-700', border: 'border-blue-200' },
|
||||
};
|
||||
|
||||
/** 단위 옵션 */
|
||||
export const UNIT_OPTIONS = [
|
||||
{ value: 'EA', label: 'EA (개)' },
|
||||
{ value: 'SET', label: 'SET (세트)' },
|
||||
{ value: 'KG', label: 'KG (킬로그램)' },
|
||||
{ value: 'G', label: 'G (그램)' },
|
||||
{ value: 'M', label: 'M (미터)' },
|
||||
{ value: 'CM', label: 'CM (센티미터)' },
|
||||
{ value: 'MM', label: 'MM (밀리미터)' },
|
||||
{ value: 'L', label: 'L (리터)' },
|
||||
{ value: 'ML', label: 'ML (밀리리터)' },
|
||||
{ value: 'BOX', label: 'BOX (박스)' },
|
||||
{ value: 'ROLL', label: 'ROLL (롤)' },
|
||||
{ value: 'SHEET', label: 'SHEET (장)' },
|
||||
];
|
||||
|
||||
/** 반올림 규칙 옵션 */
|
||||
export const ROUNDING_RULE_OPTIONS = [
|
||||
{ value: 'round', label: '반올림' },
|
||||
{ value: 'ceil', label: '올림' },
|
||||
{ value: 'floor', label: '내림' },
|
||||
];
|
||||
|
||||
/** 반올림 단위 옵션 */
|
||||
export const ROUNDING_UNIT_OPTIONS = [
|
||||
{ value: 1, label: '1원 단위' },
|
||||
{ value: 10, label: '10원 단위' },
|
||||
{ value: 100, label: '100원 단위' },
|
||||
{ value: 1000, label: '1,000원 단위' },
|
||||
{ value: 10000, label: '10,000원 단위' },
|
||||
];
|
||||
@@ -277,13 +277,12 @@ export function IntegratedListTemplateV2<T = any>({
|
||||
검색 결과가 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
// 백엔드가 created_at ASC로 정렬해서 보내줌 (오래된 순)
|
||||
(allData || data).map((item, index) => {
|
||||
const itemId = getItemId(item);
|
||||
const isSelected = selectedItems.has(itemId);
|
||||
// 역순 번호 계산: totalCount가 있으면 역순, 없으면 순차
|
||||
const globalIndex = totalCount
|
||||
? totalCount - index
|
||||
: index + 1;
|
||||
// 순차 번호: 1번부터 시작
|
||||
const globalIndex = index + 1;
|
||||
|
||||
return (
|
||||
<div key={itemId}>
|
||||
@@ -343,12 +342,11 @@ export function IntegratedListTemplateV2<T = any>({
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
// 백엔드가 created_at ASC로 정렬해서 보내줌 (오래된 순)
|
||||
data.map((item, index) => {
|
||||
const itemId = getItemId(item);
|
||||
// 역순 번호 계산: totalCount가 있으면 역순, 없으면 순차
|
||||
const globalIndex = totalCount
|
||||
? totalCount - (startIndex + index)
|
||||
: startIndex + index;
|
||||
// 순차 번호: startIndex 기준으로 1부터 시작
|
||||
const globalIndex = startIndex + index + 1;
|
||||
return (
|
||||
<Fragment key={itemId}>
|
||||
{renderTableRow(item, index, globalIndex)}
|
||||
|
||||
Reference in New Issue
Block a user