Files
HRM-System/resources/js/components/ColumnMappingModal.tsx
2026-04-13 08:16:56 +08:00

193 lines
7.4 KiB
TypeScript
Executable File

import { useState, useEffect } from 'react';
import { router } from '@inertiajs/react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Info } from 'lucide-react';
import { toast } from '@/components/custom-toast';
import { useTranslation } from 'react-i18next';
interface ColumnMappingModalProps {
isOpen: boolean;
onClose: () => void;
excelColumns: string[];
databaseFields: { key: string; required?: boolean }[];
importRoute: string;
data: Record<string, string>[];
previewData?: Record<string, string>[];
}
export function ColumnMappingModal({
isOpen,
onClose,
excelColumns,
databaseFields,
importRoute,
data,
previewData = []
}: ColumnMappingModalProps) {
const { t } = useTranslation();
const [mapping, setMapping] = useState<Record<string, string>>({});
const [isImporting, setIsImporting] = useState(false);
useEffect(() => {
if (isOpen && excelColumns.length > 0) {
const autoMapping: Record<string, string> = {};
databaseFields.forEach(field => {
const match = excelColumns.find(col =>col.toLowerCase().replace(/[_\s]/g, '') === field.key.toLowerCase().replace(/[_\s]/g, ''));
if (match) {
autoMapping[field.key] = match;
}
});
setMapping(autoMapping);
}
}, [isOpen, excelColumns, databaseFields]);
const handleSubmit = () => {
if (!data || data.length === 0) {
toast.error(t('No data available for import'));
return;
}
const requiredFields = databaseFields.filter(f => f.required);
const missingFields = requiredFields.filter(f => !mapping[f.key]);
if (missingFields.length > 0) {
toast.error(t('Please map all required fields: {{fields}}', { fields: missingFields.map(f => f.key).join(', ') }));
return;
}
// Map data according to column mapping
const mappedData = (data || []).map(row => {
const mappedRow: Record<string, any> = {};
Object.entries(mapping).forEach(([dbField, excelColumn]) => {
mappedRow[dbField] = row[excelColumn];
});
return mappedRow;
});
setIsImporting(true);
toast.loading(t('Importing...'));
router.post(route(importRoute), {
data: mappedData
}, {
preserveState: true,
onSuccess: (page) => {
onClose();
setIsImporting(false);
toast.dismiss();
if (page.props.flash.success) {
toast.success(t(page.props.flash.success));
} else if (page.props.flash.error) {
toast.error(t(page.props.flash.error));
}
},
onError: (errors) => {
setIsImporting(false);
toast.dismiss();
if (typeof errors === 'string') {
toast.error(errors);
} else {
toast.error(t('Failed to import'));
}
}
});
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
<DialogContent className="max-w-7xl max-h-[85vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>{t('Map Columns')}</DialogTitle>
</DialogHeader>
<Alert className="bg-amber-50 border-amber-200">
<Info className="h-4 w-4 text-amber-600" />
<AlertDescription className="text-amber-800">
{t('Map your CSV columns to database fields')}
</AlertDescription>
</Alert>
<div className="flex-1 overflow-auto">
<h3 className="text-base font-semibold mb-3">{t('Column Mapping & Preview')}</h3>
<div className="border rounded-lg overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 border-b sticky top-0">
<tr>
{databaseFields.map((field) => (
<th key={field.key} className="px-4 py-3 text-left font-medium text-gray-700 min-w-[180px]">
<div className="space-y-2">
<div className="text-sm font-semibold">
{field.key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
{field.required && <span className="text-red-500 ml-1">*</span>}
</div>
<Select
value={mapping[field.key] || '__unselect__'}
onValueChange={(value) => {
setMapping(prev => {
const newMapping = { ...prev };
if (value === '__unselect__') {
delete newMapping[field.key];
} else {
Object.keys(newMapping).forEach(key => {
if (newMapping[key] === value) delete newMapping[key];
});
newMapping[field.key] = value;
}
return newMapping;
});
}}
>
<SelectTrigger className="h-9 text-sm w-full bg-white">
<SelectValue placeholder={t('Select...')} />
</SelectTrigger>
<SelectContent position="popper" className="z-[9999]">
<SelectItem value="__unselect__">{t('-- Not Mapped --')}</SelectItem>
{excelColumns.map(col => {
const isUsed = Object.values(mapping).includes(col) && mapping[field.key] !== col;
return (
<SelectItem key={col} value={col} disabled={isUsed}>
{col}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
</th>
))}
</tr>
</thead>
<tbody>
{previewData.map((row, idx) => (
<tr key={idx} className="border-b hover:bg-gray-50">
{databaseFields.map(field => (
<td key={field.key} className="px-4 py-3 text-gray-700 text-sm">
<div className="truncate max-w-[200px]" title={mapping[field.key] ? row[mapping[field.key]] : ''}>
{mapping[field.key] ? (row[mapping[field.key]] || <span className="text-gray-400 italic text-xs">empty</span>) : <span className="text-gray-400">-</span>}
</div>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<p className="text-sm text-gray-500 mt-3">{t('Showing all {{count}} rows', { count: previewData.length })}</p>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose} disabled={isImporting}>
{t('Back')}
</Button>
<Button type="button" onClick={handleSubmit} disabled={isImporting}>
{t('Import Data')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}