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:
gpt-engineer-app[bot]
2025-07-28 19:14:38 +00:00
committed by PhatPhuckDave
parent cf5f666651
commit ec8f2cbc75
10 changed files with 1077 additions and 26 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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;
});
}
});

View File

@@ -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];
}

View 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
View 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');
}