Files
acmcc/src/components/ImportDialog.jsx
T
2026-06-01 20:19:26 -04:00

772 lines
34 KiB
React

import React, { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { useToast } from '@/hooks/use-toast';
import { supabase } from '@/integrations/supabase/client';
import * as XLSX from 'xlsx';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { AlertCircle, CheckCircle2, AlertTriangle, ArrowRight, HelpCircle } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
/**
* ImportDialog handles the parsing and mapping of bank/lockbox files.
* Maps imported data to associations, GL accounts, units, and vendors.
*/
const ImportDialog = ({ open, onOpenChange, requiredFields = [], onSuccess, additionalData = {}, defaultFileType = 'bank' }) => {
const { toast } = useToast();
const LOCKBOX_ACCEPT = '.txt,text/plain,text/*,.asc,.dat';
const [step, setStep] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [fileType, setFileType] = useState(defaultFileType);
const [file, setFile] = useState(null);
const [fileHeaders, setFileHeaders] = useState([]);
const [mapping, setMapping] = useState({});
const [bankData, setBankData] = useState([]);
const [bankPreviewData, setBankPreviewData] = useState([]);
const [lockboxRows, setLockboxRows] = useState([]);
const [importSummary, setImportSummary] = useState(null);
// Reference Data
const [associations, setAssociations] = useState([]);
const [chartOfAccounts, setChartOfAccounts] = useState([]);
const [vendors, setVendors] = useState([]);
const [units, setUnits] = useState([]);
useEffect(() => {
if (!open) {
setStep(1);
setIsLoading(false);
setFileType(defaultFileType);
setFile(null);
setFileHeaders([]);
setMapping({});
setBankData([]);
setBankPreviewData([]);
setLockboxRows([]);
setImportSummary(null);
} else {
fetchReferenceData();
}
}, [open, defaultFileType]);
const fetchReferenceData = async () => {
setIsLoading(true);
const [assocRes, coaRes, vendorsRes, unitsRes] = await Promise.all([
supabase.from('associations').select('id, name').eq('status', 'active'),
supabase.from('chart_of_accounts').select('id, account_number, account_name, association_id'),
supabase.from('vendors').select('id, vendor_name, association_id'),
supabase.from('units').select('id, unit_number, account_number, association_id')
]);
if (assocRes.data) setAssociations(assocRes.data);
if (coaRes.data) setChartOfAccounts(coaRes.data);
if (vendorsRes.data) setVendors(vendorsRes.data);
if (unitsRes.data) setUnits(unitsRes.data);
setIsLoading(false);
};
const handleFileChange = (e) => {
const selectedFile = e.target.files[0];
if (!selectedFile) return;
if (fileType === 'lockbox') {
const fileName = selectedFile.name.toLowerCase();
const isAsciiLockboxFile = fileName.endsWith('.txt') || fileName.endsWith('.asc') || fileName.endsWith('.dat') || selectedFile.type === 'text/plain' || selectedFile.type.startsWith('text/') || selectedFile.type === '' || selectedFile.type === 'application/octet-stream';
if (isAsciiLockboxFile) {
setFile(selectedFile);
} else {
toast({ variant: 'destructive', title: 'Invalid File Type', description: 'Please upload a .txt ASCII lockbox file.' });
}
} else {
if (selectedFile.type === 'text/csv' || selectedFile.name.endsWith('.csv') || selectedFile.name.endsWith('.xlsx') || selectedFile.name.endsWith('.xls')) {
setFile(selectedFile);
} else {
toast({ variant: 'destructive', title: 'Invalid File Type', description: 'Please upload a CSV or Excel file.' });
}
}
};
const handleParseFile = () => {
if (!file) {
toast({ variant: 'destructive', title: 'No file selected' });
return;
}
setIsLoading(true);
if (fileType === 'lockbox') {
parseLockboxFile();
} else {
parseBankFile();
}
};
const parseBankFile = () => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = new Uint8Array(e.target.result);
const workbook = XLSX.read(data, { type: 'array', cellDates: true, dateNF: 'yyyy-mm-dd' });
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
if (jsonData.length < 2) {
toast({ variant: 'destructive', title: 'Empty File', description: 'The file appears to be empty or has only headers.' });
setIsLoading(false);
return;
}
const headers = jsonData[0].map(h => String(h).trim());
const rows = jsonData.slice(1).map(row => {
let rowData = {};
headers.forEach((header, index) => {
rowData[header] = row[index];
});
return rowData;
});
setFileHeaders(headers);
setBankData(rows);
const initialMapping = {};
requiredFields.forEach(field => {
const matchedHeader = headers.find(h =>
h.toLowerCase().replace(/[\s_]/g, '') === field.label.toLowerCase().replace(/[\s_]/g, '') ||
h.toLowerCase().replace(/[\s_]/g, '') === field.key.toLowerCase().replace(/[\s_]/g, '')
);
if(matchedHeader) initialMapping[field.key] = matchedHeader;
});
setMapping(initialMapping);
setStep(2);
} catch (error) {
toast({ variant: 'destructive', title: 'File Parsing Error', description: 'Could not read the file.' });
} finally {
setIsLoading(false);
}
};
reader.readAsArrayBuffer(file);
};
const parseLockboxFile = () => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const text = e.target.result;
const lines = text.split(/\r?\n/);
const parsedRows = [];
lines.forEach((line, index) => {
const cleanLine = line.trim();
if (!cleanLine) return;
const cols = cleanLine.replace(/^"|"$/g, '').split('","');
const [acctRaw, amtRaw, dateRaw, txnRaw, memoRaw] = cols;
const acct = acctRaw?.trim() || '';
const amt = parseFloat(amtRaw) || 0;
let isoDate = dateRaw || '';
if (dateRaw && dateRaw.length === 8) {
isoDate = `${dateRaw.substring(4,8)}-${dateRaw.substring(0,2)}-${dateRaw.substring(2,4)}`;
}
const normalizedAcct = acct.replace(/^0+/, '').trim();
const matchedUnit = units.find(u => {
const unitAccount = String(u.account_number || '').trim();
return unitAccount === acct || unitAccount.replace(/^0+/, '') === normalizedAcct;
});
parsedRows.push({
_id: index,
accountNumber: acct,
unitId: matchedUnit ? matchedUnit.id : '',
amount: amt,
date: isoDate,
transactionNumber: txnRaw || '',
memo: memoRaw || ''
});
});
setLockboxRows(parsedRows);
setStep(3);
} catch (error) {
toast({ variant: 'destructive', title: 'Parse Error', description: 'Error parsing Lockbox file.' });
} finally {
setIsLoading(false);
}
};
reader.readAsText(file);
};
const handleMappingChange = (fieldKey, header) => {
setMapping(prev => ({ ...prev, [fieldKey]: header }));
};
const handleGenerateBankPreview = () => {
const requiredUnmapped = requiredFields.filter(f => f.required && !mapping[f.key]);
if (requiredUnmapped.length > 0) {
toast({ variant: 'destructive', title: 'Mapping Incomplete', description: `Please map required fields: ${requiredUnmapped.map(f => f.label).join(', ')}` });
return;
}
const preview = bankData.map((row, index) => {
const getVal = (key) => row[mapping[key]];
const assocRaw = getVal('association');
const glRaw = getVal('gl_account');
const vendorRaw = getVal('vendor');
const unitRaw = getVal('unit');
const paymentRaw = getVal('payment');
const creditRaw = getVal('credit');
const amountRaw = getVal('amount');
const dateRaw = getVal('transaction_date');
const errors = [];
const warnings = [];
let parsedDate = new Date();
if (dateRaw) {
parsedDate = new Date(dateRaw);
if (isNaN(parsedDate)) {
parsedDate = new Date();
warnings.push('Invalid Date, using today');
}
} else {
warnings.push('Missing Date, using today');
}
let matchedAssoc = null;
if (assocRaw) {
matchedAssoc = associations.find(c => c.name.toLowerCase() === String(assocRaw).trim().toLowerCase());
if (!matchedAssoc) errors.push('Association not found');
} else {
errors.push('Association required');
}
let matchedCoa = null;
if (glRaw && matchedAssoc) {
matchedCoa = chartOfAccounts.find(c =>
c.association_id === matchedAssoc.id &&
(c.account_number === String(glRaw).trim() || c.account_name.toLowerCase() === String(glRaw).trim().toLowerCase())
);
if (!matchedCoa) errors.push('GL Account not found for this association');
} else if (!glRaw) {
errors.push('GL Account required');
}
let matchedVendor = null;
if (vendorRaw && matchedAssoc) {
matchedVendor = vendors.find(v => v.association_id === matchedAssoc.id && v.vendor_name.toLowerCase() === String(vendorRaw).trim().toLowerCase());
if (!matchedVendor) warnings.push('Vendor unmapped (will import as text)');
}
let matchedUnit = null;
if (unitRaw && matchedAssoc) {
matchedUnit = units.find(u =>
u.association_id === matchedAssoc.id &&
u.unit_number === String(unitRaw).trim()
);
if (!matchedUnit) warnings.push('Unit unmapped');
}
if (!paymentRaw) errors.push('Payment required');
if (!creditRaw) errors.push('Credit indicator required');
const parsedAmount = parseFloat(amountRaw);
if (isNaN(parsedAmount)) errors.push('Invalid Amount');
return {
_id: index,
raw: row,
association: assocRaw || '',
gl_account: glRaw || '',
vendor: vendorRaw || '',
unit: unitRaw || '',
payment: paymentRaw || '',
credit: creditRaw || '',
amount: isNaN(parsedAmount) ? amountRaw : parsedAmount,
transaction_date: parsedDate.toISOString().split('T')[0],
matchedAssoc,
matchedCoa,
matchedVendor,
matchedUnit,
errors,
warnings,
isValid: errors.length === 0
};
});
setBankPreviewData(preview);
setStep(3);
};
const handleImport = async () => {
setIsLoading(true);
if (fileType === 'lockbox') {
await importLockboxData();
} else {
await importBankData();
}
};
const importBankData = async () => {
const validRows = bankPreviewData.filter(r => r.isValid);
if (validRows.length === 0) {
toast({ variant: 'destructive', title: 'Import Failed', description: 'No valid rows to import.' });
setIsLoading(false);
return;
}
const processedBankData = [];
validRows.forEach(r => {
const isDebit = String(r.credit).toLowerCase().includes('debit');
const txType = isDebit ? 'debit' : 'credit';
const desc = r.matchedVendor ? `Vendor: ${r.matchedVendor.vendor_name}` : (r.vendor ? `Vendor: ${r.vendor}` : 'Imported Bank Transaction');
processedBankData.push({
association_id: r.matchedAssoc.id,
description: desc,
[isDebit ? 'debit' : 'credit']: r.amount,
date: r.transaction_date,
transaction_type: txType === 'debit' ? 'payment' : 'deposit',
...additionalData
});
});
try {
const { error: bankErr } = await supabase.from('bank_transactions').insert(processedBankData);
if (bankErr) throw bankErr;
setImportSummary({
total: bankPreviewData.length,
success: validRows.length,
failed: bankPreviewData.length - validRows.length
});
setStep(4);
if(onSuccess) onSuccess();
} catch (error) {
toast({ variant: 'destructive', title: 'Import Failed', description: error.message });
}
setIsLoading(false);
};
const importLockboxData = async () => {
const validRows = lockboxRows.filter(r => r.unitId && !isNaN(r.amount) && r.amount > 0 && r.date);
if (validRows.length === 0) {
toast({ variant: 'destructive', title: 'Import Failed', description: 'No valid rows to import. Each row needs a matched unit, positive amount, and date.' });
setIsLoading(false);
return;
}
try {
// Resolve owner_id + association_id for each unit
const unitIds = [...new Set(validRows.map(r => r.unitId))];
const { data: ownerRows, error: ownerErr } = await supabase
.from('owners')
.select('id, unit_id, association_id, status')
.in('unit_id', unitIds)
.neq('status', 'archived');
if (ownerErr) throw ownerErr;
const ownerByUnit = {};
(ownerRows || []).forEach(o => {
const existing = ownerByUnit[o.unit_id];
// Prefer active owners
if (!existing || (existing.status !== 'active' && o.status === 'active')) {
ownerByUnit[o.unit_id] = o;
}
});
const entries = [];
const skipped = [];
validRows.forEach(r => {
const owner = ownerByUnit[r.unitId];
if (!owner) { skipped.push(r); return; }
entries.push({
association_id: owner.association_id,
owner_id: owner.id,
unit_id: r.unitId,
date: r.date,
transaction_type: 'payment',
description: `Lockbox payment${r.transactionNumber ? ` #${r.transactionNumber}` : ''}${r.memo ? ` - ${r.memo}` : ''}`,
debit: 0,
credit: r.amount,
reference_id: r.transactionNumber || `lockbox-${r.date}-${r.accountNumber}-${r.amount}`,
reference_type: 'lockbox',
});
});
if (entries.length === 0) {
toast({ variant: 'destructive', title: 'Import Failed', description: 'No matching owners found for the imported units.' });
setIsLoading(false);
return;
}
// Batch insert (500 at a time)
const BATCH = 500;
let inserted = 0;
for (let i = 0; i < entries.length; i += BATCH) {
const batch = entries.slice(i, i + BATCH);
const { error } = await supabase.from('owner_ledger_entries').insert(batch);
if (error) throw error;
inserted += batch.length;
}
toast({ title: 'Import Complete', description: `${inserted} payment(s) posted to the ledger.` });
setImportSummary({
total: lockboxRows.length,
success: inserted,
failed: lockboxRows.length - inserted,
});
setStep(4);
if (onSuccess) onSuccess();
} catch (error) {
console.error('Lockbox import error:', error);
toast({ variant: 'destructive', title: 'Import Failed', description: error.message });
} finally {
setIsLoading(false);
}
};
const handleLockboxRowChange = (index, field, value) => {
const newRows = [...lockboxRows];
newRows[index][field] = value;
if (field === 'accountNumber') {
const normalizedAcct = String(value || '').replace(/^0+/, '').trim();
const matchedUnit = units.find(u => {
const unitAccount = String(u.account_number || '').trim();
return unitAccount === String(value || '').trim() || unitAccount.replace(/^0+/, '') === normalizedAcct;
});
newRows[index].unitId = matchedUnit ? matchedUnit.id : '';
}
setLockboxRows(newRows);
};
return (
<Dialog open={open} onOpenChange={(val) => { if (!isLoading) onOpenChange(val); }}>
<DialogContent className="sm:max-w-4xl max-h-[90vh] flex flex-col">
<DialogHeader className="shrink-0">
<DialogTitle className="text-xl font-bold">
{step === 1 && "Import Transactions"}
{step === 2 && "Map Fields"}
{step === 3 && "Preview Data"}
{step === 4 && "Import Complete"}
</DialogTitle>
<DialogDescription>
{step === 1 && 'Select the file type and upload your document to begin.'}
{step === 2 && 'Map your file columns to the database fields.'}
{step === 3 && 'Review parsed data and correct any errors before importing.'}
{step === 4 && 'Review the results of your import operation.'}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto py-4 min-h-[300px]">
{step === 1 && (
<div className="space-y-6">
<div className="space-y-3">
<Label className="text-sm font-semibold">File Type</Label>
<Select value={fileType} onValueChange={setFileType}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select import type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="bank">Bank Transactions (CSV/Excel)</SelectItem>
<SelectItem value="lockbox">Lockbox Payments (.txt)</SelectItem>
</SelectContent>
</Select>
</div>
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertTitle className="font-semibold">
{fileType === 'lockbox' ? 'Lockbox Format Requirements' : 'Bank Transaction Format'}
</AlertTitle>
<AlertDescription className="mt-2 text-sm leading-relaxed">
{fileType === 'lockbox'
? 'Upload a .txt ASCII lockbox file with quoted, comma-separated values.'
: 'Upload a CSV or Excel file containing the required fields. You will map the columns in the next step.'}
</AlertDescription>
</Alert>
<div className="space-y-3 pt-2">
<Label htmlFor="import-file" className="text-sm font-semibold">Select File</Label>
<Input
id="import-file"
type="file"
onChange={handleFileChange}
accept={fileType === 'lockbox' ? LOCKBOX_ACCEPT : ".csv, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel"}
className="w-full cursor-pointer"
/>
</div>
</div>
)}
{step === 2 && fileType === 'bank' && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground mb-4">Map columns from your file to the required transaction fields.</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{requiredFields.map(field => (
<div key={field.key} className="space-y-1.5 p-4 border rounded-lg bg-muted">
<div className="flex items-center justify-between">
<Label className="font-semibold flex items-center">
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
<TooltipProvider>
<Tooltip>
<TooltipTrigger type="button" className="ml-2 cursor-help"><HelpCircle className="w-4 h-4 text-muted-foreground" /></TooltipTrigger>
<TooltipContent><p>{field.description}</p></TooltipContent>
</Tooltip>
</TooltipProvider>
</Label>
</div>
<Select value={mapping[field.key] || ''} onValueChange={(val) => handleMappingChange(field.key, val)}>
<SelectTrigger className={`bg-background ${field.required && !mapping[field.key] ? 'border-amber-300 ring-1 ring-amber-100' : ''}`}>
<SelectValue placeholder="Select a column..." />
</SelectTrigger>
<SelectContent>
{fileHeaders.map(header => (
<SelectItem key={header} value={header}>{header}</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</div>
</div>
)}
{step === 3 && fileType === 'bank' && (
<div className="space-y-4">
<Alert variant={bankPreviewData.some(r => !r.isValid) ? "destructive" : "default"} className="mb-4">
{bankPreviewData.some(r => !r.isValid) ? <AlertCircle className="h-4 w-4" /> : <CheckCircle2 className="h-4 w-4 text-emerald-600" />}
<AlertTitle>{bankPreviewData.some(r => !r.isValid) ? 'Errors Found' : 'Ready to Import'}</AlertTitle>
<AlertDescription>
{bankPreviewData.some(r => !r.isValid)
? `${bankPreviewData.filter(r => !r.isValid).length} row(s) have errors and will be skipped.`
: `All ${bankPreviewData.length} rows are valid and ready to be imported.`}
</AlertDescription>
</Alert>
<div className="rounded-md border overflow-x-auto shadow-sm">
<Table className="min-w-[800px]">
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Association</TableHead>
<TableHead>GL Account</TableHead>
<TableHead>Vendor</TableHead>
<TableHead>Unit</TableHead>
<TableHead>Payment</TableHead>
<TableHead>Credit</TableHead>
<TableHead className="text-right">Amount</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bankPreviewData.map((row) => (
<TableRow key={row._id} className={!row.isValid ? "bg-destructive/5" : (row.warnings.length > 0 ? "bg-amber-50/30" : "")}>
<TableCell className="text-xs">{row.transaction_date}</TableCell>
<TableCell className="text-xs truncate max-w-[120px]">{row.matchedAssoc ? row.matchedAssoc.name : <span className="text-destructive font-medium">{row.association || 'Missing'}</span>}</TableCell>
<TableCell className="text-xs truncate max-w-[120px]">{row.matchedCoa ? row.matchedCoa.account_number : <span className="text-destructive font-medium">{row.gl_account || 'Missing'}</span>}</TableCell>
<TableCell className="text-xs truncate max-w-[100px]">{row.matchedVendor ? row.matchedVendor.vendor_name : <span className="text-muted-foreground">{row.vendor || '-'}</span>}</TableCell>
<TableCell className="text-xs truncate max-w-[100px]">{row.matchedUnit ? row.matchedUnit.unit_number : <span className="text-muted-foreground">{row.unit || '-'}</span>}</TableCell>
<TableCell className="text-xs">{row.payment || <span className="text-destructive">Missing</span>}</TableCell>
<TableCell className="text-xs">{row.credit || <span className="text-destructive">Missing</span>}</TableCell>
<TableCell className="text-xs text-right font-medium">${!isNaN(row.amount) ? Number(row.amount).toFixed(2) : <span className="text-destructive">Invalid</span>}</TableCell>
<TableCell className="text-xs">
{row.isValid ? (
row.warnings.length > 0 ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger><AlertTriangle className="w-4 h-4 text-amber-500" /></TooltipTrigger>
<TooltipContent><p>{row.warnings.join(', ')}</p></TooltipContent>
</Tooltip>
</TooltipProvider>
) : <CheckCircle2 className="w-4 h-4 text-emerald-500" />
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger><AlertCircle className="w-4 h-4 text-destructive" /></TooltipTrigger>
<TooltipContent><p>{row.errors.join(', ')}</p></TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
{step === 3 && fileType === 'lockbox' && (
<div className="space-y-4">
<Alert className="bg-amber-50 border-amber-200">
<AlertTriangle className="h-4 w-4 text-amber-600" />
<AlertTitle>Review Data</AlertTitle>
<AlertDescription>
Verify the mapped units and amounts. Rows with missing units or invalid amounts will not be imported.
</AlertDescription>
</Alert>
<div className="rounded-md border overflow-hidden shadow-sm">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-24">Acct #</TableHead>
<TableHead className="min-w-[200px]">Matched Unit</TableHead>
<TableHead className="w-32">Amount ($)</TableHead>
<TableHead className="w-40">Date</TableHead>
<TableHead className="min-w-[150px]">Memo</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{lockboxRows.map((row, i) => {
const hasError = !row.unitId || isNaN(row.amount) || row.amount <= 0 || !row.date;
return (
<TableRow key={row._id} className={hasError ? "bg-destructive/5" : ""}>
<TableCell>
<Input
value={row.accountNumber}
onChange={(e) => handleLockboxRowChange(i, 'accountNumber', e.target.value)}
className="h-8 text-xs"
/>
</TableCell>
<TableCell>
<Select value={row.unitId} onValueChange={(val) => handleLockboxRowChange(i, 'unitId', val)}>
<SelectTrigger className={`h-8 text-xs ${!row.unitId ? 'border-destructive' : ''}`}>
<SelectValue placeholder="Select unit..." />
</SelectTrigger>
<SelectContent>
{units.map(u => (
<SelectItem key={u.id} value={u.id} className="text-xs">
{u.account_number ? `${u.account_number} - ${u.unit_number}` : u.unit_number}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell>
<Input
type="number" step="0.01"
value={row.amount}
onChange={(e) => handleLockboxRowChange(i, 'amount', parseFloat(e.target.value))}
className={`h-8 text-xs ${isNaN(row.amount) || row.amount <= 0 ? 'border-destructive' : ''}`}
/>
</TableCell>
<TableCell>
<Input
type="date"
value={row.date}
onChange={(e) => handleLockboxRowChange(i, 'date', e.target.value)}
className={`h-8 text-xs ${!row.date ? 'border-destructive' : ''}`}
/>
</TableCell>
<TableCell>
<Input
value={row.memo}
onChange={(e) => handleLockboxRowChange(i, 'memo', e.target.value)}
className="h-8 text-xs"
/>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
)}
{step === 4 && importSummary && (
<div className="flex flex-col items-center justify-center py-8 space-y-6">
<div className="w-16 h-16 rounded-full bg-emerald-100 flex items-center justify-center text-emerald-600 mb-2 shadow-sm">
<CheckCircle2 className="w-8 h-8" />
</div>
<div className="text-center space-y-2">
<h3 className="text-2xl font-bold">Import Complete</h3>
<p className="text-muted-foreground">Your file has been processed successfully.</p>
</div>
<div className="grid grid-cols-3 gap-4 w-full max-w-md">
<div className="bg-muted border rounded-xl p-4 text-center shadow-sm">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1">Total</p>
<p className="text-2xl font-bold">{importSummary.total}</p>
</div>
<div className="bg-emerald-50 border border-emerald-100 rounded-xl p-4 text-center shadow-sm">
<p className="text-xs font-semibold text-emerald-600 uppercase tracking-wider mb-1">Imported</p>
<p className="text-2xl font-bold text-emerald-700">{importSummary.success}</p>
</div>
<div className="bg-destructive/5 border border-destructive/20 rounded-xl p-4 text-center shadow-sm">
<p className="text-xs font-semibold text-destructive uppercase tracking-wider mb-1">Skipped</p>
<p className="text-2xl font-bold text-destructive">{importSummary.failed}</p>
</div>
</div>
{importSummary.failed > 0 && (
<Alert variant="destructive" className="max-w-md w-full shadow-sm mt-4">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Some rows skipped</AlertTitle>
<AlertDescription>
Rows with missing required data or validation errors were not imported.
</AlertDescription>
</Alert>
)}
</div>
)}
</div>
<DialogFooter className="shrink-0 pt-4 border-t">
{step < 4 ? (
<>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}>Cancel</Button>
{step === 1 && (
<Button onClick={handleParseFile} disabled={!file || isLoading}>
{isLoading ? 'Parsing...' : 'Continue'}
</Button>
)}
{step === 2 && fileType === 'bank' && (
<>
<Button variant="outline" onClick={() => setStep(1)} disabled={isLoading}>Back</Button>
<Button onClick={handleGenerateBankPreview} disabled={isLoading}>
Generate Preview
</Button>
</>
)}
{step === 3 && (
<>
<Button variant="outline" onClick={() => setStep(fileType === 'bank' ? 2 : 1)} disabled={isLoading}>Back</Button>
<Button onClick={handleImport} disabled={isLoading || (fileType === 'bank' && bankPreviewData.filter(r => r.isValid).length === 0) || (fileType === 'lockbox' && lockboxRows.filter(r => r.unitId && r.amount > 0 && r.date).length === 0)}>
{isLoading ? 'Importing...' : 'Run Import'}
</Button>
</>
)}
</>
) : (
<div className="w-full flex justify-between items-center">
<Button variant="outline" onClick={() => onOpenChange(false)}>Close</Button>
<Button onClick={() => onOpenChange(false)}>
Done <ArrowRight className="w-4 h-4 ml-2" />
</Button>
</div>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default ImportDialog;