mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
Add ACMCC app source, Supabase backend, and project config
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user