feat: Add interactive charts for job metrics
Adds line/area charts for costs, revenue, and profit, both per job and overall. Implements modal popups for graph display and adds graph icons for easy access.
This commit is contained in:
@@ -1,12 +1,15 @@
|
|||||||
|
|
||||||
import { CardTitle } from '@/components/ui/card';
|
import { CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Copy } from 'lucide-react';
|
import { Copy, BarChart3 } from 'lucide-react';
|
||||||
import { IndJob } from '@/lib/types';
|
import { IndJob } from '@/lib/types';
|
||||||
import { useClipboard } from '@/hooks/useClipboard';
|
import { useClipboard } from '@/hooks/useClipboard';
|
||||||
|
import { useJobs } from '@/hooks/useDataService';
|
||||||
|
import { useState } from 'react';
|
||||||
import JobStatusDropdown from './JobStatusDropdown';
|
import JobStatusDropdown from './JobStatusDropdown';
|
||||||
import BOMActions from './BOMActions';
|
import BOMActions from './BOMActions';
|
||||||
import EditableProduced from './EditableProduced';
|
import EditableProduced from './EditableProduced';
|
||||||
|
import TransactionChart from './TransactionChart';
|
||||||
|
|
||||||
interface JobCardHeaderProps {
|
interface JobCardHeaderProps {
|
||||||
job: IndJob;
|
job: IndJob;
|
||||||
@@ -24,6 +27,8 @@ const JobCardHeader: React.FC<JobCardHeaderProps> = ({
|
|||||||
onImportBOM
|
onImportBOM
|
||||||
}) => {
|
}) => {
|
||||||
const { copying, copyToClipboard } = useClipboard();
|
const { copying, copyToClipboard } = useClipboard();
|
||||||
|
const { jobs } = useJobs();
|
||||||
|
const [overviewChartOpen, setOverviewChartOpen] = useState(false);
|
||||||
|
|
||||||
const sortedIncome = [...job.income].sort((a, b) =>
|
const sortedIncome = [...job.income].sort((a, b) =>
|
||||||
new Date(b.date).getTime() - new Date(a.date).getTime()
|
new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||||
@@ -55,8 +60,16 @@ const JobCardHeader: React.FC<JobCardHeaderProps> = ({
|
|||||||
<span className="ml-4">
|
<span className="ml-4">
|
||||||
Produced: <EditableProduced job={job} onUpdateProduced={onUpdateProduced} />
|
Produced: <EditableProduced job={job} onUpdateProduced={onUpdateProduced} />
|
||||||
</span>
|
</span>
|
||||||
<span className="ml-4">
|
<span className="ml-4 flex items-center gap-1">
|
||||||
Sold: <span className="text-green-400">{itemsSold.toLocaleString()}</span>
|
Sold: <span className="text-green-400">{itemsSold.toLocaleString()}</span>
|
||||||
|
<div
|
||||||
|
className="cursor-pointer hover:text-blue-300 transition-colors"
|
||||||
|
onClick={() => setOverviewChartOpen(true)}
|
||||||
|
data-no-navigate
|
||||||
|
title="View overview charts"
|
||||||
|
>
|
||||||
|
<BarChart3 className="w-3 h-3" />
|
||||||
|
</div>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -84,6 +97,13 @@ const JobCardHeader: React.FC<JobCardHeaderProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<BOMActions job={job} onImportBOM={onImportBOM} />
|
<BOMActions job={job} onImportBOM={onImportBOM} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<TransactionChart
|
||||||
|
jobs={jobs}
|
||||||
|
type="overview"
|
||||||
|
isOpen={overviewChartOpen}
|
||||||
|
onClose={() => setOverviewChartOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@@ -4,7 +4,9 @@ import { IndJob } from '@/lib/types';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { useJobs } from '@/hooks/useDataService';
|
import { useJobs } from '@/hooks/useDataService';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { BarChart3 } from 'lucide-react';
|
||||||
import JobTransactionPopover from './JobTransactionPopover';
|
import JobTransactionPopover from './JobTransactionPopover';
|
||||||
|
import TransactionChart from './TransactionChart';
|
||||||
|
|
||||||
interface JobCardMetricsProps {
|
interface JobCardMetricsProps {
|
||||||
job: IndJob;
|
job: IndJob;
|
||||||
@@ -13,6 +15,7 @@ interface JobCardMetricsProps {
|
|||||||
const JobCardMetrics: React.FC<JobCardMetricsProps> = ({ job }) => {
|
const JobCardMetrics: React.FC<JobCardMetricsProps> = ({ job }) => {
|
||||||
const [editingField, setEditingField] = useState<string | null>(null);
|
const [editingField, setEditingField] = useState<string | null>(null);
|
||||||
const [tempValues, setTempValues] = useState<{ [key: string]: string }>({});
|
const [tempValues, setTempValues] = useState<{ [key: string]: string }>({});
|
||||||
|
const [chartModal, setChartModal] = useState<{ type: 'costs' | 'revenue' | 'profit'; isOpen: boolean }>({ type: 'costs', isOpen: false });
|
||||||
const { updateJob } = useJobs();
|
const { updateJob } = useJobs();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@@ -77,10 +80,25 @@ const JobCardMetrics: React.FC<JobCardMetricsProps> = ({ job }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openChart = (type: 'costs' | 'revenue' | 'profit') => {
|
||||||
|
setChartModal({ type, isOpen: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeChart = () => {
|
||||||
|
setChartModal({ type: 'costs', isOpen: false });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-3 gap-3 pt-4 border-t border-gray-700/50 flex-shrink-0">
|
<div className="grid grid-cols-3 gap-3 pt-4 border-t border-gray-700/50 flex-shrink-0">
|
||||||
<div className="text-center space-y-1">
|
<div className="text-center space-y-1">
|
||||||
<div className="text-xs font-medium text-red-400 uppercase tracking-wide">Costs</div>
|
<div className="text-xs font-medium text-red-400 uppercase tracking-wide flex items-center gap-1">
|
||||||
|
Costs
|
||||||
|
<BarChart3
|
||||||
|
className="w-3 h-3 cursor-pointer hover:text-red-300 transition-colors"
|
||||||
|
onClick={() => openChart('costs')}
|
||||||
|
data-no-navigate
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<JobTransactionPopover job={job} type="costs">
|
<JobTransactionPopover job={job} type="costs">
|
||||||
<div className="text-lg font-bold text-red-400 cursor-pointer hover:text-red-300 transition-colors" data-no-navigate>
|
<div className="text-lg font-bold text-red-400 cursor-pointer hover:text-red-300 transition-colors" data-no-navigate>
|
||||||
{formatISK(totalExpenditure)}
|
{formatISK(totalExpenditure)}
|
||||||
@@ -121,7 +139,14 @@ const JobCardMetrics: React.FC<JobCardMetricsProps> = ({ job }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center space-y-1">
|
<div className="text-center space-y-1">
|
||||||
<div className="text-xs font-medium text-green-400 uppercase tracking-wide">Revenue</div>
|
<div className="text-xs font-medium text-green-400 uppercase tracking-wide flex items-center gap-1">
|
||||||
|
Revenue
|
||||||
|
<BarChart3
|
||||||
|
className="w-3 h-3 cursor-pointer hover:text-green-300 transition-colors"
|
||||||
|
onClick={() => openChart('revenue')}
|
||||||
|
data-no-navigate
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<JobTransactionPopover job={job} type="revenue">
|
<JobTransactionPopover job={job} type="revenue">
|
||||||
<div className="text-lg font-bold text-green-400 cursor-pointer hover:text-green-300 transition-colors" data-no-navigate>
|
<div className="text-lg font-bold text-green-400 cursor-pointer hover:text-green-300 transition-colors" data-no-navigate>
|
||||||
{formatISK(totalIncome)}
|
{formatISK(totalIncome)}
|
||||||
@@ -177,7 +202,14 @@ const JobCardMetrics: React.FC<JobCardMetricsProps> = ({ job }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center space-y-1">
|
<div className="text-center space-y-1">
|
||||||
<div className="text-xs font-medium text-gray-300 uppercase tracking-wide">Profit</div>
|
<div className="text-xs font-medium text-gray-300 uppercase tracking-wide flex items-center gap-1">
|
||||||
|
Profit
|
||||||
|
<BarChart3
|
||||||
|
className="w-3 h-3 cursor-pointer hover:text-gray-100 transition-colors"
|
||||||
|
onClick={() => openChart('profit')}
|
||||||
|
data-no-navigate
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<JobTransactionPopover job={job} type="profit">
|
<JobTransactionPopover job={job} type="profit">
|
||||||
<div className={`text-lg font-bold cursor-pointer transition-colors ${profit >= 0 ? 'text-green-400 hover:text-green-300' : 'text-red-400 hover:text-red-300'}`} data-no-navigate>
|
<div className={`text-lg font-bold cursor-pointer transition-colors ${profit >= 0 ? 'text-green-400 hover:text-green-300' : 'text-red-400 hover:text-red-300'}`} data-no-navigate>
|
||||||
{formatISK(profit)}
|
{formatISK(profit)}
|
||||||
@@ -192,6 +224,13 @@ const JobCardMetrics: React.FC<JobCardMetricsProps> = ({ job }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<TransactionChart
|
||||||
|
job={job}
|
||||||
|
type={chartModal.type}
|
||||||
|
isOpen={chartModal.isOpen}
|
||||||
|
onClose={closeChart}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
260
src/components/TransactionChart.tsx
Normal file
260
src/components/TransactionChart.tsx
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { LineChart, Line, AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { formatISK } from '@/utils/priceUtils';
|
||||||
|
import { IndJob } from '@/lib/types';
|
||||||
|
import { format, parseISO } from 'date-fns';
|
||||||
|
|
||||||
|
interface TransactionChartProps {
|
||||||
|
job?: IndJob;
|
||||||
|
jobs?: IndJob[];
|
||||||
|
type: 'costs' | 'revenue' | 'profit' | 'overview';
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TransactionChart: React.FC<TransactionChartProps> = ({
|
||||||
|
job,
|
||||||
|
jobs,
|
||||||
|
type,
|
||||||
|
isOpen,
|
||||||
|
onClose
|
||||||
|
}) => {
|
||||||
|
const [hiddenLines, setHiddenLines] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const toggleLine = (dataKey: string) => {
|
||||||
|
const newHidden = new Set(hiddenLines);
|
||||||
|
if (newHidden.has(dataKey)) {
|
||||||
|
newHidden.delete(dataKey);
|
||||||
|
} else {
|
||||||
|
newHidden.add(dataKey);
|
||||||
|
}
|
||||||
|
setHiddenLines(newHidden);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getJobChartData = (job: IndJob) => {
|
||||||
|
// Combine all transactions and group by date
|
||||||
|
const allTransactions = [
|
||||||
|
...job.expenditures.map(tx => ({ ...tx, type: 'expenditure' })),
|
||||||
|
...job.income.map(tx => ({ ...tx, type: 'income' }))
|
||||||
|
];
|
||||||
|
|
||||||
|
// Group by date
|
||||||
|
const dateMap = new Map<string, { costs: number; revenue: number; date: string }>();
|
||||||
|
|
||||||
|
allTransactions.forEach(tx => {
|
||||||
|
const dateStr = format(parseISO(tx.date), 'yyyy-MM-dd');
|
||||||
|
if (!dateMap.has(dateStr)) {
|
||||||
|
dateMap.set(dateStr, { costs: 0, revenue: 0, date: dateStr });
|
||||||
|
}
|
||||||
|
const entry = dateMap.get(dateStr)!;
|
||||||
|
if (tx.type === 'expenditure') {
|
||||||
|
entry.costs += tx.totalPrice;
|
||||||
|
} else {
|
||||||
|
entry.revenue += tx.totalPrice;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert to array and calculate profit
|
||||||
|
return Array.from(dateMap.values())
|
||||||
|
.map(entry => ({
|
||||||
|
...entry,
|
||||||
|
profit: entry.revenue - entry.costs,
|
||||||
|
formattedDate: format(new Date(entry.date), 'MMM dd')
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOverviewChartData = (jobs: IndJob[]) => {
|
||||||
|
const dateMap = new Map<string, { revenue: number; profit: number; date: string }>();
|
||||||
|
|
||||||
|
jobs.forEach(job => {
|
||||||
|
const jobData = getJobChartData(job);
|
||||||
|
jobData.forEach(entry => {
|
||||||
|
if (!dateMap.has(entry.date)) {
|
||||||
|
dateMap.set(entry.date, { revenue: 0, profit: 0, date: entry.date });
|
||||||
|
}
|
||||||
|
const overviewEntry = dateMap.get(entry.date)!;
|
||||||
|
overviewEntry.revenue += entry.revenue;
|
||||||
|
overviewEntry.profit += entry.profit;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(dateMap.values())
|
||||||
|
.map(entry => ({
|
||||||
|
...entry,
|
||||||
|
formattedDate: format(new Date(entry.date), 'MMM dd')
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTooltipValue = (value: number) => formatISK(value);
|
||||||
|
|
||||||
|
const data = type === 'overview' && jobs ? getOverviewChartData(jobs) : job ? getJobChartData(job) : [];
|
||||||
|
|
||||||
|
const getTitle = () => {
|
||||||
|
if (type === 'overview') return 'Overview - Revenue & Profit Over Time';
|
||||||
|
if (job) {
|
||||||
|
switch (type) {
|
||||||
|
case 'costs': return `${job.outputItem} - Costs Over Time`;
|
||||||
|
case 'revenue': return `${job.outputItem} - Revenue Over Time`;
|
||||||
|
case 'profit': return `${job.outputItem} - Costs, Revenue & Profit Over Time`;
|
||||||
|
default: return `${job.outputItem} - Transaction History`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'Transaction History';
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderChart = () => {
|
||||||
|
if (type === 'overview') {
|
||||||
|
return (
|
||||||
|
<AreaChart data={data} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
||||||
|
<XAxis dataKey="formattedDate" stroke="#9CA3AF" />
|
||||||
|
<YAxis stroke="#9CA3AF" tickFormatter={formatTooltipValue} />
|
||||||
|
<Tooltip
|
||||||
|
formatter={formatTooltipValue}
|
||||||
|
labelStyle={{ color: '#F3F4F6' }}
|
||||||
|
contentStyle={{ backgroundColor: '#1F2937', border: '1px solid #374151' }}
|
||||||
|
/>
|
||||||
|
<Legend />
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="revenue"
|
||||||
|
stackId="1"
|
||||||
|
stroke="#10B981"
|
||||||
|
fill="#10B981"
|
||||||
|
fillOpacity={0.6}
|
||||||
|
name="Revenue"
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="profit"
|
||||||
|
stackId="2"
|
||||||
|
stroke="#3B82F6"
|
||||||
|
fill="#3B82F6"
|
||||||
|
fillOpacity={0.6}
|
||||||
|
name="Profit"
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'costs') {
|
||||||
|
return (
|
||||||
|
<AreaChart data={data} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
||||||
|
<XAxis dataKey="formattedDate" stroke="#9CA3AF" />
|
||||||
|
<YAxis stroke="#9CA3AF" tickFormatter={formatTooltipValue} />
|
||||||
|
<Tooltip
|
||||||
|
formatter={formatTooltipValue}
|
||||||
|
labelStyle={{ color: '#F3F4F6' }}
|
||||||
|
contentStyle={{ backgroundColor: '#1F2937', border: '1px solid #374151' }}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="costs"
|
||||||
|
stroke="#EF4444"
|
||||||
|
fill="#EF4444"
|
||||||
|
fillOpacity={0.6}
|
||||||
|
name="Costs"
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'revenue') {
|
||||||
|
return (
|
||||||
|
<AreaChart data={data} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
||||||
|
<XAxis dataKey="formattedDate" stroke="#9CA3AF" />
|
||||||
|
<YAxis stroke="#9CA3AF" tickFormatter={formatTooltipValue} />
|
||||||
|
<Tooltip
|
||||||
|
formatter={formatTooltipValue}
|
||||||
|
labelStyle={{ color: '#F3F4F6' }}
|
||||||
|
contentStyle={{ backgroundColor: '#1F2937', border: '1px solid #374151' }}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="revenue"
|
||||||
|
stroke="#10B981"
|
||||||
|
fill="#10B981"
|
||||||
|
fillOpacity={0.6}
|
||||||
|
name="Revenue"
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combined profit chart (costs, revenue, profit)
|
||||||
|
return (
|
||||||
|
<LineChart data={data} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
||||||
|
<XAxis dataKey="formattedDate" stroke="#9CA3AF" />
|
||||||
|
<YAxis stroke="#9CA3AF" tickFormatter={formatTooltipValue} />
|
||||||
|
<Tooltip
|
||||||
|
formatter={formatTooltipValue}
|
||||||
|
labelStyle={{ color: '#F3F4F6' }}
|
||||||
|
contentStyle={{ backgroundColor: '#1F2937', border: '1px solid #374151' }}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
onClick={(e) => toggleLine(e.dataKey as string)}
|
||||||
|
wrapperStyle={{ cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
{!hiddenLines.has('costs') && (
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="costs"
|
||||||
|
stroke="#EF4444"
|
||||||
|
strokeWidth={2}
|
||||||
|
name="Costs"
|
||||||
|
dot={{ fill: '#EF4444', strokeWidth: 2, r: 4 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!hiddenLines.has('revenue') && (
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="revenue"
|
||||||
|
stroke="#10B981"
|
||||||
|
strokeWidth={2}
|
||||||
|
name="Revenue"
|
||||||
|
dot={{ fill: '#10B981', strokeWidth: 2, r: 4 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!hiddenLines.has('profit') && (
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="profit"
|
||||||
|
stroke="#3B82F6"
|
||||||
|
strokeWidth={2}
|
||||||
|
name="Profit"
|
||||||
|
dot={{ fill: '#3B82F6', strokeWidth: 2, r: 4 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</LineChart>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="max-w-6xl w-[90vw] h-[80vh] bg-gray-900 border-gray-700">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-white">{getTitle()}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex-1 h-full">
|
||||||
|
<ResponsiveContainer width="100%" height="90%">
|
||||||
|
{renderChart()}
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button variant="outline" onClick={onClose} className="border-gray-600">
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TransactionChart;
|
Reference in New Issue
Block a user