Files
sam-react-prod/src/components/hr/EmployeeManagement/CSVUploadPage.tsx
유병철 b1686aaf66 feat: 모바일 반응형 UI 개선 및 공휴일/일정 시스템 통합
- MobileCard 접기/펼치기(collapsible) 기능 추가 및 반응형 레이아웃 개선
- DatePicker 공휴일/세무일정 색상 코딩 통합, DateTimePicker 신규 추가
- useCalendarScheduleInit 훅으로 전역 공휴일/일정 데이터 캐싱
- 전 도메인 날짜 필드 DatePicker 표준화 (104 files)
- 생산대시보드/작업지시 모바일 호환성 강화
- 견적서/주문관리 반응형 그리드 적용
- 회계 모듈 기능 개선 (매입상세 결재연동, 미수금현황 조회조건 등)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 21:27:40 +09:00

355 lines
13 KiB
TypeScript

'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 { Trash2, 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"
>
<Trash2 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>
);
}