diff --git a/apps/admin/src/pages/ProductsPage.tsx b/apps/admin/src/pages/ProductsPage.tsx index 392e959..70b2853 100644 --- a/apps/admin/src/pages/ProductsPage.tsx +++ b/apps/admin/src/pages/ProductsPage.tsx @@ -37,6 +37,8 @@ export default function ProductsPage() { const activeCategory = searchParams.get('category') ? Number(searchParams.get('category')) : null const [selectedProduct, setSelectedProduct] = useState(null) const [isGalleryOpen, setIsGalleryOpen] = useState(false) + const [selectedIds, setSelectedIds] = useState([]) + const [isBulkDeleting, setIsBulkDeleting] = useState(false) // Pagination State 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) => { if (!confirm(t('common.confirmDelete', 'Are you sure you want to delete this product?'))) return try { const res = await fetch(`${API_BASE}/products/${id}`, { method: 'DELETE' }) if (res.ok) { + setSelectedIds(prev => prev.filter(item => item !== id)) fetchData() } else { 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 nextUrlPage = typeof newOrUpdaterData === 'function' ? newOrUpdaterData(page) : newOrUpdaterData setSearchParams(prev => { @@ -245,7 +295,7 @@ export default function ProductsPage() { } return ( -
+

{t('products.title', 'Products Management')}

@@ -300,6 +350,16 @@ export default function ProductsPage() { + @@ -311,14 +371,14 @@ export default function ProductsPage() { {loading ? ( Array.from({ length: 5 }).map((_, i) => ( - )) ) : products.length === 0 ? ( - @@ -330,9 +390,21 @@ export default function ProductsPage() { const localizedCategory = typeof product.category.name === 'string' ? product.category.name : (product.category.name?.[i18n.language] || (product.category.name as any)?.th || (product.category.name as any)?.en || '') + const isSelected = selectedIds.includes(product.id) return ( - + +
+ { 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')} + /> + {t('products.name', 'Product')} {t('products.category', 'Category')} {t('products.variants', 'Variants')}
+
+

{t('common.noData', 'No products matched your search.')}

+ 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" + /> +
@@ -376,8 +448,8 @@ export default function ProductsPage() { onClick={() => { setSelectedProduct(product); setIsGalleryOpen(true); }} className={`p-2 rounded-lg transition-all cursor-pointer ${ product.images && product.images.length > 0 - ? 'bg-cyan-glow/10 text-cyan-glow hover:bg-cyan-glow/20' - : 'hover:bg-white/5 text-slate-600' + ? 'bg-cyan-glow/10 text-cyan-glow hover:bg-cyan-glow/20' + : 'hover:bg-white/5 text-slate-600' }`} title={product.images && product.images.length > 0 ? "View Gallery" : "No Images"} > @@ -578,6 +650,40 @@ export default function ProductsPage() {
)} + {/* Floating Bulk Actions Bar */} + {selectedIds.length > 0 && ( +
+
+ + + {t('products.selectedCount', `เลือกอยู่ ${selectedIds.length} รายการ`, { count: selectedIds.length })} + +
+
+
+ + +
+
+ )} +
) } diff --git a/apps/backend/prisma/migrations/20260923063500_add_product_soft_delete/migration.sql b/apps/backend/prisma/migrations/20260923063500_add_product_soft_delete/migration.sql new file mode 100644 index 0000000..e57a01f --- /dev/null +++ b/apps/backend/prisma/migrations/20260923063500_add_product_soft_delete/migration.sql @@ -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"); diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 7d21d10..1bc6ddf 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -72,8 +72,13 @@ model Product { reviews Review[] detailSections ProductDetailSection[] translations ProductTranslation[] + isDeleted Boolean @default(false) + deletedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + + @@index([isDeleted]) + @@index([categoryId, isDeleted]) } model ProductTranslation { diff --git a/apps/backend/src/routes/categories.ts b/apps/backend/src/routes/categories.ts index 412cc9c..0a8e95f 100644 --- a/apps/backend/src/routes/categories.ts +++ b/apps/backend/src/routes/categories.ts @@ -50,6 +50,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" }) include: { translations: true, products: { + where: { isDeleted: false }, include: { brand: true, category: { include: { translations: true } }, @@ -62,11 +63,11 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" }) take: limit, }, _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" }); diff --git a/apps/backend/src/routes/pages.ts b/apps/backend/src/routes/pages.ts index d36f213..390a61e 100644 --- a/apps/backend/src/routes/pages.ts +++ b/apps/backend/src/routes/pages.ts @@ -1242,7 +1242,7 @@ async function resolveProductsForSection(payload: any, lang: string) { if (payload.type === "featured") { products = await prisma.product.findMany({ - where: { categoryId: Number(payload.categoryId) || 1 }, + where: { categoryId: Number(payload.categoryId) || 1, isDeleted: false }, include: productInclude, take: limit, }); @@ -1251,7 +1251,7 @@ async function resolveProductsForSection(payload: any, lang: string) { (payload.scentId || payload.brandId) ) { products = await prisma.product.findMany({ - where: { brandId: Number(payload.brandId || payload.scentId) }, + where: { brandId: Number(payload.brandId || payload.scentId), isDeleted: false }, include: productInclude, take: limit, }); @@ -1259,13 +1259,14 @@ async function resolveProductsForSection(payload: any, lang: string) { const ids = payload.productIds.map(Number); if (ids.length > 0) { products = await prisma.product.findMany({ - where: { id: { in: ids } }, + where: { id: { in: ids }, isDeleted: false }, include: productInclude, }); products.sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id)); } } else { products = await prisma.product.findMany({ + where: { isDeleted: false }, include: productInclude, take: limit, }); diff --git a/apps/backend/src/routes/products.ts b/apps/backend/src/routes/products.ts index a64705f..d55a515 100644 --- a/apps/backend/src/routes/products.ts +++ b/apps/backend/src/routes/products.ts @@ -122,7 +122,10 @@ export const productsRoute = new Elysia({ prefix: "/products" }) const limit = query.limit ? parseInt(query.limit) : 12; const skip = (page - 1) * limit; + const includeDeleted = query.includeDeleted === 'true'; + const where = { + ...(!includeDeleted && { isDeleted: false }), ...(categoryId && { categoryId }), ...(brandId && { brandId }), ...(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') { const nameObj: any = {}; @@ -1011,4 +1015,73 @@ export const productsRoute = new Elysia({ prefix: "/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"], + }, + } ); diff --git a/packages/types/index.ts b/packages/types/index.ts index 72b1ebd..a927e06 100644 --- a/packages/types/index.ts +++ b/packages/types/index.ts @@ -109,6 +109,8 @@ export interface Product { variants: Variant[] images: ProductImage[] attributes?: ProductAttribute[] + isDeleted?: boolean + deletedAt?: string | null } const DEFAULT_DEV_API_PORT = 3051 diff --git a/theme-settings-2026-09-22.json b/theme-settings-2026-09-22.json index cb15aba..d98fa95 100644 --- a/theme-settings-2026-09-22.json +++ b/theme-settings-2026-09-22.json @@ -1,38 +1,30 @@ { "version": "1.0", "themeTokens": { - "brandPrimary": "#B8956A", - "brandPrimaryHover": "#A07D3A", - "brandAccent": "#D4B97A", - "brandWarm": "#D4785A", - "brandCool": "#EDE6DD", - "brandSuccess": "#5B9A4D", - "brandSoft": "#DABE7E", - - "surfacePage": "#F5F0EB", - "surfaceNavRest": "#14171C", - "surfaceCard": "#FFFFFF", - - "textMain": "#EDE7E1", - "mutedText": "#BDB7B0", - - "linePrimary": "#B8956A", - "linePrimaryHover": "#D4B97A", - - "captionText": "#A9A39D", - "borderColor": "#2A2E34", - "surfaceMuted": "#1B1F24", - + "brandPrimary": "#A100FF", + "brandPrimaryHover": "#C23BFF", + "brandAccent": "#FF7A1A", + "brandWarm": "#FF8F45", + "brandCool": "#E7EBF5", + "brandSuccess": "#46D36A", + "brandSoft": "#D16BFF", + "surfacePage": "#0A0D14", + "surfaceNavRest": "#0D1018", + "surfaceCard": "#151A24", + "textMain": "#F5F7FF", + "mutedText": "#A7AFBF", + "linePrimary": "#A100FF", + "linePrimaryHover": "#FF7A1A", + "captionText": "#7D8598", + "borderColor": "#242A38", + "surfaceMuted": "#111622", "fontBody": "", "fontDisplay": "", - - "shadowCard": "0 1px 4px rgba(0,0,0,0.12)", - "shadowCardHover": "0 4px 12px rgba(0,0,0,0.18)", - + "shadowCard": "0 2px 8px rgba(0,0,0,0.30)", + "shadowCardHover": "0 8px 24px rgba(0,0,0,0.40)", "radiusBase": "8px", "radiusCard": "16px" }, - "scrollRevealEnabled": true, "scrollRevealStyle": "fade-up", "scrollRevealDuration": 700,