mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
183fe0a93c
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
772 lines
34 KiB
React
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;
|