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) {
|
if (mounted) {
|
||||||
const currentJobs = dataService.getJobs();
|
const currentJobs = dataService.getJobs();
|
||||||
setJobs(prevJobs => {
|
setJobs(prevJobs => {
|
||||||
// Only update if the jobs have actually changed
|
// Use simple reference check instead of expensive JSON.stringify
|
||||||
const prevJson = JSON.stringify(prevJobs);
|
// DataService already creates new arrays when data changes
|
||||||
const currentJson = JSON.stringify(currentJobs);
|
if (prevJobs === currentJobs) return prevJobs;
|
||||||
return prevJson !== currentJson ? currentJobs : 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;
|
private notificationTimeout: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
getJobs(): IndJob[] {
|
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 {
|
getJob(id: string): IndJob | null {
|
||||||
@@ -84,17 +85,17 @@ export class DataService {
|
|||||||
// Merge with existing jobs, replacing jobs with same IDs
|
// Merge with existing jobs, replacing jobs with same IDs
|
||||||
const existingJobIds = new Set(jobs.map(job => job.id));
|
const existingJobIds = new Set(jobs.map(job => job.id));
|
||||||
const otherJobs = this.jobs.filter(job => !existingJobIds.has(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 {
|
} else {
|
||||||
// Loading all jobs
|
// Loading all jobs - create new array to trigger updates
|
||||||
this.jobs = jobs;
|
this.jobs = [...jobs];
|
||||||
// Mark all unique statuses as loaded
|
// Mark all unique statuses as loaded
|
||||||
const allStatuses = new Set(jobs.map(job => job.status));
|
const allStatuses = new Set(jobs.map(job => job.status));
|
||||||
allStatuses.forEach(status => this.loadedStatuses.add(status));
|
allStatuses.forEach(status => this.loadedStatuses.add(status));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use setTimeout to defer the notification to prevent immediate re-renders
|
// Notify listeners immediately since we now use efficient reference checking
|
||||||
setTimeout(() => this.notifyListeners(), 0);
|
this.notifyListeners();
|
||||||
|
|
||||||
return this.getJobs();
|
return this.getJobs();
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
@@ -107,7 +108,7 @@ export class DataService {
|
|||||||
async createJob(jobData: IndJobRecordNoId): Promise<IndJob> {
|
async createJob(jobData: IndJobRecordNoId): Promise<IndJob> {
|
||||||
console.log('Creating job:', jobData);
|
console.log('Creating job:', jobData);
|
||||||
const newJob = await jobService.createJob(jobData);
|
const newJob = await jobService.createJob(jobData);
|
||||||
this.jobs.push(newJob);
|
this.jobs = [...this.jobs, newJob]; // Create new array for reference change
|
||||||
this.notifyListeners();
|
this.notifyListeners();
|
||||||
return newJob;
|
return newJob;
|
||||||
}
|
}
|
||||||
@@ -131,7 +132,10 @@ export class DataService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (Object.keys(safeUpdates).length > 0) {
|
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();
|
this.notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,14 +143,18 @@ export class DataService {
|
|||||||
// Update in database
|
// Update in database
|
||||||
const updatedRecord = await jobService.updateJob(id, updates);
|
const updatedRecord = await jobService.updateJob(id, updates);
|
||||||
|
|
||||||
// Replace with server response
|
// Replace with server response - create new array for reference change
|
||||||
this.jobs[jobIndex] = updatedRecord;
|
this.jobs = this.jobs.map((job, i) =>
|
||||||
|
i === jobIndex ? updatedRecord : job
|
||||||
|
);
|
||||||
this.notifyListeners();
|
this.notifyListeners();
|
||||||
|
|
||||||
return this.jobs[jobIndex];
|
return this.jobs[jobIndex];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Revert optimistic update on error
|
// Revert optimistic update on error - create new array for reference change
|
||||||
this.jobs[jobIndex] = originalJob;
|
this.jobs = this.jobs.map((job, i) =>
|
||||||
|
i === jobIndex ? originalJob : job
|
||||||
|
);
|
||||||
this.notifyListeners();
|
this.notifyListeners();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -181,10 +189,12 @@ export class DataService {
|
|||||||
const updatedJob = await jobService.getJob(jobId);
|
const updatedJob = await jobService.getJob(jobId);
|
||||||
if (!updatedJob) throw new Error(`Job with id ${jobId} not found after update`);
|
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);
|
const jobIndex = this.jobs.findIndex(j => j.id === jobId);
|
||||||
if (jobIndex !== -1) {
|
if (jobIndex !== -1) {
|
||||||
this.jobs[jobIndex] = updatedJob;
|
this.jobs = this.jobs.map((job, i) =>
|
||||||
|
i === jobIndex ? updatedJob : job
|
||||||
|
);
|
||||||
this.notifyListeners();
|
this.notifyListeners();
|
||||||
return this.jobs[jobIndex];
|
return this.jobs[jobIndex];
|
||||||
}
|
}
|
||||||
@@ -225,13 +235,17 @@ export class DataService {
|
|||||||
const updatedJob = await jobService.getJob(jobId);
|
const updatedJob = await jobService.getJob(jobId);
|
||||||
if (!updatedJob) throw new Error(`Job with id ${jobId} not found after update`);
|
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
|
||||||
this.jobs[jobIndex] = updatedJob;
|
this.jobs = this.jobs.map((job, i) =>
|
||||||
|
i === jobIndex ? updatedJob : job
|
||||||
|
);
|
||||||
this.notifyListeners();
|
this.notifyListeners();
|
||||||
return this.jobs[jobIndex];
|
return this.jobs[jobIndex];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Revert optimistic update on error
|
// Revert optimistic update on error - create new array for reference change
|
||||||
this.jobs[jobIndex] = originalJob;
|
this.jobs = this.jobs.map((job, i) =>
|
||||||
|
i === jobIndex ? originalJob : job
|
||||||
|
);
|
||||||
this.notifyListeners();
|
this.notifyListeners();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -252,7 +266,9 @@ export class DataService {
|
|||||||
// Update local state with fresh data
|
// Update local state with fresh data
|
||||||
const jobIndex = this.jobs.findIndex(j => j.id === jobId);
|
const jobIndex = this.jobs.findIndex(j => j.id === jobId);
|
||||||
if (jobIndex !== -1) {
|
if (jobIndex !== -1) {
|
||||||
this.jobs[jobIndex] = updatedJob;
|
this.jobs = this.jobs.map((job, i) =>
|
||||||
|
i === jobIndex ? updatedJob : job
|
||||||
|
);
|
||||||
this.notifyListeners();
|
this.notifyListeners();
|
||||||
return this.jobs[jobIndex];
|
return this.jobs[jobIndex];
|
||||||
}
|
}
|
||||||
@@ -275,7 +291,9 @@ export class DataService {
|
|||||||
// Update local state with fresh data
|
// Update local state with fresh data
|
||||||
const jobIndex = this.jobs.findIndex(j => j.id === jobId);
|
const jobIndex = this.jobs.findIndex(j => j.id === jobId);
|
||||||
if (jobIndex !== -1) {
|
if (jobIndex !== -1) {
|
||||||
this.jobs[jobIndex] = updatedJob;
|
this.jobs = this.jobs.map((job, i) =>
|
||||||
|
i === jobIndex ? updatedJob : job
|
||||||
|
);
|
||||||
this.notifyListeners();
|
this.notifyListeners();
|
||||||
return this.jobs[jobIndex];
|
return this.jobs[jobIndex];
|
||||||
}
|
}
|
||||||
@@ -304,7 +322,9 @@ export class DataService {
|
|||||||
// Update local state with fresh data
|
// Update local state with fresh data
|
||||||
const jobIndex = this.jobs.findIndex(j => j.id === jobId);
|
const jobIndex = this.jobs.findIndex(j => j.id === jobId);
|
||||||
if (jobIndex !== -1) {
|
if (jobIndex !== -1) {
|
||||||
this.jobs[jobIndex] = updatedJob;
|
this.jobs = this.jobs.map((job, i) =>
|
||||||
|
i === jobIndex ? updatedJob : job
|
||||||
|
);
|
||||||
this.notifyListeners();
|
this.notifyListeners();
|
||||||
return this.jobs[jobIndex];
|
return this.jobs[jobIndex];
|
||||||
}
|
}
|
||||||
@@ -345,7 +365,9 @@ export class DataService {
|
|||||||
// Update local state with fresh data
|
// Update local state with fresh data
|
||||||
const jobIndex = this.jobs.findIndex(j => j.id === jobId);
|
const jobIndex = this.jobs.findIndex(j => j.id === jobId);
|
||||||
if (jobIndex !== -1) {
|
if (jobIndex !== -1) {
|
||||||
this.jobs[jobIndex] = updatedJob;
|
this.jobs = this.jobs.map((job, i) =>
|
||||||
|
i === jobIndex ? updatedJob : job
|
||||||
|
);
|
||||||
this.notifyListeners();
|
this.notifyListeners();
|
||||||
return this.jobs[jobIndex];
|
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