This commit is contained in:
Your Name committed 2026-09-23 06:57:13 +07:00
1 parent c2269ea090
commit 14d3282a81
8 files changed
+224 -37

No files matched your search

+110 -4
View File
@@ -37,6 +37,8 @@ export default function ProductsPage() {
const activeCategory = searchParams.get('category') ? Number(searchParams.get('category')) : null const activeCategory = searchParams.get('category') ? Number(searchParams.get('category')) : null
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null) const [selectedProduct, setSelectedProduct] = useState<Product | null>(null)
const [isGalleryOpen, setIsGalleryOpen] = useState(false) const [isGalleryOpen, setIsGalleryOpen] = useState(false)
const [selectedIds, setSelectedIds] = useState<number[]>([])
const [isBulkDeleting, setIsBulkDeleting] = useState(false)
// Pagination State // Pagination State
const page = searchParams.get('page') ? Number(searchParams.get('page')) : 1 const page = searchParams.get('page') ? Number(searchParams.get('page')) : 1
@@ -188,11 +190,31 @@ export default function ProductsPage() {
} }
} }
const isAllSelected = products.length > 0 && products.every(p => selectedIds.includes(p.id))
const isSomeSelected = products.some(p => selectedIds.includes(p.id)) && !isAllSelected
const handleSelectAll = () => {
if (isAllSelected) {
const pageIds = new Set(products.map(p => p.id))
setSelectedIds(prev => prev.filter(id => !pageIds.has(id)))
} else {
const pageIds = products.map(p => p.id)
setSelectedIds(prev => Array.from(new Set([...prev, ...pageIds])))
}
}
const handleToggleSelect = (id: number) => {
setSelectedIds(prev =>
prev.includes(id) ? prev.filter(item => item !== id) : [...prev, id]
)
}
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
if (!confirm(t('common.confirmDelete', 'Are you sure you want to delete this product?'))) return if (!confirm(t('common.confirmDelete', 'Are you sure you want to delete this product?'))) return
try { try {
const res = await fetch(`${API_BASE}/products/${id}`, { method: 'DELETE' }) const res = await fetch(`${API_BASE}/products/${id}`, { method: 'DELETE' })
if (res.ok) { if (res.ok) {
setSelectedIds(prev => prev.filter(item => item !== id))
fetchData() fetchData()
} else { } else {
const errorData = await res.json().catch(() => null) const errorData = await res.json().catch(() => null)
@@ -203,6 +225,34 @@ export default function ProductsPage() {
} }
} }
const handleBulkDelete = async () => {
if (selectedIds.length === 0) return
const count = selectedIds.length
if (!confirm(t('products.confirmBulkDelete', `Are you sure you want to delete ${count} selected products?`, { count }))) return
setIsBulkDeleting(true)
try {
const res = await fetch(`${API_BASE}/products/bulk-delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: selectedIds })
})
if (res.ok) {
setSelectedIds([])
fetchData()
} else {
const errorData = await res.json().catch(() => null)
alert(errorData?.message || 'Failed to delete selected products')
}
} catch (err) {
console.error('Bulk delete error:', err)
alert('Failed to delete selected products')
} finally {
setIsBulkDeleting(false)
}
}
const setPage = (newOrUpdaterData: number | ((p: number) => number)) => { const setPage = (newOrUpdaterData: number | ((p: number) => number)) => {
const nextUrlPage = typeof newOrUpdaterData === 'function' ? newOrUpdaterData(page) : newOrUpdaterData const nextUrlPage = typeof newOrUpdaterData === 'function' ? newOrUpdaterData(page) : newOrUpdaterData
setSearchParams(prev => { setSearchParams(prev => {
@@ -245,7 +295,7 @@ export default function ProductsPage() {
} }
return ( return (
<div className="space-y-8 animate-fade-in"> <div className="space-y-8 animate-fade-in relative pb-16">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6"> <div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-3xl font-bold">{t('products.title', 'Products Management')}</h1> <h1 className="text-3xl font-bold">{t('products.title', 'Products Management')}</h1>
@@ -300,6 +350,16 @@ export default function ProductsPage() {
<table className="w-full text-left border-collapse"> <table className="w-full text-left border-collapse">
<thead> <thead>
<tr className="bg-white/[0.02] border-b border-white/5"> <tr className="bg-white/[0.02] border-b border-white/5">
<th className="w-12 px-4 py-4 text-center">
<input
type="checkbox"
checked={isAllSelected}
ref={el => { if (el) el.indeterminate = isSomeSelected }}
onChange={handleSelectAll}
className="w-4 h-4 rounded border-white/20 bg-night-950 text-cyan-glow focus:ring-cyan-glow/30 accent-cyan-glow cursor-pointer"
title={t('common.selectAll', 'Select all on this page')}
/>
</th>
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">{t('products.name', 'Product')}</th> <th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">{t('products.name', 'Product')}</th>
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">{t('products.category', 'Category')}</th> <th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">{t('products.category', 'Category')}</th>
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">{t('products.variants', 'Variants')}</th> <th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">{t('products.variants', 'Variants')}</th>
@@ -311,14 +371,14 @@ export default function ProductsPage() {
{loading ? ( {loading ? (
Array.from({ length: 5 }).map((_, i) => ( Array.from({ length: 5 }).map((_, i) => (
<tr key={i}> <tr key={i}>
<td colSpan={5} className="px-6 py-8"> <td colSpan={6} className="px-6 py-8">
<div className="h-10 w-full bg-white/5 animate-pulse rounded-lg" /> <div className="h-10 w-full bg-white/5 animate-pulse rounded-lg" />
</td> </td>
</tr> </tr>
)) ))
) : products.length === 0 ? ( ) : products.length === 0 ? (
<tr> <tr>
<td colSpan={5} className="px-6 py-20 text-center space-y-3"> <td colSpan={6} className="px-6 py-20 text-center space-y-3">
<Package className="w-10 h-10 text-slate-700 mx-auto" /> <Package className="w-10 h-10 text-slate-700 mx-auto" />
<p className="text-slate-500 font-medium italic">{t('common.noData', 'No products matched your search.')}</p> <p className="text-slate-500 font-medium italic">{t('common.noData', 'No products matched your search.')}</p>
</td> </td>
@@ -330,9 +390,21 @@ export default function ProductsPage() {
const localizedCategory = typeof product.category.name === 'string' const localizedCategory = typeof product.category.name === 'string'
? product.category.name ? product.category.name
: (product.category.name?.[i18n.language] || (product.category.name as any)?.th || (product.category.name as any)?.en || '') : (product.category.name?.[i18n.language] || (product.category.name as any)?.th || (product.category.name as any)?.en || '')
const isSelected = selectedIds.includes(product.id)
return ( return (
<tr key={product.id} className="hover:bg-white/[0.01] transition-colors group"> <tr
key={product.id}
className={`transition-colors group ${isSelected ? 'bg-cyan-glow/[0.04] hover:bg-cyan-glow/[0.07]' : 'hover:bg-white/[0.01]'}`}
>
<td className="w-12 px-4 py-4 text-center">
<input
type="checkbox"
checked={isSelected}
onChange={() => handleToggleSelect(product.id)}
className="w-4 h-4 rounded border-white/20 bg-night-950 text-cyan-glow focus:ring-cyan-glow/30 accent-cyan-glow cursor-pointer"
/>
</td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-lg overflow-hidden border border-white/5 bg-night-950/50 p-1 flex-shrink-0 flex items-center justify-center"> <div className="w-12 h-12 rounded-lg overflow-hidden border border-white/5 bg-night-950/50 p-1 flex-shrink-0 flex items-center justify-center">
@@ -578,6 +650,40 @@ export default function ProductsPage() {
</div> </div>
)} )}
{/* Floating Bulk Actions Bar */}
{selectedIds.length > 0 && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40 bg-night-900/95 border border-cyan-glow/30 backdrop-blur-xl shadow-2xl rounded-2xl px-6 py-3.5 flex items-center gap-6 animate-slide-up">
<div className="flex items-center gap-2.5">
<span className="w-2.5 h-2.5 rounded-full bg-cyan-glow animate-pulse" />
<span className="text-sm font-semibold text-white">
{t('products.selectedCount', `เลือกอยู่ ${selectedIds.length} รายการ`, { count: selectedIds.length })}
</span>
</div>
<div className="h-4 w-px bg-white/10" />
<div className="flex items-center gap-2">
<button
onClick={handleBulkDelete}
disabled={isBulkDeleting}
className="flex items-center gap-2 px-4 py-2 bg-rose-500/20 hover:bg-rose-500/30 text-rose-400 border border-rose-500/30 hover:border-rose-500/50 rounded-xl text-xs font-bold transition-all disabled:opacity-50 cursor-pointer shadow-lg shadow-rose-950/40"
>
{isBulkDeleting ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Trash2 className="w-3.5 h-3.5" />
)}
<span>{t('products.deleteSelected', `ลบรายการที่เลือก (${selectedIds.length})`, { count: selectedIds.length })}</span>
</button>
<button
onClick={() => setSelectedIds([])}
className="p-2 hover:bg-white/5 text-slate-400 hover:text-white rounded-xl transition-all cursor-pointer"
title={t('common.cancel', 'ยกเลิกการเลือก')}
>
<X className="w-4 h-4" />
</button>
</div>
</div>
)}
</div> </div>
) )
} }
@@ -0,0 +1,7 @@
-- AlterTable
ALTER TABLE "Product" ADD COLUMN IF NOT EXISTS "isDeleted" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Product" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3);
-- CreateIndex
CREATE INDEX IF NOT EXISTS "Product_isDeleted_idx" ON "Product"("isDeleted");
CREATE INDEX IF NOT EXISTS "Product_categoryId_isDeleted_idx" ON "Product"("categoryId", "isDeleted");
+5
View File
@@ -72,8 +72,13 @@ model Product {
reviews Review[] reviews Review[]
detailSections ProductDetailSection[] detailSections ProductDetailSection[]
translations ProductTranslation[] translations ProductTranslation[]
isDeleted Boolean @default(false)
deletedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([isDeleted])
@@index([categoryId, isDeleted])
} }
model ProductTranslation { model ProductTranslation {
+3 -2
View File
@@ -50,6 +50,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
include: { include: {
translations: true, translations: true,
products: { products: {
where: { isDeleted: false },
include: { include: {
brand: true, brand: true,
category: { include: { translations: true } }, category: { include: { translations: true } },
@@ -62,11 +63,11 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
take: limit, take: limit,
}, },
_count: { _count: {
select: { products: true } select: { products: { where: { isDeleted: false } } }
} }
}, },
}), }),
prisma.product.count({ where: { categoryId: id } }), prisma.product.count({ where: { categoryId: id, isDeleted: false } }),
]); ]);
if (!category) return status(404, { message: "Category not found" }); if (!category) return status(404, { message: "Category not found" });
+4 -3
View File
@@ -1242,7 +1242,7 @@ async function resolveProductsForSection(payload: any, lang: string) {
if (payload.type === "featured") { if (payload.type === "featured") {
products = await prisma.product.findMany({ products = await prisma.product.findMany({
where: { categoryId: Number(payload.categoryId) || 1 }, where: { categoryId: Number(payload.categoryId) || 1, isDeleted: false },
include: productInclude, include: productInclude,
take: limit, take: limit,
}); });
@@ -1251,7 +1251,7 @@ async function resolveProductsForSection(payload: any, lang: string) {
(payload.scentId || payload.brandId) (payload.scentId || payload.brandId)
) { ) {
products = await prisma.product.findMany({ products = await prisma.product.findMany({
where: { brandId: Number(payload.brandId || payload.scentId) }, where: { brandId: Number(payload.brandId || payload.scentId), isDeleted: false },
include: productInclude, include: productInclude,
take: limit, take: limit,
}); });
@@ -1259,13 +1259,14 @@ async function resolveProductsForSection(payload: any, lang: string) {
const ids = payload.productIds.map(Number); const ids = payload.productIds.map(Number);
if (ids.length > 0) { if (ids.length > 0) {
products = await prisma.product.findMany({ products = await prisma.product.findMany({
where: { id: { in: ids } }, where: { id: { in: ids }, isDeleted: false },
include: productInclude, include: productInclude,
}); });
products.sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id)); products.sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id));
} }
} else { } else {
products = await prisma.product.findMany({ products = await prisma.product.findMany({
where: { isDeleted: false },
include: productInclude, include: productInclude,
take: limit, take: limit,
}); });
+74 -1
View File
@@ -122,7 +122,10 @@ export const productsRoute = new Elysia({ prefix: "/products" })
const limit = query.limit ? parseInt(query.limit) : 12; const limit = query.limit ? parseInt(query.limit) : 12;
const skip = (page - 1) * limit; const skip = (page - 1) * limit;
const includeDeleted = query.includeDeleted === 'true';
const where = { const where = {
...(!includeDeleted && { isDeleted: false }),
...(categoryId && { categoryId }), ...(categoryId && { categoryId }),
...(brandId && { brandId }), ...(brandId && { brandId }),
...(search && { ...(search && {
@@ -255,7 +258,8 @@ export const productsRoute = new Elysia({ prefix: "/products" })
}, },
}); });
if (!product) return status(404, { message: "Product not found" }); const includeDeleted = query.includeDeleted === 'true';
if (!product || (!includeDeleted && product.isDeleted)) return status(404, { message: "Product not found" });
if (lang === 'all') { if (lang === 'all') {
const nameObj: any = {}; const nameObj: any = {};
@@ -1011,4 +1015,73 @@ export const productsRoute = new Elysia({ prefix: "/products" })
tags: ["products"], tags: ["products"],
}, },
} }
)
.delete(
"/:id",
async ({ params, status }) => {
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { success: false, message: "Invalid product ID" });
const existing = await prisma.product.findUnique({
where: { id },
select: { id: true, isDeleted: true },
});
if (!existing) {
return status(404, { success: false, message: "Product not found" });
}
await prisma.product.update({
where: { id },
data: {
isDeleted: true,
deletedAt: new Date(),
},
});
return { success: true, message: "Product deleted successfully" };
},
{
detail: {
summary: "Soft Delete Product",
tags: ["products"],
},
}
)
.post(
"/bulk-delete",
async ({ body, status }) => {
const { ids } = body as { ids?: number[] };
if (!Array.isArray(ids) || ids.length === 0) {
return status(400, { success: false, message: "Invalid or empty IDs array" });
}
const validIds = ids.map(id => Number(id)).filter(id => !isNaN(id));
if (validIds.length === 0) {
return status(400, { success: false, message: "No valid IDs provided" });
}
const result = await prisma.product.updateMany({
where: { id: { in: validIds } },
data: {
isDeleted: true,
deletedAt: new Date(),
},
});
return {
success: true,
count: result.count,
message: `Successfully deleted ${result.count} products`,
};
},
{
body: t.Object({
ids: t.Array(t.Numeric()),
}),
detail: {
summary: "Bulk Soft Delete Products",
tags: ["products"],
},
}
); );
+2
View File
@@ -109,6 +109,8 @@ export interface Product {
variants: Variant[] variants: Variant[]
images: ProductImage[] images: ProductImage[]
attributes?: ProductAttribute[] attributes?: ProductAttribute[]
isDeleted?: boolean
deletedAt?: string | null
} }
const DEFAULT_DEV_API_PORT = 3051 const DEFAULT_DEV_API_PORT = 3051
+19 -27
View File
@@ -1,38 +1,30 @@
{ {
"version": "1.0", "version": "1.0",
"themeTokens": { "themeTokens": {
"brandPrimary": "#B8956A", "brandPrimary": "#A100FF",
"brandPrimaryHover": "#A07D3A", "brandPrimaryHover": "#C23BFF",
"brandAccent": "#D4B97A", "brandAccent": "#FF7A1A",
"brandWarm": "#D4785A", "brandWarm": "#FF8F45",
"brandCool": "#EDE6DD", "brandCool": "#E7EBF5",
"brandSuccess": "#5B9A4D", "brandSuccess": "#46D36A",
"brandSoft": "#DABE7E", "brandSoft": "#D16BFF",
"surfacePage": "#0A0D14",
"surfacePage": "#F5F0EB", "surfaceNavRest": "#0D1018",
"surfaceNavRest": "#14171C", "surfaceCard": "#151A24",
"surfaceCard": "#FFFFFF", "textMain": "#F5F7FF",
"mutedText": "#A7AFBF",
"textMain": "#EDE7E1", "linePrimary": "#A100FF",
"mutedText": "#BDB7B0", "linePrimaryHover": "#FF7A1A",
"captionText": "#7D8598",
"linePrimary": "#B8956A", "borderColor": "#242A38",
"linePrimaryHover": "#D4B97A", "surfaceMuted": "#111622",
"captionText": "#A9A39D",
"borderColor": "#2A2E34",
"surfaceMuted": "#1B1F24",
"fontBody": "", "fontBody": "",
"fontDisplay": "", "fontDisplay": "",
"shadowCard": "0 2px 8px rgba(0,0,0,0.30)",
"shadowCard": "0 1px 4px rgba(0,0,0,0.12)", "shadowCardHover": "0 8px 24px rgba(0,0,0,0.40)",
"shadowCardHover": "0 4px 12px rgba(0,0,0,0.18)",
"radiusBase": "8px", "radiusBase": "8px",
"radiusCard": "16px" "radiusCard": "16px"
}, },
"scrollRevealEnabled": true, "scrollRevealEnabled": true,
"scrollRevealStyle": "fade-up", "scrollRevealStyle": "fade-up",
"scrollRevealDuration": 700, "scrollRevealDuration": 700,