diff --git a/src/components/OptimizedJobCard.tsx b/src/components/OptimizedJobCard.tsx new file mode 100644 index 0000000..c92318c --- /dev/null +++ b/src/components/OptimizedJobCard.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { IndJob } from '@/lib/types'; +import { getStatusBackgroundColor } from '@/utils/jobStatusUtils'; +import { getAttentionGlowClasses } from '@/utils/jobAttentionUtils'; +import JobCardHeader from './JobCardHeader'; +import JobCardDetails from './JobCardDetails'; +import JobCardMetrics from './JobCardMetrics'; + +interface OptimizedJobCardProps { + job: IndJob; + onEdit: (job: any) => void; + onDelete: (jobId: string) => void; + onUpdateProduced?: (jobId: string, produced: number) => void; + onImportBOM?: (jobId: string, items: { name: string; quantity: number }[]) => void; + needsAttention: boolean; // Pre-calculated + isTracked?: boolean; +} + +const OptimizedJobCard: React.FC = React.memo(({ + job, + onEdit, + onDelete, + onUpdateProduced, + onImportBOM, + needsAttention, + isTracked = false +}) => { + const navigate = useNavigate(); + + const handleCardClick = (e: React.MouseEvent) => { + const target = e.target as HTMLElement; + const hasNoNavigate = target.closest('[data-no-navigate]'); + + if (hasNoNavigate) { + return; + } + + navigate(`/${job.id}`); + }; + + return ( + + + + + + +
+ + + + ); +}); + +OptimizedJobCard.displayName = 'OptimizedJobCard'; + +export default OptimizedJobCard; \ No newline at end of file diff --git a/src/components/OptimizedJobGroup.tsx b/src/components/OptimizedJobGroup.tsx new file mode 100644 index 0000000..5fe08df --- /dev/null +++ b/src/components/OptimizedJobGroup.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { IndJob } from '@/lib/types'; +import { getStatusColor } from '@/utils/jobStatusUtils'; +import { getAttentionGlowClasses } from '@/utils/jobAttentionUtils'; +import OptimizedJobCard from './OptimizedJobCard'; +import { Loader2 } from 'lucide-react'; + +interface OptimizedJobGroupProps { + status: string; + jobs: IndJob[]; + isCollapsed: boolean; + onToggle: (status: string) => void; + onEdit: (job: IndJob) => void; + onDelete: (jobId: string) => void; + onUpdateProduced?: (jobId: string, produced: number) => void; + onImportBOM?: (jobId: string, items: { name: string; quantity: number }[]) => void; + hasAttentionJobs: boolean; // Pre-calculated + jobAttentionMap: (jobId: string) => boolean; // Pre-calculated lookup + isTracked?: boolean; + isLoading?: boolean; +} + +const OptimizedJobGroup: React.FC = React.memo(({ + status, + jobs, + isCollapsed, + onToggle, + onEdit, + onDelete, + onUpdateProduced, + onImportBOM, + hasAttentionJobs, + jobAttentionMap, + isTracked = false, + isLoading = false +}) => { + return ( +
+
onToggle(status)} + > +
+

+ {status} + ({jobs.length} jobs) + {isLoading && } +

+
+ ⌄ +
+
+
+ + {!isCollapsed && ( +
+ {isLoading ? ( +
+ + Loading jobs... +
+ ) : ( + jobs.map(job => ( + + )) + )} +
+ )} +
+ ); +}); + +OptimizedJobGroup.displayName = 'OptimizedJobGroup'; + +export default OptimizedJobGroup; \ No newline at end of file diff --git a/src/components/OptimizedJobsSection.tsx b/src/components/OptimizedJobsSection.tsx index 859d04f..948d0f4 100644 --- a/src/components/OptimizedJobsSection.tsx +++ b/src/components/OptimizedJobsSection.tsx @@ -1,6 +1,7 @@ import React, { useMemo } from 'react'; import { IndJob } from '@/lib/types'; -import JobGroup from './JobGroup'; +import OptimizedJobGroup from './OptimizedJobGroup'; +import { useJobAttentionMetrics } from '@/hooks/useJobAttentionMetrics'; interface OptimizedJobsSectionProps { regularJobs: IndJob[]; @@ -37,11 +38,15 @@ const OptimizedJobsSection = React.memo(({ }, {} as Record); }, [regularJobs]); + // Pre-calculate all attention metrics once + const allJobs = useMemo(() => [...regularJobs, ...trackedJobs], [regularJobs, trackedJobs]); + const { jobNeedsAttention, groupNeedsAttention } = useJobAttentionMetrics(allJobs); + return (
{Object.entries(jobGroups).map(([status, statusJobs]) => ( - ))} @@ -58,7 +65,7 @@ const OptimizedJobsSection = React.memo(({ {trackedJobs.length > 0 && (
- diff --git a/src/hooks/useJobAttentionMetrics.ts b/src/hooks/useJobAttentionMetrics.ts new file mode 100644 index 0000000..8c5631f --- /dev/null +++ b/src/hooks/useJobAttentionMetrics.ts @@ -0,0 +1,80 @@ +import { useMemo } from 'react'; +import { IndJob } from '@/lib/types'; + +// Optimized attention calculation with caching +export const useJobAttentionMetrics = (jobs: IndJob[]) => { + return useMemo(() => { + const attentionMap = new Map(); + const groupAttentionMap = new Map(); + const statusGroups = new Map(); + + // Group jobs by status + jobs.forEach(job => { + const status = job.status; + if (!statusGroups.has(status)) { + statusGroups.set(status, []); + } + statusGroups.get(status)!.push(job); + }); + + // Calculate attention for all jobs in one pass + jobs.forEach(job => { + let needsAttention = false; + + // Acquisition jobs need attention when all materials are satisfied + if (job.status === 'Acquisition') { + if (job.billOfMaterials && job.billOfMaterials.length > 0) { + const requiredMaterials = new Map(); + job.billOfMaterials.forEach(item => { + requiredMaterials.set(item.name, item.quantity); + }); + + const ownedMaterials = new Map(); + job.expenditures?.forEach(transaction => { + const currentOwned = ownedMaterials.get(transaction.itemName) || 0; + ownedMaterials.set(transaction.itemName, currentOwned + transaction.quantity); + }); + + let allMaterialsSatisfied = true; + requiredMaterials.forEach((required, materialName) => { + const owned = ownedMaterials.get(materialName) || 0; + if (owned < required) { + allMaterialsSatisfied = false; + } + }); + + needsAttention = allMaterialsSatisfied; + } + } + // Running jobs need attention when they have finished + else if (job.status === 'Running') { + if (job.jobStart && job.runtime) { + const startTime = new Date(job.jobStart).getTime(); + const runtimeMs = job.runtime * 1000; + const finishTime = startTime + runtimeMs; + const currentTime = Date.now(); + needsAttention = currentTime >= finishTime; + } + } + // Selling jobs need attention when sold count reaches produced count + else if (job.status === 'Selling') { + const produced = job.produced || 0; + const sold = job.income?.reduce((sum, tx) => sum + tx.quantity, 0) || 0; + needsAttention = sold >= produced && produced > 0; + } + + attentionMap.set(job.id, needsAttention); + }); + + // Calculate group attention + statusGroups.forEach((jobs, status) => { + const hasAttention = jobs.some(job => attentionMap.get(job.id) || false); + groupAttentionMap.set(status, hasAttention); + }); + + return { + jobNeedsAttention: (jobId: string) => attentionMap.get(jobId) || false, + groupNeedsAttention: (status: string) => groupAttentionMap.get(status) || false + }; + }, [jobs]); +}; \ No newline at end of file