- MobileCard 접기/펼치기(collapsible) 기능 추가 및 반응형 레이아웃 개선 - DatePicker 공휴일/세무일정 색상 코딩 통합, DateTimePicker 신규 추가 - useCalendarScheduleInit 훅으로 전역 공휴일/일정 데이터 캐싱 - 전 도메인 날짜 필드 DatePicker 표준화 (104 files) - 생산대시보드/작업지시 모바일 호환성 강화 - 견적서/주문관리 반응형 그리드 적용 - 회계 모듈 기능 개선 (매입상세 결재연동, 미수금현황 조회조건 등) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
355 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|