This commit is contained in:
Your Name committed 2026-10-15 06:56:21 +07:00
1 parent 570810acdb
commit 64bcd6388a
5 files changed
+915 -28

No files matched your search

+504 -13
View File
@@ -1,13 +1,78 @@
import { useState } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { API_BASE } from '@homaroma/types'
import { Loader2, DatabaseZap, CheckCircle2, AlertCircle } from 'lucide-react'
import {
Loader2,
DatabaseZap,
CheckCircle2,
AlertCircle,
Trash2,
AlertTriangle,
RefreshCw,
Package,
FolderTree,
Tag,
Sparkles,
ShieldAlert,
Layers,
X
} from 'lucide-react'
type SeedResult = { success: boolean; message: string; results?: { slug: string; status: string }[] }
type CatalogStats = {
products: number
variants: number
categories: number
brands: number
demoCategories: { id: number; name: string; productCount: number }[]
demoCategoriesCount: number
demoBrandsCount: number
}
type ActionModal = {
type: 'purge-demo' | 'purge-products' | 'purge-categories' | 'purge-catalog'
title: string
description: string
confirmText: string
dangerLevel: 'high' | 'critical'
} | null
export default function SystemMaintenancePage() {
// Page Seeding State
const [seeding, setSeeding] = useState(false)
const [seedResult, setSeedResult] = useState<SeedResult | null>(null)
// Catalog Stats State
const [stats, setStats] = useState<CatalogStats | null>(null)
const [loadingStats, setLoadingStats] = useState(false)
// Maintenance Action Modal & Loading
const [actionModal, setActionModal] = useState<ActionModal>(null)
const [actionLoading, setActionLoading] = useState(false)
const [actionResult, setActionResult] = useState<{ success: boolean; message: string } | null>(null)
const [cascadeCategories, setCascadeCategories] = useState(true)
const [clearBrandsWithCatalog, setClearBrandsWithCatalog] = useState(false)
const [confirmedByUser, setConfirmedByUser] = useState(false)
const fetchStats = useCallback(async () => {
setLoadingStats(true)
try {
const res = await fetch(`${API_BASE}/settings/system/catalog-stats`)
const data = await res.json()
if (res.ok && data.success) {
setStats(data.stats)
}
} catch (err) {
console.error('Failed to load stats:', err)
} finally {
setLoadingStats(false)
}
}, [])
useEffect(() => {
fetchStats()
}, [fetchStats])
const handleSeedPages = async () => {
setSeeding(true)
setSeedResult(null)
@@ -26,16 +91,347 @@ export default function SystemMaintenancePage() {
}
}
const openActionModal = (modal: ActionModal) => {
setActionResult(null)
setConfirmedByUser(false)
setActionModal(modal)
}
const closeActionModal = () => {
if (actionLoading) return
setActionModal(null)
setConfirmedByUser(false)
}
const executeAction = async () => {
if (!actionModal) return
setActionLoading(true)
setActionResult(null)
try {
let endpoint = ''
let payload: any = {}
if (actionModal.type === 'purge-demo') {
endpoint = `${API_BASE}/settings/system/purge-demo-homaroma`
} else if (actionModal.type === 'purge-products') {
endpoint = `${API_BASE}/settings/system/purge-products`
} else if (actionModal.type === 'purge-categories') {
endpoint = `${API_BASE}/settings/system/purge-categories`
payload = { forceCascadeProducts: cascadeCategories }
} else if (actionModal.type === 'purge-catalog') {
endpoint = `${API_BASE}/settings/system/purge-catalog`
payload = { clearProducts: true, clearCategories: true, clearBrands: clearBrandsWithCatalog }
}
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: Object.keys(payload).length > 0 ? JSON.stringify(payload) : undefined,
})
const data = await res.json()
if (res.ok && data.success) {
setActionResult({ success: true, message: data.message || 'Operation completed successfully' })
setActionModal(null)
await fetchStats()
} else {
setActionResult({ success: false, message: data.error || data.message || 'Operation failed' })
}
} catch (err: any) {
setActionResult({ success: false, message: err.message || 'Network error occurred' })
} finally {
setActionLoading(false)
}
}
return (
<div className="max-w-3xl space-y-8 animate-fade-in">
<div className="max-w-4xl space-y-8 animate-fade-in pb-16">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="space-y-1">
<h1 className="text-3xl font-bold">System Maintenance</h1>
<p className="text-slate-400 text-sm">Utilities for initializing or repairing system data.</p>
<h1 className="text-3xl font-bold text-white">System Maintenance</h1>
<p className="text-slate-400 text-sm">
เครื่องมือจัดการและกู้คืนระบบ ซ่อมแซม และล้างข้อมูลแคตตาล็อกที่ตกค้างจากการ Seed
</p>
</div>
<button
type="button"
onClick={fetchStats}
disabled={loadingStats}
className="self-start md:self-auto flex items-center gap-2 px-3.5 py-2 rounded-lg bg-white/5 border border-white/10 hover:bg-white/10 text-xs font-semibold text-slate-300 transition cursor-pointer disabled:opacity-50"
>
<RefreshCw className={`w-3.5 h-3.5 ${loadingStats ? 'animate-spin text-cyan-400' : 'text-slate-400'}`} />
<span>รีเฟรชสถิติ</span>
</button>
</div>
<div className="admin-card p-8 space-y-6">
{/* Global Status Banner if any action completed */}
{actionResult && (
<div
className={`rounded-xl p-4 text-sm flex items-start gap-3 border shadow-lg ${
actionResult.success
? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-200'
: 'bg-red-500/10 border-red-500/30 text-red-200'
}`}
>
{actionResult.success ? (
<CheckCircle2 className="w-5 h-5 text-emerald-400 shrink-0 mt-0.5" />
) : (
<AlertCircle className="w-5 h-5 text-red-400 shrink-0 mt-0.5" />
)}
<div className="flex-1">
<h4 className="font-bold text-xs uppercase tracking-wider mb-0.5">
{actionResult.success ? 'ดำเนินการสำเร็จ' : 'เกิดข้อผิดพลาด'}
</h4>
<p className="text-xs leading-relaxed">{actionResult.message}</p>
</div>
<button
onClick={() => setActionResult(null)}
className="text-slate-400 hover:text-white p-1 rounded-md transition"
>
<X className="w-4 h-4" />
</button>
</div>
)}
{/* Live Catalog Statistics Card */}
<div className="admin-card p-6 bg-night-900 border border-white/10 rounded-2xl shadow-xl space-y-4">
<div className="flex items-center justify-between border-b border-white/10 pb-3">
<div className="flex items-center gap-2.5">
<Layers className="w-5 h-5 text-cyan-400" />
<h2 className="text-sm font-bold uppercase tracking-wider text-white">
สถิติข้อมูลในฐานข้อมูล (Database Catalog Stats)
</h2>
</div>
{stats && stats.demoCategoriesCount > 0 && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-amber-500/10 text-amber-300 border border-amber-500/20">
<Sparkles className="w-3.5 h-3.5" />
พบข้อมูล Demo Hom Aroma ({stats.demoCategoriesCount} หมวดหมู่)
</span>
)}
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="p-4 rounded-xl bg-night-950/60 border border-white/5 space-y-1">
<div className="flex items-center gap-2 text-xs text-slate-400">
<Package className="w-3.5 h-3.5 text-blue-400" />
<span>สินค้า (Products)</span>
</div>
<div className="flex items-baseline gap-2">
<span className="text-2xl font-bold text-white">{stats ? stats.products.toLocaleString() : '...'}</span>
<span className="text-[11px] text-slate-500">
({stats ? stats.variants.toLocaleString() : '...'} variants)
</span>
</div>
</div>
<div className="p-4 rounded-xl bg-night-950/60 border border-white/5 space-y-1">
<div className="flex items-center gap-2 text-xs text-slate-400">
<FolderTree className="w-3.5 h-3.5 text-emerald-400" />
<span>หมวดหมู่ (Categories)</span>
</div>
<div className="flex items-baseline gap-2">
<span className="text-2xl font-bold text-white">{stats ? stats.categories.toLocaleString() : '...'}</span>
<span className="text-[11px] text-slate-500">หมวดหมู่</span>
</div>
</div>
<div className="p-4 rounded-xl bg-night-950/60 border border-white/5 space-y-1">
<div className="flex items-center gap-2 text-xs text-slate-400">
<Tag className="w-3.5 h-3.5 text-purple-400" />
<span>แบรนด์ (Brands)</span>
</div>
<div className="flex items-baseline gap-2">
<span className="text-2xl font-bold text-white">{stats ? stats.brands.toLocaleString() : '...'}</span>
<span className="text-[11px] text-slate-500">แบรนด์</span>
</div>
</div>
<div className="p-4 rounded-xl bg-night-950/60 border border-white/5 space-y-1">
<div className="flex items-center gap-2 text-xs text-slate-400">
<AlertTriangle className="w-3.5 h-3.5 text-amber-400" />
<span>Demo ติดมา</span>
</div>
<div className="flex items-baseline gap-2">
<span className={`text-2xl font-bold ${stats && stats.demoCategoriesCount > 0 ? 'text-amber-400' : 'text-slate-400'}`}>
{stats ? stats.demoCategoriesCount : '...'}
</span>
<span className="text-[11px] text-slate-500">หมวดหมู่เดโม</span>
</div>
</div>
</div>
{stats && stats.demoCategories.length > 0 && (
<div className="pt-2 text-xs text-slate-400 flex flex-wrap items-center gap-2">
<span className="text-slate-500 font-medium">หมวดหมู่ตัวอย่าง Hom Aroma:</span>
{stats.demoCategories.map((c) => (
<span
key={c.id}
className="px-2 py-0.5 rounded bg-white/5 border border-white/10 text-slate-300 font-mono text-[11px]"
>
{c.name} {c.productCount > 0 && <span className="text-amber-400">({c.productCount})</span>}
</span>
))}
</div>
)}
</div>
{/* Catalog Cleanup & Relational Reset Card (NEW) */}
<div className="admin-card p-8 bg-night-900 border border-red-500/20 rounded-2xl shadow-xl space-y-6">
<div className="flex items-center justify-between border-b border-white/10 pb-4">
<div className="flex items-center gap-3">
<div className="p-2.5 bg-red-500/10 border border-red-500/20 rounded-xl">
<ShieldAlert className="w-6 h-6 text-red-400" />
</div>
<div>
<h2 className="text-lg font-bold uppercase tracking-wider text-white">
Catalog & Data Cleanup (จัดการและล้างข้อมูลสินค้า / หมวดหมู่)
</h2>
<p className="text-xs text-slate-400 mt-0.5">
ล้างข้อมูล Seed หรือสินค้า/หมวดหมู่ที่ติด Relation Constraint อย่างปลอดภัยและสะอาด 100%
</p>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Option 1: ล้างเฉพาะข้อมูลตัวอย่าง Hom Aroma (Recommended) */}
<div className="p-5 rounded-xl bg-night-950/80 border border-amber-500/30 hover:border-amber-500/50 transition flex flex-col justify-between space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-amber-400" />
<h3 className="text-sm font-bold text-white">ล้างเฉพาะข้อมูลตัวอย่าง Hom Aroma</h3>
</div>
<span className="px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider bg-amber-500/20 text-amber-300 border border-amber-500/30">
แนะนำ
</span>
</div>
<p className="text-xs text-slate-300 leading-relaxed">
ลบเฉพาะ 5 หมวดหมู่ตัวอย่าง (Oil Ozone, Aroma Oil, Reed Diffuser, Room Spray, Car Diffuser), สินค้าที่ผูกอยู่, และแบรนด์กลิ่นอโรมาที่ไม่ถูกใช้งาน <strong>โดยไม่กระทบข้อมูลสินค้าหรือหมวดหมู่อื่นๆ ที่สร้างไว้</strong>
</p>
</div>
<button
type="button"
onClick={() =>
openActionModal({
type: 'purge-demo',
title: 'ยืนยันล้างข้อมูลตัวอย่าง Hom Aroma',
description:
'ระบบจะทำการลบ 5 หมวดหมู่ตัวอย่าง (Oil Ozone, Aroma Oil, Reed Diffuser, Room Spray, Car Diffuser) พร้อมสินค้าและ Variants ที่ผูกอยู่ และเคลียร์แบรนด์กลิ่นอโรมาที่ไม่ถูกใช้งาน',
confirmText: 'ล้างข้อมูล Demo Hom Aroma',
dangerLevel: 'high',
})
}
className="w-full flex items-center justify-center gap-2 py-2.5 px-4 rounded-lg bg-amber-500/10 border border-amber-500/30 hover:bg-amber-500/20 text-amber-400 text-xs font-bold uppercase tracking-wider transition cursor-pointer"
>
<Trash2 className="w-4 h-4" />
<span>ล้างข้อมูล Demo Hom Aroma</span>
</button>
</div>
{/* Option 2: ลบสินค้าทั้งหมด (Purge All Products) */}
<div className="p-5 rounded-xl bg-night-950/80 border border-red-500/20 hover:border-red-500/40 transition flex flex-col justify-between space-y-4">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Package className="w-4 h-4 text-red-400" />
<h3 className="text-sm font-bold text-white">ลบสินค้าทั้งหมด (Purge All Products)</h3>
</div>
<p className="text-xs text-slate-300 leading-relaxed">
ลบสินค้าทุกรายการในระบบ ({stats?.products || 0} รายการ) พร้อมตัด Foreign Keys ลบ Variants, ภาพถ่าย, สเปก (Attributes), Detail Sections และรีวิวทั้งหมด <strong>ไม่ติดปัญหา Relation ใดๆ แน่นอน</strong>
</p>
</div>
<button
type="button"
onClick={() =>
openActionModal({
type: 'purge-products',
title: 'ยืนยันลบสินค้าทั้งหมดในระบบ',
description: `คุณกำลังจะลบสินค้าทั้งหมดจำนวน ${stats?.products || 0} รายการ (${stats?.variants || 0} variants) ข้อมูลและภาพถ่ายทั้งหมดจะถูกลบออกจากฐานข้อมูลอย่างถาวร`,
confirmText: 'ลบสินค้าทั้งหมดทันที',
dangerLevel: 'critical',
})
}
className="w-full flex items-center justify-center gap-2 py-2.5 px-4 rounded-lg bg-red-500/10 border border-red-500/30 hover:bg-red-500/20 text-red-400 text-xs font-bold uppercase tracking-wider transition cursor-pointer"
>
<Trash2 className="w-4 h-4" />
<span>ลบสินค้าทั้งหมด ({stats?.products || 0})</span>
</button>
</div>
{/* Option 3: ลบหมวดหมู่ทั้งหมด (Purge All Categories) */}
<div className="p-5 rounded-xl bg-night-950/80 border border-red-500/20 hover:border-red-500/40 transition flex flex-col justify-between space-y-4">
<div className="space-y-2">
<div className="flex items-center gap-2">
<FolderTree className="w-4 h-4 text-red-400" />
<h3 className="text-sm font-bold text-white">ลบหมวดหมู่ทั้งหมด (Purge All Categories)</h3>
</div>
<p className="text-xs text-slate-300 leading-relaxed">
ลบหมวดหมู่ทั้งหมดในระบบ ({stats?.categories || 0} หมวดหมู่) รวมถึง Media, Section และคำแปลภาษาทั้งหมด สามารถเปิดตัวเลือกล้างสินค้าที่ผูกอยู่ไปด้วยกันได้
</p>
</div>
<button
type="button"
onClick={() =>
openActionModal({
type: 'purge-categories',
title: 'ยืนยันลบหมวดหมู่ทั้งหมดในระบบ',
description: `คุณกำลังจะลบหมวดหมู่สินค้าทั้งหมดจำนวน ${stats?.categories || 0} หมวดหมู่`,
confirmText: 'ลบหมวดหมู่ทั้งหมดทันที',
dangerLevel: 'critical',
})
}
className="w-full flex items-center justify-center gap-2 py-2.5 px-4 rounded-lg bg-red-500/10 border border-red-500/30 hover:bg-red-500/20 text-red-400 text-xs font-bold uppercase tracking-wider transition cursor-pointer"
>
<Trash2 className="w-4 h-4" />
<span>ลบหมวดหมู่ทั้งหมด ({stats?.categories || 0})</span>
</button>
</div>
{/* Option 4: ล้างแคตตาล็อกทั้งหมด (Reset Entire Catalog) */}
<div className="p-5 rounded-xl bg-night-950/80 border border-red-600/30 hover:border-red-500/60 transition flex flex-col justify-between space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<DatabaseZap className="w-4 h-4 text-red-400" />
<h3 className="text-sm font-bold text-white">ล้างแคตตาล็อกทั้งหมด (Full Catalog Reset)</h3>
</div>
<span className="px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider bg-red-500/20 text-red-300 border border-red-500/30">
Full Reset
</span>
</div>
<p className="text-xs text-slate-300 leading-relaxed">
ล้างทั้งสินค้า ({stats?.products || 0}) และหมวดหมู่ ({stats?.categories || 0}) ทั้งหมดในครั้งเดียว คืนพื้นที่ให้ฐานข้อมูลว่างพร้อมสำหรับการ <strong>Import สินค้าและหมวดหมู่ชุดใหม่</strong> ทันที
</p>
</div>
<button
type="button"
onClick={() =>
openActionModal({
type: 'purge-catalog',
title: 'ยืนยันล้างแคตตาล็อกสินค้าและหมวดหมู่ทั้งหมด',
description:
'การดำเนินการนี้จะลบสินค้าและหมวดหมู่ทั้งหมดในระบบอย่างสมบูรณ์แบบ ข้อมูลร้านค้า โครงสร้างหน้าเว็บ (CMS) และบัญชีผู้ใช้จะยังคงอยู่',
confirmText: 'ล้างแคตตาล็อกทั้งหมดทันที',
dangerLevel: 'critical',
})
}
className="w-full flex items-center justify-center gap-2 py-2.5 px-4 rounded-lg bg-red-600/20 border border-red-500/40 hover:bg-red-600/30 text-red-300 text-xs font-bold uppercase tracking-wider transition cursor-pointer"
>
<Trash2 className="w-4 h-4" />
<span>ล้างแคตตาล็อกทั้งหมด</span>
</button>
</div>
</div>
</div>
{/* Seed Default Pages Card (Original) */}
<div className="admin-card p-8 bg-night-900 border border-white/10 rounded-2xl shadow-xl space-y-6">
<div className="flex items-center gap-3 border-b border-white/10 pb-4">
<div className="p-2 bg-amber-500/10 rounded-lg"><DatabaseZap className="w-5 h-5 text-amber-400" /></div>
<div className="p-2 bg-amber-500/10 rounded-lg">
<DatabaseZap className="w-5 h-5 text-amber-400" />
</div>
<div>
<h2 className="text-lg font-bold uppercase tracking-widest text-white">Seed Default Pages</h2>
<p className="text-xs text-slate-400 mt-0.5">สร้าง CMS page layouts เริ่มต้นสำหรับทุกหน้าในระบบ</p>
@@ -55,7 +451,7 @@ export default function SystemMaintenancePage() {
type="button"
onClick={handleSeedPages}
disabled={seeding}
className="shrink-0 flex items-center gap-2 rounded-lg bg-amber-500/10 border border-amber-500/30 px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-amber-400 transition hover:bg-amber-500/20 disabled:opacity-50 disabled:cursor-not-allowed"
className="shrink-0 flex items-center gap-2 rounded-lg bg-amber-500/10 border border-amber-500/30 px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-amber-400 transition hover:bg-amber-500/20 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
{seeding ? <Loader2 className="w-4 h-4 animate-spin" /> : <DatabaseZap className="w-4 h-4" />}
{seeding ? 'Running...' : 'Run Seed'}
@@ -63,16 +459,24 @@ export default function SystemMaintenancePage() {
</div>
{seedResult && (
<div className={`rounded-lg p-4 text-sm space-y-3 ${seedResult.success ? 'bg-emerald-500/10 border border-emerald-500/20' : 'bg-red-500/10 border border-red-500/20'}`}>
<div
className={`rounded-lg p-4 text-sm space-y-3 ${
seedResult.success
? 'bg-emerald-500/10 border border-emerald-500/20'
: 'bg-red-500/10 border border-red-500/20'
}`}
>
<div className="flex items-center gap-2">
{seedResult.success
? <CheckCircle2 className="w-4 h-4 text-emerald-400 shrink-0" />
: <AlertCircle className="w-4 h-4 text-red-400 shrink-0" />}
{seedResult.success ? (
<CheckCircle2 className="w-4 h-4 text-emerald-400 shrink-0" />
) : (
<AlertCircle className="w-4 h-4 text-red-400 shrink-0" />
)}
<span className={seedResult.success ? 'text-emerald-300' : 'text-red-300'}>{seedResult.message}</span>
</div>
{seedResult.results && (
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{seedResult.results.map(r => (
{seedResult.results.map((r) => (
<div key={r.slug} className="flex items-center gap-1.5 text-xs">
<span className={r.status === 'created' ? 'text-emerald-400' : 'text-slate-500'}>●</span>
<span className={r.status === 'created' ? 'text-slate-200' : 'text-slate-500'}>{r.slug}</span>
@@ -84,6 +488,93 @@ export default function SystemMaintenancePage() {
</div>
)}
</div>
{/* Confirmation Modal */}
{actionModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-fade-in">
<div className="w-full max-w-lg bg-night-900 border border-red-500/40 rounded-2xl p-6 shadow-2xl space-y-6">
<div className="flex items-start gap-4">
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-xl shrink-0">
<AlertTriangle className="w-6 h-6 text-red-400" />
</div>
<div className="flex-1 space-y-1">
<h3 className="text-lg font-bold text-white">{actionModal.title}</h3>
<p className="text-xs text-slate-400 leading-relaxed">{actionModal.description}</p>
</div>
</div>
{/* Sub-options for Categories / Catalog */}
{actionModal.type === 'purge-categories' && (
<div className="p-3.5 rounded-xl bg-night-950 border border-white/10 space-y-2">
<label className="flex items-center gap-2.5 text-xs text-slate-300 cursor-pointer">
<input
type="checkbox"
checked={cascadeCategories}
onChange={(e) => setCascadeCategories(e.target.checked)}
className="rounded border-white/20 bg-night-900 text-red-500 focus:ring-red-500/40"
/>
<span>
ลบสินค้าทั้งหมดที่ผูกอยู่กับหมวดหมู่อัตโนมัติ (Cascade Delete Products)
</span>
</label>
<p className="text-[11px] text-slate-500 pl-6">
แนะนำให้เปิดไว้เพื่อป้องกัน Error ข้อจำกัด Foreign Key ระหว่างตาราง
</p>
</div>
)}
{actionModal.type === 'purge-catalog' && (
<div className="p-3.5 rounded-xl bg-night-950 border border-white/10 space-y-2">
<label className="flex items-center gap-2.5 text-xs text-slate-300 cursor-pointer">
<input
type="checkbox"
checked={clearBrandsWithCatalog}
onChange={(e) => setClearBrandsWithCatalog(e.target.checked)}
className="rounded border-white/20 bg-night-900 text-red-500 focus:ring-red-500/40"
/>
<span>
ลบแบรนด์สินค้าทั้งหมดที่ไม่มีสินค้าผูกอยู่ด้วย (Clear Unused Brands)
</span>
</label>
</div>
)}
{/* Mandatory User Confirmation Checkbox */}
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/20">
<label className="flex items-center gap-2.5 text-xs font-semibold text-red-300 cursor-pointer">
<input
type="checkbox"
checked={confirmedByUser}
onChange={(e) => setConfirmedByUser(e.target.checked)}
className="rounded border-red-500/40 bg-night-900 text-red-500 focus:ring-red-500/40 w-4 h-4"
/>
<span>ฉันเข้าใจความเสี่ยง และต้องการยืนยันการลบข้อมูลนี้ถาวร</span>
</label>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-3 pt-2 border-t border-white/10">
<button
type="button"
onClick={closeActionModal}
disabled={actionLoading}
className="px-4 py-2 rounded-lg bg-white/5 hover:bg-white/10 border border-white/10 text-xs font-semibold text-slate-300 transition cursor-pointer disabled:opacity-50"
>
ยกเลิก
</button>
<button
type="button"
onClick={executeAction}
disabled={!confirmedByUser || actionLoading}
className="flex items-center gap-2 px-5 py-2 rounded-lg bg-red-600 hover:bg-red-500 border border-red-400 text-xs font-bold text-white uppercase tracking-wider transition shadow-lg shadow-red-600/30 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
>
{actionLoading && <Loader2 className="w-4 h-4 animate-spin" />}
<span>{actionLoading ? 'กำลังดำเนินการ...' : actionModal.confirmText}</span>
</button>
</div>
</div>
</div>
)}
</div>
)
}
+244
View File
@@ -0,0 +1,244 @@
import prisma from "./prisma";
export const HOMAROMA_DEMO_CATEGORIES = [
"Oil Ozone",
"Aroma Oil",
"Reed Diffuser",
"Room Spray",
"Car Diffuser",
];
export const HOMAROMA_DEMO_BRANDS = [
"ตะไคร้", "ลาเวนเดอร์", "วานิลลา", "ซากุระ", "มะลิ", "ยูคาลิปตัส", "เฟรชลาเวนเดอร์",
"เปเปอร์มิ้น", "โอเชี่ยน", "เซเว่นซี", "ดอกแก้ว", "ดอกโมก", "ลีลาวดี", "ส้ม", "มะนาว",
"แคนตาลูป", "แอปเปิ้ลแดง", "แอปเปิ้ลเขียว", "ดอกลิลลี่", "โรสเมรี่", "อัญชัน",
"กุหลาบแดง", "หมากฝรั่ง", "ผลไม้รวม", "มะกรูด", "แซนด้าวูส", "ดอกปีบ", "ดอกบัว",
"โอโซน", "ไทยสปา", "กล้วย", "กาแฟ", "ดอกชมนาด", "กลิ่นน้ำนม", "ดอกการะเวก",
"ดอกจำปา", "ดอกพิกุล", "ดอกพุด", "ดอกราตรี", "ดอกลำดวน", "ทับทิม", "มะละกอสุก",
"ดอกราชาวดี", "กลิ่นลูกพีช", "กลิ่นองุ่น"
];
/**
* Fetch current catalog counts and demo item status
*/
export async function getCatalogStats() {
const [products, variants, categories, brands, demoCategoriesFound] = await Promise.all([
prisma.product.count(),
prisma.productVariant.count(),
prisma.category.count(),
prisma.productBrand.count(),
prisma.category.findMany({
where: { name: { in: HOMAROMA_DEMO_CATEGORIES } },
select: { id: true, name: true, _count: { select: { products: true } } },
}),
]);
const demoBrandsCount = await prisma.productBrand.count({
where: { name: { in: HOMAROMA_DEMO_BRANDS } },
});
return {
products,
variants,
categories,
brands,
demoCategories: demoCategoriesFound.map((c) => ({
id: c.id,
name: c.name,
productCount: c._count.products,
})),
demoCategoriesCount: demoCategoriesFound.length,
demoBrandsCount,
};
}
/**
* Safely purge products and all dependent records in relational order.
*/
export async function purgeProducts(tx: any, productIds?: number[]) {
const hasSpecificIds = Array.isArray(productIds) && productIds.length > 0;
if (Array.isArray(productIds) && productIds.length === 0) {
return { count: 0 };
}
const prodFilter = hasSpecificIds ? { id: { in: productIds } } : {};
const relProdFilter = hasSpecificIds ? { productId: { in: productIds } } : {};
const variantProdFilter = hasSpecificIds
? { productVariant: { productId: { in: productIds } } }
: {};
// 1. Unlink marketplace products
await tx.marketplaceProduct.updateMany({
where: hasSpecificIds
? { masterVariant: { productId: { in: productIds } } }
: { masterVariantId: { not: null } },
data: { masterVariantId: null },
});
// 2. Delete bomItems linked to variants
await tx.bomItem.deleteMany({
where: variantProdFilter,
});
// 3. Delete product variants
await tx.productVariant.deleteMany({
where: relProdFilter,
});
// 4. Delete product attribute translations & attributes
await tx.productAttributeTranslation.deleteMany({
where: { productAttribute: relProdFilter },
});
await tx.productAttribute.deleteMany({
where: relProdFilter,
});
// 5. Delete product images
await tx.productImage.deleteMany({
where: relProdFilter,
});
// 6. Delete product translations
await tx.productTranslation.deleteMany({
where: relProdFilter,
});
// 7. Delete product detail sections
await tx.productDetailSection.deleteMany({
where: relProdFilter,
});
// 8. Delete reviews
await tx.review.deleteMany({
where: relProdFilter,
});
// 9. Hard delete products
const res = await tx.product.deleteMany({
where: prodFilter,
});
return { count: res.count };
}
/**
* Safely purge categories and their dependent records.
*/
export async function purgeCategories(
tx: any,
options: { categoryIds?: number[]; forceCascadeProducts?: boolean } = {}
) {
const { categoryIds, forceCascadeProducts = false } = options;
const hasSpecificIds = Array.isArray(categoryIds) && categoryIds.length > 0;
if (Array.isArray(categoryIds) && categoryIds.length === 0) {
return { deletedCategories: 0, deletedProducts: 0 };
}
const catFilter = hasSpecificIds ? { id: { in: categoryIds } } : {};
const relCatFilter = hasSpecificIds ? { categoryId: { in: categoryIds } } : {};
// Check if any products belong to these categories
const productsInCats = await tx.product.findMany({
where: relCatFilter,
select: { id: true },
});
let deletedProducts = 0;
if (productsInCats.length > 0) {
if (!forceCascadeProducts) {
throw new Error(
`Cannot delete categories: ${productsInCats.length} product(s) still belong to these categories. Please enable cascade delete or delete products first.`
);
}
// Cascade purge products
const pRes = await purgeProducts(
tx,
productsInCats.map((p: any) => p.id)
);
deletedProducts = pRes.count;
}
// Unlink AttributeDefinitions to avoid deleting global attribute models
await tx.attributeDefinition.updateMany({
where: hasSpecificIds
? { categoryId: { in: categoryIds } }
: { categoryId: { not: null } },
data: { categoryId: null },
});
// Delete category media
await tx.categoryMedia.deleteMany({
where: relCatFilter,
});
// Delete category translations
await tx.categoryTranslation.deleteMany({
where: relCatFilter,
});
// Delete category sections
await tx.categorySection.deleteMany({
where: relCatFilter,
});
// Delete categories
const catRes = await tx.category.deleteMany({
where: catFilter,
});
return {
deletedCategories: catRes.count,
deletedProducts,
};
}
/**
* Purge specifically the HomAroma demo categories, attached products, and unused demo brands.
*/
export async function purgeDemoHomAroma(tx: any) {
// 1. Find demo categories
const demoCats = await tx.category.findMany({
where: { name: { in: HOMAROMA_DEMO_CATEGORIES } },
select: { id: true, name: true },
});
const demoCatIds = demoCats.map((c: any) => c.id);
let deletedProducts = 0;
let deletedCategories = 0;
if (demoCatIds.length > 0) {
const res = await purgeCategories(tx, {
categoryIds: demoCatIds,
forceCascadeProducts: true,
});
deletedCategories = res.deletedCategories;
deletedProducts = res.deletedProducts;
}
// 2. Find and delete demo scent brands that have NO products attached
const unusedBrands = await tx.productBrand.findMany({
where: {
name: { in: HOMAROMA_DEMO_BRANDS },
products: { none: {} },
},
select: { id: true },
});
let deletedBrands = 0;
if (unusedBrands.length > 0) {
const brandIds = unusedBrands.map((b: any) => b.id);
await tx.productBrandTranslation.deleteMany({
where: { brandId: { in: brandIds } },
});
const brandRes = await tx.productBrand.deleteMany({
where: { id: { in: brandIds } },
});
deletedBrands = brandRes.count;
}
return {
deletedCategories,
deletedProducts,
deletedBrands,
};
}
+24 -11
View File
@@ -1,6 +1,7 @@
import { Elysia, t } from "elysia";
import type { Prisma } from "@prisma/client";
import prisma from "../lib/prisma";
import { purgeCategories } from "../lib/catalogMaintenance";
const LANGS = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it'];
const CATEGORY_MEDIA_ROLES = ['thumbnail', 'hero_desktop', 'hero_mobile', 'card_portrait', 'social_share'] as const;
@@ -599,28 +600,40 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
media: t.Optional(t.Array(categoryMediaInput, { maxItems: CATEGORY_MEDIA_ROLES.length })),
})
})
.delete("/:id", async ({ params, status }) => {
.delete("/:id", async ({ params, query, status }) => {
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid category ID" });
const force = (query as any)?.force === 'true' || (query as any)?.cascade === 'true';
try {
const category = await prisma.category.findUnique({
where: { id, deletedAt: null },
include: { _count: { select: { products: { where: { isDeleted: false } } } } }
where: { id },
include: { _count: { select: { products: true } } },
});
if (!category) return status(404, { message: "Category not found" });
if (category._count.products > 0) {
return status(400, { message: "Cannot delete category with existing products" });
if (category._count.products > 0 && !force) {
return status(400, {
message: `Cannot delete category: ${category._count.products} product(s) still exist. Use ?force=true to cascade delete.`,
});
}
const deletedAt = new Date();
await prisma.category.update({
where: { id },
data: { deletedAt },
const result = await prisma.$transaction(async (tx) => {
return await purgeCategories(tx, {
categoryIds: [id],
forceCascadeProducts: force,
});
return { success: true, softDeleted: true, id, deletedAt };
});
return {
success: true,
id,
deletedProducts: result.deletedProducts,
message: `Category deleted successfully${result.deletedProducts > 0 ? ` with ${result.deletedProducts} cascade products` : ""}`,
};
} catch (err: any) {
return status(500, { message: "Failed to delete category" });
console.error("[Categories] Delete error:", err);
return status(500, { message: err.message || "Failed to delete category" });
}
});
+28 -4
View File
@@ -1,6 +1,7 @@
import { Elysia, t } from "elysia";
import type { Prisma } from "@prisma/client";
import prisma from "../lib/prisma";
import { purgeProducts } from "../lib/catalogMaintenance";
import { localizeProductAttribute } from "../lib/productAttributes";
import {
createCustomProductDetailSection,
@@ -1036,7 +1037,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
)
.delete(
"/:id",
async ({ params, status }) => {
async ({ params, query, status }) => {
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { success: false, message: "Invalid product ID" });
@@ -1049,6 +1050,15 @@ export const productsRoute = new Elysia({ prefix: "/products" })
return status(404, { success: false, message: "Product not found" });
}
const hard = (query as any)?.hard === 'true' || (query as any)?.purge === 'true';
if (hard) {
await prisma.$transaction(async (tx) => {
await purgeProducts(tx, [id]);
});
return { success: true, hardDeleted: true, message: "Product hard-deleted successfully" };
}
await prisma.product.update({
where: { id },
data: {
@@ -1061,14 +1071,14 @@ export const productsRoute = new Elysia({ prefix: "/products" })
},
{
detail: {
summary: "Soft Delete Product",
summary: "Delete Product (Soft or Hard via ?hard=true)",
tags: ["products"],
},
}
)
.post(
"/bulk-delete",
async ({ body, status }) => {
async ({ body, query, 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" });
@@ -1079,6 +1089,20 @@ export const productsRoute = new Elysia({ prefix: "/products" })
return status(400, { success: false, message: "No valid IDs provided" });
}
const hard = (query as any)?.hard === 'true' || (query as any)?.purge === 'true';
if (hard) {
const result = await prisma.$transaction(async (tx) => {
return await purgeProducts(tx, validIds);
});
return {
success: true,
count: result.count,
hardDeleted: true,
message: `Successfully hard-deleted ${result.count} products`,
};
}
const result = await prisma.product.updateMany({
where: { id: { in: validIds } },
data: {
@@ -1098,7 +1122,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
ids: t.Array(t.Numeric()),
}),
detail: {
summary: "Bulk Soft Delete Products",
summary: "Bulk Delete Products (Soft or Hard via ?hard=true)",
tags: ["products"],
},
}
+115
View File
@@ -7,6 +7,12 @@ import {
mergeFooterLayoutSettings,
normalizeFooterLayoutSettings,
} from "../lib/footerLayoutSettings";
import {
getCatalogStats,
purgeProducts,
purgeCategories,
purgeDemoHomAroma,
} from "../lib/catalogMaintenance";
const FooterLayoutUpdateRequestSchema = FooterLayoutSettingsPatchSchema.extend({
expectedVersion: z.string().optional(),
@@ -879,4 +885,113 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
set.status = 500;
return { error: "Failed to seed pages" };
}
})
.get("/system/catalog-stats", async ({ set }: { set: any }) => {
try {
const stats = await getCatalogStats();
return { success: true, stats };
} catch (err: any) {
console.error("[Settings] Catalog stats error:", err);
set.status = 500;
return { error: err.message || "Failed to fetch catalog stats" };
}
})
.post("/system/purge-products", async ({ body, set }: { body: any; set: any }) => {
try {
const { productIds } = body || {};
const result = await prisma.$transaction(async (tx: any) => {
return await purgeProducts(tx, productIds);
});
return {
success: true,
count: result.count,
message: `ลบสินค้าทั้งหมดสำเร็จ: ${result.count} รายการ (พร้อมล้างข้อมูล Variant, รูปภาพ, สเปก และรีวิวที่เกี่ยวข้องเรียบร้อย)`,
};
} catch (err: any) {
console.error("[Settings] Purge products error:", err);
set.status = 500;
return { error: err.message || "Failed to purge products" };
}
}, { body: t.Optional(t.Any()) })
.post("/system/purge-categories", async ({ body, set }: { body: any; set: any }) => {
try {
const { categoryIds, forceCascadeProducts } = body || {};
const result = await prisma.$transaction(async (tx: any) => {
return await purgeCategories(tx, {
categoryIds,
forceCascadeProducts: Boolean(forceCascadeProducts),
});
});
return {
success: true,
deletedCategories: result.deletedCategories,
deletedProducts: result.deletedProducts,
message: `ลบหมวดหมู่สำเร็จ: ${result.deletedCategories} หมวดหมู่${result.deletedProducts > 0 ? ` (และลบสินค้าที่ผูกอยู่ ${result.deletedProducts} รายการ)` : ""}`,
};
} catch (err: any) {
console.error("[Settings] Purge categories error:", err);
set.status = 400;
return { error: err.message || "Failed to purge categories" };
}
}, { body: t.Optional(t.Any()) })
.post("/system/purge-demo-homaroma", async ({ set }: { set: any }) => {
try {
const result = await prisma.$transaction(async (tx: any) => {
return await purgeDemoHomAroma(tx);
});
return {
success: true,
...result,
message: `ล้างข้อมูล Demo ของ Hom Aroma สำเร็จ: หมวดหมู่ ${result.deletedCategories} รายการ, สินค้า ${result.deletedProducts} รายการ, แบรนด์กลิ่น ${result.deletedBrands} รายการ`,
};
} catch (err: any) {
console.error("[Settings] Purge demo error:", err);
set.status = 500;
return { error: err.message || "Failed to purge demo data" };
}
})
.post("/system/purge-catalog", async ({ body, set }: { body: any; set: any }) => {
try {
const { clearProducts = true, clearCategories = true, clearBrands = false } = body || {};
const result = await prisma.$transaction(async (tx: any) => {
let pCount = 0;
let cCount = 0;
let bCount = 0;
if (clearProducts || clearCategories) {
const pRes = await purgeProducts(tx);
pCount = pRes.count;
}
if (clearCategories) {
const cRes = await purgeCategories(tx, { forceCascadeProducts: true });
cCount = cRes.deletedCategories;
}
if (clearBrands) {
const unusedBrands = await tx.productBrand.findMany({
where: { products: { none: {} } },
select: { id: true },
});
if (unusedBrands.length > 0) {
const bIds = unusedBrands.map((b: any) => b.id);
await tx.productBrandTranslation.deleteMany({ where: { brandId: { in: bIds } } });
const bRes = await tx.productBrand.deleteMany({ where: { id: { in: bIds } } });
bCount = bRes.count;
}
}
return { deletedProducts: pCount, deletedCategories: cCount, deletedBrands: bCount };
});
return {
success: true,
...result,
message: `ล้างแคตตาล็อกทั้งหมดสำเร็จ: สินค้า ${result.deletedProducts} รายการ, หมวดหมู่ ${result.deletedCategories} รายการ${result.deletedBrands > 0 ? `, แบรนด์ ${result.deletedBrands} รายการ` : ""}`,
};
} catch (err: any) {
console.error("[Settings] Purge catalog error:", err);
set.status = 500;
return { error: err.message || "Failed to purge catalog" };
}
}, { body: t.Optional(t.Any()) });