From 2ada1121781a22c3e3a63e6b348a417800fdd658 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Mon, 7 Jul 2025 11:55:18 +0000 Subject: [PATCH] Refactor: Split JobCard.tsx into smaller components Refactored JobCard.tsx into smaller, more manageable components to improve code organization and readability. --- src/components/JobCard.tsx | 410 +----------------------------- src/components/JobCardDetails.tsx | 87 +++++++ src/components/JobCardHeader.tsx | 300 ++++++++++++++++++++++ src/components/JobCardMetrics.tsx | 66 +++++ 4 files changed, 467 insertions(+), 396 deletions(-) create mode 100644 src/components/JobCardDetails.tsx create mode 100644 src/components/JobCardHeader.tsx create mode 100644 src/components/JobCardMetrics.tsx diff --git a/src/components/JobCard.tsx b/src/components/JobCard.tsx index 0f79595..eb81e79 100644 --- a/src/components/JobCard.tsx +++ b/src/components/JobCard.tsx @@ -1,14 +1,10 @@ -import { useState } from 'react'; + import { useNavigate } from 'react-router-dom'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'; -import { Calendar, Factory, TrendingUp, TrendingDown, Clock, Import, Upload, Check, Copy } from 'lucide-react'; -import { formatISK } from '@/utils/priceUtils'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { IndJob } from '@/lib/types'; -import { Input } from '@/components/ui/input'; -import { useToast } from '@/components/ui/use-toast'; +import JobCardHeader from './JobCardHeader'; +import JobCardDetails from './JobCardDetails'; +import JobCardMetrics from './JobCardMetrics'; interface JobCardProps { job: IndJob; @@ -28,407 +24,29 @@ const JobCard: React.FC = ({ isTracked = false }) => { const navigate = useNavigate(); - const [isEditingProduced, setIsEditingProduced] = useState(false); - const [producedValue, setProducedValue] = useState(job.produced?.toString() || '0'); - const [copyingBom, setCopyingBom] = useState(false); - const [copyingName, setCopyingName] = useState(false); - const { toast } = useToast(); - - const sortedExpenditures = [...job.expenditures].sort((a, b) => - new Date(b.date).getTime() - new Date(a.date).getTime() - ); - const sortedIncome = [...job.income].sort((a, b) => - new Date(b.date).getTime() - new Date(a.date).getTime() - ); - - const totalExpenditure = sortedExpenditures.reduce((sum, tx) => sum + tx.totalPrice, 0); - const totalIncome = sortedIncome.reduce((sum, tx) => sum + tx.totalPrice, 0); - const profit = totalIncome - totalExpenditure; - const margin = totalIncome > 0 ? ((profit / totalIncome) * 100) : 0; - - const itemsSold = sortedIncome.reduce((sum, tx) => sum + tx.quantity, 0); - const saleStartTime = job.saleStart ? new Date(job.saleStart).getTime() : null; - const daysSinceStart = saleStartTime ? Math.max(1, Math.ceil((Date.now() - saleStartTime) / (1000 * 60 * 60 * 24))) : 0; - const itemsPerDay = daysSinceStart > 0 ? itemsSold / daysSinceStart : 0; - - const getStatusColor = (status: string) => { - switch (status) { - case 'Planned': return 'bg-gray-600'; - case 'Acquisition': return 'bg-yellow-600'; - case 'Running': return 'bg-blue-600'; - case 'Done': return 'bg-purple-600'; - case 'Selling': return 'bg-orange-600'; - case 'Closed': return 'bg-green-600'; - case 'Tracked': return 'bg-cyan-600'; - default: return 'bg-gray-600'; - } - }; - - 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 handleProducedUpdate = () => { - const newValue = parseInt(producedValue); - if (!isNaN(newValue) && onUpdateProduced) { - onUpdateProduced(job.id, newValue); - setIsEditingProduced(false); - } else { - setProducedValue(job.produced?.toString() || '0'); - setIsEditingProduced(false); - } - }; - - const handleProducedKeyPress = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - handleProducedUpdate(); - } else if (e.key === 'Escape') { - setIsEditingProduced(false); - setProducedValue(job.produced?.toString() || '0'); - } - }; - - const importBillOfMaterials = async () => { - if (!onImportBOM) { - toast({ - title: "Error", - description: "Import functionality is not available", - variant: "destructive", - duration: 2000, - }); - return; - } - - try { - const clipboardText = await navigator.clipboard.readText(); - const lines = clipboardText.split('\n').filter(line => line.trim()); - const items: { name: string; quantity: number }[] = []; - - for (const line of lines) { - const parts = line.trim().split(/[\s\t]+/); - if (parts.length >= 2) { - const name = parts.slice(0, -1).join(' '); - const quantityPart = parts[parts.length - 1].replace(/,/g, ''); - const quantity = parseInt(quantityPart); - if (name && !isNaN(quantity)) { - items.push({ name, quantity }); - } - } - } - - if (items.length > 0) { - onImportBOM(job.id, items); - toast({ - title: "BOM Imported", - description: `Successfully imported ${items.length} items`, - duration: 3000, - }); - } else { - toast({ - title: "No Valid Items", - description: "No valid items found in clipboard. Format: 'Item Name Quantity' per line", - variant: "destructive", - duration: 3000, - }); - } - } catch (err) { - toast({ - title: "Error", - description: "Failed to read from clipboard", - variant: "destructive", - duration: 2000, - }); - } - }; - - const exportBillOfMaterials = async () => { - if (!job.billOfMaterials?.length) { - toast({ - title: "Nothing to Export", - description: "No bill of materials found for this job", - variant: "destructive", - duration: 2000, - }); - return; - } - - const text = job.billOfMaterials - .map(item => `${item.name}\t${item.quantity.toLocaleString()}`) - .join('\n'); - - try { - await navigator.clipboard.writeText(text); - setCopyingBom(true); - toast({ - title: "Exported!", - description: "Bill of materials copied to clipboard", - duration: 2000, - }); - setTimeout(() => setCopyingBom(false), 1000); - } catch (err) { - toast({ - title: "Error", - description: "Failed to copy to clipboard", - variant: "destructive", - duration: 2000, - }); - } - }; const handleCardClick = () => { navigate(`/${job.id}`); }; - const handleJobNameClick = async (e: React.MouseEvent) => { - e.stopPropagation(); - try { - await navigator.clipboard.writeText(job.outputItem); - setCopyingName(true); - toast({ - title: "Copied!", - description: "Job name copied to clipboard", - duration: 2000, - }); - setTimeout(() => setCopyingName(false), 1000); - } catch (err) { - toast({ - title: "Error", - description: "Failed to copy to clipboard", - variant: "destructive", - duration: 2000, - }); - } - }; - - const handleProducedClick = (e: React.MouseEvent) => { - e.stopPropagation(); - if (job.status !== 'Closed') { - setIsEditingProduced(true); - } - }; - - const handleEditClick = (e: React.MouseEvent) => { - e.stopPropagation(); - onEdit(job); - }; - - const handleDeleteClick = (e: React.MouseEvent) => { - e.stopPropagation(); - onDelete(job.id); - }; - - const handleImportClick = (e: React.MouseEvent) => { - e.stopPropagation(); - importBillOfMaterials(); - }; - - const handleExportClick = (e: React.MouseEvent) => { - e.stopPropagation(); - exportBillOfMaterials(); - }; - return ( -
-
-
- - {job.outputItem} - {copyingName && } - -
-

- Quantity: {job.outputQuantity.toLocaleString()} - - Produced: { - isEditingProduced && job.status !== 'Closed' ? ( - setProducedValue(e.target.value)} - onBlur={handleProducedUpdate} - onKeyDown={handleProducedKeyPress} - onClick={(e) => e.stopPropagation()} - className="w-24 h-6 px-2 py-1 inline-block bg-gray-800 border-gray-600 text-white" - min="0" - autoFocus - /> - ) : ( - - {(job.produced || 0).toLocaleString()} - - ) - } - - - Sold: {itemsSold.toLocaleString()} - -

-
-
-
-
- {job.status} -
- - -
-
- - -
-
-
+
-
-
-
- - Created: {formatDateTime(job.created)} -
-
- - Start: {formatDateTime(job.jobStart)} -
-
- - Job ID: {job.id} -
-
- - {job.saleStart && ( -
-
- Sale Period: {formatDateTime(job.saleStart)} - {formatDateTime(job.saleEnd)} -
- {itemsPerDay > 0 && ( -
- Items/Day: {itemsPerDay.toFixed(2)} -
- )} -
- )} - - {job.billOfMaterials && job.billOfMaterials.length > 0 && ( - - -
- BOM: {job.billOfMaterials.length} items (hover to view) -
-
- -
-

Bill of Materials

-
- {job.billOfMaterials.map((item, index) => ( -
- {item.name} - {item.quantity.toLocaleString()} -
- ))} -
-
-
-
- )} -
- +
- -
-
-
Costs
-
{formatISK(totalExpenditure)}
- {job.projectedCost > 0 && ( -
- vs {formatISK(job.projectedCost)} -
- {((totalExpenditure / job.projectedCost) * 100).toFixed(0)}% -
-
- )} -
-
-
Revenue
-
{formatISK(totalIncome)}
- {job.projectedRevenue > 0 && ( -
- vs {formatISK(job.projectedRevenue)} -
= job.projectedRevenue ? 'text-green-400' : 'text-red-400'}`}> - {((totalIncome / job.projectedRevenue) * 100).toFixed(0)}% -
-
- )} -
-
-
Profit
-
= 0 ? 'text-green-400' : 'text-red-400'}`}> - {formatISK(profit)} -
-
= 0 ? 'text-green-400' : 'text-red-400'}`}> - {margin.toFixed(1)}% margin -
- {job.projectedRevenue > 0 && job.projectedCost > 0 && ( -
- vs {formatISK(job.projectedRevenue - job.projectedCost)} -
- )} -
-
+ ); diff --git a/src/components/JobCardDetails.tsx b/src/components/JobCardDetails.tsx new file mode 100644 index 0000000..050ee6a --- /dev/null +++ b/src/components/JobCardDetails.tsx @@ -0,0 +1,87 @@ + +import { Calendar, Factory, Clock } from 'lucide-react'; +import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'; +import { IndJob } from '@/lib/types'; + +interface JobCardDetailsProps { + job: IndJob; +} + +const JobCardDetails: React.FC = ({ job }) => { + 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 sortedIncome = [...job.income].sort((a, b) => + new Date(b.date).getTime() - new Date(a.date).getTime() + ); + + const itemsSold = sortedIncome.reduce((sum, tx) => sum + tx.quantity, 0); + const saleStartTime = job.saleStart ? new Date(job.saleStart).getTime() : null; + const daysSinceStart = saleStartTime ? Math.max(1, Math.ceil((Date.now() - saleStartTime) / (1000 * 60 * 60 * 24))) : 0; + const itemsPerDay = daysSinceStart > 0 ? itemsSold / daysSinceStart : 0; + + return ( +
+
+
+ + Created: {formatDateTime(job.created)} +
+
+ + Start: {formatDateTime(job.jobStart)} +
+
+ + Job ID: {job.id} +
+
+ + {job.saleStart && ( +
+
+ Sale Period: {formatDateTime(job.saleStart)} - {formatDateTime(job.saleEnd)} +
+ {itemsPerDay > 0 && ( +
+ Items/Day: {itemsPerDay.toFixed(2)} +
+ )} +
+ )} + + {job.billOfMaterials && job.billOfMaterials.length > 0 && ( + + +
+ BOM: {job.billOfMaterials.length} items (hover to view) +
+
+ +
+

Bill of Materials

+
+ {job.billOfMaterials.map((item, index) => ( +
+ {item.name} + {item.quantity.toLocaleString()} +
+ ))} +
+
+
+
+ )} +
+ ); +}; + +export default JobCardDetails; diff --git a/src/components/JobCardHeader.tsx b/src/components/JobCardHeader.tsx new file mode 100644 index 0000000..213205c --- /dev/null +++ b/src/components/JobCardHeader.tsx @@ -0,0 +1,300 @@ + +import { useState } from 'react'; +import { CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Import, Upload, Check, Copy } from 'lucide-react'; +import { IndJob } from '@/lib/types'; +import { useToast } from '@/hooks/use-toast'; + +interface JobCardHeaderProps { + 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; +} + +const JobCardHeader: React.FC = ({ + job, + onEdit, + onDelete, + onUpdateProduced, + onImportBOM +}) => { + const [isEditingProduced, setIsEditingProduced] = useState(false); + const [producedValue, setProducedValue] = useState(job.produced?.toString() || '0'); + const [copyingBom, setCopyingBom] = useState(false); + const [copyingName, setCopyingName] = useState(false); + const { toast } = useToast(); + + const getStatusColor = (status: string) => { + switch (status) { + case 'Planned': return 'bg-gray-600'; + case 'Acquisition': return 'bg-yellow-600'; + case 'Running': return 'bg-blue-600'; + case 'Done': return 'bg-purple-600'; + case 'Selling': return 'bg-orange-600'; + case 'Closed': return 'bg-green-600'; + case 'Tracked': return 'bg-cyan-600'; + default: return 'bg-gray-600'; + } + }; + + const handleProducedUpdate = () => { + const newValue = parseInt(producedValue); + if (!isNaN(newValue) && onUpdateProduced) { + onUpdateProduced(job.id, newValue); + setIsEditingProduced(false); + } else { + setProducedValue(job.produced?.toString() || '0'); + setIsEditingProduced(false); + } + }; + + const handleProducedKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleProducedUpdate(); + } else if (e.key === 'Escape') { + setIsEditingProduced(false); + setProducedValue(job.produced?.toString() || '0'); + } + }; + + const importBillOfMaterials = async () => { + if (!onImportBOM) { + toast({ + title: "Error", + description: "Import functionality is not available", + variant: "destructive", + duration: 2000, + }); + return; + } + + try { + const clipboardText = await navigator.clipboard.readText(); + const lines = clipboardText.split('\n').filter(line => line.trim()); + const items: { name: string; quantity: number }[] = []; + + for (const line of lines) { + const parts = line.trim().split(/[\s\t]+/); + if (parts.length >= 2) { + const name = parts.slice(0, -1).join(' '); + const quantityPart = parts[parts.length - 1].replace(/,/g, ''); + const quantity = parseInt(quantityPart); + if (name && !isNaN(quantity)) { + items.push({ name, quantity }); + } + } + } + + if (items.length > 0) { + onImportBOM(job.id, items); + toast({ + title: "BOM Imported", + description: `Successfully imported ${items.length} items`, + duration: 3000, + }); + } else { + toast({ + title: "No Valid Items", + description: "No valid items found in clipboard. Format: 'Item Name Quantity' per line", + variant: "destructive", + duration: 3000, + }); + } + } catch (err) { + toast({ + title: "Error", + description: "Failed to read from clipboard", + variant: "destructive", + duration: 2000, + }); + } + }; + + const exportBillOfMaterials = async () => { + if (!job.billOfMaterials?.length) { + toast({ + title: "Nothing to Export", + description: "No bill of materials found for this job", + variant: "destructive", + duration: 2000, + }); + return; + } + + const text = job.billOfMaterials + .map(item => `${item.name}\t${item.quantity.toLocaleString()}`) + .join('\n'); + + try { + await navigator.clipboard.writeText(text); + setCopyingBom(true); + toast({ + title: "Exported!", + description: "Bill of materials copied to clipboard", + duration: 2000, + }); + setTimeout(() => setCopyingBom(false), 1000); + } catch (err) { + toast({ + title: "Error", + description: "Failed to copy to clipboard", + variant: "destructive", + duration: 2000, + }); + } + }; + + const handleJobNameClick = async (e: React.MouseEvent) => { + e.stopPropagation(); + try { + await navigator.clipboard.writeText(job.outputItem); + setCopyingName(true); + toast({ + title: "Copied!", + description: "Job name copied to clipboard", + duration: 2000, + }); + setTimeout(() => setCopyingName(false), 1000); + } catch (err) { + toast({ + title: "Error", + description: "Failed to copy to clipboard", + variant: "destructive", + duration: 2000, + }); + } + }; + + const handleProducedClick = (e: React.MouseEvent) => { + e.stopPropagation(); + if (job.status !== 'Closed') { + setIsEditingProduced(true); + } + }; + + const handleEditClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onEdit(job); + }; + + const handleDeleteClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onDelete(job.id); + }; + + const handleImportClick = (e: React.MouseEvent) => { + e.stopPropagation(); + importBillOfMaterials(); + }; + + const handleExportClick = (e: React.MouseEvent) => { + e.stopPropagation(); + exportBillOfMaterials(); + }; + + const sortedIncome = [...job.income].sort((a, b) => + new Date(b.date).getTime() - new Date(a.date).getTime() + ); + const itemsSold = sortedIncome.reduce((sum, tx) => sum + tx.quantity, 0); + + return ( +
+
+
+ + {job.outputItem} + {copyingName && } + +
+

+ Quantity: {job.outputQuantity.toLocaleString()} + + Produced: { + isEditingProduced && job.status !== 'Closed' ? ( + setProducedValue(e.target.value)} + onBlur={handleProducedUpdate} + onKeyDown={handleProducedKeyPress} + onClick={(e) => e.stopPropagation()} + className="w-24 h-6 px-2 py-1 inline-block bg-gray-800 border-gray-600 text-white" + min="0" + autoFocus + /> + ) : ( + + {(job.produced || 0).toLocaleString()} + + ) + } + + + Sold: {itemsSold.toLocaleString()} + +

+
+
+
+
+ {job.status} +
+ + +
+
+ + +
+
+
+ ); +}; + +export default JobCardHeader; diff --git a/src/components/JobCardMetrics.tsx b/src/components/JobCardMetrics.tsx new file mode 100644 index 0000000..beee06a --- /dev/null +++ b/src/components/JobCardMetrics.tsx @@ -0,0 +1,66 @@ + +import { formatISK } from '@/utils/priceUtils'; +import { IndJob } from '@/lib/types'; + +interface JobCardMetricsProps { + job: IndJob; +} + +const JobCardMetrics: React.FC = ({ job }) => { + const sortedExpenditures = [...job.expenditures].sort((a, b) => + new Date(b.date).getTime() - new Date(a.date).getTime() + ); + const sortedIncome = [...job.income].sort((a, b) => + new Date(b.date).getTime() - new Date(a.date).getTime() + ); + + const totalExpenditure = sortedExpenditures.reduce((sum, tx) => sum + tx.totalPrice, 0); + const totalIncome = sortedIncome.reduce((sum, tx) => sum + tx.totalPrice, 0); + const profit = totalIncome - totalExpenditure; + const margin = totalIncome > 0 ? ((profit / totalIncome) * 100) : 0; + + return ( +
+
+
Costs
+
{formatISK(totalExpenditure)}
+ {job.projectedCost > 0 && ( +
+ vs {formatISK(job.projectedCost)} +
+ {((totalExpenditure / job.projectedCost) * 100).toFixed(0)}% +
+
+ )} +
+
+
Revenue
+
{formatISK(totalIncome)}
+ {job.projectedRevenue > 0 && ( +
+ vs {formatISK(job.projectedRevenue)} +
= job.projectedRevenue ? 'text-green-400' : 'text-red-400'}`}> + {((totalIncome / job.projectedRevenue) * 100).toFixed(0)}% +
+
+ )} +
+
+
Profit
+
= 0 ? 'text-green-400' : 'text-red-400'}`}> + {formatISK(profit)} +
+
= 0 ? 'text-green-400' : 'text-red-400'}`}> + {margin.toFixed(1)}% margin +
+ {job.projectedRevenue > 0 && job.projectedCost > 0 && ( +
+ vs {formatISK(job.projectedRevenue - job.projectedCost)} +
+ )} +
+
+ ); +}; + +export default JobCardMetrics;