feat(bom): complete 100% master catalog BOM and add Profitability Management Suite
- Configured BOMs for all 585 master variants across 5 categories (Oil Ozone, Aroma Oil, Car Diffuser, Room Spray, Reed Diffuser) - Mapped 2,111 marketplace products (Shopee and Lazada) to master catalog variants - Added backend profitability routes (/api/profitability/matrix, /omnichannel, /mrp) - Added 3 admin pages under Product menu: Cost & Profitability Matrix, Omnichannel Margin, and MRP & Material Demand - Added comprehensive ledgers and production sync scripts
This commit is contained in:
1 parent
fdbbaea021
commit
c78a898af7
21 files changed
+4336
-2
No files matched your search
@@ -79,6 +79,9 @@ import MarketplaceCrmPage from './pages/crm/MarketplaceCrmPage'
|
||||
import MarketplaceCustomerDetailPage from './pages/crm/MarketplaceCustomerDetailPage'
|
||||
import PosPage from './pages/PosPage'
|
||||
import UserLlmPromptsPage from './pages/UserLlmPromptsPage'
|
||||
import CostProfitabilityMatrixPage from './pages/profitability/CostProfitabilityMatrixPage'
|
||||
import OmnichannelMarginPage from './pages/profitability/OmnichannelMarginPage'
|
||||
import MrpPlanningPage from './pages/profitability/MrpPlanningPage'
|
||||
import { StoreSettingsProvider } from './contexts/StoreSettingsContext'
|
||||
|
||||
function App() {
|
||||
@@ -126,6 +129,9 @@ function App() {
|
||||
<Route path="/raw-materials" element={<RawMaterialsPage />} />
|
||||
<Route path="/raw-materials/new" element={<RawMaterialDetailPage />} />
|
||||
<Route path="/raw-materials/:id" element={<RawMaterialDetailPage />} />
|
||||
<Route path="/profitability" element={<CostProfitabilityMatrixPage />} />
|
||||
<Route path="/omnichannel-margin" element={<OmnichannelMarginPage />} />
|
||||
<Route path="/mrp-planning" element={<MrpPlanningPage />} />
|
||||
<Route path="/settings" element={<StoreSettingsPage />} />
|
||||
<Route path="/settings/brand-intelligence" element={<BrandIntelligencePage />} />
|
||||
<Route path="/brand-intelligence" element={<BrandIntelligencePage />} />
|
||||
|
||||
@@ -56,7 +56,7 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
const isInProduct = ['/categories', '/brands', '/products', '/product-attributes', '/raw-materials'].some(p => location.pathname.startsWith(p))
|
||||
const isInProduct = ['/categories', '/brands', '/products', '/product-attributes', '/raw-materials', '/profitability', '/omnichannel-margin', '/mrp-planning'].some(p => location.pathname.startsWith(p))
|
||||
const isInSales = ['/orders', '/promotions', '/coupons', '/global-analytics'].some(p => location.pathname.startsWith(p))
|
||||
const isRepeatCustomers = location.pathname.startsWith('/business-analytics/repeat-customer')
|
||||
const isInBusinessAnalytics = location.pathname.startsWith('/business-analytics') && !isRepeatCustomers
|
||||
@@ -97,6 +97,9 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
|
||||
{ icon: Package, label: t('nav.products', 'Products'), to: '/products' },
|
||||
{ icon: Ruler, label: t('nav.productAttributes', 'Product Attributes'), to: '/product-attributes' },
|
||||
{ icon: PackageOpen, label: t('nav.rawMaterials', 'Raw Materials'), to: '/raw-materials' },
|
||||
{ icon: Calculator, label: t('nav.profitability', 'Cost & Profitability'), to: '/profitability' },
|
||||
{ icon: Layers, label: t('nav.omnichannelMargin', 'Omnichannel Margin'), to: '/omnichannel-margin' },
|
||||
{ icon: BrainCircuit, label: t('nav.mrpPlanning', 'MRP & Material Demand'), to: '/mrp-planning' },
|
||||
]
|
||||
|
||||
const SALES_MENU = [
|
||||
|
||||
@@ -0,0 +1,692 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Search,
|
||||
Download,
|
||||
TrendingUp,
|
||||
Percent,
|
||||
Coins,
|
||||
Package,
|
||||
Layers,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
RefreshCw,
|
||||
SlidersHorizontal,
|
||||
} from 'lucide-react';
|
||||
import { API_BASE } from '@homaroma/types';
|
||||
import ProfitabilityHeaderTabs from './ProfitabilityHeaderTabs';
|
||||
|
||||
function fmtMoney(val: number | null | undefined): string {
|
||||
if (val == null || isNaN(val)) return '฿0.00';
|
||||
return (
|
||||
'฿' +
|
||||
new Intl.NumberFormat('th-TH', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(val)
|
||||
);
|
||||
}
|
||||
|
||||
function fmtPercent(val: number | null | undefined): string {
|
||||
if (val == null || isNaN(val)) return '0.0%';
|
||||
return `${val.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
interface BomComponent {
|
||||
id: number;
|
||||
rawMaterialId: number;
|
||||
name: string;
|
||||
unit: string;
|
||||
unitPrice: number;
|
||||
quantity: number;
|
||||
totalCost: number;
|
||||
isLiquid: boolean;
|
||||
}
|
||||
|
||||
interface VariantProfitItem {
|
||||
id: number;
|
||||
sku: string;
|
||||
productId: number;
|
||||
productName: string;
|
||||
categoryName: string;
|
||||
brandName: string;
|
||||
optionsStr: string;
|
||||
retailPrice: number;
|
||||
liquidCost: number;
|
||||
packagingCost: number;
|
||||
totalBomCost: number;
|
||||
grossProfit: number;
|
||||
grossMargin: number;
|
||||
marginHealth: 'high' | 'mid' | 'low';
|
||||
maxDiscountPercent: number;
|
||||
bomCount: number;
|
||||
bomItems: BomComponent[];
|
||||
}
|
||||
|
||||
interface CategoryBreakdown {
|
||||
categoryName: string;
|
||||
variantsCount: number;
|
||||
avgPrice: number;
|
||||
avgCost: number;
|
||||
avgMargin: number;
|
||||
}
|
||||
|
||||
interface MatrixKpis {
|
||||
totalVariants: number;
|
||||
configuredBomVariants: number;
|
||||
avgGrossMargin: number;
|
||||
totalCatalogRevenueAtFullList: number;
|
||||
totalCatalogBomCost: number;
|
||||
highMarginCount: number;
|
||||
midMarginCount: number;
|
||||
lowMarginCount: number;
|
||||
categoryBreakdown: CategoryBreakdown[];
|
||||
}
|
||||
|
||||
export default function CostProfitabilityMatrixPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [data, setData] = useState<{ kpis: MatrixKpis; items: VariantProfitItem[] } | null>(null);
|
||||
|
||||
// Filters & Search
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState('all');
|
||||
const [selectedMargin, setSelectedMargin] = useState('all');
|
||||
const [sortBy, setSortBy] = useState('margin_desc');
|
||||
const [expandedVariantId, setExpandedVariantId] = useState<number | null>(null);
|
||||
|
||||
// Cost Sensitivity Simulator
|
||||
const [isSimulatorOpen, setIsSimulatorOpen] = useState(false);
|
||||
const [fragranceShiftPercent, setFragranceShiftPercent] = useState(0); // e.g., +10%
|
||||
const [solventShiftPercent, setSolventShiftPercent] = useState(0); // e.g., +15%
|
||||
const [packagingShiftPercent, setPackagingShiftPercent] = useState(0);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams({
|
||||
limit: '600',
|
||||
search,
|
||||
category: selectedCategory,
|
||||
margin: selectedMargin,
|
||||
sortBy,
|
||||
});
|
||||
|
||||
const res = await fetch(`${API_BASE}/profitability/matrix?${params.toString()}`);
|
||||
if (!res.ok) throw new Error(`HTTP error ${res.status}`);
|
||||
const json = await res.json();
|
||||
setData(json);
|
||||
} catch (err) {
|
||||
console.error('Failed to load matrix data:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [selectedCategory, selectedMargin, sortBy]);
|
||||
|
||||
// Handle live search with debounce
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
fetchData();
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search]);
|
||||
|
||||
// Simulated items when what-if sliders are adjusted
|
||||
const displayItems = useMemo(() => {
|
||||
if (!data?.items) return [];
|
||||
if (fragranceShiftPercent === 0 && solventShiftPercent === 0 && packagingShiftPercent === 0) {
|
||||
return data.items;
|
||||
}
|
||||
|
||||
return data.items.map((item) => {
|
||||
let simLiquidCost = 0;
|
||||
let simPackagingCost = 0;
|
||||
|
||||
const simBomItems = item.bomItems.map((b) => {
|
||||
let multiplier = 1;
|
||||
if (b.name.includes('หัวน้ำหอม')) {
|
||||
multiplier = 1 + fragranceShiftPercent / 100;
|
||||
} else if (b.name.includes('DPG') || b.name.includes('DPM')) {
|
||||
multiplier = 1 + solventShiftPercent / 100;
|
||||
} else {
|
||||
multiplier = 1 + packagingShiftPercent / 100;
|
||||
}
|
||||
|
||||
const newCost = Math.round(b.totalCost * multiplier * 100) / 100;
|
||||
if (b.isLiquid) simLiquidCost += newCost;
|
||||
else simPackagingCost += newCost;
|
||||
|
||||
return { ...b, totalCost: newCost };
|
||||
});
|
||||
|
||||
const simTotalCost = Math.round((simLiquidCost + simPackagingCost) * 100) / 100;
|
||||
const simGrossProfit = Math.round((item.retailPrice - simTotalCost) * 100) / 100;
|
||||
const simMargin =
|
||||
item.retailPrice > 0 ? Math.round(((item.retailPrice - simTotalCost) / item.retailPrice) * 1000) / 10 : 0;
|
||||
|
||||
let marginHealth: 'high' | 'mid' | 'low' = 'mid';
|
||||
if (simMargin >= 70) marginHealth = 'high';
|
||||
else if (simMargin < 55) marginHealth = 'low';
|
||||
|
||||
return {
|
||||
...item,
|
||||
liquidCost: simLiquidCost,
|
||||
packagingCost: simPackagingCost,
|
||||
totalBomCost: simTotalCost,
|
||||
grossProfit: simGrossProfit,
|
||||
grossMargin: simMargin,
|
||||
marginHealth,
|
||||
bomItems: simBomItems,
|
||||
};
|
||||
});
|
||||
}, [data?.items, fragranceShiftPercent, solventShiftPercent, packagingShiftPercent]);
|
||||
|
||||
// Export to CSV
|
||||
const handleExportCsv = () => {
|
||||
if (!displayItems.length) return;
|
||||
const headers = [
|
||||
'SKU',
|
||||
'Product Name',
|
||||
'Category',
|
||||
'Size/Options',
|
||||
'Retail Price',
|
||||
'Liquid Cost',
|
||||
'Packaging Cost',
|
||||
'Total BOM Cost',
|
||||
'Gross Profit',
|
||||
'Gross Margin %',
|
||||
'Max Safe Discount %',
|
||||
];
|
||||
|
||||
const rows = displayItems.map((i) => [
|
||||
i.sku,
|
||||
`"${i.productName.replace(/"/g, '""')}"`,
|
||||
`"${i.categoryName}"`,
|
||||
`"${i.optionsStr}"`,
|
||||
i.retailPrice,
|
||||
i.liquidCost,
|
||||
i.packagingCost,
|
||||
i.totalBomCost,
|
||||
i.grossProfit,
|
||||
`${i.grossMargin}%`,
|
||||
`${i.maxDiscountPercent}%`,
|
||||
]);
|
||||
|
||||
const csvContent = '\uFEFF' + [headers.join(','), ...rows.map((e) => e.join(','))].join('\n');
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', `HomAroma_BOM_Profitability_Matrix_${new Date().toISOString().slice(0, 10)}.csv`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const kpis = data?.kpis;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Shared Header Navigation */}
|
||||
<ProfitabilityHeaderTabs
|
||||
title="Cost & Profitability Matrix"
|
||||
subtitle="ตารางวิเคราะห์โครงสร้างต้นทุนวัตถุดิบ (BOM), ราคาขายปลีก และอัตรากำไรขั้นต้น (Gross Profit & Margin) ครบทั้งแคตตาล็อก"
|
||||
/>
|
||||
|
||||
{/* ── Executive KPI Cards ────────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Total Master Variants */}
|
||||
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm relative overflow-hidden">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Master Variants & BOM</span>
|
||||
<div className="p-2 rounded-xl bg-cyan-500/10 text-cyan-400">
|
||||
<Package className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-extrabold text-white">{kpis?.totalVariants || 0}</span>
|
||||
<span className="text-xs font-semibold text-emerald-400">
|
||||
BOM {kpis?.configuredBomVariants === kpis?.totalVariants ? '100% ครบ' : `${kpis?.configuredBomVariants} รายการ`}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 mt-2">ครอบคลุมทั้ง 5 กลุ่มสินค้าหลักในระบบ</p>
|
||||
</div>
|
||||
|
||||
{/* Average Gross Margin */}
|
||||
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm relative overflow-hidden">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Average Catalog Margin</span>
|
||||
<div className="p-2 rounded-xl bg-emerald-500/10 text-emerald-400">
|
||||
<Percent className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-extrabold text-emerald-400">
|
||||
{kpis ? fmtPercent(kpis.avgGrossMargin) : '0.0%'}
|
||||
</span>
|
||||
<span className="text-xs font-bold text-slate-400">Gross Margin</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-800 h-1.5 rounded-full mt-3 overflow-hidden">
|
||||
<div
|
||||
className="bg-gradient-to-r from-emerald-500 to-teal-400 h-full rounded-full transition-all duration-500"
|
||||
style={{ width: `${Math.min(100, kpis?.avgGrossMargin || 0)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Catalog Value vs BOM Cost */}
|
||||
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">List Value vs BOM Cost</span>
|
||||
<div className="p-2 rounded-xl bg-blue-500/10 text-blue-400">
|
||||
<Coins className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-lg font-bold text-white flex items-center justify-between">
|
||||
<span className="text-slate-400 text-xs">Retail:</span>
|
||||
<span>{fmtMoney(kpis?.totalCatalogRevenueAtFullList)}</span>
|
||||
</div>
|
||||
<div className="text-sm font-bold text-rose-300 flex items-center justify-between mt-1">
|
||||
<span className="text-slate-400 text-xs">Total BOM:</span>
|
||||
<span>{fmtMoney(kpis?.totalCatalogBomCost)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Margin Health Breakdown */}
|
||||
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Margin Distribution</span>
|
||||
<div className="p-2 rounded-xl bg-purple-500/10 text-purple-400">
|
||||
<TrendingUp className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className="px-2.5 py-1 rounded-lg bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 text-xs font-bold flex-1 text-center">
|
||||
🟢 {kpis?.highMarginCount || 0} >70%
|
||||
</span>
|
||||
<span className="px-2.5 py-1 rounded-lg bg-amber-500/15 text-amber-400 border border-amber-500/30 text-xs font-bold flex-1 text-center">
|
||||
🟡 {kpis?.midMarginCount || 0} 55-70%
|
||||
</span>
|
||||
<span className="px-2.5 py-1 rounded-lg bg-rose-500/15 text-rose-400 border border-rose-500/30 text-xs font-bold flex-1 text-center">
|
||||
🔴 {kpis?.lowMarginCount || 0} <55%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Category Quick Summary Pill Bar ────────────────────────────── */}
|
||||
{kpis?.categoryBreakdown && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
{kpis.categoryBreakdown.map((cat) => {
|
||||
const isSelected = selectedCategory.toLowerCase() === cat.categoryName.toLowerCase();
|
||||
return (
|
||||
<button
|
||||
key={cat.categoryName}
|
||||
type="button"
|
||||
onClick={() => setSelectedCategory(isSelected ? 'all' : cat.categoryName)}
|
||||
className={`p-3.5 rounded-2xl border text-left transition-all duration-200 ${
|
||||
isSelected
|
||||
? 'bg-cyan-500/15 border-cyan-500/50 shadow-md shadow-cyan-500/10'
|
||||
: 'bg-slate-900/80 border-white/10 hover:border-white/20 hover:bg-slate-800/60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs font-bold text-slate-300 truncate">{cat.categoryName}</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-md bg-white/10 text-slate-300 font-mono">
|
||||
{cat.variantsCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between mt-2">
|
||||
<span className="text-xs text-slate-400">Avg Cost ฿{cat.avgCost}</span>
|
||||
<span className="text-sm font-extrabold text-emerald-400">{cat.avgMargin}%</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── What-If Cost Sensitivity Simulator Accordion ───────────────── */}
|
||||
<div className="bg-slate-900/90 border border-white/10 rounded-2xl overflow-hidden shadow-lg backdrop-blur-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSimulatorOpen(!isSimulatorOpen)}
|
||||
className="w-full px-5 py-4 flex items-center justify-between hover:bg-white/5 transition-all text-left"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-xl bg-purple-500/10 text-purple-400 border border-purple-500/20">
|
||||
<SlidersHorizontal className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm md:text-base font-bold text-white flex items-center gap-2">
|
||||
What-If Cost Sensitivity Simulator (จำลองต้นทุนวัตถุดิบผันผวน)
|
||||
{(fragranceShiftPercent !== 0 || solventShiftPercent !== 0 || packagingShiftPercent !== 0) && (
|
||||
<span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-purple-500 text-white animate-pulse">
|
||||
Active Sim
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400">
|
||||
ทดลองปรับราคาน้ำหอม, ตัวทำละลาย (DPG/DPM) หรือบรรจุภัณฑ์ เพื่อดูผลกระทบต่อ Gross Margin ทั้งแคตตาล็อกทันที
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-slate-400">
|
||||
{isSimulatorOpen ? <ChevronUp className="w-5 h-5" /> : <ChevronDown className="w-5 h-5" />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isSimulatorOpen && (
|
||||
<div className="p-5 border-t border-white/10 bg-slate-950/60 space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Fragrance Shift */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-xs font-semibold">
|
||||
<span className="text-slate-300">หัวน้ำหอม (Fragrance Oil)</span>
|
||||
<span className={fragranceShiftPercent > 0 ? 'text-rose-400' : fragranceShiftPercent < 0 ? 'text-emerald-400' : 'text-slate-400'}>
|
||||
{fragranceShiftPercent > 0 ? `+${fragranceShiftPercent}%` : `${fragranceShiftPercent}%`}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="-30"
|
||||
max="50"
|
||||
step="5"
|
||||
value={fragranceShiftPercent}
|
||||
onChange={(e) => setFragranceShiftPercent(parseInt(e.target.value))}
|
||||
className="w-full accent-purple-500 cursor-pointer"
|
||||
/>
|
||||
<div className="flex justify-between text-[10px] text-slate-500">
|
||||
<span>-30%</span>
|
||||
<span>0% (ปัจจุบัน)</span>
|
||||
<span>+50%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Solvent Shift */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-xs font-semibold">
|
||||
<span className="text-slate-300">ตัวทำละลาย (DPG / DPM)</span>
|
||||
<span className={solventShiftPercent > 0 ? 'text-rose-400' : solventShiftPercent < 0 ? 'text-emerald-400' : 'text-slate-400'}>
|
||||
{solventShiftPercent > 0 ? `+${solventShiftPercent}%` : `${solventShiftPercent}%`}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="-30"
|
||||
max="50"
|
||||
step="5"
|
||||
value={solventShiftPercent}
|
||||
onChange={(e) => setSolventShiftPercent(parseInt(e.target.value))}
|
||||
className="w-full accent-cyan-500 cursor-pointer"
|
||||
/>
|
||||
<div className="flex justify-between text-[10px] text-slate-500">
|
||||
<span>-30%</span>
|
||||
<span>0% (ปัจจุบัน)</span>
|
||||
<span>+50%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Packaging Shift */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-xs font-semibold">
|
||||
<span className="text-slate-300">บรรจุภัณฑ์ (ขวด / ก้าน / ฝา / สเปรย์)</span>
|
||||
<span className={packagingShiftPercent > 0 ? 'text-rose-400' : packagingShiftPercent < 0 ? 'text-emerald-400' : 'text-slate-400'}>
|
||||
{packagingShiftPercent > 0 ? `+${packagingShiftPercent}%` : `${packagingShiftPercent}%`}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="-30"
|
||||
max="50"
|
||||
step="5"
|
||||
value={packagingShiftPercent}
|
||||
onChange={(e) => setPackagingShiftPercent(parseInt(e.target.value))}
|
||||
className="w-full accent-blue-500 cursor-pointer"
|
||||
/>
|
||||
<div className="flex justify-between text-[10px] text-slate-500">
|
||||
<span>-30%</span>
|
||||
<span>0% (ปัจจุบัน)</span>
|
||||
<span>+50%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t border-white/5">
|
||||
<span className="text-xs text-slate-400 italic">
|
||||
* ค่าที่ปรับเปลี่ยนนี้เป็นเพียงการจำลองผลแบบเรียลไทม์บนหน้าจอ ไม่มีการบันทึกทับฐานข้อมูลจริง
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFragranceShiftPercent(0);
|
||||
setSolventShiftPercent(0);
|
||||
setPackagingShiftPercent(0);
|
||||
}}
|
||||
className="px-3 py-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-xs font-semibold text-slate-300 transition-all flex items-center gap-1.5"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
รีเซ็ตค่าจำลอง
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Filter Bar & Actions ────────────────────────────────────────── */}
|
||||
<div className="flex flex-col md:flex-row items-stretch md:items-center justify-between gap-3 bg-slate-900/90 border border-white/10 rounded-2xl p-4 shadow-lg backdrop-blur-sm">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-[240px]">
|
||||
<Search className="w-4 h-4 text-slate-400 absolute left-3.5 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="ค้นหาชื่อสินค้า, กลิ่น, SKU หรือขนาด..."
|
||||
className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-slate-800/80 border border-white/10 text-sm text-white placeholder-slate-400 focus:outline-none focus:border-cyan-500 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dropdowns */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Category Dropdown */}
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
className="px-3 py-2.5 rounded-xl bg-slate-800/80 border border-white/10 text-xs md:text-sm text-white focus:outline-none focus:border-cyan-500"
|
||||
>
|
||||
<option value="all">ทุกหมวดหมู่ (All Categories)</option>
|
||||
{kpis?.categoryBreakdown?.map((c) => (
|
||||
<option key={c.categoryName} value={c.categoryName}>
|
||||
{c.categoryName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Margin Health Filter */}
|
||||
<select
|
||||
value={selectedMargin}
|
||||
onChange={(e) => setSelectedMargin(e.target.value)}
|
||||
className="px-3 py-2.5 rounded-xl bg-slate-800/80 border border-white/10 text-xs md:text-sm text-white focus:outline-none focus:border-cyan-500"
|
||||
>
|
||||
<option value="all">ทุกระดับมาร์จิ้น (All Margins)</option>
|
||||
<option value="high">🟢 High Margin (>= 70%)</option>
|
||||
<option value="mid">🟡 Mid Margin (55% - 70%)</option>
|
||||
<option value="low">🔴 Low Margin (< 55%)</option>
|
||||
</select>
|
||||
|
||||
{/* Sort By */}
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value)}
|
||||
className="px-3 py-2.5 rounded-xl bg-slate-800/80 border border-white/10 text-xs md:text-sm text-white focus:outline-none focus:border-cyan-500"
|
||||
>
|
||||
<option value="margin_desc">มาร์จิ้นสูงสุด (Margin % สูงสุด)</option>
|
||||
<option value="margin_asc">มาร์จิ้นต่ำสุด (Margin % ต่ำสุด)</option>
|
||||
<option value="profit_desc">กำไรบาทสูงสุด (GP บาทสูงสุด)</option>
|
||||
<option value="profit_asc">กำไรบาทต่ำสุด (GP บาทต่ำสุด)</option>
|
||||
<option value="cost_desc">ต้นทุน BOM สูงสุด</option>
|
||||
<option value="cost_asc">ต้นทุน BOM ต่ำสุด</option>
|
||||
<option value="price_desc">ราคาขายสูงสุด</option>
|
||||
<option value="price_asc">ราคาขายต่ำสุด</option>
|
||||
</select>
|
||||
|
||||
{/* Export CSV */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExportCsv}
|
||||
className="px-4 py-2.5 rounded-xl bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-400 border border-cyan-500/30 text-xs md:text-sm font-bold flex items-center gap-2 transition-all"
|
||||
title="ดาวน์โหลดข้อมูลเป็นไฟล์ Excel / CSV"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
<span>Export CSV</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Main Data Table ─────────────────────────────────────────────── */}
|
||||
<div className="bg-slate-900/90 border border-white/10 rounded-2xl overflow-hidden shadow-xl backdrop-blur-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-slate-950/80 border-b border-white/10 text-[11px] font-bold text-slate-400 uppercase tracking-wider">
|
||||
<th className="py-4 px-4 w-10"></th>
|
||||
<th className="py-4 px-4">สินค้า & Variant</th>
|
||||
<th className="py-4 px-4">หมวดหมู่</th>
|
||||
<th className="py-4 px-4 text-center">ขนาด</th>
|
||||
<th className="py-4 px-4 text-right">ราคาขายปลีก</th>
|
||||
<th className="py-4 px-4 text-right">ค่าน้ำยา (Liquid)</th>
|
||||
<th className="py-4 px-4 text-right">ค่าแพ็กเกจ (Pack)</th>
|
||||
<th className="py-4 px-4 text-right bg-white/5">ต้นทุนรวม (COGS)</th>
|
||||
<th className="py-4 px-4 text-right">กำไร (GP ฿)</th>
|
||||
<th className="py-4 px-4 text-right">Gross Margin %</th>
|
||||
<th className="py-4 px-4 text-center">Max Safe Disc.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5 text-xs md:text-sm">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={11} className="py-12 text-center text-slate-400">
|
||||
<RefreshCw className="w-6 h-6 animate-spin mx-auto text-cyan-400 mb-2" />
|
||||
กำลังคำนวณและประมวลผลข้อมูล BOM แคตตาล็อก...
|
||||
</td>
|
||||
</tr>
|
||||
) : displayItems.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={11} className="py-12 text-center text-slate-400">
|
||||
ไม่พบรายการสินค้าที่ตรงกับเงื่อนไขการค้นหา
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
displayItems.map((item) => {
|
||||
const isExpanded = expandedVariantId === item.id;
|
||||
let badgeClass = 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30';
|
||||
if (item.marginHealth === 'mid') badgeClass = 'bg-amber-500/15 text-amber-400 border-amber-500/30';
|
||||
else if (item.marginHealth === 'low') badgeClass = 'bg-rose-500/15 text-rose-400 border-rose-500/30';
|
||||
|
||||
return (
|
||||
<React.Fragment key={item.id}>
|
||||
<tr
|
||||
onClick={() => setExpandedVariantId(isExpanded ? null : item.id)}
|
||||
className={`hover:bg-white/5 transition-all cursor-pointer ${
|
||||
isExpanded ? 'bg-white/5' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="py-3.5 px-4 text-slate-400 text-center">
|
||||
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
</td>
|
||||
<td className="py-3.5 px-4">
|
||||
<div className="font-bold text-white leading-tight">{item.productName}</div>
|
||||
<div className="text-[11px] font-mono text-cyan-400 mt-0.5">{item.sku}</div>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-slate-300">{item.categoryName}</td>
|
||||
<td className="py-3.5 px-4 text-center">
|
||||
<span className="px-2 py-0.5 rounded-md bg-white/10 text-slate-200 text-xs font-mono font-medium">
|
||||
{item.optionsStr || '-'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-right font-bold text-white">{fmtMoney(item.retailPrice)}</td>
|
||||
<td className="py-3.5 px-4 text-right text-slate-300 font-mono">{fmtMoney(item.liquidCost)}</td>
|
||||
<td className="py-3.5 px-4 text-right text-slate-300 font-mono">{fmtMoney(item.packagingCost)}</td>
|
||||
<td className="py-3.5 px-4 text-right font-mono font-bold text-rose-300 bg-white/5">
|
||||
{fmtMoney(item.totalBomCost)}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-right font-bold text-emerald-400 font-mono">
|
||||
+{fmtMoney(item.grossProfit)}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-right">
|
||||
<span
|
||||
className={`px-2.5 py-1 rounded-full text-xs font-bold border inline-block min-w-[65px] text-center font-mono ${badgeClass}`}
|
||||
>
|
||||
{fmtPercent(item.grossMargin)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-center">
|
||||
<span
|
||||
className="px-2 py-0.5 rounded-md bg-blue-500/10 text-blue-400 border border-blue-500/20 text-xs font-bold font-mono"
|
||||
title="ลดราคาได้สูงสุดเท่านี้ก่อนที่มาร์จิ้นจะตกต่ำกว่า 40%"
|
||||
>
|
||||
ลดได้ {item.maxDiscountPercent}%
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Expandable Recipe Row */}
|
||||
{isExpanded && (
|
||||
<tr className="bg-slate-950/60 border-y border-cyan-500/20">
|
||||
<td colSpan={11} className="py-4 px-6">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-xs font-bold uppercase tracking-wider text-cyan-400 flex items-center gap-1.5">
|
||||
<Layers className="w-3.5 h-3.5" />
|
||||
แจกแจงโครงสร้างสูตร BOM สำหรับ {item.productName} ({item.optionsStr})
|
||||
</h4>
|
||||
<span className="text-xs text-slate-400 font-mono">
|
||||
วัตถุดิบทั้งหมด {item.bomItems.length} รายการ
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-2.5">
|
||||
{item.bomItems.map((bi) => (
|
||||
<div
|
||||
key={bi.id}
|
||||
className="p-2.5 rounded-xl bg-slate-900 border border-white/10 flex items-center justify-between text-xs"
|
||||
>
|
||||
<div>
|
||||
<p className="font-semibold text-slate-200">{bi.name}</p>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5 font-mono">
|
||||
{bi.quantity} {bi.unit} @ {fmtMoney(bi.unitPrice)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-bold text-white font-mono">{fmtMoney(bi.totalCost)}</p>
|
||||
<span
|
||||
className={`text-[9px] px-1.5 py-0.5 rounded font-semibold ${
|
||||
bi.isLiquid
|
||||
? 'bg-purple-500/10 text-purple-400'
|
||||
: 'bg-blue-500/10 text-blue-400'
|
||||
}`}
|
||||
>
|
||||
{bi.isLiquid ? 'Liquid' : 'Pack'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Search,
|
||||
Download,
|
||||
Package,
|
||||
RefreshCw,
|
||||
Droplets,
|
||||
Box,
|
||||
Flame,
|
||||
} from 'lucide-react';
|
||||
import { API_BASE } from '@homaroma/types';
|
||||
import ProfitabilityHeaderTabs from './ProfitabilityHeaderTabs';
|
||||
|
||||
function fmtMoney(val: number | null | undefined): string {
|
||||
if (val == null || isNaN(val)) return '฿0.00';
|
||||
return (
|
||||
'฿' +
|
||||
new Intl.NumberFormat('th-TH', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(val)
|
||||
);
|
||||
}
|
||||
|
||||
function fmtNum(val: number | null | undefined): string {
|
||||
if (val == null || isNaN(val)) return '0';
|
||||
return new Intl.NumberFormat('th-TH').format(val);
|
||||
}
|
||||
|
||||
interface TopProductUsage {
|
||||
name: string;
|
||||
qty: number;
|
||||
}
|
||||
|
||||
interface RawMaterialUsageItem {
|
||||
id: number;
|
||||
name: string;
|
||||
unit: string;
|
||||
pricePerUnit: number;
|
||||
isLiquid: boolean;
|
||||
linkedVariantsCount: number;
|
||||
totalConsumedQty: number;
|
||||
totalConsumedCost: number;
|
||||
topProducts: TopProductUsage[];
|
||||
}
|
||||
|
||||
interface MrpKpis {
|
||||
totalRawMaterials: number;
|
||||
totalMaterialCostConsumed: number;
|
||||
topFragrancesByCost: RawMaterialUsageItem[];
|
||||
topPackagingByCost: RawMaterialUsageItem[];
|
||||
}
|
||||
|
||||
export default function MrpPlanningPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [data, setData] = useState<{
|
||||
kpis: MrpKpis;
|
||||
categories: {
|
||||
fragrances: RawMaterialUsageItem[];
|
||||
solvents: RawMaterialUsageItem[];
|
||||
packaging: RawMaterialUsageItem[];
|
||||
};
|
||||
allMaterials: RawMaterialUsageItem[];
|
||||
} | null>(null);
|
||||
|
||||
// Filters
|
||||
const [search, setSearch] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<'all' | 'fragrances' | 'solvents' | 'packaging'>('all');
|
||||
const [sortBy, setSortBy] = useState('cost_desc');
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fetch(`${API_BASE}/profitability/mrp`);
|
||||
if (!res.ok) throw new Error(`HTTP error ${res.status}`);
|
||||
const json = await res.json();
|
||||
setData(json);
|
||||
} catch (err) {
|
||||
console.error('Failed to load MRP data:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!data) return [];
|
||||
let list: RawMaterialUsageItem[] = [];
|
||||
|
||||
if (activeTab === 'fragrances') list = data.categories.fragrances;
|
||||
else if (activeTab === 'solvents') list = data.categories.solvents;
|
||||
else if (activeTab === 'packaging') list = data.categories.packaging;
|
||||
else list = data.allMaterials;
|
||||
|
||||
if (search) {
|
||||
const s = search.toLowerCase();
|
||||
list = list.filter((m) => m.name.toLowerCase().includes(s));
|
||||
}
|
||||
|
||||
const sorted = [...list];
|
||||
sorted.sort((a, b) => {
|
||||
if (sortBy === 'cost_desc') return b.totalConsumedCost - a.totalConsumedCost;
|
||||
if (sortBy === 'qty_desc') return b.totalConsumedQty - a.totalConsumedQty;
|
||||
if (sortBy === 'name_asc') return a.name.localeCompare(b.name);
|
||||
if (sortBy === 'price_desc') return b.pricePerUnit - a.pricePerUnit;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return sorted;
|
||||
}, [data, activeTab, search, sortBy]);
|
||||
|
||||
const handleExportCsv = () => {
|
||||
if (!filteredItems.length) return;
|
||||
const headers = [
|
||||
'Raw Material ID',
|
||||
'Material Name',
|
||||
'Unit',
|
||||
'Unit Cost (THB)',
|
||||
'Type',
|
||||
'Linked BOM Formulas',
|
||||
'Total Consumed Qty',
|
||||
'Total Consumed Cost (THB)',
|
||||
'Top Consumer Products',
|
||||
];
|
||||
|
||||
const rows = filteredItems.map((i) => [
|
||||
i.id,
|
||||
`"${i.name.replace(/"/g, '""')}"`,
|
||||
i.unit,
|
||||
i.pricePerUnit,
|
||||
i.isLiquid ? 'Liquid' : 'Packaging',
|
||||
i.linkedVariantsCount,
|
||||
i.totalConsumedQty,
|
||||
i.totalConsumedCost,
|
||||
`"${i.topProducts.map((p) => `${p.name} (${p.qty} ${i.unit})`).join('; ')}"`,
|
||||
]);
|
||||
|
||||
const csvContent = '\uFEFF' + [headers.join(','), ...rows.map((e) => e.join(','))].join('\n');
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', `HomAroma_MRP_Material_Demand_${new Date().toISOString().slice(0, 10)}.csv`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const kpis = data?.kpis;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Shared Header Navigation */}
|
||||
<ProfitabilityHeaderTabs
|
||||
title="MRP & Material Demand Planning"
|
||||
subtitle="Reverse BOM Explosion: คำนวณปริมาณการใช้วัตถุดิบจริงย้อนกลับจากประวัติคำสั่งซื้อ เพื่อบริหารสต็อกและวางแผนการจัดซื้อ"
|
||||
/>
|
||||
|
||||
{/* ── Executive KPI Cards ────────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Total Raw Materials */}
|
||||
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Active Raw Materials</span>
|
||||
<div className="p-2 rounded-xl bg-cyan-500/10 text-cyan-400">
|
||||
<Box className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-3xl font-extrabold text-white">{kpis?.totalRawMaterials || 0} รายการ</div>
|
||||
<p className="text-[11px] text-slate-400 mt-2">
|
||||
หัวน้ำหอม 45 กลิ่น + ตัวทำละลาย DPG/DPM + บรรจุภัณฑ์
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Total Material Cost Consumed */}
|
||||
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Total Raw Material Burned</span>
|
||||
<div className="p-2 rounded-xl bg-rose-500/10 text-rose-400">
|
||||
<Flame className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-2xl font-extrabold text-rose-400">{fmtMoney(kpis?.totalMaterialCostConsumed)}</div>
|
||||
<p className="text-[11px] text-slate-400 mt-2">มูลค่ายอดใช้วัตถุดิบรวมจากการผลิตขายจริง</p>
|
||||
</div>
|
||||
|
||||
{/* Top Fragrance Burn Driver */}
|
||||
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">#1 Cost Driver (Fragrance)</span>
|
||||
<div className="p-2 rounded-xl bg-purple-500/10 text-purple-400">
|
||||
<Droplets className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
{kpis?.topFragrancesByCost?.[0] ? (
|
||||
<div>
|
||||
<div className="text-sm font-bold text-white truncate">{kpis.topFragrancesByCost[0].name}</div>
|
||||
<div className="flex items-baseline justify-between mt-1 text-xs">
|
||||
<span className="text-slate-400 font-mono">
|
||||
{fmtNum(kpis.topFragrancesByCost[0].totalConsumedQty)} ml
|
||||
</span>
|
||||
<span className="font-bold text-purple-400 font-mono">
|
||||
{fmtMoney(kpis.topFragrancesByCost[0].totalConsumedCost)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-slate-500">-</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Top Packaging Burn Driver */}
|
||||
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">#1 Packaging Volume</span>
|
||||
<div className="p-2 rounded-xl bg-emerald-500/10 text-emerald-400">
|
||||
<Package className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
{kpis?.topPackagingByCost?.[0] ? (
|
||||
<div>
|
||||
<div className="text-sm font-bold text-white truncate">{kpis.topPackagingByCost[0].name}</div>
|
||||
<div className="flex items-baseline justify-between mt-1 text-xs">
|
||||
<span className="text-slate-400 font-mono">
|
||||
{fmtNum(kpis.topPackagingByCost[0].totalConsumedQty)} {kpis.topPackagingByCost[0].unit}
|
||||
</span>
|
||||
<span className="font-bold text-emerald-400 font-mono">
|
||||
{fmtMoney(kpis.topPackagingByCost[0].totalConsumedCost)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-slate-500">-</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Category Tabs & Filter Bar ─────────────────────────────────── */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 bg-slate-900/90 border border-white/10 rounded-2xl p-3 shadow-lg backdrop-blur-sm">
|
||||
{/* Sub Tabs */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('all')}
|
||||
className={`px-3.5 py-2 rounded-xl text-xs font-bold transition-all ${
|
||||
activeTab === 'all'
|
||||
? 'bg-cyan-500 text-white shadow-sm'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
ทั้งหมด ({data?.allMaterials?.length || 0})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('fragrances')}
|
||||
className={`px-3.5 py-2 rounded-xl text-xs font-bold transition-all ${
|
||||
activeTab === 'fragrances'
|
||||
? 'bg-purple-500 text-white shadow-sm'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
🌸 หัวน้ำหอม ({data?.categories.fragrances?.length || 0})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('solvents')}
|
||||
className={`px-3.5 py-2 rounded-xl text-xs font-bold transition-all ${
|
||||
activeTab === 'solvents'
|
||||
? 'bg-blue-500 text-white shadow-sm'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
🧪 ตัวทำละลาย DPG/DPM ({data?.categories.solvents?.length || 0})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('packaging')}
|
||||
className={`px-3.5 py-2 rounded-xl text-xs font-bold transition-all ${
|
||||
activeTab === 'packaging'
|
||||
? 'bg-emerald-500 text-white shadow-sm'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
📦 บรรจุภัณฑ์ ขวด/ก้าน/ฝา ({data?.categories.packaging?.length || 0})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right Controls */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Search */}
|
||||
<div className="relative min-w-[200px]">
|
||||
<Search className="w-3.5 h-3.5 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="ค้นหาชื่อวัตถุดิบ..."
|
||||
className="w-full pl-9 pr-3 py-1.5 rounded-xl bg-slate-800 border border-white/10 text-xs text-white placeholder-slate-400 focus:outline-none focus:border-cyan-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sort */}
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value)}
|
||||
className="px-3 py-1.5 rounded-xl bg-slate-800 border border-white/10 text-xs text-white focus:outline-none focus:border-cyan-500"
|
||||
>
|
||||
<option value="cost_desc">มูลค่าที่ใช้สูงสุด (Consumed Cost)</option>
|
||||
<option value="qty_desc">ปริมาณที่ใช้สูงสุด (Volume Qty)</option>
|
||||
<option value="price_desc">ราคาต่อหน่วยสูงสุด</option>
|
||||
<option value="name_asc">ชื่อ ก-ฮ (A-Z)</option>
|
||||
</select>
|
||||
|
||||
{/* Export CSV */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExportCsv}
|
||||
className="px-3 py-1.5 rounded-xl bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-400 border border-cyan-500/30 text-xs font-bold flex items-center gap-1.5 transition-all"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
<span>Export CSV</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Main MRP Table ─────────────────────────────────────────────── */}
|
||||
<div className="bg-slate-900/90 border border-white/10 rounded-2xl overflow-hidden shadow-xl backdrop-blur-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-slate-950/80 border-b border-white/10 text-[11px] font-bold text-slate-400 uppercase tracking-wider">
|
||||
<th className="py-4 px-4">ชื่อวัตถุดิบ (Raw Material)</th>
|
||||
<th className="py-4 px-4 text-center">ประเภท</th>
|
||||
<th className="py-4 px-4 text-right">ราคาต่อหน่วย</th>
|
||||
<th className="py-4 px-4 text-center">ผูกในสูตร (Variants)</th>
|
||||
<th className="py-4 px-4 text-right">ปริมาณที่ผลิตไปแล้ว</th>
|
||||
<th className="py-4 px-4 text-right bg-white/5">มูลค่าที่ใช้ไป (Total Value)</th>
|
||||
<th className="py-4 px-4 text-center">Burn Velocity</th>
|
||||
<th className="py-4 px-4">สินค้าหลักที่ใช้วัตถุดิบนี้ (Top Consumers)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5 text-xs md:text-sm">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="py-12 text-center text-slate-400">
|
||||
<RefreshCw className="w-6 h-6 animate-spin mx-auto text-cyan-400 mb-2" />
|
||||
กำลังประมวลผลข้อมูลการใช้วัตถุดิบย้อนกลับ (Reverse Explosion)...
|
||||
</td>
|
||||
</tr>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="py-12 text-center text-slate-400">
|
||||
ไม่พบรายการวัตถุดิบที่ตรงกับเงื่อนไข
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredItems.map((item) => {
|
||||
let burnBadge = 'bg-slate-800 text-slate-400';
|
||||
let burnLabel = 'Low Burn';
|
||||
if (item.totalConsumedCost >= 20000) {
|
||||
burnBadge = 'bg-rose-500/20 text-rose-400 border border-rose-500/30';
|
||||
burnLabel = '🔥 Very High';
|
||||
} else if (item.totalConsumedCost >= 5000) {
|
||||
burnBadge = 'bg-amber-500/20 text-amber-400 border border-amber-500/30';
|
||||
burnLabel = '⚡ High Burn';
|
||||
} else if (item.totalConsumedCost > 0) {
|
||||
burnBadge = 'bg-blue-500/20 text-blue-400 border border-blue-500/30';
|
||||
burnLabel = '💧 Moderate';
|
||||
}
|
||||
|
||||
return (
|
||||
<tr key={item.id} className="hover:bg-white/5 transition-all">
|
||||
<td className="py-3.5 px-4">
|
||||
<div className="font-bold text-white">{item.name}</div>
|
||||
<div className="text-[11px] text-slate-400 font-mono">ID: #{item.id}</div>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-center">
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded-md text-[10px] font-bold ${
|
||||
item.isLiquid
|
||||
? 'bg-purple-500/10 text-purple-400 border border-purple-500/20'
|
||||
: 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20'
|
||||
}`}
|
||||
>
|
||||
{item.isLiquid ? 'Liquid' : 'Packaging'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-right font-mono text-white">
|
||||
{fmtMoney(item.pricePerUnit)} / {item.unit}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-center font-mono font-semibold text-slate-300">
|
||||
{item.linkedVariantsCount} สูตร
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-right font-mono font-bold text-white">
|
||||
{fmtNum(item.totalConsumedQty)} {item.unit}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-right font-mono font-bold text-rose-300 bg-white/5">
|
||||
{fmtMoney(item.totalConsumedCost)}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-center">
|
||||
<span className={`px-2.5 py-1 rounded-full text-[11px] font-bold inline-block ${burnBadge}`}>
|
||||
{burnLabel}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 max-w-[280px]">
|
||||
{item.topProducts.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{item.topProducts.map((p, pIdx) => (
|
||||
<div key={pIdx} className="text-[11px] text-slate-300 flex items-center justify-between">
|
||||
<span className="truncate pr-2">{p.name}</span>
|
||||
<span className="text-cyan-400 font-mono shrink-0">
|
||||
{fmtNum(p.qty)} {item.unit}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-slate-500">-</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Search,
|
||||
Download,
|
||||
ShoppingBag,
|
||||
TrendingUp,
|
||||
Percent,
|
||||
Coins,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { API_BASE } from '@homaroma/types';
|
||||
import ProfitabilityHeaderTabs from './ProfitabilityHeaderTabs';
|
||||
|
||||
function fmtMoney(val: number | null | undefined): string {
|
||||
if (val == null || isNaN(val)) return '฿0.00';
|
||||
return (
|
||||
'฿' +
|
||||
new Intl.NumberFormat('th-TH', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(val)
|
||||
);
|
||||
}
|
||||
|
||||
function fmtNum(val: number | null | undefined): string {
|
||||
if (val == null || isNaN(val)) return '0';
|
||||
return new Intl.NumberFormat('th-TH').format(val);
|
||||
}
|
||||
|
||||
function fmtPercent(val: number | null | undefined): string {
|
||||
if (val == null || isNaN(val)) return '0.0%';
|
||||
return `${val.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
interface OmnichannelItem {
|
||||
id: number;
|
||||
channel: string;
|
||||
shop: string;
|
||||
marketplaceSku: string;
|
||||
marketplaceProductName: string;
|
||||
variation: string;
|
||||
avgSellingPrice: number;
|
||||
totalSoldQty: number;
|
||||
totalRevenue: number;
|
||||
masterVariantId: number;
|
||||
masterSku: string;
|
||||
productName: string;
|
||||
categoryName: string;
|
||||
unitBomCost: number;
|
||||
totalBomCost: number;
|
||||
totalFeePercent: number;
|
||||
estPlatformFee: number;
|
||||
netRevenue: number;
|
||||
netProfit: number;
|
||||
netMarginPercent: number;
|
||||
directPrice: number;
|
||||
directMarginPercent: number;
|
||||
}
|
||||
|
||||
interface ChannelStat {
|
||||
soldQty: number;
|
||||
revenue: number;
|
||||
bomCost: number;
|
||||
netProfit: number;
|
||||
}
|
||||
|
||||
interface OmnichannelKpis {
|
||||
totalMappedProducts: number;
|
||||
totalGrossRevenue: number;
|
||||
totalEstNetRevenue: number;
|
||||
totalBOMCost: number;
|
||||
totalEstNetProfit: number;
|
||||
overallNetMargin: number;
|
||||
channelStats: Record<string, ChannelStat>;
|
||||
}
|
||||
|
||||
export default function OmnichannelMarginPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [data, setData] = useState<{ kpis: OmnichannelKpis; items: OmnichannelItem[] } | null>(null);
|
||||
|
||||
// Filters
|
||||
const [search, setSearch] = useState('');
|
||||
const [channelFilter, setChannelFilter] = useState<'all' | 'shopee' | 'lazada' | 'tiktok'>('all');
|
||||
const [sortBy, setSortBy] = useState('revenue_desc');
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams({
|
||||
channel: channelFilter,
|
||||
search,
|
||||
});
|
||||
|
||||
const res = await fetch(`${API_BASE}/profitability/omnichannel?${params.toString()}`);
|
||||
if (!res.ok) throw new Error(`HTTP error ${res.status}`);
|
||||
const json = await res.json();
|
||||
setData(json);
|
||||
} catch (err) {
|
||||
console.error('Failed to load omnichannel margin data:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [channelFilter]);
|
||||
|
||||
// Live search debounce
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
fetchData();
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search]);
|
||||
|
||||
// Client-side sort
|
||||
const sortedItems = useMemo(() => {
|
||||
if (!data?.items) return [];
|
||||
const items = [...data.items];
|
||||
|
||||
items.sort((a, b) => {
|
||||
if (sortBy === 'revenue_desc') return b.totalRevenue - a.totalRevenue;
|
||||
if (sortBy === 'profit_desc') return b.netProfit - a.netProfit;
|
||||
if (sortBy === 'margin_desc') return b.netMarginPercent - a.netMarginPercent;
|
||||
if (sortBy === 'margin_asc') return a.netMarginPercent - b.netMarginPercent;
|
||||
if (sortBy === 'qty_desc') return b.totalSoldQty - a.totalSoldQty;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return items;
|
||||
}, [data?.items, sortBy]);
|
||||
|
||||
const handleExportCsv = () => {
|
||||
if (!sortedItems.length) return;
|
||||
const headers = [
|
||||
'Channel',
|
||||
'Shop',
|
||||
'Marketplace SKU',
|
||||
'Marketplace Product',
|
||||
'Variation',
|
||||
'Master SKU',
|
||||
'Master Product',
|
||||
'Units Sold',
|
||||
'Avg Price',
|
||||
'Total Revenue',
|
||||
'Unit BOM Cost',
|
||||
'Total BOM Cost',
|
||||
'Platform Fee %',
|
||||
'Net Revenue',
|
||||
'Net Profit',
|
||||
'Net Margin %',
|
||||
'Direct Price',
|
||||
'Direct Margin %',
|
||||
];
|
||||
|
||||
const rows = sortedItems.map((i) => [
|
||||
i.channel,
|
||||
i.shop,
|
||||
i.marketplaceSku,
|
||||
`"${i.marketplaceProductName.replace(/"/g, '""')}"`,
|
||||
`"${i.variation}"`,
|
||||
i.masterSku,
|
||||
`"${i.productName.replace(/"/g, '""')}"`,
|
||||
i.totalSoldQty,
|
||||
i.avgSellingPrice,
|
||||
i.totalRevenue,
|
||||
i.unitBomCost,
|
||||
i.totalBomCost,
|
||||
`${i.totalFeePercent}%`,
|
||||
i.netRevenue,
|
||||
i.netProfit,
|
||||
`${i.netMarginPercent}%`,
|
||||
i.directPrice,
|
||||
`${i.directMarginPercent}%`,
|
||||
]);
|
||||
|
||||
const csvContent = '\uFEFF' + [headers.join(','), ...rows.map((e) => e.join(','))].join('\n');
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', `HomAroma_Omnichannel_Net_Margin_${new Date().toISOString().slice(0, 10)}.csv`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const kpis = data?.kpis;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Shared Header Navigation */}
|
||||
<ProfitabilityHeaderTabs
|
||||
title="Omnichannel Net Margin & Contribution"
|
||||
subtitle="วิเคราะห์กำไรสุทธิตามช่องทางขาย (Shopee, Lazada, TikTok, Direct) หลังหักค่าธรรมเนียม Platform และต้นทุนผลิต BOM จริง"
|
||||
/>
|
||||
|
||||
{/* ── Executive KPI Cards ────────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Total Gross Revenue */}
|
||||
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Gross Revenue (Omnichannel)</span>
|
||||
<div className="p-2 rounded-xl bg-cyan-500/10 text-cyan-400">
|
||||
<Coins className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-2xl font-extrabold text-white">{fmtMoney(kpis?.totalGrossRevenue)}</div>
|
||||
<p className="text-[11px] text-slate-400 mt-2 font-mono">
|
||||
{fmtNum(kpis?.totalMappedProducts)} สินค้า Mapped เข้า Master Catalog
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Total Product BOM Cost */}
|
||||
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Total Product BOM (COGS)</span>
|
||||
<div className="p-2 rounded-xl bg-rose-500/10 text-rose-400">
|
||||
<ShoppingBag className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-2xl font-extrabold text-rose-400">{fmtMoney(kpis?.totalBOMCost)}</div>
|
||||
<p className="text-[11px] text-slate-400 mt-2">
|
||||
ต้นทุนวัตถุดิบจริงสะสมของสินค้าที่ขายได้
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Total Est Net Profit */}
|
||||
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Est. Net Profit (หลังหัก Fee)</span>
|
||||
<div className="p-2 rounded-xl bg-emerald-500/10 text-emerald-400">
|
||||
<TrendingUp className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-2xl font-extrabold text-emerald-400">{fmtMoney(kpis?.totalEstNetProfit)}</div>
|
||||
<p className="text-[11px] text-slate-400 mt-2">
|
||||
หักทั้งค่าธรรมเนียม Platform และต้นทุน BOM
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Overall Net Margin % */}
|
||||
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Overall Net Margin</span>
|
||||
<div className="p-2 rounded-xl bg-purple-500/10 text-purple-400">
|
||||
<Percent className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-2xl font-extrabold text-purple-400">{fmtPercent(kpis?.overallNetMargin)}</div>
|
||||
<div className="w-full bg-slate-800 h-1.5 rounded-full mt-3 overflow-hidden">
|
||||
<div
|
||||
className="bg-gradient-to-r from-purple-500 to-indigo-400 h-full rounded-full"
|
||||
style={{ width: `${Math.min(100, kpis?.overallNetMargin || 0)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Channel Breakdown Cards ────────────────────────────────────── */}
|
||||
{kpis?.channelStats && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Shopee */}
|
||||
<div
|
||||
onClick={() => setChannelFilter(channelFilter === 'shopee' ? 'all' : 'shopee')}
|
||||
className={`p-5 rounded-2xl border cursor-pointer transition-all ${
|
||||
channelFilter === 'shopee'
|
||||
? 'bg-orange-500/15 border-orange-500 shadow-md shadow-orange-500/10'
|
||||
: 'bg-slate-900/90 border-white/10 hover:border-orange-500/40'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2.5 py-1 rounded-lg bg-orange-500/20 text-orange-400 text-xs font-extrabold">
|
||||
SHOPEE
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">Hom Aroma</span>
|
||||
</div>
|
||||
<span className="text-xs font-bold text-slate-300 font-mono">
|
||||
{fmtNum(kpis.channelStats.shopee?.soldQty)} ชิ้น
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between mt-3">
|
||||
<span className="text-xs text-slate-400">Revenue:</span>
|
||||
<span className="text-base font-bold text-white font-mono">
|
||||
{fmtMoney(kpis.channelStats.shopee?.revenue)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between mt-1">
|
||||
<span className="text-xs text-slate-400">Net Profit:</span>
|
||||
<span className="text-base font-bold text-emerald-400 font-mono">
|
||||
{fmtMoney(kpis.channelStats.shopee?.netProfit)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lazada */}
|
||||
<div
|
||||
onClick={() => setChannelFilter(channelFilter === 'lazada' ? 'all' : 'lazada')}
|
||||
className={`p-5 rounded-2xl border cursor-pointer transition-all ${
|
||||
channelFilter === 'lazada'
|
||||
? 'bg-blue-500/15 border-blue-500 shadow-md shadow-blue-500/10'
|
||||
: 'bg-slate-900/90 border-white/10 hover:border-blue-500/40'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2.5 py-1 rounded-lg bg-blue-500/20 text-blue-400 text-xs font-extrabold">
|
||||
LAZADA
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">Hom Aroma</span>
|
||||
</div>
|
||||
<span className="text-xs font-bold text-slate-300 font-mono">
|
||||
{fmtNum(kpis.channelStats.lazada?.soldQty)} ชิ้น
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between mt-3">
|
||||
<span className="text-xs text-slate-400">Revenue:</span>
|
||||
<span className="text-base font-bold text-white font-mono">
|
||||
{fmtMoney(kpis.channelStats.lazada?.revenue)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between mt-1">
|
||||
<span className="text-xs text-slate-400">Net Profit:</span>
|
||||
<span className="text-base font-bold text-emerald-400 font-mono">
|
||||
{fmtMoney(kpis.channelStats.lazada?.netProfit)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TikTok Shop */}
|
||||
<div
|
||||
onClick={() => setChannelFilter(channelFilter === 'tiktok' ? 'all' : 'tiktok')}
|
||||
className={`p-5 rounded-2xl border cursor-pointer transition-all ${
|
||||
channelFilter === 'tiktok'
|
||||
? 'bg-pink-500/15 border-pink-500 shadow-md shadow-pink-500/10'
|
||||
: 'bg-slate-900/90 border-white/10 hover:border-pink-500/40'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2.5 py-1 rounded-lg bg-pink-500/20 text-pink-400 text-xs font-extrabold">
|
||||
TIKTOK SHOP
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">Hom Aroma</span>
|
||||
</div>
|
||||
<span className="text-xs font-bold text-slate-300 font-mono">
|
||||
{fmtNum(kpis.channelStats.tiktok?.soldQty)} ชิ้น
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between mt-3">
|
||||
<span className="text-xs text-slate-400">Revenue:</span>
|
||||
<span className="text-base font-bold text-white font-mono">
|
||||
{fmtMoney(kpis.channelStats.tiktok?.revenue)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between mt-1">
|
||||
<span className="text-xs text-slate-400">Net Profit:</span>
|
||||
<span className="text-base font-bold text-emerald-400 font-mono">
|
||||
{fmtMoney(kpis.channelStats.tiktok?.netProfit)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Filter Bar ─────────────────────────────────────────────────── */}
|
||||
<div className="flex flex-col md:flex-row items-stretch md:items-center justify-between gap-3 bg-slate-900/90 border border-white/10 rounded-2xl p-4 shadow-lg backdrop-blur-sm">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-[240px]">
|
||||
<Search className="w-4 h-4 text-slate-400 absolute left-3.5 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="ค้นหาชื่อสินค้าบน Marketplace, Variation หรือ Master SKU..."
|
||||
className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-slate-800/80 border border-white/10 text-sm text-white placeholder-slate-400 focus:outline-none focus:border-cyan-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dropdowns */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Channel Filter */}
|
||||
<select
|
||||
value={channelFilter}
|
||||
onChange={(e) => setChannelFilter(e.target.value as any)}
|
||||
className="px-3 py-2.5 rounded-xl bg-slate-800/80 border border-white/10 text-xs md:text-sm text-white focus:outline-none focus:border-cyan-500"
|
||||
>
|
||||
<option value="all">ทุกช่องทาง (All Channels)</option>
|
||||
<option value="shopee">Shopee</option>
|
||||
<option value="lazada">Lazada</option>
|
||||
<option value="tiktok">TikTok Shop</option>
|
||||
</select>
|
||||
|
||||
{/* Sort By */}
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value)}
|
||||
className="px-3 py-2.5 rounded-xl bg-slate-800/80 border border-white/10 text-xs md:text-sm text-white focus:outline-none focus:border-cyan-500"
|
||||
>
|
||||
<option value="revenue_desc">ยอดขายรวมสูงสุด (Revenue)</option>
|
||||
<option value="profit_desc">กำไรสุทธิรวมสูงสุด (Net Profit)</option>
|
||||
<option value="margin_desc">อัตรากำไรสุทธิสูงสุด (Net Margin %)</option>
|
||||
<option value="margin_asc">อัตรากำไรสุทธิต่ำสุด (Risk Margin %)</option>
|
||||
<option value="qty_desc">จำนวนชิ้นที่ขายได้สูงสุด (Units Sold)</option>
|
||||
</select>
|
||||
|
||||
{/* Export CSV */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExportCsv}
|
||||
className="px-4 py-2.5 rounded-xl bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-400 border border-cyan-500/30 text-xs md:text-sm font-bold flex items-center gap-2 transition-all"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
<span>Export CSV</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Main Omnichannel Table ─────────────────────────────────────── */}
|
||||
<div className="bg-slate-900/90 border border-white/10 rounded-2xl overflow-hidden shadow-xl backdrop-blur-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-slate-950/80 border-b border-white/10 text-[11px] font-bold text-slate-400 uppercase tracking-wider">
|
||||
<th className="py-4 px-4">Channel</th>
|
||||
<th className="py-4 px-4">Marketplace Listing</th>
|
||||
<th className="py-4 px-4">Linked Master Variant</th>
|
||||
<th className="py-4 px-4 text-center">ขายได้</th>
|
||||
<th className="py-4 px-4 text-right">ราคาขายเฉลี่ย</th>
|
||||
<th className="py-4 px-4 text-right">ต้นทุน BOM (Unit)</th>
|
||||
<th className="py-4 px-4 text-right">หัก Fee (%)</th>
|
||||
<th className="py-4 px-4 text-right">Net Rev / Unit</th>
|
||||
<th className="py-4 px-4 text-right bg-white/5">Net Profit / Unit</th>
|
||||
<th className="py-4 px-4 text-right">Net Margin %</th>
|
||||
<th className="py-4 px-4 text-center">Direct vs Channel</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5 text-xs md:text-sm">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={11} className="py-12 text-center text-slate-400">
|
||||
<RefreshCw className="w-6 h-6 animate-spin mx-auto text-cyan-400 mb-2" />
|
||||
กำลังประมวลผลข้อมูลกำไรสุทธิของ Marketplace...
|
||||
</td>
|
||||
</tr>
|
||||
) : sortedItems.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={11} className="py-12 text-center text-slate-400">
|
||||
ไม่พบรายการสินค้า Marketplace ที่ตรงกับเงื่อนไข
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
sortedItems.map((item) => {
|
||||
let chBadge = 'bg-orange-500/20 text-orange-400 border-orange-500/30';
|
||||
if (item.channel === 'lazada') chBadge = 'bg-blue-500/20 text-blue-400 border-blue-500/30';
|
||||
else if (item.channel === 'tiktok') chBadge = 'bg-pink-500/20 text-pink-400 border-pink-500/30';
|
||||
|
||||
const unitNetProfit = Math.round((item.netProfit / (item.totalSoldQty || 1)) * 100) / 100;
|
||||
const unitNetRevenue = Math.round((item.netRevenue / (item.totalSoldQty || 1)) * 100) / 100;
|
||||
|
||||
return (
|
||||
<tr key={item.id} className="hover:bg-white/5 transition-all">
|
||||
<td className="py-3.5 px-4">
|
||||
<span className={`px-2 py-1 rounded-md text-[10px] font-extrabold uppercase border ${chBadge}`}>
|
||||
{item.channel}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 max-w-[280px]">
|
||||
<div className="font-bold text-white truncate" title={item.marketplaceProductName}>
|
||||
{item.marketplaceProductName}
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-400 flex items-center gap-1.5 mt-0.5">
|
||||
<span className="font-mono text-cyan-400">{item.variation || 'Standard'}</span>
|
||||
{item.marketplaceSku && <span>• {item.marketplaceSku}</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3.5 px-4">
|
||||
<div className="font-semibold text-slate-200">{item.productName}</div>
|
||||
<div className="text-[11px] font-mono text-slate-400">{item.masterSku}</div>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-center font-mono font-bold text-white">
|
||||
{fmtNum(item.totalSoldQty)}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-right font-mono text-white">
|
||||
{fmtMoney(item.avgSellingPrice)}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-right font-mono text-rose-300">
|
||||
{fmtMoney(item.unitBomCost)}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-right font-mono text-slate-400">
|
||||
~{item.totalFeePercent}%
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-right font-mono text-slate-200">
|
||||
{fmtMoney(unitNetRevenue)}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-right font-mono font-bold text-emerald-400 bg-white/5">
|
||||
+{fmtMoney(unitNetProfit)}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-right">
|
||||
<span
|
||||
className={`px-2.5 py-1 rounded-full text-xs font-bold border inline-block min-w-[65px] text-center font-mono ${
|
||||
item.netMarginPercent >= 60
|
||||
? 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30'
|
||||
: item.netMarginPercent >= 45
|
||||
? 'bg-amber-500/15 text-amber-400 border-amber-500/30'
|
||||
: 'bg-rose-500/15 text-rose-400 border-rose-500/30'
|
||||
}`}
|
||||
>
|
||||
{fmtPercent(item.netMarginPercent)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 text-center">
|
||||
<span className="text-[11px] text-slate-400" title="ขายตรงหน้าร้านจะได้มาร์จิ้นสูงกว่านี้">
|
||||
Direct: <strong className="text-cyan-400">{item.directMarginPercent}%</strong>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { Calculator, Layers, BrainCircuit, Sparkles } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
export default function ProfitabilityHeaderTabs({ title, subtitle }: Props) {
|
||||
const tabs = [
|
||||
{
|
||||
to: '/profitability',
|
||||
label: 'Cost & Profitability Matrix',
|
||||
icon: Calculator,
|
||||
description: 'วิเคราะห์ต้นทุน BOM และกำไรแคตตาล็อก',
|
||||
},
|
||||
{
|
||||
to: '/omnichannel-margin',
|
||||
label: 'Omnichannel Net Margin',
|
||||
icon: Layers,
|
||||
description: 'วิเคราะห์กำไรสุทธิตามช่องทางขาย',
|
||||
},
|
||||
{
|
||||
to: '/mrp-planning',
|
||||
label: 'MRP & Material Demand',
|
||||
icon: BrainCircuit,
|
||||
description: 'คำนวณการใช้วัตถุดิบและสต็อก',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4 mb-6">
|
||||
{/* Title & Badge */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="px-2.5 py-0.5 rounded-full text-[10px] font-bold tracking-wider uppercase bg-cyan-500/10 text-cyan-400 border border-cyan-500/30 flex items-center gap-1">
|
||||
<Sparkles className="w-3 h-3 text-cyan-400" />
|
||||
Profit & Intelligence Suite
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">{title}</h1>
|
||||
<p className="text-xs md:text-sm text-slate-400 mt-1">{subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modern Navigation Tabs */}
|
||||
<div className="flex flex-wrap gap-2 p-1.5 bg-slate-900/80 backdrop-blur-md rounded-2xl border border-white/10 shadow-lg">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<NavLink
|
||||
key={tab.to}
|
||||
to={tab.to}
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2.5 px-4 py-2.5 rounded-xl text-xs md:text-sm font-semibold transition-all duration-200 ${
|
||||
isActive
|
||||
? 'bg-gradient-to-r from-cyan-500 to-blue-600 text-white shadow-md shadow-cyan-500/20'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-white/5'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon className="w-4 h-4 shrink-0" />
|
||||
<span>{tab.label}</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import { brandKnowledgeRoute } from "./routes/brandKnowledge";
|
||||
import { categorySectionsRoute } from "./routes/categorySections";
|
||||
import { llmPromptsRoute } from "./routes/llmPrompts";
|
||||
import { frameSequencesRoute, getCandidateSequenceDirs } from "./routes/frame-sequences";
|
||||
import { profitabilityRoute } from "./routes/profitability";
|
||||
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3051;
|
||||
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
|
||||
const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_brand";
|
||||
@@ -158,7 +159,7 @@ const app = new Elysia()
|
||||
return file;
|
||||
})
|
||||
.group("/api", (app) =>
|
||||
app.use(authRoute).use(categoriesRoute).use(categorySectionsRoute).use(productsRoute).use(productAttributesRoute).use(uploadRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute).use(backupRoute).use(businessAnalyticsRoute).use(toolsRoute).use(crmRoute).use(brandKnowledgeRoute).use(llmPromptsRoute).use(frameSequencesRoute)
|
||||
app.use(authRoute).use(categoriesRoute).use(categorySectionsRoute).use(productsRoute).use(productAttributesRoute).use(uploadRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute).use(backupRoute).use(businessAnalyticsRoute).use(toolsRoute).use(crmRoute).use(brandKnowledgeRoute).use(llmPromptsRoute).use(frameSequencesRoute).use(profitabilityRoute)
|
||||
)
|
||||
.listen({ port: PORT, hostname: "0.0.0.0" });
|
||||
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
|
||||
export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 1. Cost & Profitability Matrix
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
.get(
|
||||
"/matrix",
|
||||
async ({ query }) => {
|
||||
const search = (query.search || "").trim();
|
||||
const category = (query.category || "all").trim();
|
||||
const marginFilter = (query.margin || "all").trim(); // "all" | "high" (>70%) | "mid" (55-70%) | "low" (<55%)
|
||||
const sortBy = query.sortBy || "margin_desc";
|
||||
const page = query.page ? parseInt(query.page) : 1;
|
||||
const limit = query.limit ? parseInt(query.limit) : 50;
|
||||
|
||||
// 1. Fetch all active variants with Product, Category, and BOM
|
||||
const variants = await prisma.productVariant.findMany({
|
||||
where: {
|
||||
product: { isDeleted: false },
|
||||
},
|
||||
include: {
|
||||
product: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
category: { select: { id: true, name: true } },
|
||||
brand: { select: { id: true, name: true } },
|
||||
translations: { select: { langCode: true, name: true } },
|
||||
},
|
||||
},
|
||||
bomItems: {
|
||||
include: {
|
||||
rawMaterial: {
|
||||
select: { id: true, name: true, unit: true, pricePerUnit: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ product: { name: "asc" } },
|
||||
{ sku: "asc" },
|
||||
],
|
||||
});
|
||||
|
||||
// 2. Map and compute profitability metrics for each variant
|
||||
const mapped = variants.map((v) => {
|
||||
const thName = v.product.translations?.find((t) => t.langCode === "th")?.name;
|
||||
const productName = v.product.name || thName || `Product #${v.product.id}`;
|
||||
const categoryName = v.product.category?.name || "Uncategorized";
|
||||
const brandName = v.product.brand?.name || "HomAroma";
|
||||
const optionsStr = [v.option1, v.option2, v.option3].filter(Boolean).join(" / ");
|
||||
const retailPrice = v.price || 0;
|
||||
|
||||
let liquidCost = 0;
|
||||
let packagingCost = 0;
|
||||
|
||||
const bomItemsFormatted = (v.bomItems || []).map((b) => {
|
||||
const rm = b.rawMaterial;
|
||||
const unitPrice = rm?.pricePerUnit || 0;
|
||||
const cost = Math.round(b.quantity * unitPrice * 100) / 100;
|
||||
const isLiquid =
|
||||
rm?.unit === "ml" ||
|
||||
rm?.name.includes("หัวน้ำหอม") ||
|
||||
rm?.name.includes("DPG") ||
|
||||
rm?.name.includes("DPM") ||
|
||||
rm?.name.includes("น้ำหอม");
|
||||
|
||||
if (isLiquid) {
|
||||
liquidCost += cost;
|
||||
} else {
|
||||
packagingCost += cost;
|
||||
}
|
||||
|
||||
return {
|
||||
id: b.id,
|
||||
rawMaterialId: rm?.id,
|
||||
name: rm?.name || "Unknown Material",
|
||||
unit: rm?.unit || "piece",
|
||||
unitPrice,
|
||||
quantity: b.quantity,
|
||||
totalCost: cost,
|
||||
isLiquid,
|
||||
};
|
||||
});
|
||||
|
||||
liquidCost = Math.round(liquidCost * 100) / 100;
|
||||
packagingCost = Math.round(packagingCost * 100) / 100;
|
||||
const totalBomCost = Math.round((liquidCost + packagingCost) * 100) / 100;
|
||||
const grossProfit = Math.round((retailPrice - totalBomCost) * 100) / 100;
|
||||
const grossMargin = retailPrice > 0 ? Math.round(((retailPrice - totalBomCost) / retailPrice) * 1000) / 10 : 0;
|
||||
|
||||
// Max discount % before margin drops below 40% safety threshold
|
||||
const minAcceptablePrice = totalBomCost / (1 - 0.40);
|
||||
const maxDiscountPercent = retailPrice > minAcceptablePrice
|
||||
? Math.round(((retailPrice - minAcceptablePrice) / retailPrice) * 100)
|
||||
: 0;
|
||||
|
||||
let marginHealth: "high" | "mid" | "low" = "mid";
|
||||
if (grossMargin >= 70) marginHealth = "high";
|
||||
else if (grossMargin < 55) marginHealth = "low";
|
||||
|
||||
return {
|
||||
id: v.id,
|
||||
sku: v.sku || `VAR-${v.id}`,
|
||||
productId: v.product.id,
|
||||
productName,
|
||||
categoryName,
|
||||
brandName,
|
||||
optionsStr,
|
||||
retailPrice,
|
||||
liquidCost,
|
||||
packagingCost,
|
||||
totalBomCost,
|
||||
grossProfit,
|
||||
grossMargin,
|
||||
marginHealth,
|
||||
maxDiscountPercent,
|
||||
bomCount: bomItemsFormatted.length,
|
||||
bomItems: bomItemsFormatted,
|
||||
};
|
||||
});
|
||||
|
||||
// 3. Compute KPI Summary across the entire catalog (before pagination)
|
||||
const totalVariants = mapped.length;
|
||||
const configuredBomVariants = mapped.filter((m) => m.bomCount > 0).length;
|
||||
const totalCatalogRevenueAtFullList = mapped.reduce((s, m) => s + m.retailPrice, 0);
|
||||
const totalCatalogBomCost = mapped.reduce((s, m) => s + m.totalBomCost, 0);
|
||||
const avgGrossMargin =
|
||||
totalCatalogRevenueAtFullList > 0
|
||||
? Math.round(((totalCatalogRevenueAtFullList - totalCatalogBomCost) / totalCatalogRevenueAtFullList) * 1000) / 10
|
||||
: 0;
|
||||
|
||||
const highMarginCount = mapped.filter((m) => m.marginHealth === "high").length;
|
||||
const midMarginCount = mapped.filter((m) => m.marginHealth === "mid").length;
|
||||
const lowMarginCount = mapped.filter((m) => m.marginHealth === "low").length;
|
||||
|
||||
// Category breakdown
|
||||
const categoryMap = new Map<string, { count: number; totalRev: number; totalCost: number }>();
|
||||
for (const item of mapped) {
|
||||
let entry = categoryMap.get(item.categoryName);
|
||||
if (!entry) {
|
||||
entry = { count: 0, totalRev: 0, totalCost: 0 };
|
||||
categoryMap.set(item.categoryName, entry);
|
||||
}
|
||||
entry.count++;
|
||||
entry.totalRev += item.retailPrice;
|
||||
entry.totalCost += item.totalBomCost;
|
||||
}
|
||||
|
||||
const categoryBreakdown = Array.from(categoryMap.entries()).map(([name, stat]) => ({
|
||||
categoryName: name,
|
||||
variantsCount: stat.count,
|
||||
avgPrice: stat.count > 0 ? Math.round(stat.totalRev / stat.count) : 0,
|
||||
avgCost: stat.count > 0 ? Math.round((stat.totalCost / stat.count) * 100) / 100 : 0,
|
||||
avgMargin: stat.totalRev > 0 ? Math.round(((stat.totalRev - stat.totalCost) / stat.totalRev) * 1000) / 10 : 0,
|
||||
}));
|
||||
|
||||
// 4. Apply Filters
|
||||
let filtered = mapped;
|
||||
if (search) {
|
||||
const s = search.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(m) =>
|
||||
m.productName.toLowerCase().includes(s) ||
|
||||
m.sku.toLowerCase().includes(s) ||
|
||||
m.optionsStr.toLowerCase().includes(s) ||
|
||||
m.categoryName.toLowerCase().includes(s)
|
||||
);
|
||||
}
|
||||
|
||||
if (category !== "all") {
|
||||
filtered = filtered.filter((m) => m.categoryName.toLowerCase() === category.toLowerCase());
|
||||
}
|
||||
|
||||
if (marginFilter !== "all") {
|
||||
filtered = filtered.filter((m) => m.marginHealth === marginFilter);
|
||||
}
|
||||
|
||||
// 5. Apply Sorting
|
||||
filtered.sort((a, b) => {
|
||||
if (sortBy === "margin_desc") return b.grossMargin - a.grossMargin;
|
||||
if (sortBy === "margin_asc") return a.grossMargin - b.grossMargin;
|
||||
if (sortBy === "profit_desc") return b.grossProfit - a.grossProfit;
|
||||
if (sortBy === "profit_asc") return a.grossProfit - b.grossProfit;
|
||||
if (sortBy === "cost_desc") return b.totalBomCost - a.totalBomCost;
|
||||
if (sortBy === "cost_asc") return a.totalBomCost - b.totalBomCost;
|
||||
if (sortBy === "price_desc") return b.retailPrice - a.retailPrice;
|
||||
if (sortBy === "price_asc") return a.retailPrice - b.retailPrice;
|
||||
if (sortBy === "name_asc") return a.productName.localeCompare(b.productName);
|
||||
return 0;
|
||||
});
|
||||
|
||||
// 6. Paginate
|
||||
const totalFiltered = filtered.length;
|
||||
const paginated = filtered.slice((page - 1) * limit, page * limit);
|
||||
|
||||
return {
|
||||
kpis: {
|
||||
totalVariants,
|
||||
configuredBomVariants,
|
||||
avgGrossMargin,
|
||||
totalCatalogRevenueAtFullList,
|
||||
totalCatalogBomCost,
|
||||
highMarginCount,
|
||||
midMarginCount,
|
||||
lowMarginCount,
|
||||
categoryBreakdown,
|
||||
},
|
||||
pagination: {
|
||||
total: totalFiltered,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(totalFiltered / limit) || 1,
|
||||
},
|
||||
items: paginated,
|
||||
};
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
search: t.Optional(t.String()),
|
||||
category: t.Optional(t.String()),
|
||||
margin: t.Optional(t.String()),
|
||||
sortBy: t.Optional(t.String()),
|
||||
page: t.Optional(t.String()),
|
||||
limit: t.Optional(t.String()),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 2. Omnichannel Net Margin & Contribution Matrix
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
.get("/omnichannel", async ({ query }) => {
|
||||
const search = (query.search || "").trim().toLowerCase();
|
||||
const channel = (query.channel || "all").trim().toLowerCase();
|
||||
|
||||
// 1. Fetch mapped marketplace products with master variants & BOM items
|
||||
const mpProducts = await prisma.marketplaceProduct.findMany({
|
||||
where: {
|
||||
masterVariantId: { not: null },
|
||||
...(channel !== "all" ? { channel } : {}),
|
||||
},
|
||||
include: {
|
||||
masterVariant: {
|
||||
include: {
|
||||
product: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
category: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
bomItems: {
|
||||
include: { rawMaterial: { select: { pricePerUnit: true, name: true } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { totalRevenue: "desc" },
|
||||
});
|
||||
|
||||
// Marketplace estimated fee rates
|
||||
const CHANNEL_FEES: Record<string, { commissionPercent: number; servicePercent: number; paymentPercent: number }> = {
|
||||
shopee: { commissionPercent: 8.0, servicePercent: 5.0, paymentPercent: 3.21 }, // ~16.21% total standard Shopee
|
||||
lazada: { commissionPercent: 6.0, servicePercent: 4.0, paymentPercent: 3.21 }, // ~13.21%
|
||||
tiktok: { commissionPercent: 6.0, servicePercent: 4.5, paymentPercent: 3.0 }, // ~13.5%
|
||||
};
|
||||
|
||||
let totalGrossRevenue = 0;
|
||||
let totalEstNetRevenue = 0;
|
||||
let totalBOMCost = 0;
|
||||
let totalEstNetProfit = 0;
|
||||
|
||||
const channelStats: Record<string, { soldQty: number; revenue: number; bomCost: number; netProfit: number }> = {
|
||||
shopee: { soldQty: 0, revenue: 0, bomCost: 0, netProfit: 0 },
|
||||
lazada: { soldQty: 0, revenue: 0, bomCost: 0, netProfit: 0 },
|
||||
tiktok: { soldQty: 0, revenue: 0, bomCost: 0, netProfit: 0 },
|
||||
};
|
||||
|
||||
const mappedItems = mpProducts.map((item) => {
|
||||
const v = item.masterVariant!;
|
||||
const unitBomCost = (v.bomItems || []).reduce(
|
||||
(sum, b) => sum + b.quantity * (b.rawMaterial?.pricePerUnit || 0),
|
||||
0
|
||||
);
|
||||
|
||||
const itemBomCost = Math.round(unitBomCost * item.totalSoldQty * 100) / 100;
|
||||
const ch = item.channel.toLowerCase();
|
||||
const feeConfig = CHANNEL_FEES[ch] || { commissionPercent: 7.0, servicePercent: 4.0, paymentPercent: 3.0 };
|
||||
const totalFeePercent = feeConfig.commissionPercent + feeConfig.servicePercent + feeConfig.paymentPercent;
|
||||
|
||||
const estPlatformFee = Math.round(item.totalRevenue * (totalFeePercent / 100) * 100) / 100;
|
||||
const netRevenue = Math.round((item.totalRevenue - estPlatformFee) * 100) / 100;
|
||||
const netProfit = Math.round((netRevenue - itemBomCost) * 100) / 100;
|
||||
const netMarginPercent = item.totalRevenue > 0 ? Math.round((netProfit / item.totalRevenue) * 1000) / 10 : 0;
|
||||
|
||||
// Direct comparison: what if sold directly on website/POS at retail price?
|
||||
const directPrice = v.price || item.avgPrice || 0;
|
||||
const directGrossProfit = directPrice - unitBomCost;
|
||||
const directMarginPercent = directPrice > 0 ? Math.round((directGrossProfit / directPrice) * 1000) / 10 : 0;
|
||||
|
||||
// Accumulate
|
||||
totalGrossRevenue += item.totalRevenue;
|
||||
totalEstNetRevenue += netRevenue;
|
||||
totalBOMCost += itemBomCost;
|
||||
totalEstNetProfit += netProfit;
|
||||
|
||||
if (channelStats[ch]) {
|
||||
channelStats[ch].soldQty += item.totalSoldQty;
|
||||
channelStats[ch].revenue += item.totalRevenue;
|
||||
channelStats[ch].bomCost += itemBomCost;
|
||||
channelStats[ch].netProfit += netProfit;
|
||||
}
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
channel: item.channel,
|
||||
shop: item.shop,
|
||||
marketplaceSku: item.marketplaceSku,
|
||||
marketplaceProductName: item.productName,
|
||||
variation: item.variation,
|
||||
avgSellingPrice: item.avgPrice,
|
||||
totalSoldQty: item.totalSoldQty,
|
||||
totalRevenue: item.totalRevenue,
|
||||
masterVariantId: v.id,
|
||||
masterSku: v.sku,
|
||||
productName: v.product?.name || "Product",
|
||||
categoryName: v.product?.category?.name || "Uncategorized",
|
||||
unitBomCost: Math.round(unitBomCost * 100) / 100,
|
||||
totalBomCost: itemBomCost,
|
||||
totalFeePercent,
|
||||
estPlatformFee,
|
||||
netRevenue,
|
||||
netProfit,
|
||||
netMarginPercent,
|
||||
directPrice,
|
||||
directMarginPercent,
|
||||
};
|
||||
});
|
||||
|
||||
let filtered = mappedItems;
|
||||
if (search) {
|
||||
filtered = filtered.filter(
|
||||
(m) =>
|
||||
m.marketplaceProductName.toLowerCase().includes(search) ||
|
||||
m.productName.toLowerCase().includes(search) ||
|
||||
m.masterSku?.toLowerCase().includes(search) ||
|
||||
m.variation.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
const overallNetMargin =
|
||||
totalGrossRevenue > 0 ? Math.round((totalEstNetProfit / totalGrossRevenue) * 1000) / 10 : 0;
|
||||
|
||||
return {
|
||||
kpis: {
|
||||
totalMappedProducts: mpProducts.length,
|
||||
totalGrossRevenue: Math.round(totalGrossRevenue * 100) / 100,
|
||||
totalEstNetRevenue: Math.round(totalEstNetRevenue * 100) / 100,
|
||||
totalBOMCost: Math.round(totalBOMCost * 100) / 100,
|
||||
totalEstNetProfit: Math.round(totalEstNetProfit * 100) / 100,
|
||||
overallNetMargin,
|
||||
channelStats,
|
||||
},
|
||||
items: filtered,
|
||||
};
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 3. MRP & Material Requirement Planning (Reverse BOM Explosion)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
.get("/mrp", async ({ query }) => {
|
||||
// 1. Fetch all raw materials with unit and price
|
||||
const rawMaterials = await prisma.rawMaterial.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
include: {
|
||||
bomItems: {
|
||||
include: {
|
||||
productVariant: {
|
||||
include: {
|
||||
product: { select: { id: true, name: true, category: { select: { name: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 2. Fetch sales quantities per Master Variant across Marketplace Products & Orders
|
||||
const mpProducts = await prisma.marketplaceProduct.findMany({
|
||||
where: { masterVariantId: { not: null } },
|
||||
select: {
|
||||
masterVariantId: true,
|
||||
totalSoldQty: true,
|
||||
totalRevenue: true,
|
||||
},
|
||||
});
|
||||
|
||||
const variantSoldMap = new Map<number, number>();
|
||||
for (const mp of mpProducts) {
|
||||
if (mp.masterVariantId) {
|
||||
const current = variantSoldMap.get(mp.masterVariantId) || 0;
|
||||
variantSoldMap.set(mp.masterVariantId, current + mp.totalSoldQty);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Reverse Explosion: For each Raw Material, calculate total consumption
|
||||
let totalMaterialCostConsumed = 0;
|
||||
const materialUsageList = rawMaterials.map((rm) => {
|
||||
let totalConsumedQty = 0;
|
||||
const associatedProductsMap = new Map<string, { qty: number; variantCount: number }>();
|
||||
|
||||
for (const b of rm.bomItems) {
|
||||
const vId = b.productVariantId;
|
||||
const unitsSold = variantSoldMap.get(vId) || 0;
|
||||
const materialUsedForVariant = unitsSold * b.quantity;
|
||||
totalConsumedQty += materialUsedForVariant;
|
||||
|
||||
const prodName = b.productVariant.product?.name || "Product";
|
||||
const entry = associatedProductsMap.get(prodName) || { qty: 0, variantCount: 0 };
|
||||
entry.qty += materialUsedForVariant;
|
||||
entry.variantCount++;
|
||||
associatedProductsMap.set(prodName, entry);
|
||||
}
|
||||
|
||||
const totalCost = Math.round(totalConsumedQty * rm.pricePerUnit * 100) / 100;
|
||||
totalMaterialCostConsumed += totalCost;
|
||||
|
||||
const topProducts = Array.from(associatedProductsMap.entries())
|
||||
.map(([name, stat]) => ({ name, qty: Math.round(stat.qty * 10) / 10 }))
|
||||
.sort((a, b) => b.qty - a.qty)
|
||||
.slice(0, 3);
|
||||
|
||||
const isLiquid =
|
||||
rm.unit === "ml" ||
|
||||
rm.name.includes("หัวน้ำหอม") ||
|
||||
rm.name.includes("DPG") ||
|
||||
rm.name.includes("DPM");
|
||||
|
||||
return {
|
||||
id: rm.id,
|
||||
name: rm.name,
|
||||
unit: rm.unit,
|
||||
pricePerUnit: rm.pricePerUnit,
|
||||
isLiquid,
|
||||
linkedVariantsCount: rm.bomItems.length,
|
||||
totalConsumedQty: Math.round(totalConsumedQty * 100) / 100,
|
||||
totalConsumedCost: totalCost,
|
||||
topProducts,
|
||||
};
|
||||
});
|
||||
|
||||
// Sort by consumption cost descending (high-burn materials first)
|
||||
materialUsageList.sort((a, b) => b.totalConsumedCost - a.totalConsumedCost);
|
||||
|
||||
const fragranceMaterials = materialUsageList.filter((m) => m.name.includes("หัวน้ำหอม"));
|
||||
const packagingMaterials = materialUsageList.filter((m) => !m.isLiquid);
|
||||
const solventMaterials = materialUsageList.filter((m) => m.name.includes("DPG") || m.name.includes("DPM"));
|
||||
|
||||
return {
|
||||
kpis: {
|
||||
totalRawMaterials: rawMaterials.length,
|
||||
totalMaterialCostConsumed: Math.round(totalMaterialCostConsumed * 100) / 100,
|
||||
topFragrancesByCost: fragranceMaterials.slice(0, 5),
|
||||
topPackagingByCost: packagingMaterials.slice(0, 5),
|
||||
},
|
||||
categories: {
|
||||
fragrances: fragranceMaterials,
|
||||
solvents: solventMaterials,
|
||||
packaging: packagingMaterials,
|
||||
},
|
||||
allMaterials: materialUsageList,
|
||||
};
|
||||
});
|
||||
Reference in new issue
Block a user