578 lines
16 KiB
JavaScript
578 lines
16 KiB
JavaScript
// import React from "react";
|
||
// import { useGetInventoryStatsQuery } from "../../features/report/inventoryAPI";
|
||
|
||
// const StockBadge = ({ stock }) => {
|
||
// if (stock === 0) return <span className="px-2 py-1 text-xs bg-red-100 text-red-700 rounded-full">Out of Stock</span>;
|
||
// if (stock <= 5) return <span className="px-2 py-1 text-xs bg-yellow-100 text-yellow-800 rounded-full">Low Stock</span>;
|
||
// return <span className="px-2 py-1 text-xs bg-green-100 text-green-700 rounded-full">{stock}</span>;
|
||
// };
|
||
|
||
// const ProductCard = ({ product }) => (
|
||
// <div className="bg-white shadow-md rounded-xl p-4 hover:shadow-xl transition transform hover:scale-105">
|
||
// <div className="flex justify-between items-center mb-2">
|
||
// <h3 className="font-semibold text-gray-800">{product.name}</h3>
|
||
// <StockBadge stock={product.stock} />
|
||
// </div>
|
||
// <p className="text-gray-500 text-sm">{product.category?.name || "Unknown Category"}</p>
|
||
// </div>
|
||
// );
|
||
|
||
// const InventorySection = ({ title, products }) => (
|
||
// <div className="flex flex-col gap-4">
|
||
// <h2 className="text-xl font-bold text-gray-700">{title}</h2>
|
||
// {products.length === 0 ? (
|
||
// <p className="text-gray-400 italic">No products found</p>
|
||
// ) : (
|
||
// <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||
// {products.map((p) => (
|
||
// <ProductCard key={p._id} product={p} />
|
||
// ))}
|
||
// </div>
|
||
// )}
|
||
// </div>
|
||
// );
|
||
|
||
// const InventoryReport = () => {
|
||
// const { data, isLoading, isError } = useGetInventoryStatsQuery();
|
||
|
||
// if (isLoading) return <div className="text-center p-6 text-gray-500">Loading inventory...</div>;
|
||
// if (isError) return <div className="text-center p-6 text-red-500">Failed to load inventory</div>;
|
||
|
||
// const { lowStock, outOfStock, fastMoving } = data.data;
|
||
|
||
// return (
|
||
// <div className="grid grid-cols-1 lg:grid-cols-3 gap-6 p-6">
|
||
// <InventorySection title="Low Stock" products={lowStock} />
|
||
// <InventorySection title="Out of Stock" products={outOfStock} />
|
||
// <InventorySection title="Fast Moving" products={fastMoving} />
|
||
// </div>
|
||
// );
|
||
// };
|
||
|
||
// export default InventoryReport;
|
||
|
||
// InventoryReport v2 - Editorial warm theme
|
||
import React, { useState } from "react";
|
||
import { useGetInventoryStatsQuery } from "../../features/report/inventoryAPI";
|
||
|
||
const styles = `
|
||
@import url('https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,600;9..144,700&family=Instrument+Sans:wght@400;500;600&display=swap');
|
||
|
||
:root {
|
||
--bg: #f7f5f2;
|
||
--surface: #ffffff;
|
||
--border: #e8e4de;
|
||
--border2: #f0ece6;
|
||
--text: #1a1714;
|
||
--muted: #9c9489;
|
||
--red: #e8442a;
|
||
--red-bg: #fdf1ef;
|
||
--red-bdr: #f4c4bc;
|
||
--amber: #d97706;
|
||
--amber-bg: #fffbeb;
|
||
--amber-bdr: #fde68a;
|
||
--teal: #0d7a6b;
|
||
--teal-bg: #f0faf8;
|
||
--teal-bdr: #a7f3e4;
|
||
--ink: #2d2a26;
|
||
--shadow: 0 1px 3px rgba(0,0,0,0.06), 0 4px 12px rgba(0,0,0,0.04);
|
||
--shadow-lg: 0 2px 8px rgba(0,0,0,0.08), 0 12px 32px rgba(0,0,0,0.06);
|
||
}
|
||
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
|
||
.inv-root {
|
||
font-family: 'Instrument Sans', sans-serif;
|
||
background: var(--bg);
|
||
min-height: 100vh;
|
||
color: var(--text);
|
||
padding: 40px 36px;
|
||
}
|
||
|
||
.inv-header {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
margin-bottom: 36px;
|
||
flex-wrap: wrap;
|
||
gap: 20px;
|
||
}
|
||
.inv-eyebrow {
|
||
font-size: 0.68rem;
|
||
font-weight: 600;
|
||
letter-spacing: 0.14em;
|
||
text-transform: uppercase;
|
||
color: var(--muted);
|
||
margin-bottom: 6px;
|
||
}
|
||
.inv-title {
|
||
font-family: 'Fraunces', serif;
|
||
font-size: 2.2rem;
|
||
font-weight: 700;
|
||
color: var(--ink);
|
||
line-height: 1;
|
||
letter-spacing: -0.02em;
|
||
}
|
||
|
||
.inv-summary {
|
||
display: flex;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.inv-pill {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 10px 16px;
|
||
border-radius: 12px;
|
||
border: 1px solid var(--border);
|
||
background: var(--surface);
|
||
box-shadow: var(--shadow);
|
||
}
|
||
.inv-pill-icon {
|
||
width: 32px;
|
||
height: 32px;
|
||
border-radius: 8px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 0.9rem;
|
||
flex-shrink: 0;
|
||
}
|
||
.inv-pill-val {
|
||
font-family: 'Fraunces', serif;
|
||
font-size: 1.3rem;
|
||
font-weight: 700;
|
||
line-height: 1;
|
||
color: var(--ink);
|
||
}
|
||
.inv-pill-label {
|
||
font-size: 0.65rem;
|
||
font-weight: 500;
|
||
color: var(--muted);
|
||
letter-spacing: 0.06em;
|
||
text-transform: uppercase;
|
||
margin-top: 2px;
|
||
}
|
||
.pill-total .inv-pill-icon { background: #f0ece6; }
|
||
.pill-ok .inv-pill-icon { background: var(--teal-bg); }
|
||
.pill-low .inv-pill-icon { background: var(--amber-bg); }
|
||
.pill-out .inv-pill-icon { background: var(--red-bg); }
|
||
|
||
.inv-tabs {
|
||
display: flex;
|
||
gap: 2px;
|
||
border-bottom: 1.5px solid var(--border);
|
||
margin-bottom: 28px;
|
||
}
|
||
.inv-tab {
|
||
padding: 10px 18px;
|
||
border: none;
|
||
background: transparent;
|
||
color: var(--muted);
|
||
font-family: 'Instrument Sans', sans-serif;
|
||
font-size: 0.82rem;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 7px;
|
||
transition: color 0.18s;
|
||
letter-spacing: 0.01em;
|
||
border-bottom: 2px solid transparent;
|
||
margin-bottom: -1.5px;
|
||
}
|
||
.inv-tab:hover { color: var(--ink); }
|
||
.inv-tab.active { color: var(--ink); border-bottom-color: var(--ink); }
|
||
.inv-tab-chip {
|
||
font-size: 0.6rem;
|
||
font-weight: 600;
|
||
padding: 2px 6px;
|
||
border-radius: 99px;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
.chip-out { background: var(--red-bg); color: var(--red); }
|
||
.chip-low { background: var(--amber-bg); color: var(--amber); }
|
||
.chip-fast { background: var(--teal-bg); color: var(--teal); }
|
||
|
||
.inv-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
|
||
gap: 14px;
|
||
}
|
||
|
||
.inv-card {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 16px;
|
||
padding: 20px;
|
||
box-shadow: var(--shadow);
|
||
transition: box-shadow 0.2s, transform 0.2s, border-color 0.2s;
|
||
position: relative;
|
||
overflow: hidden;
|
||
animation: riseIn 0.3s both;
|
||
}
|
||
.inv-card:hover {
|
||
box-shadow: var(--shadow-lg);
|
||
transform: translateY(-2px);
|
||
border-color: #d8d2ca;
|
||
}
|
||
@keyframes riseIn {
|
||
from { opacity: 0; transform: translateY(10px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
.inv-card-stripe {
|
||
position: absolute;
|
||
left: 0; top: 16px; bottom: 16px;
|
||
width: 3px;
|
||
border-radius: 0 3px 3px 0;
|
||
}
|
||
.stripe-red { background: var(--red); }
|
||
.stripe-amber { background: var(--amber); }
|
||
.stripe-teal { background: var(--teal); }
|
||
.inv-card-inner { padding-left: 14px; }
|
||
|
||
.inv-card-top {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
gap: 8px;
|
||
margin-bottom: 4px;
|
||
}
|
||
.inv-card-name {
|
||
font-family: 'Fraunces', serif;
|
||
font-size: 0.97rem;
|
||
font-weight: 600;
|
||
color: var(--ink);
|
||
line-height: 1.3;
|
||
}
|
||
.inv-badge {
|
||
font-size: 0.62rem;
|
||
font-weight: 600;
|
||
padding: 3px 8px;
|
||
border-radius: 20px;
|
||
white-space: nowrap;
|
||
flex-shrink: 0;
|
||
letter-spacing: 0.05em;
|
||
text-transform: uppercase;
|
||
}
|
||
.badge-out { background: var(--red-bg); color: var(--red); border: 1px solid var(--red-bdr); }
|
||
.badge-low { background: var(--amber-bg); color: var(--amber); border: 1px solid var(--amber-bdr); }
|
||
.badge-ok { background: var(--teal-bg); color: var(--teal); border: 1px solid var(--teal-bdr); }
|
||
|
||
.inv-card-meta {
|
||
font-size: 0.68rem;
|
||
font-weight: 500;
|
||
color: var(--muted);
|
||
margin-bottom: 14px;
|
||
letter-spacing: 0.03em;
|
||
}
|
||
.inv-bar-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
.inv-bar-bg {
|
||
flex: 1;
|
||
height: 4px;
|
||
background: var(--border2);
|
||
border-radius: 99px;
|
||
overflow: hidden;
|
||
}
|
||
.inv-bar-fill {
|
||
height: 100%;
|
||
border-radius: 99px;
|
||
transition: width 0.55s cubic-bezier(0.34,1.4,0.64,1);
|
||
}
|
||
.inv-bar-label {
|
||
font-size: 0.7rem;
|
||
font-weight: 600;
|
||
color: var(--muted);
|
||
min-width: 24px;
|
||
text-align: right;
|
||
}
|
||
|
||
.inv-rank-row {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 12px;
|
||
margin-bottom: 4px;
|
||
}
|
||
.inv-rank-num {
|
||
font-family: 'Fraunces', serif;
|
||
font-size: 2rem;
|
||
font-weight: 700;
|
||
color: var(--border);
|
||
line-height: 1;
|
||
min-width: 44px;
|
||
flex-shrink: 0;
|
||
}
|
||
.inv-purchase-tag {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
font-size: 0.65rem;
|
||
font-weight: 600;
|
||
color: var(--teal);
|
||
background: var(--teal-bg);
|
||
border: 1px solid var(--teal-bdr);
|
||
padding: 2px 7px;
|
||
border-radius: 99px;
|
||
margin-top: 3px;
|
||
letter-spacing: 0.04em;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.inv-empty {
|
||
grid-column: 1/-1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 72px 20px;
|
||
gap: 10px;
|
||
}
|
||
.inv-empty-icon { font-size: 2.5rem; opacity: 0.25; }
|
||
.inv-empty-text {
|
||
font-size: 0.75rem;
|
||
font-weight: 600;
|
||
color: var(--muted);
|
||
letter-spacing: 0.08em;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.inv-state {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 60vh;
|
||
gap: 14px;
|
||
}
|
||
.inv-spinner {
|
||
width: 32px; height: 32px;
|
||
border: 2px solid var(--border);
|
||
border-top-color: var(--ink);
|
||
border-radius: 50%;
|
||
animation: spin 0.75s linear infinite;
|
||
}
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
.inv-state-text {
|
||
font-size: 0.72rem;
|
||
font-weight: 600;
|
||
color: var(--muted);
|
||
letter-spacing: 0.1em;
|
||
text-transform: uppercase;
|
||
}
|
||
.inv-state-text.err { color: var(--red); }
|
||
`;
|
||
|
||
const shortId = (id = "") =>
|
||
typeof id === "string" && id.length > 8
|
||
? id.slice(-6).toUpperCase()
|
||
: String(id).toUpperCase();
|
||
|
||
const getStockPct = (stock, max = 50) =>
|
||
stock === 0 ? 0 : Math.min(100, Math.round((stock / max) * 100));
|
||
|
||
const StockBadge = ({ stock }) => {
|
||
if (stock === 0) return <span className="inv-badge badge-out">No stock</span>;
|
||
if (stock <= 5)
|
||
return <span className="inv-badge badge-low">{stock} left</span>;
|
||
return <span className="inv-badge badge-ok">In stock</span>;
|
||
};
|
||
|
||
const StockBar = ({ stock, fillColor }) => (
|
||
<div className="inv-bar-row">
|
||
<div className="inv-bar-bg">
|
||
<div
|
||
className="inv-bar-fill"
|
||
style={{ width: `${getStockPct(stock)}%`, background: fillColor }}
|
||
/>
|
||
</div>
|
||
<span className="inv-bar-label">{stock}</span>
|
||
</div>
|
||
);
|
||
|
||
const OutCard = ({ product, idx }) => (
|
||
<div className="inv-card" style={{ animationDelay: `${idx * 35}ms` }}>
|
||
<div className="inv-card-stripe stripe-red" />
|
||
<div className="inv-card-inner">
|
||
<div className="inv-card-top">
|
||
<span className="inv-card-name">{product.name}</span>
|
||
<StockBadge stock={0} />
|
||
</div>
|
||
<div className="inv-card-meta">
|
||
Category · {shortId(product.category)}
|
||
</div>
|
||
<StockBar stock={0} fillColor="var(--red)" />
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const LowCard = ({ product, idx }) => (
|
||
<div className="inv-card" style={{ animationDelay: `${idx * 35}ms` }}>
|
||
<div className="inv-card-stripe stripe-amber" />
|
||
<div className="inv-card-inner">
|
||
<div className="inv-card-top">
|
||
<span className="inv-card-name">{product.name}</span>
|
||
<StockBadge stock={product.stock} />
|
||
</div>
|
||
<div className="inv-card-meta">
|
||
Category · {shortId(product.category)}
|
||
</div>
|
||
<StockBar stock={product.stock} fillColor="var(--amber)" />
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const FastCard = ({ product, idx }) => (
|
||
<div className="inv-card" style={{ animationDelay: `${idx * 35}ms` }}>
|
||
<div className="inv-card-stripe stripe-teal" />
|
||
<div className="inv-card-inner">
|
||
<div className="inv-rank-row">
|
||
<span className="inv-rank-num">{String(idx + 1).padStart(2, "0")}</span>
|
||
<div style={{ flex: 1 }}>
|
||
<div className="inv-card-top" style={{ marginBottom: 2 }}>
|
||
<span className="inv-card-name">{product.name}</span>
|
||
<StockBadge stock={product.stock} />
|
||
</div>
|
||
<span className="inv-purchase-tag">
|
||
↑ {product.purchaseCount ?? 0} sold
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="inv-card-meta" style={{ marginTop: 8 }}>
|
||
Category · {shortId(product.category)}
|
||
</div>
|
||
<StockBar stock={product.stock} fillColor="var(--teal)" />
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const EmptyState = ({ label }) => (
|
||
<div className="inv-empty">
|
||
<div className="inv-empty-icon">○</div>
|
||
<div className="inv-empty-text">No {label} products</div>
|
||
</div>
|
||
);
|
||
|
||
const TABS = [
|
||
{ key: "out", label: "Out of Stock", chipClass: "chip-out" },
|
||
{ key: "low", label: "Low Stock", chipClass: "chip-low" },
|
||
{ key: "fast", label: "Fast Moving", chipClass: "chip-fast" },
|
||
];
|
||
|
||
const InventoryReport = () => {
|
||
const [activeTab, setActiveTab] = useState("out");
|
||
const { data, isLoading, isError } = useGetInventoryStatsQuery();
|
||
|
||
if (isLoading)
|
||
return (
|
||
<div className="inv-root">
|
||
<style>{styles}</style>
|
||
<div className="inv-state">
|
||
<div className="inv-spinner" />
|
||
<span className="inv-state-text">Loading inventory…</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
if (isError)
|
||
return (
|
||
<div className="inv-root">
|
||
<style>{styles}</style>
|
||
<div className="inv-state">
|
||
<span style={{ fontSize: "1.8rem" }}>✕</span>
|
||
<span className="inv-state-text err">Failed to load data</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const {
|
||
summary,
|
||
lowStock = [],
|
||
outOfStock = [],
|
||
fastMoving = [],
|
||
} = data.data;
|
||
|
||
const tabContent = {
|
||
out: { products: outOfStock, Card: OutCard, emptyLabel: "out-of-stock" },
|
||
low: { products: lowStock, Card: LowCard, emptyLabel: "low-stock" },
|
||
fast: { products: fastMoving, Card: FastCard, emptyLabel: "fast-moving" },
|
||
};
|
||
|
||
const { products, Card, emptyLabel } = tabContent[activeTab];
|
||
|
||
return (
|
||
<div className="inv-root">
|
||
<style>{styles}</style>
|
||
|
||
<div className="inv-header">
|
||
<div>
|
||
<div className="inv-eyebrow">Admin · Reports</div>
|
||
<div className="inv-title">Inventory</div>
|
||
</div>
|
||
|
||
{summary && (
|
||
<div className="inv-summary">
|
||
<div className="inv-pill pill-total">
|
||
<div className="inv-pill-icon">📦</div>
|
||
<div>
|
||
<div className="inv-pill-val">{summary.totalProducts}</div>
|
||
<div className="inv-pill-label">Total</div>
|
||
</div>
|
||
</div>
|
||
<div className="inv-pill pill-ok">
|
||
<div className="inv-pill-icon">✅</div>
|
||
<div>
|
||
<div className="inv-pill-val">{summary.healthyStockCount}</div>
|
||
<div className="inv-pill-label">Healthy</div>
|
||
</div>
|
||
</div>
|
||
<div className="inv-pill pill-low">
|
||
<div className="inv-pill-icon">⚠️</div>
|
||
<div>
|
||
<div className="inv-pill-val">{summary.lowStockCount}</div>
|
||
<div className="inv-pill-label">Low Stock</div>
|
||
</div>
|
||
</div>
|
||
<div className="inv-pill pill-out">
|
||
<div className="inv-pill-icon">🚫</div>
|
||
<div>
|
||
<div className="inv-pill-val">{summary.outOfStockCount}</div>
|
||
<div className="inv-pill-label">Out of Stock</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="inv-tabs">
|
||
{TABS.map((tab) => (
|
||
<button
|
||
key={tab.key}
|
||
className={`inv-tab${activeTab === tab.key ? " active" : ""}`}
|
||
onClick={() => setActiveTab(tab.key)}
|
||
>
|
||
{tab.label}
|
||
<span className={`inv-tab-chip ${tab.chipClass}`}>
|
||
{tabContent[tab.key].products.length}
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="inv-grid">
|
||
{products.length === 0 ? (
|
||
<EmptyState label={emptyLabel} />
|
||
) : (
|
||
products.map((p, i) => <Card key={p._id} product={p} idx={i} />)
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default InventoryReport;
|