585 lines
14 KiB
TypeScript
585 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import {
|
|
Plus,
|
|
Pencil,
|
|
Trash2,
|
|
X,
|
|
Package,
|
|
Eye,
|
|
} from "lucide-react";
|
|
import { api } from "@/lib/api";
|
|
|
|
export default function BrandsPanel() {
|
|
const empty = {
|
|
name: "",
|
|
logo_url: "",
|
|
description: "",
|
|
};
|
|
|
|
const [brands, setBrands] = useState<any[]>([]);
|
|
const [show, setShow] = useState(false);
|
|
const [editing, setEditing] = useState<any>(null);
|
|
const [form, setForm] = useState(empty);
|
|
const [logoFile, setLogoFile] = useState<File | null>(null);
|
|
const [viewOpen, setViewOpen] = useState(false);
|
|
const [brandProducts, setBrandProducts] = useState<any[]>([]);
|
|
const [brandName, setBrandName] = useState("");
|
|
|
|
async function loadBrands() {
|
|
try {
|
|
const data = await api("/brands");
|
|
setBrands(data);
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
loadBrands();
|
|
}, []);
|
|
|
|
function openAdd() {
|
|
setEditing(null);
|
|
setForm(empty);
|
|
setShow(true);
|
|
}
|
|
async function viewProducts(brand: any) {
|
|
try {
|
|
const data = await api(`/brands/${brand.id}`);
|
|
|
|
setBrandName(data.name);
|
|
setBrandProducts(data.products || []);
|
|
setViewOpen(true);
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
}
|
|
function openEdit(brand: any) {
|
|
setEditing(brand);
|
|
setForm({
|
|
name: brand.name ?? "",
|
|
logo_url: brand.logo_url ?? "",
|
|
description: brand.description ?? "",
|
|
});
|
|
setShow(true);
|
|
}
|
|
async function uploadLogo() {
|
|
if (!logoFile) return form.logo_url;
|
|
|
|
const data = new FormData();
|
|
data.append("file", logoFile);
|
|
|
|
const res = await fetch("http://localhost:8000/brands/upload-logo", {
|
|
method: "POST",
|
|
body: data,
|
|
});
|
|
|
|
const json = await res.json();
|
|
|
|
return json.url;
|
|
}
|
|
|
|
async function save() {
|
|
|
|
const logo_url = await uploadLogo();
|
|
|
|
const body = {
|
|
...form,
|
|
logo_url,
|
|
};
|
|
|
|
if (editing) {
|
|
await api(`/brands/${editing.id}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(body),
|
|
});
|
|
} else {
|
|
await api("/brands", {
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
setShow(false);
|
|
loadBrands();
|
|
}
|
|
|
|
async function remove(id: string) {
|
|
if (!confirm("Delete this brand?")) return;
|
|
|
|
await api(`/brands/${id}`, {
|
|
method: "DELETE",
|
|
});
|
|
|
|
loadBrands();
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
|
|
{/* Header */}
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
<div>
|
|
<h2 className="text-xl font-bold flex items-center gap-2">
|
|
<Package className="w-6 h-6 text-brand-600" />
|
|
Brands
|
|
</h2>
|
|
|
|
<p className="text-sm text-slate-500 mt-1">
|
|
Manage all organization brands.
|
|
</p>
|
|
</div>
|
|
|
|
<button
|
|
onClick={openAdd}
|
|
className="flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-white font-medium hover:bg-brand-700 transition"
|
|
>
|
|
<Plus size={18} />
|
|
Add Brand
|
|
</button>
|
|
|
|
</div>
|
|
|
|
{/* Table */}
|
|
|
|
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
|
|
|
<table className="min-w-full">
|
|
|
|
<thead className="bg-slate-50 border-b">
|
|
|
|
<tr>
|
|
|
|
<th className="px-6 py-4 text-left text-sm font-semibold text-slate-600">
|
|
Brand
|
|
</th>
|
|
|
|
<th className="px-6 py-4 text-left text-sm font-semibold text-slate-600">
|
|
Description
|
|
</th>
|
|
|
|
<th className="px-6 py-4 text-center text-sm font-semibold text-slate-600">
|
|
Products
|
|
</th>
|
|
|
|
<th className="px-6 py-4 text-center text-sm font-semibold text-slate-600">
|
|
Actions
|
|
</th>
|
|
|
|
</tr>
|
|
|
|
</thead>
|
|
|
|
<tbody>
|
|
|
|
{brands.length === 0 && (
|
|
|
|
<tr>
|
|
|
|
<td
|
|
colSpan={4}
|
|
className="py-16 text-center text-slate-400"
|
|
>
|
|
No brands found.
|
|
</td>
|
|
|
|
</tr>
|
|
|
|
)}
|
|
|
|
{brands.map((brand) => (
|
|
|
|
<tr
|
|
key={brand.id}
|
|
className="border-b last:border-b-0 hover:bg-slate-50 transition"
|
|
>
|
|
|
|
<td className="px-6 py-4">
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
<div className="h-10 w-10 rounded-lg bg-brand-100 flex items-center justify-center">
|
|
|
|
{brand.logo_url ? (
|
|
<img
|
|
src={brand.logo_url}
|
|
alt={brand.name}
|
|
className="h-10 w-10 rounded-lg object-cover"
|
|
/>
|
|
) : (
|
|
<Package className="h-5 w-5 text-brand-600" />
|
|
)}
|
|
|
|
</div>
|
|
|
|
<div>
|
|
|
|
<div className="font-semibold text-slate-800">
|
|
{brand.name}
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</td>
|
|
|
|
<td className="px-6 py-4 text-sm text-slate-600 max-w-md">
|
|
{brand.description || "-"}
|
|
</td>
|
|
|
|
<td className="px-6 py-4 text-center">
|
|
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold">
|
|
{brand.product_count}
|
|
</span>
|
|
</td>
|
|
|
|
<td className="px-6 py-4">
|
|
|
|
<div className="flex justify-center gap-2">
|
|
<button
|
|
onClick={() => viewProducts(brand)}
|
|
className="rounded-lg border border-slate-200 p-2 hover:bg-green-50 hover:text-green-600 transition"
|
|
>
|
|
<Eye size={18} />
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => openEdit(brand)}
|
|
className="rounded-lg border border-slate-200 p-2 hover:bg-blue-50 hover:text-blue-600 transition"
|
|
>
|
|
<Pencil size={18} />
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => remove(brand.id)}
|
|
className="rounded-lg border border-slate-200 p-2 hover:bg-red-50 hover:text-red-600 transition"
|
|
>
|
|
<Trash2 size={18} />
|
|
</button>
|
|
|
|
</div>
|
|
|
|
</td>
|
|
|
|
</tr>
|
|
|
|
))}
|
|
|
|
</tbody>
|
|
|
|
</table>
|
|
|
|
</div>
|
|
|
|
{/* view popup */}
|
|
{viewOpen && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
|
|
|
<div className="w-full max-w-6xl rounded-2xl bg-white shadow-2xl">
|
|
|
|
{/* Header */}
|
|
|
|
<div className="flex items-center justify-between border-b px-6 py-4">
|
|
|
|
<div>
|
|
|
|
<h2 className="text-xl font-bold text-slate-800">
|
|
{brandName} Products
|
|
</h2>
|
|
|
|
<p className="text-sm text-slate-500">
|
|
Total Products : {brandProducts.length}
|
|
</p>
|
|
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => setViewOpen(false)}
|
|
className="rounded-lg p-2 hover:bg-slate-100"
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
|
|
</div>
|
|
|
|
{/* Body */}
|
|
|
|
<div className="max-h-[70vh] overflow-auto">
|
|
|
|
{brandProducts.length === 0 ? (
|
|
|
|
<div className="py-20 text-center text-slate-500">
|
|
|
|
<Package className="mx-auto mb-3 h-10 w-10 text-slate-300" />
|
|
|
|
No Products Found
|
|
|
|
</div>
|
|
|
|
) : (
|
|
|
|
<table className="min-w-full">
|
|
|
|
<thead className="sticky top-0 bg-slate-50">
|
|
|
|
<tr>
|
|
|
|
<th className="px-5 py-4 text-left text-sm font-semibold">
|
|
Image
|
|
</th>
|
|
|
|
<th className="px-5 py-4 text-left text-sm font-semibold">
|
|
Product
|
|
</th>
|
|
|
|
|
|
|
|
<th className="px-5 py-4 text-left text-sm font-semibold">
|
|
SKU
|
|
</th>
|
|
|
|
<th className="px-5 py-4 text-center text-sm font-semibold">
|
|
QRs
|
|
</th>
|
|
|
|
<th className="px-5 py-4 text-center text-sm font-semibold">
|
|
Status
|
|
</th>
|
|
|
|
</tr>
|
|
|
|
</thead>
|
|
|
|
<tbody>
|
|
|
|
{brandProducts.map((p: any) => (
|
|
|
|
<tr
|
|
key={p.id}
|
|
className="border-b hover:bg-slate-50"
|
|
>
|
|
|
|
{/* Product Image */}
|
|
|
|
<td className="px-5 py-4">
|
|
|
|
<div className="h-14 w-14 overflow-hidden rounded-lg border bg-slate-100">
|
|
|
|
{p.image_url ? (
|
|
|
|
<img
|
|
src={p.image_url}
|
|
alt={p.name}
|
|
className="h-full w-full object-cover"
|
|
/>
|
|
|
|
) : (
|
|
|
|
<div className="flex h-full items-center justify-center">
|
|
|
|
<Package className="h-6 w-6 text-slate-400" />
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
</td>
|
|
|
|
{/* Product */}
|
|
|
|
<td className="px-5 py-4">
|
|
|
|
<div className="font-semibold text-slate-800">
|
|
|
|
{p.name}
|
|
|
|
</div>
|
|
|
|
</td>
|
|
|
|
{/* Brand */}
|
|
|
|
|
|
|
|
{/* SKU */}
|
|
|
|
<td className="px-5 py-4 text-slate-600">
|
|
|
|
{p.sku || "-"}
|
|
|
|
</td>
|
|
|
|
{/* QR Count */}
|
|
|
|
<td className="px-5 py-4 text-center">
|
|
|
|
<span className="rounded-full bg-purple-100 px-3 py-1 text-xs font-bold text-purple-700">
|
|
|
|
{p.qr_count}
|
|
|
|
</span>
|
|
|
|
</td>
|
|
|
|
{/* Status */}
|
|
|
|
<td className="px-5 py-4 text-center">
|
|
|
|
<span
|
|
className={`rounded-full px-3 py-1 text-xs font-semibold ${
|
|
p.status === "active"
|
|
? "bg-green-100 text-green-700"
|
|
: p.status === "draft"
|
|
? "bg-yellow-100 text-yellow-700"
|
|
: "bg-red-100 text-red-700"
|
|
}`}
|
|
>
|
|
{p.status}
|
|
</span>
|
|
|
|
</td>
|
|
|
|
</tr>
|
|
|
|
))}
|
|
|
|
</tbody>
|
|
|
|
</table>
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
|
|
<div className="flex justify-end border-t px-6 py-4">
|
|
|
|
<button
|
|
onClick={() => setViewOpen(false)}
|
|
className="rounded-lg bg-brand-600 px-5 py-2 font-medium text-white hover:bg-brand-700"
|
|
>
|
|
Close
|
|
</button>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
)}
|
|
|
|
{/* Modal */}
|
|
|
|
{show && (
|
|
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
|
|
|
<div className="w-full max-w-lg rounded-xl bg-white shadow-xl">
|
|
|
|
<div className="flex items-center justify-between border-b px-6 py-4">
|
|
|
|
<h3 className="text-lg font-bold">
|
|
{editing ? "Edit Brand" : "Add Brand"}
|
|
</h3>
|
|
|
|
<button
|
|
onClick={() => setShow(false)}
|
|
className="rounded-lg p-2 hover:bg-slate-100"
|
|
>
|
|
<X size={18} />
|
|
</button>
|
|
|
|
</div>
|
|
|
|
<div className="space-y-4 p-6">
|
|
|
|
<div>
|
|
|
|
<label className="mb-1 block text-sm font-medium">
|
|
Brand Name
|
|
</label>
|
|
|
|
<input
|
|
value={form.name}
|
|
onChange={(e) =>
|
|
setForm({ ...form, name: e.target.value })
|
|
}
|
|
className="w-full rounded-lg border border-slate-300 px-3 py-2 focus:border-brand-500 focus:outline-none"
|
|
/>
|
|
|
|
</div>
|
|
|
|
<div>
|
|
<label className="mb-1 block text-sm font-medium">
|
|
Brand Logo
|
|
</label>
|
|
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
onChange={(e) => {
|
|
if (e.target.files?.length) {
|
|
setLogoFile(e.target.files[0]);
|
|
}
|
|
}}
|
|
className="w-full rounded-lg border border-slate-300 px-3 py-2"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
|
|
<label className="mb-1 block text-sm font-medium">
|
|
Description
|
|
</label>
|
|
|
|
<textarea
|
|
rows={4}
|
|
value={form.description}
|
|
onChange={(e) =>
|
|
setForm({
|
|
...form,
|
|
description: e.target.value,
|
|
})
|
|
}
|
|
className="w-full rounded-lg border border-slate-300 px-3 py-2 focus:border-brand-500 focus:outline-none"
|
|
/>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-3 border-t px-6 py-4">
|
|
|
|
<button
|
|
onClick={() => setShow(false)}
|
|
className="rounded-lg border border-slate-300 px-5 py-2 hover:bg-slate-100"
|
|
>
|
|
Cancel
|
|
</button>
|
|
|
|
<button
|
|
onClick={save}
|
|
className="rounded-lg bg-brand-600 px-5 py-2 font-medium text-white hover:bg-brand-700"
|
|
>
|
|
{editing ? "Update Brand" : "Create Brand"}
|
|
</button>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
</div>
|
|
);
|
|
} |