diff --git a/src/components/BatchExpenditureForm.tsx b/src/components/BatchExpenditureForm.tsx new file mode 100644 index 0000000..7c3f45b --- /dev/null +++ b/src/components/BatchExpenditureForm.tsx @@ -0,0 +1,340 @@ + +import { useState } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { Badge } from '@/components/ui/badge'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { parseTransactionLine, formatISK, PastedTransaction } from '@/utils/priceUtils'; +import { IndTransactionRecordNoId, IndJobStatusOptions } from '@/lib/pbtypes'; +import { IndJob } from '@/lib/types'; +import { X } from 'lucide-react'; + +interface BatchExpenditureFormProps { + onClose: () => void; + onTransactionsAssigned: (assignments: { jobId: string, transactions: IndTransactionRecordNoId[] }[]) => void; + jobs: IndJob[]; +} + +interface TransactionGroup { + itemName: string; + transactions: PastedTransaction[]; + totalQuantity: number; + totalValue: number; +} + +const BatchExpenditureForm: React.FC = ({ onClose, onTransactionsAssigned, jobs }) => { + const [pastedData, setPastedData] = useState(''); + const [transactionGroups, setTransactionGroups] = useState([]); + const [duplicatesFound, setDuplicatesFound] = useState(0); + + // Filter jobs that are in acquisition status + const eligibleJobs = jobs.filter(job => job.status === IndJobStatusOptions.Acquisition); + + const findMatchingJob = (itemName: string): string | undefined => { + // Find jobs where the item is in the bill of materials and not satisfied + for (const job of eligibleJobs) { + const billItem = job.billOfMaterials?.find(item => + item.name.toLowerCase() === itemName.toLowerCase() + ); + + if (billItem) { + // Check if this material is already satisfied + const ownedQuantity = job.expenditures?.reduce((total, exp) => + exp.itemName.toLowerCase() === itemName.toLowerCase() ? total + exp.quantity : total, 0 + ) || 0; + + // Only return this job if we still need more of this material + if (ownedQuantity < billItem.quantity) { + return job.id; + } + } + } + return undefined; + }; + + const normalizeDate = (dateStr: string): string => { + return dateStr.replace('T', ' '); + }; + + const createTransactionKey = (parsed: PastedTransaction): string => { + if (!parsed) return ''; + const key = [ + normalizeDate(parsed.date.toString()), + parsed.itemName, + parsed.quantity.toString(), + Math.abs(parsed.totalPrice).toString(), // Use absolute value for expenditures + parsed.buyer, + parsed.location + ].join('|'); + return key; + }; + + const createTransactionKeyFromRecord = (tx: IndTransactionRecordNoId): string => { + const key = [ + normalizeDate(tx.date), + tx.itemName, + tx.quantity.toString(), + Math.abs(tx.totalPrice).toString(), // Use absolute value for expenditures + tx.buyer, + tx.location + ].join('|'); + return key; + }; + + const handlePaste = (value: string) => { + setPastedData(value); + const lines = value.trim().split('\n'); + const pasteTransactionMap = new Map(); + + // STEP 1: Combine identical transactions within the pasted data + lines.forEach((line) => { + const parsed: PastedTransaction | null = parseTransactionLine(line); + if (parsed && parsed.totalPrice < 0) { // Only process expenditures (negative amounts) + // Convert to positive values for expenditures + parsed.totalPrice = Math.abs(parsed.totalPrice); + parsed.unitPrice = Math.abs(parsed.unitPrice); + + const transactionKey: string = createTransactionKey(parsed); + + if (pasteTransactionMap.has(transactionKey)) { + const existing = pasteTransactionMap.get(transactionKey)!; + existing.quantity += parsed.quantity; + existing.totalPrice += parsed.totalPrice; + const newKey = createTransactionKey(existing); + pasteTransactionMap.set(newKey, existing); + pasteTransactionMap.delete(transactionKey); + } else { + pasteTransactionMap.set(transactionKey, parsed); + } + } + }); + + // STEP 2: Identify which jobs these transactions belong to + const relevantJobIds = new Set(); + pasteTransactionMap.forEach((transaction) => { + const matchingJobId = findMatchingJob(transaction.itemName); + if (matchingJobId) { + relevantJobIds.add(matchingJobId); + transaction.assignedJobId = matchingJobId; + } + }); + + // STEP 3: Check against existing expenditures from relevant jobs + const existingTransactionKeys = new Set(); + eligibleJobs.forEach(job => { + if (relevantJobIds.has(job.id)) { + job.expenditures?.forEach(tx => { + const key = createTransactionKeyFromRecord(tx); + existingTransactionKeys.add(key); + }); + } + }); + + // STEP 4: Mark duplicates and assign jobs + let duplicates = 0; + pasteTransactionMap.forEach((transaction, key) => { + const isDuplicate = existingTransactionKeys.has(key); + transaction.isDuplicate = isDuplicate; + + if (isDuplicate) { + duplicates++; + transaction.assignedJobId = undefined; + } else if (!transaction.assignedJobId) { + transaction.assignedJobId = findMatchingJob(transaction.itemName); + } + }); + + const transactionList = Array.from(pasteTransactionMap.values()); + setDuplicatesFound(duplicates); + + // Create individual transaction groups + const groups = transactionList.map(tx => ({ + itemName: tx.itemName, + transactions: [tx], + totalQuantity: tx.quantity, + totalValue: tx.totalPrice + })); + + setTransactionGroups(groups); + }; + + const handleAssignJob = (groupIndex: number, jobId: string) => { + setTransactionGroups(prev => { + const newGroups = [...prev]; + newGroups[groupIndex].transactions.forEach(tx => { + tx.assignedJobId = jobId; + }); + return newGroups; + }); + }; + + const handleSubmit = () => { + // Group transactions by assigned job + const assignments = transactionGroups + .flatMap(group => group.transactions) + .filter(tx => tx.assignedJobId) + .reduce((acc, tx) => { + const jobId = tx.assignedJobId!; + const existing = acc.find(a => a.jobId === jobId); + if (existing) { + existing.transactions.push(tx); + } else { + acc.push({ jobId, transactions: [tx] }); + } + return acc; + }, [] as { jobId: string, transactions: IndTransactionRecordNoId[] }[]); + + onTransactionsAssigned(assignments); + onClose(); + }; + + return ( +
+ e.stopPropagation()} + > + + Batch Expenditure Assignment + + + +
+ +