frontend
This commit is contained in:
490
src/pages/AdminPage.jsx
Normal file
490
src/pages/AdminPage.jsx
Normal file
@@ -0,0 +1,490 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { PlayCircle, Loader2, CheckCircle2, XCircle, Clock, Wrench, Database, FileSpreadsheet, Percent, BarChart3, HeartPulse, Sparkles, Upload, Server } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import { NavigationHeader } from '../components/NavigationHeader';
|
||||
|
||||
const STATUS_STYLES = {
|
||||
pending: { icon: Clock, className: 'text-slate-500 bg-ink-100' },
|
||||
running: { icon: Loader2, className: 'text-amber-600 bg-amber-100', spin: true },
|
||||
done: { icon: CheckCircle2, className: 'text-leaf-600 bg-leaf-100' },
|
||||
failed: { icon: XCircle, className: 'text-maroon-600 bg-maroon-100' },
|
||||
};
|
||||
|
||||
export function AdminPage() {
|
||||
const [activeTab, setActiveTab] = useState('project'); // 'project', 'dataset', 'discounts', 'ingest'
|
||||
|
||||
// Project details state
|
||||
const [projectDetails, setProjectDetails] = useState(null);
|
||||
const [loadingProject, setLoadingProject] = useState(true);
|
||||
|
||||
// Ingestion state
|
||||
const [brand, setBrand] = useState('');
|
||||
const [maxProducts, setMaxProducts] = useState(50);
|
||||
const [jobs, setJobs] = useState([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState(null);
|
||||
const pollRef = useRef(null);
|
||||
|
||||
// Dataset upload state
|
||||
const [uploadFile, setUploadFile] = useState(null);
|
||||
const [uploadingDataset, setUploadingDataset] = useState(false);
|
||||
const [datasetResult, setDatasetResult] = useState(null);
|
||||
const [uploadError, setUploadError] = useState(null);
|
||||
|
||||
// Discount allocation state
|
||||
const [minStock, setMinStock] = useState(0);
|
||||
const [maxStock, setMaxStock] = useState(20);
|
||||
const [discountPct, setDiscountPct] = useState(25);
|
||||
const [allocating, setAllocating] = useState(false);
|
||||
const [allocationResult, setAllocationResult] = useState(null);
|
||||
|
||||
const fetchProjectDetails = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/training/project-details');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setProjectDetails(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Could not fetch project details', e);
|
||||
} finally {
|
||||
setLoadingProject(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchProjectDetails();
|
||||
|
||||
pollRef.current = setInterval(() => {
|
||||
setJobs((current) => {
|
||||
current
|
||||
.filter((j) => j.status === 'pending' || j.status === 'running')
|
||||
.forEach((j) => {
|
||||
api.getJobStatus(j.job_id).then((updated) => {
|
||||
setJobs((prev) => prev.map((p) => (p.job_id === updated.job_id ? updated : p)));
|
||||
}).catch(() => {});
|
||||
});
|
||||
return current;
|
||||
});
|
||||
}, 3000);
|
||||
return () => clearInterval(pollRef.current);
|
||||
}, []);
|
||||
|
||||
const runningJob = jobs.find((j) => j.status === 'pending' || j.status === 'running');
|
||||
|
||||
async function handleSubmitIngestion(e) {
|
||||
e.preventDefault();
|
||||
if (!brand.trim() || runningJob) return;
|
||||
setSubmitting(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
const job = await api.generateCatalog({ brand: brand.trim(), max_products: Number(maxProducts) || 50 });
|
||||
setJobs((prev) => [job, ...prev]);
|
||||
setBrand('');
|
||||
} catch (err) {
|
||||
setSubmitError(err.message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleDatasetUpload = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!uploadFile) return;
|
||||
setUploadingDataset(true);
|
||||
setUploadError(null);
|
||||
setDatasetResult(null);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', uploadFile);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/training/upload-dataset', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errData = await res.json();
|
||||
throw new Error(errData.detail || 'Upload failed');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setDatasetResult(data);
|
||||
setUploadingDataset(false);
|
||||
} catch (err) {
|
||||
setUploadingDataset(false);
|
||||
setUploadError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAllocateDiscounts = async (e) => {
|
||||
e.preventDefault();
|
||||
setAllocating(true);
|
||||
try {
|
||||
const payload = {
|
||||
rules: [
|
||||
{ min_stock: Number(minStock), max_stock: Number(maxStock), discount_pct: Number(discountPct) },
|
||||
{ min_stock: 21, max_stock: 50, discount_pct: 15.0 },
|
||||
{ min_stock: 51, max_stock: 100, discount_pct: 10.0 },
|
||||
{ min_stock: 101, max_stock: 10000, discount_pct: 5.0 },
|
||||
],
|
||||
};
|
||||
|
||||
const res = await fetch('/api/admin/training/allocate-discounts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setAllocationResult(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to allocate discounts', e);
|
||||
} finally {
|
||||
setAllocating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-col overflow-y-auto bg-paper-100 text-ink-950 font-sans">
|
||||
<NavigationHeader
|
||||
title="Admin Intelligence Panel"
|
||||
subtitle="Existing project details, ML train/test dataset uploads & stock discount allocation"
|
||||
icon={Wrench}
|
||||
/>
|
||||
|
||||
<div className="mx-auto w-full max-w-6xl px-6 py-8 space-y-6">
|
||||
{/* Navigation Admin Tabs */}
|
||||
<div className="flex items-center gap-2 border-b border-ink-900/10 pb-3 overflow-x-auto no-scrollbar">
|
||||
<button
|
||||
onClick={() => setActiveTab('project')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition ${
|
||||
activeTab === 'project'
|
||||
? 'bg-amber-500 text-slate-950 shadow-sm'
|
||||
: 'bg-paper-50 text-slate-600 hover:text-ink-950 border border-ink-900/10'
|
||||
}`}
|
||||
>
|
||||
<Database className="h-4 w-4" /> Project Overview Details
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('dataset')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition ${
|
||||
activeTab === 'dataset'
|
||||
? 'bg-amber-500 text-slate-950 shadow-sm'
|
||||
: 'bg-paper-50 text-slate-600 hover:text-ink-950 border border-ink-900/10'
|
||||
}`}
|
||||
>
|
||||
<FileSpreadsheet className="h-4 w-4" /> Train & Test Dataset Upload
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('discounts')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition ${
|
||||
activeTab === 'discounts'
|
||||
? 'bg-amber-500 text-slate-950 shadow-sm'
|
||||
: 'bg-paper-50 text-slate-600 hover:text-ink-950 border border-ink-900/10'
|
||||
}`}
|
||||
>
|
||||
<Percent className="h-4 w-4" /> Stock Remaining Discount Allocator
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('ingest')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition ${
|
||||
activeTab === 'ingest'
|
||||
? 'bg-amber-500 text-slate-950 shadow-sm'
|
||||
: 'bg-paper-50 text-slate-600 hover:text-ink-950 border border-ink-900/10'
|
||||
}`}
|
||||
>
|
||||
<Server className="h-4 w-4" /> Vector Ingestion Pipeline
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* TAB 1: EXISTING PROJECT DETAILS */}
|
||||
{activeTab === 'project' && (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-xs">
|
||||
<h2 className="font-display text-lg font-bold text-ink-950 flex items-center gap-2 mb-2">
|
||||
<Database className="h-5 w-5 text-amber-500" /> Existing Project Details & System Architecture
|
||||
</h2>
|
||||
<p className="text-xs text-slate-500 leading-relaxed max-w-3xl">
|
||||
High-level overview of active brand catalogs, PostgreSQL vector database tables, S3 image storage pipeline, and store intelligence ML models.
|
||||
</p>
|
||||
|
||||
{loadingProject ? (
|
||||
<div className="py-8 text-center text-xs text-slate-400">Loading project details...</div>
|
||||
) : projectDetails ? (
|
||||
<div className="mt-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 p-4">
|
||||
<p className="text-[11px] font-semibold uppercase text-amber-700">Total Brands</p>
|
||||
<p className="font-mono text-3xl font-extrabold text-ink-950 mt-1">{projectDetails.total_brands}</p>
|
||||
<p className="text-[10px] text-slate-400 mt-1">Ingested brand tables in pgvector</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/5 p-4">
|
||||
<p className="text-[11px] font-semibold uppercase text-emerald-700">Total Products</p>
|
||||
<p className="font-mono text-3xl font-extrabold text-ink-950 mt-1">{projectDetails.total_products}</p>
|
||||
<p className="text-[10px] text-slate-400 mt-1">Unique products with vector embeddings</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-blue-500/20 bg-blue-500/5 p-4">
|
||||
<p className="text-[11px] font-semibold uppercase text-blue-700">S3 Storage Status</p>
|
||||
<p className="font-mono text-xl font-bold text-ink-950 mt-2 uppercase">{projectDetails.s3_image_status}</p>
|
||||
<p className="text-[10px] text-slate-400 mt-1">Bucket: {projectDetails.storage_bucket}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-purple-500/20 bg-purple-500/5 p-4">
|
||||
<p className="text-[11px] font-semibold uppercase text-purple-700">System Version</p>
|
||||
<p className="font-mono text-xl font-bold text-ink-950 mt-2">{projectDetails.version}</p>
|
||||
<p className="text-[10px] text-slate-400 mt-1">Multi-Store & RAG Engine</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{projectDetails?.brand_product_counts && (
|
||||
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-xs">
|
||||
<h3 className="font-display text-sm font-bold text-ink-950 mb-4">Ingested Brand Database Breakdown</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
||||
{Object.entries(projectDetails.brand_product_counts).map(([bName, count]) => (
|
||||
<div key={bName} className="flex items-center justify-between p-3 rounded-xl border border-ink-900/10 bg-white">
|
||||
<span className="text-xs font-bold text-ink-950">{bName}</span>
|
||||
<span className="font-mono text-xs font-bold bg-amber-100 text-amber-800 px-2 py-0.5 rounded-full">{count} items</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 2: UPLOAD TRAIN/TEST DATASET */}
|
||||
{activeTab === 'dataset' && (
|
||||
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-xs space-y-6">
|
||||
<div>
|
||||
<h2 className="font-display text-lg font-bold text-ink-950 flex items-center gap-2 mb-1">
|
||||
<FileSpreadsheet className="h-5 w-5 text-amber-500" /> Upload Excel / CSV Records for Decision Training
|
||||
</h2>
|
||||
<p className="text-xs text-slate-500">
|
||||
Upload custom product dataset files (.xlsx, .csv) to train and test decision-making models. Data is automatically split into an 80/20 train/test split for optimization.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{uploadError && (
|
||||
<div className="rounded-xl bg-rose-500/10 border border-rose-500/30 p-3.5 text-xs text-rose-700">
|
||||
{uploadError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleDatasetUpload} className="flex flex-col sm:flex-row items-center gap-4 border-2 border-dashed border-slate-300 rounded-2xl p-6 bg-slate-50/50">
|
||||
<input
|
||||
type="file"
|
||||
accept=".xlsx,.xls,.csv"
|
||||
onChange={(e) => setUploadFile(e.target.files[0])}
|
||||
className="text-xs text-slate-600 file:mr-4 file:py-2 file:px-4 file:rounded-xl file:border-0 file:text-xs file:font-semibold file:bg-amber-500 file:text-slate-950 hover:file:bg-amber-400"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!uploadFile || uploadingDataset}
|
||||
className="flex items-center gap-2 rounded-xl bg-ink-900 hover:bg-ink-950 text-white px-5 py-2.5 text-xs font-bold transition disabled:opacity-40 shrink-0"
|
||||
>
|
||||
{uploadingDataset ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||
Train Decision Model
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{datasetResult && (
|
||||
<div className="rounded-xl border border-emerald-500/30 bg-emerald-500/10 p-5 space-y-4">
|
||||
<div className="flex items-center gap-2 text-emerald-800 font-bold text-sm">
|
||||
<CheckCircle2 className="h-5 w-5" /> {datasetResult.message}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 text-xs">
|
||||
<div className="bg-white p-3 rounded-lg border border-emerald-200">
|
||||
<span className="text-slate-500 font-semibold">Total Records:</span>
|
||||
<p className="font-mono text-lg font-bold text-ink-950">{datasetResult.total_records}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-3 rounded-lg border border-emerald-200">
|
||||
<span className="text-slate-500 font-semibold">Training Set (80%):</span>
|
||||
<p className="font-mono text-lg font-bold text-amber-600">{datasetResult.dataset_split.training_records}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-3 rounded-lg border border-emerald-200">
|
||||
<span className="text-slate-500 font-semibold">Testing Set (20%):</span>
|
||||
<p className="font-mono text-lg font-bold text-blue-600">{datasetResult.dataset_split.testing_records}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 3: STOCK REMAINING DISCOUNT ALLOCATOR */}
|
||||
{activeTab === 'discounts' && (
|
||||
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-xs space-y-6">
|
||||
<div>
|
||||
<h2 className="font-display text-lg font-bold text-ink-950 flex items-center gap-2 mb-1">
|
||||
<Percent className="h-5 w-5 text-amber-500" /> Allocate Product Discounts Based on Stock Remaining
|
||||
</h2>
|
||||
<p className="text-xs text-slate-500">
|
||||
Help stores optimize business performance and clear remaining stock by allocating dynamic discount rules based on inventory levels.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleAllocateDiscounts} className="grid grid-cols-1 sm:grid-cols-4 gap-4 bg-slate-50 p-4 rounded-xl border border-ink-900/10">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink-800 mb-1">Min Stock</label>
|
||||
<input
|
||||
type="number"
|
||||
value={minStock}
|
||||
onChange={(e) => setMinStock(e.target.value)}
|
||||
className="w-full rounded-lg border border-ink-900/15 p-2 text-xs font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink-800 mb-1">Max Stock Remaining</label>
|
||||
<input
|
||||
type="number"
|
||||
value={maxStock}
|
||||
onChange={(e) => setMaxStock(e.target.value)}
|
||||
className="w-full rounded-lg border border-ink-900/15 p-2 text-xs font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ink-800 mb-1">Discount %</label>
|
||||
<input
|
||||
type="number"
|
||||
value={discountPct}
|
||||
onChange={(e) => setDiscountPct(e.target.value)}
|
||||
className="w-full rounded-lg border border-ink-900/15 p-2 text-xs font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={allocating}
|
||||
className="w-full flex items-center justify-center gap-2 rounded-xl bg-amber-500 hover:bg-amber-400 text-slate-950 py-2.5 text-xs font-bold shadow-sm transition"
|
||||
>
|
||||
{allocating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Percent className="h-4 w-4" />}
|
||||
Allocate Discounts
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{allocationResult && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between text-xs text-slate-600">
|
||||
<span className="font-bold text-ink-950">Applied Discount Allocations ({allocationResult.total_products_allocated} products)</span>
|
||||
</div>
|
||||
|
||||
<div className="max-h-80 overflow-y-auto border border-ink-900/10 rounded-xl bg-white divide-y divide-slate-100">
|
||||
{allocationResult.allocations.map((a, i) => (
|
||||
<div key={i} className="flex items-center justify-between p-3 text-xs">
|
||||
<div>
|
||||
<p className="font-bold text-ink-950">{a.product_name}</p>
|
||||
<p className="text-[10px] text-slate-400 font-mono">Store: {a.store_id} · Stock Remaining: {a.stock_remaining}</p>
|
||||
</div>
|
||||
<div className="text-right font-mono">
|
||||
<span className="inline-block bg-emerald-100 text-emerald-800 font-bold px-2 py-0.5 rounded text-[11px] mr-2">
|
||||
{a.discount_pct}% OFF
|
||||
</span>
|
||||
<span className="text-slate-400 line-through mr-1">₹{a.original_price}</span>
|
||||
<span className="font-bold text-leaf-600 text-sm">₹{a.final_price}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 4: INGESTION PIPELINE */}
|
||||
{activeTab === 'ingest' && (
|
||||
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-xs space-y-6">
|
||||
<div>
|
||||
<h2 className="font-display text-lg font-bold text-ink-950 flex items-center gap-2 mb-1">
|
||||
<Server className="h-5 w-5 text-amber-500" /> Vector Database Catalog Ingestion
|
||||
</h2>
|
||||
<p className="text-xs text-slate-500">
|
||||
Discover and store brand products into pgvector (discovery → images → embeddings).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmitIngestion} className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<div className="flex-1">
|
||||
<label className="mb-1 block text-xs font-semibold text-ink-800">Brand name</label>
|
||||
<input
|
||||
value={brand}
|
||||
onChange={(e) => setBrand(e.target.value)}
|
||||
placeholder="e.g. Britannia"
|
||||
disabled={!!runningJob}
|
||||
className="w-full rounded-xl border border-ink-900/15 p-2.5 text-xs font-medium focus:border-amber-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<label className="mb-1 block text-xs font-semibold text-ink-800">Max products</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={300}
|
||||
value={maxProducts}
|
||||
onChange={(e) => setMaxProducts(e.target.value)}
|
||||
disabled={!!runningJob}
|
||||
className="w-full rounded-xl border border-ink-900/15 p-2.5 text-xs font-medium focus:border-amber-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !brand.trim() || !!runningJob}
|
||||
className="flex items-center justify-center gap-1.5 rounded-xl bg-ink-900 px-5 py-2.5 text-xs font-bold text-white transition hover:bg-ink-950 disabled:opacity-40"
|
||||
>
|
||||
<PlayCircle className="h-4 w-4" /> Start Ingestion
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{runningJob && (
|
||||
<p className="text-xs text-amber-600">
|
||||
A job for "{runningJob.brand}" is already in progress - please wait.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<h3 className="font-display text-xs font-semibold uppercase tracking-wide text-slate-500 mt-6">Recent jobs</h3>
|
||||
{jobs.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">No ingestion jobs started yet this session.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{jobs.map((j) => {
|
||||
const style = STATUS_STYLES[j.status] || STATUS_STYLES.pending;
|
||||
const Icon = style.icon;
|
||||
return (
|
||||
<li key={j.job_id} className="flex items-center gap-3 rounded-xl border border-ink-900/10 bg-white px-4 py-3">
|
||||
<span className={`flex h-8 w-8 items-center justify-center rounded-full ${style.className}`}>
|
||||
<Icon className={`h-4 w-4 ${style.spin ? 'animate-spin' : ''}`} />
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-xs font-bold text-ink-950">{j.brand}</p>
|
||||
<p className="text-[11px] text-slate-500">{j.detail || j.status}</p>
|
||||
</div>
|
||||
<span className="font-mono text-xs uppercase text-slate-400">{j.status}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user