Add profit analysis modal and cost tracking for products
Introduces a Profit Analysis modal for products, allowing users to view profit, margin, and markup calculations based on cost per unit and pricing tiers. Adds cost per unit input to the product modal, updates product types, and integrates the analysis modal into the products page and product table. This enhances product management with profit tracking and analysis features.
This commit is contained in:
@@ -307,6 +307,32 @@ const ProductBasicInfo: React.FC<{
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-background rounded-lg border border-border p-4">
|
||||
<h3 className="text-sm font-medium mb-4">💰 Cost & Profit Tracking</h3>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Track your costs to automatically calculate profit margins and markup percentages.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label htmlFor="costPerUnit" className="text-sm font-medium">
|
||||
Cost Per Unit (Optional)
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
How much you paid for each unit of this product
|
||||
</p>
|
||||
<Input
|
||||
id="costPerUnit"
|
||||
name="costPerUnit"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={productData.costPerUnit || ''}
|
||||
onChange={handleChange}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Category</label>
|
||||
|
||||
265
components/modals/profit-analysis-modal.tsx
Normal file
265
components/modals/profit-analysis-modal.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { TrendingUp, TrendingDown, Calculator, DollarSign } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { apiRequest } from "@/lib/api";
|
||||
|
||||
interface ProfitAnalysisModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
productId: string;
|
||||
productName: string;
|
||||
}
|
||||
|
||||
interface ProfitData {
|
||||
productId: string;
|
||||
name: string;
|
||||
costPerUnit: number;
|
||||
pricing: Array<{
|
||||
minQuantity: number;
|
||||
pricePerUnit: number;
|
||||
}>;
|
||||
profitMargins: Array<{
|
||||
minQuantity: number;
|
||||
pricePerUnit: number;
|
||||
profit: number | null;
|
||||
profitMargin: number | null;
|
||||
markup: number | null;
|
||||
}>;
|
||||
summary: {
|
||||
hasCostData: boolean;
|
||||
averageProfit: number | null;
|
||||
averageProfitMargin: number | null;
|
||||
averageMarkup: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export const ProfitAnalysisModal: React.FC<ProfitAnalysisModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
productId,
|
||||
productName,
|
||||
}) => {
|
||||
const [profitData, setProfitData] = useState<ProfitData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && productId) {
|
||||
fetchProfitAnalysis();
|
||||
}
|
||||
}, [open, productId]);
|
||||
|
||||
const fetchProfitAnalysis = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await apiRequest(`/products/${productId}/profit-analysis`);
|
||||
setProfitData(response);
|
||||
} catch (error) {
|
||||
console.error("Error fetching profit analysis:", error);
|
||||
toast.error("Failed to load profit analysis");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number | null) => {
|
||||
if (amount === null) return "N/A";
|
||||
return `£${amount.toFixed(2)}`;
|
||||
};
|
||||
|
||||
const formatPercentage = (percentage: number | null) => {
|
||||
if (percentage === null) return "N/A";
|
||||
return `${percentage.toFixed(1)}%`;
|
||||
};
|
||||
|
||||
const getProfitColor = (profit: number | null) => {
|
||||
if (profit === null) return "text-muted-foreground";
|
||||
return profit >= 0 ? "text-green-600" : "text-red-600";
|
||||
};
|
||||
|
||||
const getProfitIcon = (profit: number | null) => {
|
||||
if (profit === null) return Calculator;
|
||||
return profit >= 0 ? TrendingUp : TrendingDown;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Profit Analysis - {productName}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-2"></div>
|
||||
<p className="text-muted-foreground">Loading profit analysis...</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
if (!profitData) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Profit Analysis - {productName}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground">No profit data available</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<DollarSign className="h-5 w-5" />
|
||||
Profit Analysis - {productName}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Summary Cards */}
|
||||
{profitData.summary.hasCostData ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Average Profit</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{formatCurrency(profitData.summary.averageProfit)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Per unit sold</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Average Profit Margin</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{formatPercentage(profitData.summary.averageProfitMargin)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Of selling price</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Average Markup</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-purple-600">
|
||||
{formatPercentage(profitData.summary.averageMarkup)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">On cost price</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center">
|
||||
<Calculator className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">No Cost Data Available</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Add a cost per unit to this product to see profit calculations.
|
||||
</p>
|
||||
<Badge variant="outline">Cost Per Unit: {formatCurrency(profitData.costPerUnit)}</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Cost Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Cost Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Cost Per Unit:</span>
|
||||
<span className="text-lg font-semibold">{formatCurrency(profitData.costPerUnit)}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Pricing Tier Analysis */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Pricing Tier Analysis</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{profitData.profitMargins.map((tier, index) => {
|
||||
const ProfitIcon = getProfitIcon(tier.profit);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-4 border rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<ProfitIcon className={`h-5 w-5 ${getProfitColor(tier.profit)}`} />
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{tier.minQuantity}+ units @ {formatCurrency(tier.pricePerUnit)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Minimum quantity: {tier.minQuantity}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right space-y-1">
|
||||
<div className={`font-medium ${getProfitColor(tier.profit)}`}>
|
||||
Profit: {formatCurrency(tier.profit)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Margin: {formatPercentage(tier.profitMargin)} |
|
||||
Markup: {formatPercentage(tier.markup)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Help Text */}
|
||||
<Card className="bg-muted/50">
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-2 text-sm">
|
||||
<h4 className="font-medium">Understanding the Metrics:</h4>
|
||||
<ul className="space-y-1 text-muted-foreground">
|
||||
<li><strong>Profit:</strong> Selling price minus cost price</li>
|
||||
<li><strong>Profit Margin:</strong> Profit as a percentage of selling price</li>
|
||||
<li><strong>Markup:</strong> Profit as a percentage of cost price</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Edit, Trash, AlertTriangle, CheckCircle, AlertCircle } from "lucide-react";
|
||||
import { Edit, Trash, AlertTriangle, CheckCircle, AlertCircle, Calculator } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Product } from "@/models/products";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -11,6 +11,7 @@ interface ProductTableProps {
|
||||
onEdit: (product: Product) => void;
|
||||
onDelete: (productId: string) => void;
|
||||
onToggleEnabled: (productId: string, enabled: boolean) => void;
|
||||
onProfitAnalysis?: (productId: string, productName: string) => void;
|
||||
getCategoryNameById: (categoryId: string) => string;
|
||||
}
|
||||
|
||||
@@ -20,6 +21,7 @@ const ProductTable = ({
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggleEnabled,
|
||||
onProfitAnalysis,
|
||||
getCategoryNameById
|
||||
}: ProductTableProps) => {
|
||||
|
||||
@@ -93,6 +95,17 @@ const ProductTable = ({
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right flex justify-end space-x-1">
|
||||
{onProfitAnalysis && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onProfitAnalysis(product._id as string, product.name)}
|
||||
className="text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-950/20"
|
||||
title="Profit Analysis"
|
||||
>
|
||||
<Calculator className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={() => onEdit(product)}>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user