feat: Implement revenue/profit recap and fixes
- Added a hover-over recap for total revenue and profit, displaying contributing jobs in a popup. - Fixed the issue where the lower parts of letters in job names were cut off. - Implemented job ID copy-to-clipboard functionality on click.
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { CardTitle } from '@/components/ui/card';
|
import { CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -32,6 +33,7 @@ const JobCardHeader: React.FC<JobCardHeaderProps> = ({
|
|||||||
const [producedValue, setProducedValue] = useState(job.produced?.toString() || '0');
|
const [producedValue, setProducedValue] = useState(job.produced?.toString() || '0');
|
||||||
const [copyingBom, setCopyingBom] = useState(false);
|
const [copyingBom, setCopyingBom] = useState(false);
|
||||||
const [copyingName, setCopyingName] = useState(false);
|
const [copyingName, setCopyingName] = useState(false);
|
||||||
|
const [copyingId, setCopyingId] = useState(false);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { updateJob } = useJobs();
|
const { updateJob } = useJobs();
|
||||||
|
|
||||||
@@ -196,6 +198,27 @@ const JobCardHeader: React.FC<JobCardHeaderProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleJobIdClick = async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(job.id);
|
||||||
|
setCopyingId(true);
|
||||||
|
toast({
|
||||||
|
title: "Copied!",
|
||||||
|
description: "Job ID copied to clipboard",
|
||||||
|
duration: 2000,
|
||||||
|
});
|
||||||
|
setTimeout(() => setCopyingId(false), 1000);
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to copy to clipboard",
|
||||||
|
variant: "destructive",
|
||||||
|
duration: 2000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleProducedClick = (e: React.MouseEvent) => {
|
const handleProducedClick = (e: React.MouseEvent) => {
|
||||||
if (job.status !== 'Closed') {
|
if (job.status !== 'Closed') {
|
||||||
setIsEditingProduced(true);
|
setIsEditingProduced(true);
|
||||||
@@ -228,47 +251,61 @@ const JobCardHeader: React.FC<JobCardHeaderProps> = ({
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<CardTitle
|
<CardTitle
|
||||||
className="text-blue-400 truncate cursor-pointer hover:text-blue-300 transition-colors flex items-center gap-1"
|
className="text-blue-400 truncate cursor-pointer hover:text-blue-300 transition-colors flex items-center gap-1 leading-normal"
|
||||||
onClick={handleJobNameClick}
|
onClick={handleJobNameClick}
|
||||||
title="Click to copy job name"
|
title="Click to copy job name"
|
||||||
data-no-navigate
|
data-no-navigate
|
||||||
|
style={{ lineHeight: '1.4' }}
|
||||||
>
|
>
|
||||||
{job.outputItem}
|
{job.outputItem}
|
||||||
{copyingName && <Copy className="w-4 h-4 text-green-400" />}
|
{copyingName && <Copy className="w-4 h-4 text-green-400" />}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-400 text-sm">
|
<div className="text-gray-400 text-sm leading-relaxed" style={{ lineHeight: '1.4' }}>
|
||||||
Runs: {job.outputQuantity.toLocaleString()}
|
<div className="mb-1">
|
||||||
<span className="ml-4">
|
Runs: {job.outputQuantity.toLocaleString()}
|
||||||
Produced: {
|
<span className="ml-4">
|
||||||
isEditingProduced && job.status !== 'Closed' ? (
|
Produced: {
|
||||||
<Input
|
isEditingProduced && job.status !== 'Closed' ? (
|
||||||
type="number"
|
<Input
|
||||||
value={producedValue}
|
type="number"
|
||||||
onChange={(e) => setProducedValue(e.target.value)}
|
value={producedValue}
|
||||||
onBlur={handleProducedUpdate}
|
onChange={(e) => setProducedValue(e.target.value)}
|
||||||
onKeyDown={handleProducedKeyPress}
|
onBlur={handleProducedUpdate}
|
||||||
className="w-24 h-5 px-2 py-0 inline-block bg-gray-800 border-gray-600 text-white text-xs leading-5"
|
onKeyDown={handleProducedKeyPress}
|
||||||
min="0"
|
className="w-24 h-5 px-2 py-0 inline-block bg-gray-800 border-gray-600 text-white text-xs leading-5"
|
||||||
autoFocus
|
min="0"
|
||||||
data-no-navigate
|
autoFocus
|
||||||
/>
|
data-no-navigate
|
||||||
) : (
|
/>
|
||||||
<span
|
) : (
|
||||||
onClick={handleProducedClick}
|
<span
|
||||||
className={`inline-block w-20 h-5 leading-5 text-left ${job.status !== 'Closed' ? "cursor-pointer hover:text-blue-400" : ""}`}
|
onClick={handleProducedClick}
|
||||||
title={job.status !== 'Closed' ? "Click to edit" : undefined}
|
className={`inline-block w-20 h-5 leading-5 text-left ${job.status !== 'Closed' ? "cursor-pointer hover:text-blue-400" : ""}`}
|
||||||
data-no-navigate
|
title={job.status !== 'Closed' ? "Click to edit" : undefined}
|
||||||
>
|
data-no-navigate
|
||||||
{(job.produced || 0).toLocaleString()}
|
>
|
||||||
</span>
|
{(job.produced || 0).toLocaleString()}
|
||||||
)
|
</span>
|
||||||
}
|
)
|
||||||
</span>
|
}
|
||||||
<span className="ml-4">
|
</span>
|
||||||
Sold: <span className="text-green-400">{itemsSold.toLocaleString()}</span>
|
<span className="ml-4">
|
||||||
</span>
|
Sold: <span className="text-green-400">{itemsSold.toLocaleString()}</span>
|
||||||
</p>
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
ID: <span
|
||||||
|
className="cursor-pointer hover:text-blue-400 transition-colors inline-flex items-center gap-1"
|
||||||
|
onClick={handleJobIdClick}
|
||||||
|
title="Click to copy job ID"
|
||||||
|
data-no-navigate
|
||||||
|
>
|
||||||
|
{job.id}
|
||||||
|
{copyingId && <Copy className="w-3 h-3 text-green-400" />}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 flex-shrink-0 items-end">
|
<div className="flex flex-col gap-2 flex-shrink-0 items-end">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
74
src/components/RecapPopover.tsx
Normal file
74
src/components/RecapPopover.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
|
||||||
|
import { IndJob } from '@/lib/types';
|
||||||
|
import { formatISK } from '@/utils/priceUtils';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
interface RecapPopoverProps {
|
||||||
|
title: string;
|
||||||
|
jobs: IndJob[];
|
||||||
|
children: React.ReactNode;
|
||||||
|
calculateJobValue: (job: IndJob) => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RecapPopover: React.FC<RecapPopoverProps> = ({
|
||||||
|
title,
|
||||||
|
jobs,
|
||||||
|
children,
|
||||||
|
calculateJobValue
|
||||||
|
}) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const jobContributions = jobs
|
||||||
|
.map(job => ({
|
||||||
|
job,
|
||||||
|
value: calculateJobValue(job)
|
||||||
|
}))
|
||||||
|
.filter(({ value }) => value !== 0)
|
||||||
|
.sort((a, b) => Math.abs(b.value) - Math.abs(a.value));
|
||||||
|
|
||||||
|
const handleJobClick = (jobId: string) => {
|
||||||
|
navigate(`/${jobId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
{children}
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-80 bg-gray-800/95 border-gray-600 text-white max-h-96 overflow-y-auto">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-lg text-white">{title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{jobContributions.length === 0 ? (
|
||||||
|
<p className="text-gray-400 text-sm">No contributions to display</p>
|
||||||
|
) : (
|
||||||
|
jobContributions.map(({ job, value }) => (
|
||||||
|
<div
|
||||||
|
key={job.id}
|
||||||
|
onClick={() => handleJobClick(job.id)}
|
||||||
|
className="flex justify-between items-center p-2 rounded hover:bg-gray-700/50 cursor-pointer transition-colors border-l-2 border-l-gray-600"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-blue-400 truncate" title={job.outputItem}>
|
||||||
|
{job.outputItem}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">
|
||||||
|
ID: {job.id}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`text-sm font-medium ml-2 ${value >= 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||||
|
{formatISK(value)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RecapPopover;
|
@@ -10,6 +10,7 @@ import { IndJob } from '@/lib/types';
|
|||||||
import BatchTransactionForm from '@/components/BatchTransactionForm';
|
import BatchTransactionForm from '@/components/BatchTransactionForm';
|
||||||
import { useJobs } from '@/hooks/useDataService';
|
import { useJobs } from '@/hooks/useDataService';
|
||||||
import SearchOverlay from '@/components/SearchOverlay';
|
import SearchOverlay from '@/components/SearchOverlay';
|
||||||
|
import RecapPopover from '@/components/RecapPopover';
|
||||||
|
|
||||||
const Index = () => {
|
const Index = () => {
|
||||||
const {
|
const {
|
||||||
@@ -119,6 +120,15 @@ const Index = () => {
|
|||||||
sum + job.income.reduce((sum, tx) => sum + tx.totalPrice, 0), 0
|
sum + job.income.reduce((sum, tx) => sum + tx.totalPrice, 0), 0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const calculateJobRevenue = (job: IndJob) =>
|
||||||
|
job.income.reduce((sum, tx) => sum + tx.totalPrice, 0);
|
||||||
|
|
||||||
|
const calculateJobProfit = (job: IndJob) => {
|
||||||
|
const expenditure = job.expenditures.reduce((sum, tx) => sum + tx.totalPrice, 0);
|
||||||
|
const income = job.income.reduce((sum, tx) => sum + tx.totalPrice, 0);
|
||||||
|
return income - expenditure;
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreateJob = async (jobData: IndJobRecordNoId) => {
|
const handleCreateJob = async (jobData: IndJobRecordNoId) => {
|
||||||
try {
|
try {
|
||||||
await createJob(jobData);
|
await createJob(jobData);
|
||||||
@@ -254,7 +264,15 @@ const Index = () => {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold text-green-400">{formatISK(totalRevenue)}</div>
|
<RecapPopover
|
||||||
|
title="Revenue Breakdown"
|
||||||
|
jobs={regularJobs}
|
||||||
|
calculateJobValue={calculateJobRevenue}
|
||||||
|
>
|
||||||
|
<div className="text-2xl font-bold text-green-400 cursor-pointer hover:text-green-300 transition-colors">
|
||||||
|
{formatISK(totalRevenue)}
|
||||||
|
</div>
|
||||||
|
</RecapPopover>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="bg-gray-900 border-gray-700 text-white">
|
<Card className="bg-gray-900 border-gray-700 text-white">
|
||||||
@@ -265,9 +283,15 @@ const Index = () => {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className={`text-2xl font-bold ${totalProfit >= 0 ? 'text-green-400' : 'text-red-400'}`}>
|
<RecapPopover
|
||||||
{formatISK(totalProfit)}
|
title="Profit Breakdown"
|
||||||
</div>
|
jobs={regularJobs}
|
||||||
|
calculateJobValue={calculateJobProfit}
|
||||||
|
>
|
||||||
|
<div className={`text-2xl font-bold cursor-pointer transition-colors ${totalProfit >= 0 ? 'text-green-400 hover:text-green-300' : 'text-red-400 hover:text-red-300'}`}>
|
||||||
|
{formatISK(totalProfit)}
|
||||||
|
</div>
|
||||||
|
</RecapPopover>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
Reference in New Issue
Block a user