x
This commit is contained in:
1 parent
570810acdb
commit
64bcd6388a
5 files changed
+917
-30
No files matched your search
@@ -1,13 +1,78 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { API_BASE } from '@homaroma/types'
|
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 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() {
|
export default function SystemMaintenancePage() {
|
||||||
|
// Page Seeding State
|
||||||
const [seeding, setSeeding] = useState(false)
|
const [seeding, setSeeding] = useState(false)
|
||||||
const [seedResult, setSeedResult] = useState<SeedResult | null>(null)
|
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 () => {
|
const handleSeedPages = async () => {
|
||||||
setSeeding(true)
|
setSeeding(true)
|
||||||
setSeedResult(null)
|
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 (
|
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">
|
||||||
<div className="space-y-1">
|
{/* Header */}
|
||||||
<h1 className="text-3xl font-bold">System Maintenance</h1>
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
<p className="text-slate-400 text-sm">Utilities for initializing or repairing system data.</p>
|
<div className="space-y-1">
|
||||||
|
<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>
|
||||||
|
|
||||||
<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="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>
|
<div>
|
||||||
<h2 className="text-lg font-bold uppercase tracking-widest text-white">Seed Default Pages</h2>
|
<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>
|
<p className="text-xs text-slate-400 mt-0.5">สร้าง CMS page layouts เริ่มต้นสำหรับทุกหน้าในระบบ</p>
|
||||||
@@ -55,7 +451,7 @@ export default function SystemMaintenancePage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleSeedPages}
|
onClick={handleSeedPages}
|
||||||
disabled={seeding}
|
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 ? <Loader2 className="w-4 h-4 animate-spin" /> : <DatabaseZap className="w-4 h-4" />}
|
||||||
{seeding ? 'Running...' : 'Run Seed'}
|
{seeding ? 'Running...' : 'Run Seed'}
|
||||||
@@ -63,16 +459,24 @@ export default function SystemMaintenancePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{seedResult && (
|
{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">
|
<div className="flex items-center gap-2">
|
||||||
{seedResult.success
|
{seedResult.success ? (
|
||||||
? <CheckCircle2 className="w-4 h-4 text-emerald-400 shrink-0" />
|
<CheckCircle2 className="w-4 h-4 text-emerald-400 shrink-0" />
|
||||||
: <AlertCircle className="w-4 h-4 text-red-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>
|
<span className={seedResult.success ? 'text-emerald-300' : 'text-red-300'}>{seedResult.message}</span>
|
||||||
</div>
|
</div>
|
||||||
{seedResult.results && (
|
{seedResult.results && (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
<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">
|
<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-emerald-400' : 'text-slate-500'}>●</span>
|
||||||
<span className={r.status === 'created' ? 'text-slate-200' : 'text-slate-500'}>{r.slug}</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>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Elysia, t } from "elysia";
|
import { Elysia, t } from "elysia";
|
||||||
import type { Prisma } from "@prisma/client";
|
import type { Prisma } from "@prisma/client";
|
||||||
import prisma from "../lib/prisma";
|
import prisma from "../lib/prisma";
|
||||||
|
import { purgeCategories } from "../lib/catalogMaintenance";
|
||||||
|
|
||||||
const LANGS = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it'];
|
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;
|
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 })),
|
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);
|
const id = parseInt(params.id);
|
||||||
if (isNaN(id)) return status(400, { message: "Invalid category ID" });
|
if (isNaN(id)) return status(400, { message: "Invalid category ID" });
|
||||||
|
const force = (query as any)?.force === 'true' || (query as any)?.cascade === 'true';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const category = await prisma.category.findUnique({
|
const category = await prisma.category.findUnique({
|
||||||
where: { id, deletedAt: null },
|
where: { id },
|
||||||
include: { _count: { select: { products: { where: { isDeleted: false } } } } }
|
include: { _count: { select: { products: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!category) return status(404, { message: "Category not found" });
|
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();
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
await prisma.category.update({
|
return await purgeCategories(tx, {
|
||||||
where: { id },
|
categoryIds: [id],
|
||||||
data: { deletedAt },
|
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) {
|
} 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" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Elysia, t } from "elysia";
|
import { Elysia, t } from "elysia";
|
||||||
import type { Prisma } from "@prisma/client";
|
import type { Prisma } from "@prisma/client";
|
||||||
import prisma from "../lib/prisma";
|
import prisma from "../lib/prisma";
|
||||||
|
import { purgeProducts } from "../lib/catalogMaintenance";
|
||||||
import { localizeProductAttribute } from "../lib/productAttributes";
|
import { localizeProductAttribute } from "../lib/productAttributes";
|
||||||
import {
|
import {
|
||||||
createCustomProductDetailSection,
|
createCustomProductDetailSection,
|
||||||
@@ -1036,7 +1037,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
|||||||
)
|
)
|
||||||
.delete(
|
.delete(
|
||||||
"/:id",
|
"/:id",
|
||||||
async ({ params, status }) => {
|
async ({ params, query, status }) => {
|
||||||
const id = parseInt(params.id);
|
const id = parseInt(params.id);
|
||||||
if (isNaN(id)) return status(400, { success: false, message: "Invalid product 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" });
|
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({
|
await prisma.product.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
@@ -1061,14 +1071,14 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
detail: {
|
detail: {
|
||||||
summary: "Soft Delete Product",
|
summary: "Delete Product (Soft or Hard via ?hard=true)",
|
||||||
tags: ["products"],
|
tags: ["products"],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.post(
|
.post(
|
||||||
"/bulk-delete",
|
"/bulk-delete",
|
||||||
async ({ body, status }) => {
|
async ({ body, query, status }) => {
|
||||||
const { ids } = body as { ids?: number[] };
|
const { ids } = body as { ids?: number[] };
|
||||||
if (!Array.isArray(ids) || ids.length === 0) {
|
if (!Array.isArray(ids) || ids.length === 0) {
|
||||||
return status(400, { success: false, message: "Invalid or empty IDs array" });
|
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" });
|
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({
|
const result = await prisma.product.updateMany({
|
||||||
where: { id: { in: validIds } },
|
where: { id: { in: validIds } },
|
||||||
data: {
|
data: {
|
||||||
@@ -1098,7 +1122,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
|||||||
ids: t.Array(t.Numeric()),
|
ids: t.Array(t.Numeric()),
|
||||||
}),
|
}),
|
||||||
detail: {
|
detail: {
|
||||||
summary: "Bulk Soft Delete Products",
|
summary: "Bulk Delete Products (Soft or Hard via ?hard=true)",
|
||||||
tags: ["products"],
|
tags: ["products"],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ import {
|
|||||||
mergeFooterLayoutSettings,
|
mergeFooterLayoutSettings,
|
||||||
normalizeFooterLayoutSettings,
|
normalizeFooterLayoutSettings,
|
||||||
} from "../lib/footerLayoutSettings";
|
} from "../lib/footerLayoutSettings";
|
||||||
|
import {
|
||||||
|
getCatalogStats,
|
||||||
|
purgeProducts,
|
||||||
|
purgeCategories,
|
||||||
|
purgeDemoHomAroma,
|
||||||
|
} from "../lib/catalogMaintenance";
|
||||||
|
|
||||||
const FooterLayoutUpdateRequestSchema = FooterLayoutSettingsPatchSchema.extend({
|
const FooterLayoutUpdateRequestSchema = FooterLayoutSettingsPatchSchema.extend({
|
||||||
expectedVersion: z.string().optional(),
|
expectedVersion: z.string().optional(),
|
||||||
@@ -879,4 +885,113 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
|||||||
set.status = 500;
|
set.status = 500;
|
||||||
return { error: "Failed to seed pages" };
|
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()) });
|
||||||
Reference in new issue
Block a user