mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
183fe0a93c
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
277 lines
11 KiB
React
277 lines
11 KiB
React
import React, { useState, useRef } from 'react';
|
|
import Papa from 'papaparse';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
} from '@/components/ui/dialog';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
import { HelpCircle, Upload, AlertTriangle, CheckCircle, Loader2, X } from 'lucide-react';
|
|
import { supabase } from '@/integrations/supabase/client';
|
|
import { useAuth } from '@/contexts/AuthContext';
|
|
import { useToast } from '@/hooks/use-toast';
|
|
|
|
export default function ChecklistCSVImportDialog({ open, onOpenChange, onSuccess }) {
|
|
const { user } = useAuth();
|
|
const { toast } = useToast();
|
|
const fileInputRef = useRef(null);
|
|
const db = supabase;
|
|
|
|
const [file, setFile] = useState(null);
|
|
const [parsedData, setParsedData] = useState([]);
|
|
const [error, setError] = useState(null);
|
|
const [importing, setImporting] = useState(false);
|
|
const [step, setStep] = useState('upload');
|
|
|
|
const handleFileChange = (e) => {
|
|
const selectedFile = e.target.files[0];
|
|
if (selectedFile) {
|
|
if (selectedFile.type !== 'text/csv' && !selectedFile.name.endsWith('.csv')) {
|
|
setError('Please upload a valid CSV file.');
|
|
return;
|
|
}
|
|
setFile(selectedFile);
|
|
parseCSV(selectedFile);
|
|
}
|
|
};
|
|
|
|
const parseCSV = (file) => {
|
|
setError(null);
|
|
Papa.parse(file, {
|
|
header: true,
|
|
skipEmptyLines: true,
|
|
complete: (results) => {
|
|
if (results.errors.length > 0) {
|
|
setError(`Error parsing CSV: ${results.errors[0].message}`);
|
|
return;
|
|
}
|
|
|
|
const headers = results.meta.fields;
|
|
const requiredHeaders = ['Checklist Name', 'Items'];
|
|
const missingHeaders = requiredHeaders.filter(h => !headers.includes(h));
|
|
|
|
if (missingHeaders.length > 0) {
|
|
setError(`Missing required columns: ${missingHeaders.join(', ')}. Please check the tooltip for format.`);
|
|
return;
|
|
}
|
|
|
|
const processed = results.data.map((row, index) => ({
|
|
id: index,
|
|
title: row['Checklist Name'] || '',
|
|
itemsRaw: row['Items'] || '',
|
|
description: row['Description'] || '',
|
|
valid: !!row['Checklist Name'] && !!row['Items']
|
|
}));
|
|
|
|
setParsedData(processed);
|
|
setStep('preview');
|
|
},
|
|
error: (err) => {
|
|
setError(`Failed to read file: ${err.message}`);
|
|
}
|
|
});
|
|
};
|
|
|
|
const handleCellEdit = (index, field, value) => {
|
|
const newData = [...parsedData];
|
|
newData[index][field] = value;
|
|
newData[index].valid = !!newData[index].title && !!newData[index].itemsRaw;
|
|
setParsedData(newData);
|
|
};
|
|
|
|
const removeRow = (index) => {
|
|
setParsedData(prev => prev.filter((_, i) => i !== index));
|
|
};
|
|
|
|
const handleImport = async () => {
|
|
const validRows = parsedData.filter(r => r.valid);
|
|
if (validRows.length === 0) {
|
|
setError("No valid data to import.");
|
|
return;
|
|
}
|
|
|
|
setImporting(true);
|
|
try {
|
|
const records = validRows.map(row => {
|
|
const itemsArray = row.itemsRaw.split('|').map(itemText => ({
|
|
text: itemText.trim(),
|
|
required: false,
|
|
id: Math.random().toString(36).substr(2, 9)
|
|
})).filter(i => i.text.length > 0);
|
|
|
|
return {
|
|
title: row.title,
|
|
description: row.description,
|
|
items: itemsArray,
|
|
created_by: user?.id,
|
|
};
|
|
});
|
|
|
|
const { error: insertError } = await db
|
|
.from('checklists')
|
|
.insert(records);
|
|
|
|
if (insertError) throw insertError;
|
|
|
|
toast({
|
|
title: 'Import Successful',
|
|
description: `Successfully imported ${records.length} checklist templates.`,
|
|
});
|
|
|
|
if (onSuccess) onSuccess();
|
|
handleClose();
|
|
|
|
} catch (err) {
|
|
console.error('Import error:', err);
|
|
setError(`Database error: ${err.message}`);
|
|
} finally {
|
|
setImporting(false);
|
|
}
|
|
};
|
|
|
|
const handleClose = () => {
|
|
onOpenChange(false);
|
|
setFile(null);
|
|
setParsedData([]);
|
|
setError(null);
|
|
setStep('upload');
|
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={handleClose}>
|
|
<DialogContent className="sm:max-w-[800px]">
|
|
<DialogHeader>
|
|
<DialogTitle>Import Checklists from CSV</DialogTitle>
|
|
<DialogDescription>
|
|
Upload a CSV file to bulk create checklist templates.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{error && (
|
|
<Alert variant="destructive" className="mb-4">
|
|
<AlertTriangle className="h-4 w-4" />
|
|
<AlertTitle>Error</AlertTitle>
|
|
<AlertDescription>{error}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{step === 'upload' && (
|
|
<div className="py-8 space-y-6">
|
|
<div
|
|
className="border-2 border-dashed border-slate-200 rounded-lg p-10 text-center hover:bg-slate-50 transition-colors cursor-pointer"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
>
|
|
<Upload className="mx-auto h-12 w-12 text-slate-400 mb-3" />
|
|
<p className="text-sm font-medium text-slate-900">Click to upload CSV</p>
|
|
<p className="text-xs text-slate-500 mt-1">or drag and drop file here</p>
|
|
<input
|
|
type="file"
|
|
accept=".csv"
|
|
ref={fileInputRef}
|
|
onChange={handleFileChange}
|
|
className="hidden"
|
|
/>
|
|
</div>
|
|
|
|
<div className="bg-blue-50 p-4 rounded-md flex items-start gap-3 text-sm text-blue-700">
|
|
<HelpCircle className="h-5 w-5 shrink-0 mt-0.5" />
|
|
<div className="space-y-2">
|
|
<p className="font-semibold">CSV Format Requirements:</p>
|
|
<ul className="list-disc list-inside space-y-1 text-xs">
|
|
<li>Headers must be exactly: <strong>Checklist Name, Items, Description</strong></li>
|
|
<li><strong>Items</strong> should be separated by a pipe character (|) e.g. "Task 1|Task 2|Task 3"</li>
|
|
<li><strong>Description</strong> is optional.</li>
|
|
</ul>
|
|
<div className="mt-2 text-xs bg-white p-2 rounded border border-blue-200 font-mono">
|
|
Checklist Name,Items,Description<br/>
|
|
"Weekly Audit","Check Lights|Check Doors|Empty Trash","Weekly facility check"<br/>
|
|
"Safety Insp","Fire Extinguishers|Exits Clear","Monthly safety"
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{step === 'preview' && (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<h4 className="text-sm font-medium">Preview Data ({parsedData.length} rows)</h4>
|
|
<Button variant="ghost" size="sm" onClick={() => setStep('upload')}>Upload Different File</Button>
|
|
</div>
|
|
|
|
<ScrollArea className="h-[300px] border rounded-md">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Checklist Name *</TableHead>
|
|
<TableHead>Items (Pipe Separated) *</TableHead>
|
|
<TableHead>Description</TableHead>
|
|
<TableHead className="w-[50px]"></TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{parsedData.map((row, idx) => (
|
|
<TableRow key={idx} className={!row.valid ? "bg-red-50" : ""}>
|
|
<TableCell>
|
|
<Input
|
|
value={row.title}
|
|
onChange={(e) => handleCellEdit(idx, 'title', e.target.value)}
|
|
className="h-8 text-xs"
|
|
placeholder="Required"
|
|
/>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Input
|
|
value={row.itemsRaw}
|
|
onChange={(e) => handleCellEdit(idx, 'itemsRaw', e.target.value)}
|
|
className="h-8 text-xs"
|
|
placeholder="Item 1|Item 2..."
|
|
/>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Input
|
|
value={row.description}
|
|
onChange={(e) => handleCellEdit(idx, 'description', e.target.value)}
|
|
className="h-8 text-xs"
|
|
/>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Button variant="ghost" size="icon" className="h-6 w-6 text-slate-400 hover:text-red-500" onClick={() => removeRow(idx)}>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</ScrollArea>
|
|
|
|
<div className="flex justify-between items-center text-xs text-slate-500">
|
|
<span>* Required fields</span>
|
|
<span>{parsedData.filter(r => !r.valid).length} invalid rows will be skipped</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={handleClose}>Cancel</Button>
|
|
{step === 'preview' && (
|
|
<Button onClick={handleImport} disabled={importing || parsedData.filter(r => r.valid).length === 0}>
|
|
{importing && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
Import {parsedData.filter(r => r.valid).length} Templates
|
|
</Button>
|
|
)}
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
} |