Files
ember-market-frontend/components/modals/profit-analysis-modal.tsx
g e369741b2d
All checks were successful
Build Frontend / build (push) Successful in 1m14s
Enhance customer and profit analysis dialogs UI/UX
Revamps the customer details dialog with improved layout, animations, and clearer stats breakdown. Upgrades the profit analysis modal with animated cards, clearer tier breakdown, and improved cost/margin/profit explanations. Also increases recent activity fetch limit, fixes quote hydration in dashboard content, and minor animation tweak in order table.
2026-01-12 08:12:36 +00:00

339 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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, Loader2, Info } from "lucide-react";
import { toast } from "sonner";
import { apiRequest } from "@/lib/api";
import { motion, AnimatePresence } from "framer-motion";
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 new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: 'GBP',
minimumFractionDigits: 2
}).format(amount);
};
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-emerald-500" : "text-rose-500";
};
const getProfitIcon = (profit: number | null) => {
if (profit === null) return Calculator;
return profit >= 0 ? TrendingUp : TrendingDown;
};
// Variants for staggered animations
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1
}
}
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 }
};
if (loading) {
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-4xl bg-background/80 backdrop-blur-md border-border/50">
<DialogHeader>
<DialogTitle>Profit Analysis - {productName}</DialogTitle>
</DialogHeader>
<div className="flex items-center justify-center h-64">
<div className="text-center">
<Loader2 className="h-8 w-8 animate-spin text-primary mx-auto mb-2" />
<p className="text-muted-foreground">Calculating metrics...</p>
</div>
</div>
</DialogContent>
</Dialog>
);
}
if (!profitData) {
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-4xl bg-background/80 backdrop-blur-md border-border/50">
<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 bg-black/80 backdrop-blur-xl border-white/10 shadow-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-xl">
<div className="p-2 rounded-lg bg-indigo-500/10 border border-indigo-500/20">
<DollarSign className="h-5 w-5 text-indigo-400" />
</div>
<span>Profit Analysis: <span className="text-muted-foreground font-normal">{productName}</span></span>
</DialogTitle>
</DialogHeader>
<motion.div
className="space-y-6 py-4"
variants={containerVariants}
initial="hidden"
animate="visible"
>
{/* Summary Cards */}
{profitData.summary.hasCostData ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<motion.div variants={itemVariants}>
<Card className="bg-emerald-500/5 border-emerald-500/20 backdrop-blur-sm hover:bg-emerald-500/10 transition-colors">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-emerald-400">Average Profit</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-emerald-500">
{formatCurrency(profitData.summary.averageProfit)}
</div>
<p className="text-xs text-emerald-400/60 mt-1">Per unit sold</p>
</CardContent>
</Card>
</motion.div>
<motion.div variants={itemVariants}>
<Card className="bg-blue-500/5 border-blue-500/20 backdrop-blur-sm hover:bg-blue-500/10 transition-colors">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-blue-400">Avg. Margin</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-blue-500">
{formatPercentage(profitData.summary.averageProfitMargin)}
</div>
<p className="text-xs text-blue-400/60 mt-1">Of selling price</p>
</CardContent>
</Card>
</motion.div>
<motion.div variants={itemVariants}>
<Card className="bg-indigo-500/5 border-indigo-500/20 backdrop-blur-sm hover:bg-indigo-500/10 transition-colors">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-indigo-400">Avg. Markup</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-indigo-500">
{formatPercentage(profitData.summary.averageMarkup)}
</div>
<p className="text-xs text-indigo-400/60 mt-1">On cost price</p>
</CardContent>
</Card>
</motion.div>
</div>
) : (
<motion.div variants={itemVariants}>
<Card className="border-dashed border-2 border-muted bg-muted/20">
<CardContent className="pt-6">
<div className="text-center py-6">
<Calculator className="h-12 w-12 text-muted-foreground mx-auto mb-4 opacity-50" />
<h3 className="text-lg font-medium mb-2">Missing Cost Data</h3>
<p className="text-muted-foreground mb-4 max-w-sm mx-auto">
Add a generic "Cost Per Unit" to this product to see detailed profit calculations.
</p>
<Badge variant="outline" className="text-sm py-1 px-3">
Current Cost: {formatCurrency(profitData.costPerUnit)}
</Badge>
</div>
</CardContent>
</Card>
</motion.div>
)}
{/* Cost Information */}
<motion.div variants={itemVariants}>
<Card className="bg-white/5 border-white/10 backdrop-blur-sm">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 rounded-md bg-muted/50">
<Info className="h-4 w-4 text-muted-foreground" />
</div>
<span className="text-sm font-medium text-muted-foreground">Base Cost Per Unit</span>
</div>
<span className="text-xl font-bold text-white">{formatCurrency(profitData.costPerUnit)}</span>
</div>
</CardContent>
</Card>
</motion.div>
{/* Pricing Tier Analysis */}
<motion.div variants={itemVariants} className="space-y-3">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider pl-1">Tier Breakdown</h3>
<div className="space-y-3">
{profitData.profitMargins
.sort((a, b) => a.minQuantity - b.minQuantity)
.map((tier, index) => {
const ProfitIcon = getProfitIcon(tier.profit);
const totalProfitForMinQty = tier.profit !== null ? tier.profit * tier.minQuantity : null;
const totalRevenueForMinQty = tier.pricePerUnit * tier.minQuantity;
const totalCostForMinQty = profitData.costPerUnit * tier.minQuantity;
return (
<motion.div
key={index}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.2 + (index * 0.1) }}
className="relative overflow-hidden group rounded-xl border border-white/5 bg-white/5 hover:bg-white/10 transition-all duration-300"
>
<div className={`absolute left-0 top-0 bottom-0 w-1 ${tier.profit && tier.profit >= 0 ? 'bg-emerald-500' : 'bg-rose-500'}`} />
<div className="flex flex-col sm:flex-row sm:items-center justify-between p-4 pl-6 gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="font-mono text-xs">
{tier.minQuantity}+ UNITS
</Badge>
<span className="text-muted-foreground text-sm">at</span>
<span className="font-bold text-white text-lg">{formatCurrency(tier.pricePerUnit)}</span>
</div>
<div className="flex gap-4 text-xs text-muted-foreground mt-2">
<span>Rev: <span className="text-white">{formatCurrency(totalRevenueForMinQty)}</span></span>
<span className="w-px h-4 bg-white/10" />
<span>Cost: <span className="text-white">{formatCurrency(totalCostForMinQty)}</span></span>
</div>
</div>
<div className="flex items-center justify-between sm:justify-end gap-6 sm:w-auto w-full pt-2 sm:pt-0 border-t sm:border-0 border-white/5">
<div className="text-right">
<div className="text-[10px] uppercase text-muted-foreground font-medium mb-0.5">Margin</div>
<div className={`font-mono font-bold ${tier.profit && tier.profit >= 50 ? 'text-emerald-400' : 'text-blue-400'}`}>
{formatPercentage(tier.profitMargin)}
</div>
</div>
<div className="text-right pl-4 border-l border-white/10">
<div className="text-[10px] uppercase text-muted-foreground font-medium mb-0.5">Net Profit</div>
<div className={`text-xl font-bold flex items-center justify-end gap-1 ${getProfitColor(tier.profit)}`}>
{tier.profit && tier.profit > 0 ? '+' : ''}{formatCurrency(tier.profit)}
</div>
<div className={`text-[10px] ${getProfitColor(totalProfitForMinQty)} opacity-80`}>
Total: {formatCurrency(totalProfitForMinQty)}
</div>
</div>
</div>
</div>
</motion.div>
);
})}
</div>
</motion.div>
{/* Help Text */}
<motion.div variants={itemVariants}>
<div className="bg-indigo-500/5 rounded-lg border border-indigo-500/10 p-4">
<h4 className="flex items-center gap-2 text-sm font-medium text-indigo-300 mb-2">
<Info className="h-4 w-4" />
Quick Guide
</h4>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs text-muted-foreground">
<div>
<span className="text-indigo-200 font-semibold block mb-0.5">Profit</span>
Selling Price - Cost Price
</div>
<div>
<span className="text-indigo-200 font-semibold block mb-0.5">Margin</span>
(Profit / Selling Price) × 100
</div>
<div>
<span className="text-indigo-200 font-semibold block mb-0.5">Markup</span>
(Profit / Cost Price) × 100
</div>
</div>
</div>
</motion.div>
</motion.div>
<div className="flex justify-end pt-2">
<Button onClick={onClose} variant="secondary" className="hover:bg-white/20">Close Analysis</Button>
</div>
</DialogContent>
</Dialog>
);
};