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:
Your Name committed 2026-10-18 18:58:11 +07:00
1 parent fdbbaea021
commit c78a898af7
21 files changed
+4336 -2

No files matched your search

+6
View File
@@ -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 />} />
+4 -1
View File
@@ -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} &gt;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} &lt;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 (&gt;= 70%)</option>
<option value="mid">🟡 Mid Margin (55% - 70%)</option>
<option value="low">🔴 Low Margin (&lt; 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>
);
}
+2 -1
View File
@@ -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" });
+477
View File
@@ -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,
};
});