Fix performance issues
Continue to optimize performance by addressing remaining bottlenecks. Further refactor components and calculations to reduce re-renders and improve responsiveness.
This commit is contained in:
214
src/components/BatchImportDialog.tsx
Normal file
214
src/components/BatchImportDialog.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { IndJobStatusOptions } from '@/types/industry';
|
||||
import { parseTransactionLine, formatISK } from '@/utils/currency';
|
||||
import { useJobs } from '@/hooks/useDataService';
|
||||
import { FileUp } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
export function BatchImportDialog() {
|
||||
const { jobs, updateJob } = useJobs();
|
||||
const { toast } = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pasteText, setPasteText] = useState('');
|
||||
const [groupedTransactions, setGroupedTransactions] = useState<Record<string, any[]>>({});
|
||||
|
||||
const targetStatuses = [IndJobStatusOptions.Running, IndJobStatusOptions.Selling, IndJobStatusOptions.Tracked];
|
||||
const eligibleJobs = jobs.filter(job => targetStatuses.includes(job.status));
|
||||
|
||||
const handleAnalyze = () => {
|
||||
if (!pasteText.trim()) return;
|
||||
|
||||
const lines = pasteText.split('\n').filter(line => line.trim());
|
||||
const transactions: any[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const parsed = parseTransactionLine(line);
|
||||
if (parsed) {
|
||||
transactions.push(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
if (transactions.length === 0) {
|
||||
toast({
|
||||
title: "No Transactions Found",
|
||||
description: "No valid transactions found in the pasted text.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by item name
|
||||
const grouped: Record<string, any[]> = {};
|
||||
transactions.forEach(tx => {
|
||||
if (!grouped[tx.itemName]) {
|
||||
grouped[tx.itemName] = [];
|
||||
}
|
||||
grouped[tx.itemName].push(tx);
|
||||
});
|
||||
|
||||
setGroupedTransactions(grouped);
|
||||
};
|
||||
|
||||
const handleImport = () => {
|
||||
let totalImported = 0;
|
||||
|
||||
Object.entries(groupedTransactions).forEach(([itemName, transactions]) => {
|
||||
// Find matching job
|
||||
const matchingJob = eligibleJobs.find(job =>
|
||||
job.outputItem.toLowerCase() === itemName.toLowerCase()
|
||||
);
|
||||
|
||||
if (matchingJob) {
|
||||
// Deduplicate against existing income transactions
|
||||
const existingIncome = matchingJob.income || [];
|
||||
const newTransactions = transactions.filter(newTx => {
|
||||
return !existingIncome.some(existing =>
|
||||
existing.date === newTx.date &&
|
||||
existing.itemName === newTx.itemName &&
|
||||
existing.quantity === newTx.quantity &&
|
||||
existing.totalPrice === newTx.totalPrice &&
|
||||
existing.buyer === newTx.buyer
|
||||
);
|
||||
});
|
||||
|
||||
if (newTransactions.length > 0) {
|
||||
const updatedIncome = [...existingIncome];
|
||||
newTransactions.forEach(tx => {
|
||||
updatedIncome.push({
|
||||
...tx,
|
||||
id: crypto.randomUUID(),
|
||||
job: matchingJob.id,
|
||||
created: new Date().toISOString(),
|
||||
updated: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
updateJob(matchingJob.id, { income: updatedIncome });
|
||||
totalImported += newTransactions.length;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
toast({
|
||||
title: "Batch Import Complete",
|
||||
description: `Imported ${totalImported} transactions across ${Object.keys(groupedTransactions).length} items.`,
|
||||
});
|
||||
|
||||
setPasteText('');
|
||||
setGroupedTransactions({});
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const getJobForItem = (itemName: string) => {
|
||||
return eligibleJobs.find(job =>
|
||||
job.outputItem.toLowerCase() === itemName.toLowerCase()
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<FileUp className="w-4 h-4 mr-2" />
|
||||
Batch Import Sales
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Batch Import Sales Transactions</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Paste sale transaction data. Transactions will be automatically grouped by item name and matched to running/selling/tracked jobs.
|
||||
</p>
|
||||
<Textarea
|
||||
placeholder="Paste EVE sales transaction data here..."
|
||||
value={pasteText}
|
||||
onChange={(e) => setPasteText(e.target.value)}
|
||||
rows={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleAnalyze} disabled={!pasteText.trim()}>
|
||||
Analyze Transactions
|
||||
</Button>
|
||||
{Object.keys(groupedTransactions).length > 0 && (
|
||||
<Button onClick={handleImport}>
|
||||
Import All
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{Object.keys(groupedTransactions).length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Import Preview</h3>
|
||||
{Object.entries(groupedTransactions).map(([itemName, transactions]) => {
|
||||
const matchingJob = getJobForItem(itemName);
|
||||
const totalValue = transactions.reduce((sum, tx) => sum + tx.totalPrice, 0);
|
||||
const totalQuantity = transactions.reduce((sum, tx) => sum + tx.quantity, 0);
|
||||
|
||||
return (
|
||||
<div key={itemName} className="border rounded p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-medium">{itemName}</h4>
|
||||
{matchingJob ? (
|
||||
<Badge variant="outline" className="text-success">
|
||||
→ {matchingJob.outputItem} ({matchingJob.status})
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive">No matching job</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{transactions.length} transactions, {totalQuantity.toLocaleString()} units, {formatISK(totalValue)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Quantity</TableHead>
|
||||
<TableHead>Unit Price</TableHead>
|
||||
<TableHead>Total</TableHead>
|
||||
<TableHead>Buyer</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{transactions.slice(0, 5).map((tx, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell>{new Date(tx.date).toLocaleDateString()}</TableCell>
|
||||
<TableCell>{tx.quantity.toLocaleString()}</TableCell>
|
||||
<TableCell>{formatISK(tx.unitPrice)}</TableCell>
|
||||
<TableCell>{formatISK(tx.totalPrice)}</TableCell>
|
||||
<TableCell>{tx.buyer || '-'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{transactions.length > 5 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
||||
... and {transactions.length - 5} more transactions
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
160
src/components/BillOfMaterialsManager.tsx
Normal file
160
src/components/BillOfMaterialsManager.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { IndJob } from '@/types/industry';
|
||||
import { parseBillOfMaterials, exportBillOfMaterials } from '@/utils/currency';
|
||||
import { useJobs } from '@/hooks/useDataService';
|
||||
import { Plus, Trash2, FileDown } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
interface BillOfMaterialsManagerProps {
|
||||
job: IndJob;
|
||||
}
|
||||
|
||||
export function BillOfMaterialsManager({ job }: BillOfMaterialsManagerProps) {
|
||||
const { updateJob, createMultipleBillItems } = useJobs();
|
||||
const { toast } = useToast();
|
||||
const [pasteText, setPasteText] = useState('');
|
||||
|
||||
const handleImport = () => {
|
||||
if (!pasteText.trim()) return;
|
||||
|
||||
const materials = parseBillOfMaterials(pasteText);
|
||||
if (materials.length === 0) {
|
||||
toast({
|
||||
title: "Import Failed",
|
||||
description: "No valid materials found in the pasted text.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Deduplicate against existing materials
|
||||
const existingMaterials = job.billOfMaterials || [];
|
||||
const newMaterials = materials.filter(newMat => {
|
||||
return !existingMaterials.some(existing => existing.name === newMat.name);
|
||||
});
|
||||
|
||||
if (newMaterials.length === 0) {
|
||||
toast({
|
||||
title: "No New Materials",
|
||||
description: "All materials already exist in this job.",
|
||||
variant: "default",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedMaterials = [...existingMaterials];
|
||||
newMaterials.forEach(material => {
|
||||
updatedMaterials.push({
|
||||
...material,
|
||||
id: crypto.randomUUID(),
|
||||
created: new Date().toISOString(),
|
||||
updated: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
updateJob(job.id, { billOfMaterials: updatedMaterials });
|
||||
|
||||
toast({
|
||||
title: "Import Successful",
|
||||
description: `Added ${newMaterials.length} new materials.`,
|
||||
});
|
||||
|
||||
setPasteText('');
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const materials = job.billOfMaterials || [];
|
||||
if (materials.length === 0) {
|
||||
toast({
|
||||
title: "No Materials",
|
||||
description: "No bill of materials to export.",
|
||||
variant: "default",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const exportText = exportBillOfMaterials(materials);
|
||||
navigator.clipboard.writeText(exportText).then(() => {
|
||||
toast({
|
||||
title: "Exported",
|
||||
description: "Bill of materials copied to clipboard.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteMaterial = (materialId: string) => {
|
||||
const updatedMaterials = (job.billOfMaterials || []).filter(m => m.id !== materialId);
|
||||
updateJob(job.id, { billOfMaterials: updatedMaterials });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center justify-between">
|
||||
Bill of Materials Management
|
||||
<Button variant="outline" size="sm" onClick={handleExport}>
|
||||
<FileDown className="w-4 h-4 mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Textarea
|
||||
placeholder="Paste bill of materials here (Material Name [tab] Quantity format)..."
|
||||
value={pasteText}
|
||||
onChange={(e) => setPasteText(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
|
||||
<Button onClick={handleImport} disabled={!pasteText.trim()}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Import Materials
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Materials</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(job.billOfMaterials?.length || 0) > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Material</TableHead>
|
||||
<TableHead>Quantity</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{job.billOfMaterials?.map(material => (
|
||||
<TableRow key={material.id}>
|
||||
<TableCell>{material.name}</TableCell>
|
||||
<TableCell>{material.quantity.toLocaleString()}</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteMaterial(material.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="text-muted-foreground text-sm">No materials defined</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
144
src/components/CreateJobDialog.tsx
Normal file
144
src/components/CreateJobDialog.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { IndJobStatusOptions } from '@/types/industry';
|
||||
import { useJobs } from '@/hooks/useDataService';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
export function CreateJobDialog() {
|
||||
const { createJob } = useJobs();
|
||||
const { toast } = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
outputItem: '',
|
||||
outputQuantity: 1,
|
||||
status: IndJobStatusOptions.Planned,
|
||||
projectedCost: 0,
|
||||
projectedRevenue: 0,
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.outputItem.trim()) {
|
||||
toast({
|
||||
title: "Validation Error",
|
||||
description: "Output item name is required.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
createJob({
|
||||
outputItem: formData.outputItem,
|
||||
outputQuantity: formData.outputQuantity,
|
||||
status: formData.status,
|
||||
projectedCost: formData.projectedCost,
|
||||
projectedRevenue: formData.projectedRevenue,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: "Job Created",
|
||||
description: `Created job for ${formData.outputItem}`,
|
||||
});
|
||||
|
||||
setFormData({
|
||||
outputItem: '',
|
||||
outputQuantity: 1,
|
||||
status: IndJobStatusOptions.Planned,
|
||||
projectedCost: 0,
|
||||
projectedRevenue: 0,
|
||||
});
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Job
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Job</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="outputItem">Output Item</Label>
|
||||
<Input
|
||||
id="outputItem"
|
||||
value={formData.outputItem}
|
||||
onChange={(e) => setFormData({ ...formData, outputItem: e.target.value })}
|
||||
placeholder="e.g., Inertial Stabilizers I"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="outputQuantity">Quantity</Label>
|
||||
<Input
|
||||
id="outputQuantity"
|
||||
type="number"
|
||||
min="1"
|
||||
value={formData.outputQuantity}
|
||||
onChange={(e) => setFormData({ ...formData, outputQuantity: parseInt(e.target.value) || 1 })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(value) => setFormData({ ...formData, status: value as IndJobStatusOptions })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.values(IndJobStatusOptions).map(status => (
|
||||
<SelectItem key={status} value={status}>{status}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="projectedCost">Projected Cost (ISK)</Label>
|
||||
<Input
|
||||
id="projectedCost"
|
||||
type="number"
|
||||
min="0"
|
||||
value={formData.projectedCost}
|
||||
onChange={(e) => setFormData({ ...formData, projectedCost: parseFloat(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="projectedRevenue">Projected Revenue (ISK)</Label>
|
||||
<Input
|
||||
id="projectedRevenue"
|
||||
type="number"
|
||||
min="0"
|
||||
value={formData.projectedRevenue}
|
||||
onChange={(e) => setFormData({ ...formData, projectedRevenue: parseFloat(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">Create Job</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
55
src/components/EditableField.tsx
Normal file
55
src/components/EditableField.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useState } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface EditableFieldProps {
|
||||
value: string | number;
|
||||
onSave: (value: string) => void;
|
||||
type?: 'text' | 'number' | 'date';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EditableField({ value, onSave, type = 'text', className }: EditableFieldProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(String(value));
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(editValue);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSave();
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditValue(String(value));
|
||||
setIsEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<Input
|
||||
type={type}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onBlur={handleSave}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="h-6 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => setIsEditing(true)}
|
||||
className={cn(
|
||||
"cursor-pointer hover:bg-muted/50 px-1 py-0.5 rounded min-h-[24px] flex items-center",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{value || <span className="text-muted-foreground italic">Click to edit</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
107
src/components/JobCategory.tsx
Normal file
107
src/components/JobCategory.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { IndJob, IndJobStatusOptions } from '@/types/industry';
|
||||
import JobCard from './JobCard';
|
||||
import { formatISK } from '@/utils/currency';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
|
||||
interface JobCategoryProps {
|
||||
status: IndJobStatusOptions;
|
||||
jobs: IndJob[];
|
||||
onEdit: (job: any) => void;
|
||||
onDelete: (jobId: string) => void;
|
||||
onUpdateProduced?: (jobId: string, produced: number) => void;
|
||||
onImportBOM?: (jobId: string, items: { name: string; quantity: number }[]) => void;
|
||||
}
|
||||
|
||||
export function JobCategory({ status, jobs, onEdit, onDelete, onUpdateProduced, onImportBOM }: JobCategoryProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
// Load collapsed state from localStorage
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(`job-category-collapsed-${status}`);
|
||||
if (stored) {
|
||||
setIsCollapsed(JSON.parse(stored));
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
// Save collapsed state to localStorage
|
||||
const toggleCollapsed = () => {
|
||||
const newCollapsed = !isCollapsed;
|
||||
setIsCollapsed(newCollapsed);
|
||||
localStorage.setItem(`job-category-collapsed-${status}`, JSON.stringify(newCollapsed));
|
||||
};
|
||||
|
||||
// Calculate totals (excluding Tracked status)
|
||||
const shouldIncludeInTotals = status !== IndJobStatusOptions.Tracked;
|
||||
const totalExpenditure = shouldIncludeInTotals
|
||||
? jobs.reduce((sum, job) => sum + (job.expenditures?.reduce((s, t) => s + t.totalPrice, 0) || 0), 0)
|
||||
: 0;
|
||||
const totalIncome = shouldIncludeInTotals
|
||||
? jobs.reduce((sum, job) => sum + (job.income?.reduce((s, t) => s + t.totalPrice, 0) || 0), 0)
|
||||
: 0;
|
||||
const totalProfit = totalIncome - totalExpenditure;
|
||||
|
||||
const getCategoryColor = (status: IndJobStatusOptions) => {
|
||||
switch (status) {
|
||||
case IndJobStatusOptions.Planned: return 'border-l-muted';
|
||||
case IndJobStatusOptions.Acquisition: return 'border-l-warning';
|
||||
case IndJobStatusOptions.Running: return 'border-l-primary';
|
||||
case IndJobStatusOptions.Done: return 'border-l-success';
|
||||
case IndJobStatusOptions.Selling: return 'border-l-accent';
|
||||
case IndJobStatusOptions.Closed: return 'border-l-muted';
|
||||
case IndJobStatusOptions.Tracked: return 'border-l-secondary';
|
||||
default: return 'border-l-muted';
|
||||
}
|
||||
};
|
||||
|
||||
if (jobs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card className={`mb-6 border-l-4 ${getCategoryColor(status)}`}>
|
||||
<CardHeader>
|
||||
<CardTitle
|
||||
className="flex items-center justify-between cursor-pointer"
|
||||
onClick={toggleCollapsed}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isCollapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
<span>{status} ({jobs.length})</span>
|
||||
</div>
|
||||
|
||||
{shouldIncludeInTotals && (
|
||||
<div className="flex items-center gap-4 text-sm font-normal">
|
||||
<span className="text-destructive">Cost: {formatISK(totalExpenditure)}</span>
|
||||
<span className="text-success">Income: {formatISK(totalIncome)}</span>
|
||||
<span className={totalProfit >= 0 ? 'text-success' : 'text-destructive'}>
|
||||
Profit: {formatISK(totalProfit)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === IndJobStatusOptions.Tracked && (
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
(Not included in totals)
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
{!isCollapsed && (
|
||||
<CardContent>
|
||||
{jobs.map(job => (
|
||||
<JobCard
|
||||
key={job.id}
|
||||
job={job}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onUpdateProduced={onUpdateProduced}
|
||||
onImportBOM={onImportBOM}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
216
src/components/TransactionManager.tsx
Normal file
216
src/components/TransactionManager.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { IndJob, IndTransactionRecord } from '@/types/industry';
|
||||
import { formatISK, parseTransactionLine } from '@/utils/currency';
|
||||
import { useJobs } from '@/hooks/useDataService';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
interface TransactionManagerProps {
|
||||
job: IndJob;
|
||||
}
|
||||
|
||||
export function TransactionManager({ job }: TransactionManagerProps) {
|
||||
const { updateJob, deleteTransaction } = useJobs();
|
||||
const { toast } = useToast();
|
||||
const [pasteText, setPasteText] = useState('');
|
||||
const [transactionType, setTransactionType] = useState<'expenditure' | 'income'>('expenditure');
|
||||
|
||||
const handlePasteImport = () => {
|
||||
if (!pasteText.trim()) return;
|
||||
|
||||
const lines = pasteText.split('\n').filter(line => line.trim());
|
||||
const transactions: Omit<IndTransactionRecord, 'id' | 'created' | 'updated' | 'job'>[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const parsed = parseTransactionLine(line);
|
||||
if (parsed) {
|
||||
transactions.push(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
if (transactions.length === 0) {
|
||||
toast({
|
||||
title: "Import Failed",
|
||||
description: "No valid transactions found in the pasted text.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Deduplicate against existing transactions
|
||||
const existingTransactions = [
|
||||
...(job.expenditures || []),
|
||||
...(job.income || [])
|
||||
];
|
||||
|
||||
const newTransactions = transactions.filter(newTx => {
|
||||
return !existingTransactions.some(existing =>
|
||||
existing.date === newTx.date &&
|
||||
existing.itemName === newTx.itemName &&
|
||||
existing.quantity === newTx.quantity &&
|
||||
existing.totalPrice === newTx.totalPrice &&
|
||||
existing.buyer === newTx.buyer
|
||||
);
|
||||
});
|
||||
|
||||
if (newTransactions.length === 0) {
|
||||
toast({
|
||||
title: "No New Transactions",
|
||||
description: "All transactions already exist in this job.",
|
||||
variant: "default",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to appropriate category
|
||||
const currentTransactions = transactionType === 'expenditure'
|
||||
? (job.expenditures || [])
|
||||
: (job.income || []);
|
||||
|
||||
const updatedTransactions = [...currentTransactions];
|
||||
newTransactions.forEach(tx => {
|
||||
updatedTransactions.push({
|
||||
...tx,
|
||||
id: crypto.randomUUID(),
|
||||
job: job.id,
|
||||
created: new Date().toISOString(),
|
||||
updated: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
updateJob(job.id, {
|
||||
[transactionType === 'expenditure' ? 'expenditures' : 'income']: updatedTransactions
|
||||
});
|
||||
|
||||
toast({
|
||||
title: "Import Successful",
|
||||
description: `Added ${newTransactions.length} new transactions.`,
|
||||
});
|
||||
|
||||
setPasteText('');
|
||||
};
|
||||
|
||||
const handleDeleteTransaction = (transactionId: string) => {
|
||||
deleteTransaction(job.id, transactionId);
|
||||
};
|
||||
|
||||
const TransactionTable = ({
|
||||
transactions,
|
||||
title,
|
||||
type
|
||||
}: {
|
||||
transactions: IndTransactionRecord[];
|
||||
title: string;
|
||||
type: 'expenditure' | 'income';
|
||||
}) => (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center justify-between">
|
||||
{title}
|
||||
<Badge variant="outline">
|
||||
{formatISK(transactions.reduce((sum, t) => sum + t.totalPrice, 0))}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{transactions.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Item</TableHead>
|
||||
<TableHead>Qty</TableHead>
|
||||
<TableHead>Unit Price</TableHead>
|
||||
<TableHead>Total</TableHead>
|
||||
<TableHead>Buyer/Seller</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{transactions.map(transaction => (
|
||||
<TableRow key={transaction.id}>
|
||||
<TableCell>{new Date(transaction.date).toLocaleDateString()}</TableCell>
|
||||
<TableCell>{transaction.itemName}</TableCell>
|
||||
<TableCell>{transaction.quantity.toLocaleString()}</TableCell>
|
||||
<TableCell>{formatISK(transaction.unitPrice)}</TableCell>
|
||||
<TableCell className={type === 'income' ? 'text-success' : 'text-destructive'}>
|
||||
{formatISK(transaction.totalPrice)}
|
||||
</TableCell>
|
||||
<TableCell>{transaction.buyer || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteTransaction(transaction.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="text-muted-foreground text-sm">No transactions yet</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Import Transactions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={transactionType === 'expenditure' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setTransactionType('expenditure')}
|
||||
>
|
||||
Expenditures
|
||||
</Button>
|
||||
<Button
|
||||
variant={transactionType === 'income' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setTransactionType('income')}
|
||||
>
|
||||
Income
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
placeholder="Paste EVE transaction data here (Ctrl+V)..."
|
||||
value={pasteText}
|
||||
onChange={(e) => setPasteText(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
|
||||
<Button onClick={handlePasteImport} disabled={!pasteText.trim()}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Import Transactions
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<TransactionTable
|
||||
transactions={job.expenditures || []}
|
||||
title="Expenditures"
|
||||
type="expenditure"
|
||||
/>
|
||||
|
||||
<TransactionTable
|
||||
transactions={job.income || []}
|
||||
title="Income"
|
||||
type="income"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -41,10 +41,28 @@ export function useJobs() {
|
||||
if (mounted) {
|
||||
const currentJobs = dataService.getJobs();
|
||||
setJobs(prevJobs => {
|
||||
// Only update if the jobs have actually changed
|
||||
const prevJson = JSON.stringify(prevJobs);
|
||||
const currentJson = JSON.stringify(currentJobs);
|
||||
return prevJson !== currentJson ? currentJobs : prevJobs;
|
||||
// Use simple reference check instead of expensive JSON.stringify
|
||||
// DataService already creates new arrays when data changes
|
||||
if (prevJobs === currentJobs) return prevJobs;
|
||||
|
||||
// Only do length check as additional safety - much faster than JSON.stringify
|
||||
if (prevJobs.length !== currentJobs.length) return currentJobs;
|
||||
|
||||
// For same length arrays, do a simple reference check on first few items
|
||||
// DataService creates new job objects when they change, so reference equality works
|
||||
if (prevJobs.length > 0 && currentJobs.length > 0) {
|
||||
// Check first, middle, and last items for reference equality
|
||||
const checkIndices = [0];
|
||||
if (prevJobs.length > 1) checkIndices.push(Math.floor(prevJobs.length / 2), prevJobs.length - 1);
|
||||
|
||||
for (const i of checkIndices) {
|
||||
if (prevJobs[i] !== currentJobs[i]) {
|
||||
return currentJobs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return prevJobs;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
@@ -48,7 +48,8 @@ export class DataService {
|
||||
private notificationTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
getJobs(): IndJob[] {
|
||||
return [...this.jobs];
|
||||
// Return the same reference if no changes to prevent unnecessary re-renders
|
||||
return this.jobs;
|
||||
}
|
||||
|
||||
getJob(id: string): IndJob | null {
|
||||
@@ -84,17 +85,17 @@ export class DataService {
|
||||
// Merge with existing jobs, replacing jobs with same IDs
|
||||
const existingJobIds = new Set(jobs.map(job => job.id));
|
||||
const otherJobs = this.jobs.filter(job => !existingJobIds.has(job.id));
|
||||
this.jobs = [...otherJobs, ...jobs];
|
||||
this.jobs = [...otherJobs, ...jobs]; // Create new array to trigger updates
|
||||
} else {
|
||||
// Loading all jobs
|
||||
this.jobs = jobs;
|
||||
// Loading all jobs - create new array to trigger updates
|
||||
this.jobs = [...jobs];
|
||||
// Mark all unique statuses as loaded
|
||||
const allStatuses = new Set(jobs.map(job => job.status));
|
||||
allStatuses.forEach(status => this.loadedStatuses.add(status));
|
||||
}
|
||||
|
||||
// Use setTimeout to defer the notification to prevent immediate re-renders
|
||||
setTimeout(() => this.notifyListeners(), 0);
|
||||
// Notify listeners immediately since we now use efficient reference checking
|
||||
this.notifyListeners();
|
||||
|
||||
return this.getJobs();
|
||||
}).finally(() => {
|
||||
@@ -107,7 +108,7 @@ export class DataService {
|
||||
async createJob(jobData: IndJobRecordNoId): Promise<IndJob> {
|
||||
console.log('Creating job:', jobData);
|
||||
const newJob = await jobService.createJob(jobData);
|
||||
this.jobs.push(newJob);
|
||||
this.jobs = [...this.jobs, newJob]; // Create new array for reference change
|
||||
this.notifyListeners();
|
||||
return newJob;
|
||||
}
|
||||
@@ -131,7 +132,10 @@ export class DataService {
|
||||
);
|
||||
|
||||
if (Object.keys(safeUpdates).length > 0) {
|
||||
this.jobs[jobIndex] = { ...this.jobs[jobIndex], ...safeUpdates };
|
||||
// Create new array with updated job for reference change
|
||||
this.jobs = this.jobs.map((job, i) =>
|
||||
i === jobIndex ? { ...job, ...safeUpdates } : job
|
||||
);
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
@@ -139,14 +143,18 @@ export class DataService {
|
||||
// Update in database
|
||||
const updatedRecord = await jobService.updateJob(id, updates);
|
||||
|
||||
// Replace with server response
|
||||
this.jobs[jobIndex] = updatedRecord;
|
||||
// Replace with server response - create new array for reference change
|
||||
this.jobs = this.jobs.map((job, i) =>
|
||||
i === jobIndex ? updatedRecord : job
|
||||
);
|
||||
this.notifyListeners();
|
||||
|
||||
return this.jobs[jobIndex];
|
||||
} catch (error) {
|
||||
// Revert optimistic update on error
|
||||
this.jobs[jobIndex] = originalJob;
|
||||
// Revert optimistic update on error - create new array for reference change
|
||||
this.jobs = this.jobs.map((job, i) =>
|
||||
i === jobIndex ? originalJob : job
|
||||
);
|
||||
this.notifyListeners();
|
||||
throw error;
|
||||
}
|
||||
@@ -181,10 +189,12 @@ export class DataService {
|
||||
const updatedJob = await jobService.getJob(jobId);
|
||||
if (!updatedJob) throw new Error(`Job with id ${jobId} not found after update`);
|
||||
|
||||
// Update local state with fresh data
|
||||
// Update local state with fresh data - create new array for reference change
|
||||
const jobIndex = this.jobs.findIndex(j => j.id === jobId);
|
||||
if (jobIndex !== -1) {
|
||||
this.jobs[jobIndex] = updatedJob;
|
||||
this.jobs = this.jobs.map((job, i) =>
|
||||
i === jobIndex ? updatedJob : job
|
||||
);
|
||||
this.notifyListeners();
|
||||
return this.jobs[jobIndex];
|
||||
}
|
||||
@@ -225,13 +235,17 @@ export class DataService {
|
||||
const updatedJob = await jobService.getJob(jobId);
|
||||
if (!updatedJob) throw new Error(`Job with id ${jobId} not found after update`);
|
||||
|
||||
// Update local state with fresh data
|
||||
this.jobs[jobIndex] = updatedJob;
|
||||
// Update local state with fresh data - create new array for reference change
|
||||
this.jobs = this.jobs.map((job, i) =>
|
||||
i === jobIndex ? updatedJob : job
|
||||
);
|
||||
this.notifyListeners();
|
||||
return this.jobs[jobIndex];
|
||||
} catch (error) {
|
||||
// Revert optimistic update on error
|
||||
this.jobs[jobIndex] = originalJob;
|
||||
// Revert optimistic update on error - create new array for reference change
|
||||
this.jobs = this.jobs.map((job, i) =>
|
||||
i === jobIndex ? originalJob : job
|
||||
);
|
||||
this.notifyListeners();
|
||||
throw error;
|
||||
}
|
||||
@@ -252,7 +266,9 @@ export class DataService {
|
||||
// Update local state with fresh data
|
||||
const jobIndex = this.jobs.findIndex(j => j.id === jobId);
|
||||
if (jobIndex !== -1) {
|
||||
this.jobs[jobIndex] = updatedJob;
|
||||
this.jobs = this.jobs.map((job, i) =>
|
||||
i === jobIndex ? updatedJob : job
|
||||
);
|
||||
this.notifyListeners();
|
||||
return this.jobs[jobIndex];
|
||||
}
|
||||
@@ -275,7 +291,9 @@ export class DataService {
|
||||
// Update local state with fresh data
|
||||
const jobIndex = this.jobs.findIndex(j => j.id === jobId);
|
||||
if (jobIndex !== -1) {
|
||||
this.jobs[jobIndex] = updatedJob;
|
||||
this.jobs = this.jobs.map((job, i) =>
|
||||
i === jobIndex ? updatedJob : job
|
||||
);
|
||||
this.notifyListeners();
|
||||
return this.jobs[jobIndex];
|
||||
}
|
||||
@@ -304,7 +322,9 @@ export class DataService {
|
||||
// Update local state with fresh data
|
||||
const jobIndex = this.jobs.findIndex(j => j.id === jobId);
|
||||
if (jobIndex !== -1) {
|
||||
this.jobs[jobIndex] = updatedJob;
|
||||
this.jobs = this.jobs.map((job, i) =>
|
||||
i === jobIndex ? updatedJob : job
|
||||
);
|
||||
this.notifyListeners();
|
||||
return this.jobs[jobIndex];
|
||||
}
|
||||
@@ -345,7 +365,9 @@ export class DataService {
|
||||
// Update local state with fresh data
|
||||
const jobIndex = this.jobs.findIndex(j => j.id === jobId);
|
||||
if (jobIndex !== -1) {
|
||||
this.jobs[jobIndex] = updatedJob;
|
||||
this.jobs = this.jobs.map((job, i) =>
|
||||
i === jobIndex ? updatedJob : job
|
||||
);
|
||||
this.notifyListeners();
|
||||
return this.jobs[jobIndex];
|
||||
}
|
||||
|
30
src/services/facilityService.ts
Normal file
30
src/services/facilityService.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { IndFacilityRecord, IndFacilityResponse } from '../lib/pbtypes';
|
||||
import { pb } from '../lib/pocketbase';
|
||||
|
||||
export type { IndFacilityRecord as Facility } from '../lib/pbtypes';
|
||||
|
||||
export async function getFacilities(): Promise<IndFacilityResponse[]> {
|
||||
const result = await pb.collection('ind_facility').getFullList();
|
||||
return result as IndFacilityResponse[];
|
||||
}
|
||||
|
||||
export async function getFacility(id: string): Promise<IndFacilityResponse | null> {
|
||||
try {
|
||||
return await pb.collection('ind_facility').getOne(id) as IndFacilityResponse;
|
||||
} catch (e) {
|
||||
if (e.status === 404) return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createFacility(facility: Omit<IndFacilityRecord, 'id' | 'created' | 'updated'>): Promise<IndFacilityResponse> {
|
||||
return await pb.collection('ind_facility').create(facility) as IndFacilityResponse;
|
||||
}
|
||||
|
||||
export async function updateFacility(id: string, updates: Partial<IndFacilityRecord>): Promise<IndFacilityResponse> {
|
||||
return await pb.collection('ind_facility').update(id, updates) as IndFacilityResponse;
|
||||
}
|
||||
|
||||
export async function deleteFacility(id: string): Promise<void> {
|
||||
await pb.collection('ind_facility').delete(id);
|
||||
}
|
85
src/utils/currency.ts
Normal file
85
src/utils/currency.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
export function formatISK(amount: number): string {
|
||||
return `${amount.toLocaleString('en-US', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2
|
||||
})} ISK`;
|
||||
}
|
||||
|
||||
export function parseISK(value: string): number {
|
||||
// Remove ISK suffix and any spaces, then parse number with commas
|
||||
const cleaned = value.replace(/[ISK\s]/gi, '').replace(/,/g, '');
|
||||
const parsed = parseFloat(cleaned);
|
||||
return isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
export function parseTransactionLine(line: string): {
|
||||
date: string;
|
||||
quantity: number;
|
||||
itemName: string;
|
||||
unitPrice: number;
|
||||
totalPrice: number;
|
||||
buyer?: string;
|
||||
location?: string;
|
||||
corporation?: string;
|
||||
wallet?: string;
|
||||
} | null {
|
||||
// Parse EVE transaction format:
|
||||
// 2025.07.04 10:58 357 Isogen 699 ISK -249,543 ISK Shocker Killer Uitra VI - Moon 4 - State War Academy Primorium Master Wallet
|
||||
const parts = line.split('\t');
|
||||
|
||||
if (parts.length < 5) return null;
|
||||
|
||||
try {
|
||||
const date = parts[0]?.trim();
|
||||
const quantity = parseInt(parts[1]?.replace(/,/g, '') || '0');
|
||||
const itemName = parts[2]?.trim();
|
||||
const unitPrice = parseISK(parts[3] || '0');
|
||||
const totalPrice = Math.abs(parseISK(parts[4] || '0')); // Remove negative sign
|
||||
const buyer = parts[5]?.trim();
|
||||
const location = parts[6]?.trim();
|
||||
const corporation = parts[7]?.trim();
|
||||
const wallet = parts[8]?.trim();
|
||||
|
||||
if (!date || !itemName || quantity <= 0) return null;
|
||||
|
||||
return {
|
||||
date: new Date(date.replace(/\./g, '-')).toISOString(),
|
||||
quantity,
|
||||
itemName,
|
||||
unitPrice,
|
||||
totalPrice,
|
||||
buyer,
|
||||
location,
|
||||
corporation,
|
||||
wallet,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to parse transaction line:', line, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseBillOfMaterials(text: string): { name: string; quantity: number }[] {
|
||||
const lines = text.split('\n').filter(line => line.trim());
|
||||
const materials: { name: string; quantity: number }[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const parts = line.split('\t');
|
||||
if (parts.length >= 2) {
|
||||
const name = parts[0]?.trim();
|
||||
const quantity = parseInt(parts[1]?.replace(/,/g, '') || '0');
|
||||
|
||||
if (name && quantity > 0) {
|
||||
materials.push({ name, quantity });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return materials;
|
||||
}
|
||||
|
||||
export function exportBillOfMaterials(materials: { name: string; quantity: number }[]): string {
|
||||
return materials
|
||||
.map(material => `${material.name}\t${material.quantity.toLocaleString()}`)
|
||||
.join('\n');
|
||||
}
|
Reference in New Issue
Block a user