Files
eve-industrializer/src/components/JobCardDetails.tsx
gpt-engineer-app[bot] e024439cb0 Adjust runtime calculation for parallel jobs
Update runtime calculation to reflect the impact of parallel job execution, dividing the total runtime by the number of parallel runs.
2025-07-13 15:42:54 +02:00

420 lines
15 KiB
TypeScript

import { useState, useEffect } from 'react';
import { Calendar, Factory, Clock, Copy, DollarSign } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { IndJob } from '@/lib/types';
import { useJobs } from '@/hooks/useDataService';
import { useToast } from '@/hooks/use-toast';
import { useClipboard } from '@/hooks/useClipboard';
import { formatISK } from '@/utils/priceUtils';
import { formatDuration, calculateRemainingTime } from '@/utils/timeUtils';
interface JobCardDetailsProps {
job: IndJob;
}
const JobCardDetails: React.FC<JobCardDetailsProps> = ({ job }) => {
const [editingField, setEditingField] = useState<string | null>(null);
const [tempValues, setTempValues] = useState<{ [key: string]: string }>({});
const [remainingTime, setRemainingTime] = useState<number>(0);
const { updateJob } = useJobs();
const { toast } = useToast();
const { copying, copyToClipboard } = useClipboard();
// Update remaining time for running jobs
useEffect(() => {
if (job.status === 'Running' && job.jobStart && job.runtime) {
const updateRemainingTime = () => {
const remaining = calculateRemainingTime(job.jobStart, job.runtime);
setRemainingTime(remaining);
};
updateRemainingTime();
const interval = setInterval(updateRemainingTime, 1000);
return () => clearInterval(interval);
}
}, [job.status, job.jobStart, job.runtime]);
const formatDateTime = (dateString: string | null | undefined) => {
if (!dateString) return 'Not set';
return new Date(dateString).toLocaleString('en-CA', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
}).replace(',', '');
};
const handleJobIdClick = async (e: React.MouseEvent) => {
e.stopPropagation();
await copyToClipboard(job.id, 'id', 'Job ID copied to clipboard');
};
const handleFieldUpdate = async (fieldName: string, value: string) => {
try {
let updateValue: any;
if (fieldName === 'parallel') {
updateValue = Math.max(1, parseInt(value) || 1);
} else {
updateValue = value ? new Date(value).toISOString() : null;
}
await updateJob(job.id, { [fieldName]: updateValue });
setEditingField(null);
setTempValues({});
toast({
title: "Updated",
description: `${fieldName} updated successfully`,
duration: 2000,
});
} catch (error) {
console.error('Error updating field:', error);
toast({
title: "Error",
description: "Failed to update field",
variant: "destructive",
duration: 2000,
});
}
};
const handleKeyPress = (fieldName: string, e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleFieldUpdate(fieldName, tempValues[fieldName]);
} else if (e.key === 'Escape') {
setEditingField(null);
}
};
const formatDateForInput = (dateString: string | null | undefined) => {
if (!dateString) return '';
return new Date(dateString).toISOString().slice(0, 16);
};
const handleBlur = (fieldName: string) => {
const value = tempValues[fieldName];
if (value !== (job[fieldName as keyof IndJob] || '')) {
handleFieldUpdate(fieldName, value);
} else {
setEditingField(null);
}
};
const handleClick = (fieldName: string, value: string | null, e: React.MouseEvent) => {
e.stopPropagation();
// Allow editing regardless of whether value exists or not
setEditingField(fieldName);
setTempValues({ ...tempValues, [fieldName]: formatDateForInput(value) });
};
const DateField = ({ label, value, fieldName, icon }: { label: string; value: string | null; fieldName: string; icon: React.ReactNode }) => (
<>
<div className="flex items-center gap-2 text-sm text-gray-400">
{icon}
<span>{label}:</span>
</div>
<div className="flex items-center">
{editingField === fieldName ? (
<Input
type="datetime-local"
value={tempValues[fieldName] || ''}
onChange={(e) => setTempValues({ ...tempValues, [fieldName]: e.target.value })}
onBlur={() => handleBlur(fieldName)}
onKeyDown={(e) => handleKeyPress(fieldName, e)}
className="h-6 px-2 py-1 bg-gray-800 border-gray-600 text-white text-sm w-full"
autoFocus
data-no-navigate
/>
) : (
<span
onClick={(e) => handleClick(fieldName, value, e)}
className="cursor-pointer hover:text-blue-400 h-6 flex items-center text-white text-sm w-full"
title="Click to edit"
data-no-navigate
>
{formatDateTime(value)}
</span>
)}
</div>
</>
);
return (
<div className="flex-shrink-0">
<div className="grid gap-x-4 gap-y-2" style={{ gridTemplateColumns: 'auto 1fr auto 1fr' }}>
<div className="flex items-center gap-2 text-sm text-gray-400">
<Factory className="w-4 h-4" />
<span>Job ID:</span>
</div>
<div className="flex items-center gap-1">
<span
className="cursor-pointer hover:text-blue-400 transition-colors inline-flex items-center gap-1 text-sm text-white"
onClick={handleJobIdClick}
title="Click to copy job ID"
data-no-navigate
>
{job.id}
{copying === 'id' && <Copy className="w-3 h-3 text-green-400" />}
</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-400">
<Calendar className="w-4 h-4" />
<span>Created:</span>
</div>
<div className="text-sm text-white">
{formatDateTime(job.created)}
</div>
<DateField
label="Start"
value={job.jobStart}
fieldName="jobStart"
icon={<Clock className="w-4 h-4" />}
/>
<DateField
label="End"
value={job.jobEnd}
fieldName="jobEnd"
icon={<Clock className="w-4 h-4" />}
/>
<DateField
label="Sale Start"
value={job.saleStart}
fieldName="saleStart"
icon={<Calendar className="w-4 h-4" />}
/>
<DateField
label="Sale End"
value={job.saleEnd}
fieldName="saleEnd"
icon={<Calendar className="w-4 h-4" />}
/>
{job.runtime && job.runtime > 0 && (
<>
<div className="flex items-center gap-2 text-sm text-gray-400">
<Clock className="w-4 h-4" />
<span>Runtime:</span>
</div>
<div className="text-sm text-white flex items-center gap-1">
{formatDuration(job.runtime / (job.parallel || 1))}
<span className="text-gray-400">(</span>
{editingField === 'parallel' ? (
<Input
type="number"
min="1"
value={tempValues.parallel || job.parallel?.toString() || '1'}
onChange={(e) => setTempValues({ ...tempValues, parallel: e.target.value })}
onBlur={() => handleFieldUpdate('parallel', tempValues.parallel || '1')}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleFieldUpdate('parallel', tempValues.parallel || '1');
} else if (e.key === 'Escape') {
setEditingField(null);
setTempValues({});
}
}}
className="w-12 h-5 px-1 py-0 text-xs bg-gray-800 border-gray-600"
autoFocus
/>
) : (
<span
className="cursor-pointer hover:text-blue-400 transition-colors"
onClick={(e) => {
e.stopPropagation();
setEditingField('parallel');
setTempValues({ parallel: job.parallel?.toString() || '1' });
}}
>
{job.parallel || 1}
</span>
)}
<span className="text-gray-400">)</span>
</div>
{job.status === 'Running' && (
<>
<div className="flex items-center gap-2 text-sm text-gray-400">
<Clock className="w-4 h-4" />
<span>Remaining:</span>
</div>
<div className="text-sm text-green-400">
{formatDuration(remainingTime)}
</div>
</>
)}
</>
)}
</div>
{job.projectedRevenue > 0 && job.produced > 0 && (
<div className="mt-2">
<PriceDisplay job={job} />
</div>
)}
</div>
);
};
interface PriceDisplayProps {
job: IndJob;
}
const PriceDisplay: React.FC<PriceDisplayProps> = ({ job }) => {
const { copying, copyToClipboard } = useClipboard();
const [salesTax, setSalesTax] = useState(() => parseFloat(localStorage.getItem('salesTax') || '0') / 100);
// Listen for storage changes to update tax rate
useEffect(() => {
const handleStorageChange = () => {
setSalesTax(parseFloat(localStorage.getItem('salesTax') || '0') / 100);
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, []);
const roundToSignificantDigits = (num: number, digits: number = 4): number => {
if (num === 0) return 0;
const magnitude = Math.floor(Math.log10(Math.abs(num)));
const factor = Math.pow(10, digits - 1 - magnitude);
return Math.round(num * factor) / factor;
};
// Calculate total costs and income
const totalCosts = job.expenditures?.reduce((sum, tx) => sum + tx.totalPrice, 0) || 0;
const totalIncome = job.income?.reduce((sum, tx) => sum + tx.totalPrice, 0) || 0;
const itemsSold = job.income?.reduce((sum, tx) => sum + tx.quantity, 0) || 0;
const itemsRemaining = (job.produced || 0) - itemsSold;
// Original calculations (based on full revenue and costs)
const targetPricePerUnit = job.projectedRevenue / job.produced;
const targetPriceWithTax = roundToSignificantDigits(targetPricePerUnit * (1 + salesTax));
const breakEvenPricePerUnit = totalCosts / job.produced;
const breakEvenPriceWithTax = roundToSignificantDigits(breakEvenPricePerUnit * (1 + salesTax));
// Adjusted calculations (based on remaining revenue and uncovered costs)
const remainingRevenue = job.projectedRevenue - totalIncome;
const uncoveredCosts = totalCosts - totalIncome;
const adjustedTargetPricePerUnit = itemsRemaining > 0 ? remainingRevenue / itemsRemaining : 0;
const adjustedTargetPriceWithTax = roundToSignificantDigits(adjustedTargetPricePerUnit * (1 + salesTax));
const adjustedBreakEvenPricePerUnit = itemsRemaining > 0 ? Math.max(0, uncoveredCosts / itemsRemaining) : 0;
const adjustedBreakEvenPriceWithTax = roundToSignificantDigits(adjustedBreakEvenPricePerUnit * (1 + salesTax));
const handleCopyTargetPrice = async (e: React.MouseEvent) => {
e.stopPropagation();
await copyToClipboard(
targetPriceWithTax.toString(),
'targetPrice',
'Target price copied to clipboard'
);
};
const handleCopyBreakEvenPrice = async (e: React.MouseEvent) => {
e.stopPropagation();
await copyToClipboard(
breakEvenPriceWithTax.toString(),
'breakEvenPrice',
'Break-even price copied to clipboard'
);
};
const handleCopyAdjustedTargetPrice = async (e: React.MouseEvent) => {
e.stopPropagation();
await copyToClipboard(
adjustedTargetPriceWithTax.toString(),
'adjustedTargetPrice',
'Adjusted target price copied to clipboard'
);
};
const handleCopyAdjustedBreakEvenPrice = async (e: React.MouseEvent) => {
e.stopPropagation();
await copyToClipboard(
adjustedBreakEvenPriceWithTax.toString(),
'adjustedBreakEvenPrice',
'Adjusted break-even price copied to clipboard'
);
};
const taxSuffix = salesTax > 0 ? ` (+${(salesTax * 100).toFixed(1)}% tax)` : '';
return (
<div className="grid gap-x-4 gap-y-2 text-sm" style={{ gridTemplateColumns: '1fr 1fr' }}>
<div className="flex items-center gap-2 text-gray-400 justify-center">
<Factory className="w-4 h-4" />
<span>Target Price{taxSuffix}:</span>
</div>
<div className="flex items-center gap-2 text-gray-400 justify-center">
<DollarSign className="w-4 h-4" />
<span>Break-even{taxSuffix}:</span>
</div>
<div className="flex items-center gap-1 text-lg justify-center">
<span
className="cursor-pointer hover:text-blue-400 transition-colors inline-flex items-center gap-1 text-white"
onClick={handleCopyTargetPrice}
title="Click to copy target price per unit (based on projected revenue)"
data-no-navigate
>
{formatISK(targetPriceWithTax)}
{copying === 'targetPrice' && <Copy className="w-3 h-3 text-green-400" />}
</span>
</div>
<div className="flex items-center gap-1 text-lg justify-center">
<span
className="cursor-pointer hover:text-yellow-400 transition-colors inline-flex items-center gap-1 text-white"
onClick={handleCopyBreakEvenPrice}
title="Click to copy break-even price per unit (based on actual costs)"
data-no-navigate
>
{formatISK(breakEvenPriceWithTax)}
{copying === 'breakEvenPrice' && <Copy className="w-3 h-3 text-green-400" />}
</span>
</div>
<div className="flex items-center gap-2 text-gray-400 justify-center">
<Factory className="w-4 h-4" />
<span>Adjusted Target{taxSuffix}:</span>
</div>
<div className="flex items-center gap-2 text-gray-400 justify-center">
<DollarSign className="w-4 h-4" />
<span>Adjusted Break-even{taxSuffix}:</span>
</div>
<div className="flex items-center gap-1 text-lg justify-center">
<span
className="cursor-pointer hover:text-blue-400 transition-colors inline-flex items-center gap-1 text-white"
onClick={handleCopyAdjustedTargetPrice}
title="Click to copy adjusted target price per unit (based on remaining revenue)"
data-no-navigate
>
{formatISK(adjustedTargetPriceWithTax)}
{copying === 'adjustedTargetPrice' && <Copy className="w-3 h-3 text-green-400" />}
</span>
</div>
<div className="flex items-center gap-1 text-lg justify-center">
<span
className="cursor-pointer hover:text-yellow-400 transition-colors inline-flex items-center gap-1 text-white"
onClick={handleCopyAdjustedBreakEvenPrice}
title="Click to copy adjusted break-even price per unit (based on uncovered costs)"
data-no-navigate
>
{formatISK(adjustedBreakEvenPriceWithTax)}
{copying === 'adjustedBreakEvenPrice' && <Copy className="w-3 h-3 text-green-400" />}
</span>
</div>
</div>
);
};
export default JobCardDetails;