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:
byeongcheolryu
2025-12-06 11:36:38 +09:00
parent 751e65f59b
commit 48dbba0e5f
59 changed files with 9888 additions and 101 deletions

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

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

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

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

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

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

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

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

View 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>
&quot;{employeeToDelete?.name}&quot; ?
<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>
</>
);
}

View 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: '사원코드 (오름차순)' },
];