x
This commit is contained in:
1 parent
2ac5809895
commit
3afe32db7d
10 files changed
+1782
-1
No files matched your search
@@ -47,6 +47,12 @@ import ProductAttributesPage from './pages/ProductAttributesPage'
|
||||
import TiktokPriceSimulatorPage from './pages/TiktokPriceSimulatorPage'
|
||||
import TiktokShopCasePage from './pages/TiktokShopCasePage'
|
||||
import ShopeeShopCasePage from './pages/ShopeeShopCasePage'
|
||||
import BackupDashboardPage from './pages/backup/BackupDashboardPage'
|
||||
import BackupDatabasePage from './pages/backup/BackupDatabasePage'
|
||||
import BackupCatalogPage from './pages/backup/BackupCatalogPage'
|
||||
import BackupOrdersPage from './pages/backup/BackupOrdersPage'
|
||||
import BackupCmsPage from './pages/backup/BackupCmsPage'
|
||||
import BackupMediaPage from './pages/backup/BackupMediaPage'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -105,6 +111,13 @@ function App() {
|
||||
<Route path="/lazada-analytics" element={<LazadaAnalyticsPage />} />
|
||||
<Route path="/lazada-overview" element={<LazadaOverviewPage />} />
|
||||
<Route path="/global-analytics" element={<GlobalAnalyticsPage />} />
|
||||
{/* Backup & Restore */}
|
||||
<Route path="/backup" element={<BackupDashboardPage />} />
|
||||
<Route path="/backup/database" element={<BackupDatabasePage />} />
|
||||
<Route path="/backup/catalog" element={<BackupCatalogPage />} />
|
||||
<Route path="/backup/orders" element={<BackupOrdersPage />} />
|
||||
<Route path="/backup/cms" element={<BackupCmsPage />} />
|
||||
<Route path="/backup/media" element={<BackupMediaPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -30,6 +30,9 @@ import {
|
||||
Calculator,
|
||||
Info,
|
||||
ChevronLeft,
|
||||
DatabaseBackup,
|
||||
FileJson,
|
||||
FolderArchive,
|
||||
} from 'lucide-react'
|
||||
|
||||
const MAIN_MENU = [
|
||||
@@ -96,6 +99,14 @@ const MENU_MANAGER_MENU = [
|
||||
{ icon: Store, label: 'Footer Brand', to: '/menu-manager/footer-brand' },
|
||||
]
|
||||
|
||||
const BACKUP_MENU = [
|
||||
{ icon: DatabaseBackup, label: 'Database Backup', to: '/backup/database' },
|
||||
{ icon: Package, label: 'Catalog Export', to: '/backup/catalog' },
|
||||
{ icon: ShoppingCart, label: 'Orders Export', to: '/backup/orders' },
|
||||
{ icon: FileJson, label: 'CMS Export', to: '/backup/cms' },
|
||||
{ icon: FolderArchive, label: 'Media Files', to: '/backup/media' },
|
||||
]
|
||||
|
||||
const SETTINGS_MENU = [
|
||||
{ icon: Store, label: 'Store Settings', to: '/settings' },
|
||||
{ icon: CreditCard, label: 'Payment & Shipping',to: '/settings/payments' },
|
||||
@@ -121,6 +132,7 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
|
||||
const isInCms = ['/page-builder', '/cms-mockup', '/media', '/articles'].some(p => location.pathname.startsWith(p))
|
||||
const isInCustomer = ['/customers', '/subscribers', '/contacts'].some(p => location.pathname.startsWith(p))
|
||||
const isInMenuManager = location.pathname.startsWith('/menu-manager')
|
||||
const isInBackup = location.pathname.startsWith('/backup')
|
||||
const isInSettings = location.pathname.startsWith('/settings')
|
||||
|
||||
const [productOpen, setProductOpen] = useState(isInProduct)
|
||||
@@ -132,6 +144,7 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
|
||||
const [cmsOpen, setCmsOpen] = useState(isInCms)
|
||||
const [customerOpen, setCustomerOpen] = useState(isInCustomer)
|
||||
const [menuManagerOpen, setMenuManagerOpen] = useState(isInMenuManager)
|
||||
const [backupOpen, setBackupOpen] = useState(isInBackup)
|
||||
const [settingsOpen, setSettingsOpen] = useState(isInSettings)
|
||||
|
||||
const handleLogout = () => {
|
||||
@@ -455,6 +468,36 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Backup & Restore Group */}
|
||||
<div className="pt-1">
|
||||
<button
|
||||
onClick={() => handleGroupClick(setBackupOpen)}
|
||||
title={isCollapsed ? 'Backup & Restore' : undefined}
|
||||
className={`sidebar-link sidebar-link-inactive w-full ${isInBackup ? 'text-white' : ''} ${isCollapsed ? 'justify-center px-0' : ''}`}
|
||||
>
|
||||
<DatabaseBackup className="w-5 h-5 shrink-0" />
|
||||
{!isCollapsed && <span className="flex-1 text-left">Backup & Restore</span>}
|
||||
{!isCollapsed && <ChevronDown className={`w-4 h-4 transition-transform duration-200 ${backupOpen ? 'rotate-180' : ''}`} />}
|
||||
</button>
|
||||
|
||||
{!isCollapsed && backupOpen && (
|
||||
<div className="mt-1 ml-4 pl-4 border-l border-white/10 space-y-1">
|
||||
{BACKUP_MENU.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
`sidebar-link text-sm ${isActive ? 'sidebar-link-active' : 'sidebar-link-inactive'}`
|
||||
}
|
||||
>
|
||||
<item.icon className="w-4 h-4 shrink-0" />
|
||||
<span className="flex-1">{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Settings Group */}
|
||||
<div className="pt-1">
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { useState } from 'react'
|
||||
import { Package, Download, CheckCircle2, ChevronLeft, Loader2, Info } from 'lucide-react'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import { API_BASE } from '@homaroma/types'
|
||||
|
||||
type ExportFormat = 'json' | 'csv'
|
||||
type ExportSection = 'products' | 'categories' | 'attributes' | 'brands' | 'all'
|
||||
|
||||
const SECTIONS: { key: ExportSection; label: string; description: string }[] = [
|
||||
{ key: 'all', label: 'ทั้งหมด (Full Export)', description: 'รวม Products + Categories + Attributes + Brands' },
|
||||
{ key: 'products', label: 'Products & Variants', description: 'Products, Variants, Images, Translations' },
|
||||
{ key: 'categories', label: 'Categories', description: 'Categories + Translations' },
|
||||
{ key: 'attributes', label: 'Product Attributes', description: 'AttributeDefinitions + ProductAttributes' },
|
||||
{ key: 'brands', label: 'Product Brands', description: 'Brands + Translations' },
|
||||
]
|
||||
|
||||
export default function BackupCatalogPage() {
|
||||
const [selectedSection, setSelectedSection] = useState<ExportSection>('all')
|
||||
const [format, setFormat] = useState<ExportFormat>('json')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [result, setResult] = useState<{ success: boolean; message: string } | null>(null)
|
||||
|
||||
const handleExport = async () => {
|
||||
setLoading(true)
|
||||
setResult(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/backup/catalog?section=${selectedSection}&format=${format}`)
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: 'Export failed' }))
|
||||
setResult({ success: false, message: err.error || 'Export failed' })
|
||||
return
|
||||
}
|
||||
const blob = await res.blob()
|
||||
const ext = format === 'json' ? 'json' : 'csv'
|
||||
const filename = `homaroma_catalog_${selectedSection}_${new Date().toISOString().slice(0,10)}.${ext}`
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
setResult({ success: true, message: `ดาวน์โหลดสำเร็จ: ${filename}` })
|
||||
} catch {
|
||||
setResult({ success: false, message: 'Network error — ไม่สามารถเชื่อมต่อ backend ได้' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-8 animate-fade-in">
|
||||
{/* Breadcrumb */}
|
||||
<NavLink
|
||||
to="/backup"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-300 transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Backup & Restore
|
||||
</NavLink>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-cyan-500/10 rounded-xl border border-cyan-500/20">
|
||||
<Package className="w-7 h-7 text-cyan-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">Catalog Export</h1>
|
||||
<p className="text-slate-400 text-sm mt-0.5">
|
||||
Export Products, Categories, Attributes และ Brands เป็น JSON หรือ CSV
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phase 2 notice */}
|
||||
<div className="admin-card p-4 border border-blue-500/20 bg-blue-500/5 flex items-start gap-3">
|
||||
<Info className="w-4 h-4 text-blue-400 shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-slate-400">
|
||||
ปุ่ม Export จะทำงานเมื่อ Backend endpoint{' '}
|
||||
<code className="text-cyan-300 bg-white/5 px-1 py-0.5 rounded text-[11px]">GET /backup/catalog</code>{' '}
|
||||
ถูก implement แล้ว (Phase 2) — ปัจจุบัน UI พร้อมรอ API
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-card p-6 space-y-6">
|
||||
{/* Section Selector */}
|
||||
<div className="space-y-3">
|
||||
<label className="text-xs font-bold text-slate-400 uppercase tracking-widest">
|
||||
เลือกข้อมูลที่ต้องการ Export
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{SECTIONS.map((s) => (
|
||||
<button
|
||||
key={s.key}
|
||||
onClick={() => setSelectedSection(s.key)}
|
||||
className={`w-full flex items-center gap-3 p-3 rounded-xl border text-left transition-all ${
|
||||
selectedSection === s.key
|
||||
? 'border-cyan-500/40 bg-cyan-500/10'
|
||||
: 'border-white/5 hover:border-white/10'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-4 h-4 rounded-full border-2 flex items-center justify-center shrink-0 ${
|
||||
selectedSection === s.key
|
||||
? 'border-cyan-400 bg-cyan-400'
|
||||
: 'border-slate-600'
|
||||
}`}
|
||||
>
|
||||
{selectedSection === s.key && (
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-night-900" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">{s.label}</p>
|
||||
<p className="text-xs text-slate-500 mt-0.5">{s.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Format Selector */}
|
||||
<div className="space-y-3">
|
||||
<label className="text-xs font-bold text-slate-400 uppercase tracking-widest">
|
||||
Format
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
{(['json', 'csv'] as ExportFormat[]).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFormat(f)}
|
||||
className={`flex-1 py-2.5 rounded-xl border text-sm font-semibold uppercase tracking-wider transition-all ${
|
||||
format === f
|
||||
? 'border-cyan-500/40 bg-cyan-500/10 text-cyan-400'
|
||||
: 'border-white/5 text-slate-500 hover:border-white/10 hover:text-slate-300'
|
||||
}`}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{format === 'json' && (
|
||||
<p className="text-xs text-slate-500">JSON — เหมาะสำหรับ Restore หรือ Import กลับระบบ</p>
|
||||
)}
|
||||
{format === 'csv' && (
|
||||
<p className="text-xs text-slate-500">CSV — เหมาะสำหรับเปิดใน Excel หรือ Google Sheets</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Export Button */}
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={loading}
|
||||
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-cyan-500/10 border border-cyan-500/30 text-cyan-400 text-sm font-bold uppercase tracking-widest hover:bg-cyan-500/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
กำลัง Export...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-4 h-4" />
|
||||
Export {selectedSection === 'all' ? 'Catalog ทั้งหมด' : SECTIONS.find(s => s.key === selectedSection)?.label}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Result */}
|
||||
{result && (
|
||||
<div
|
||||
className={`rounded-xl p-4 flex items-center gap-3 text-sm ${
|
||||
result.success
|
||||
? 'bg-emerald-500/10 border border-emerald-500/20 text-emerald-300'
|
||||
: 'bg-red-500/10 border border-red-500/20 text-red-300'
|
||||
}`}
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4 shrink-0" />
|
||||
{result.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useState } from 'react'
|
||||
import { FileJson, Download, CheckCircle2, ChevronLeft, Loader2, Info } from 'lucide-react'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import { API_BASE } from '@homaroma/types'
|
||||
|
||||
type ExportSection = 'all' | 'pages' | 'menus' | 'articles' | 'settings'
|
||||
|
||||
const SECTIONS: { key: ExportSection; label: string; description: string }[] = [
|
||||
{ key: 'all', label: 'ทั้งหมด (Full CMS Export)', description: 'Pages + Menus + Articles + Store Settings' },
|
||||
{ key: 'pages', label: 'Pages & Sections', description: 'Page builder blocks และ Section configs ทั้งหมด' },
|
||||
{ key: 'menus', label: 'Navigation Menus', description: 'Main Navigation + Footer Menus (Explore / Support / Brand)' },
|
||||
{ key: 'articles', label: 'Articles (Scent Guide)', description: 'Articles, content และ Translations' },
|
||||
{ key: 'settings', label: 'Store Settings', description: 'StoreSettings + Theme tokens + Translations' },
|
||||
]
|
||||
|
||||
export default function BackupCmsPage() {
|
||||
const [selectedSection, setSelectedSection] = useState<ExportSection>('all')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [result, setResult] = useState<{ success: boolean; message: string } | null>(null)
|
||||
|
||||
const handleExport = async () => {
|
||||
setLoading(true)
|
||||
setResult(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/backup/cms?section=${selectedSection}`)
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: 'Export failed' }))
|
||||
setResult({ success: false, message: err.error || 'Export failed' })
|
||||
return
|
||||
}
|
||||
const blob = await res.blob()
|
||||
const filename = `homaroma_cms_${selectedSection}_${new Date().toISOString().slice(0, 10)}.json`
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
setResult({ success: true, message: `ดาวน์โหลดสำเร็จ: ${filename}` })
|
||||
} catch {
|
||||
setResult({ success: false, message: 'Network error — ไม่สามารถเชื่อมต่อ backend ได้' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-8 animate-fade-in">
|
||||
{/* Breadcrumb */}
|
||||
<NavLink
|
||||
to="/backup"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-300 transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Backup & Restore
|
||||
</NavLink>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-amber-500/10 rounded-xl border border-amber-500/20">
|
||||
<FileJson className="w-7 h-7 text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">CMS & Content Export</h1>
|
||||
<p className="text-slate-400 text-sm mt-0.5">
|
||||
Export Pages/Sections, Menus, Articles และ Store Settings เป็น JSON
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phase 2 notice */}
|
||||
<div className="admin-card p-4 border border-blue-500/20 bg-blue-500/5 flex items-start gap-3">
|
||||
<Info className="w-4 h-4 text-blue-400 shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-slate-400">
|
||||
ปุ่ม Export จะทำงานเมื่อ Backend endpoint{' '}
|
||||
<code className="text-cyan-300 bg-white/5 px-1 py-0.5 rounded text-[11px]">GET /backup/cms</code>{' '}
|
||||
ถูก implement แล้ว (Phase 2) — ปัจจุบัน UI พร้อมรอ API
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-card p-6 space-y-6">
|
||||
{/* What's included detail */}
|
||||
<div className="bg-white/3 rounded-xl border border-white/5 p-4 space-y-3">
|
||||
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest">ข้อมูลที่รวมอยู่ใน CMS Export</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{[
|
||||
'Home Page sections', 'Shop Page sections',
|
||||
'Product Detail sections', 'Category Page sections',
|
||||
'All dynamic CMS pages', 'Main Navigation tree',
|
||||
'Footer Explore menu', 'Footer Support menu',
|
||||
'Footer Brand info', 'Articles (Scent Guide)',
|
||||
'Store Settings', 'Theme tokens & colors',
|
||||
'Newsletter settings', 'Footer copy & translations',
|
||||
].map((item) => (
|
||||
<div key={item} className="flex items-center gap-2 text-xs text-slate-400">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-amber-400 shrink-0" />
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section Selector */}
|
||||
<div className="space-y-3">
|
||||
<label className="text-xs font-bold text-slate-400 uppercase tracking-widest">
|
||||
เลือกข้อมูลที่ต้องการ Export
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{SECTIONS.map((s) => (
|
||||
<button
|
||||
key={s.key}
|
||||
onClick={() => setSelectedSection(s.key)}
|
||||
className={`w-full flex items-center gap-3 p-3 rounded-xl border text-left transition-all ${
|
||||
selectedSection === s.key
|
||||
? 'border-amber-500/40 bg-amber-500/10'
|
||||
: 'border-white/5 hover:border-white/10'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-4 h-4 rounded-full border-2 flex items-center justify-center shrink-0 ${
|
||||
selectedSection === s.key ? 'border-amber-400 bg-amber-400' : 'border-slate-600'
|
||||
}`}
|
||||
>
|
||||
{selectedSection === s.key && (
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-night-900" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">{s.label}</p>
|
||||
<p className="text-xs text-slate-500 mt-0.5">{s.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Export Button — JSON only for CMS */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-slate-600">Format: JSON เท่านั้น (รองรับ Restore กลับระบบ)</p>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={loading}
|
||||
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-amber-500/10 border border-amber-500/30 text-amber-400 text-sm font-bold uppercase tracking-widest hover:bg-amber-500/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
กำลัง Export...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-4 h-4" />
|
||||
Export {SECTIONS.find(s => s.key === selectedSection)?.label}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Result */}
|
||||
{result && (
|
||||
<div
|
||||
className={`rounded-xl p-4 flex items-center gap-3 text-sm ${
|
||||
result.success
|
||||
? 'bg-emerald-500/10 border border-emerald-500/20 text-emerald-300'
|
||||
: 'bg-red-500/10 border border-red-500/20 text-red-300'
|
||||
}`}
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4 shrink-0" />
|
||||
{result.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useState } from 'react'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import {
|
||||
DatabaseBackup,
|
||||
Package,
|
||||
ShoppingCart,
|
||||
FileJson,
|
||||
FolderArchive,
|
||||
Shield,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
ChevronRight,
|
||||
Info,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface BackupCard {
|
||||
icon: React.ElementType
|
||||
title: string
|
||||
description: string
|
||||
to: string
|
||||
accentColor: string
|
||||
badgeText?: string
|
||||
badgeColor?: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
const BACKUP_CARDS: BackupCard[] = [
|
||||
{
|
||||
icon: DatabaseBackup,
|
||||
title: 'Full Database Backup',
|
||||
description:
|
||||
'Export PostgreSQL dump ทั้งหมด (.sql) ครอบคลุมทุกตาราง — ใช้สำหรับ Disaster Recovery',
|
||||
to: '/backup/database',
|
||||
accentColor: 'text-rose-400 bg-rose-500/10 border-rose-500/20',
|
||||
badgeText: 'CRITICAL',
|
||||
badgeColor: 'bg-rose-500/20 text-rose-300 border border-rose-500/30',
|
||||
note: 'ต้องการ backend endpoint (Phase 2)',
|
||||
},
|
||||
{
|
||||
icon: Package,
|
||||
title: 'Catalog Export',
|
||||
description:
|
||||
'Products, Variants, Categories, Attributes, Brands และ Translations ทั้งหมด',
|
||||
to: '/backup/catalog',
|
||||
accentColor: 'text-cyan-400 bg-cyan-500/10 border-cyan-500/20',
|
||||
badgeText: 'CRITICAL',
|
||||
badgeColor: 'bg-rose-500/20 text-rose-300 border border-rose-500/30',
|
||||
},
|
||||
{
|
||||
icon: ShoppingCart,
|
||||
title: 'Orders & Customers Export',
|
||||
description:
|
||||
'Orders, Customers, Addresses, Coupons และ Promotions พร้อม Date Range Filter',
|
||||
to: '/backup/orders',
|
||||
accentColor: 'text-violet-400 bg-violet-500/10 border-violet-500/20',
|
||||
badgeText: 'CRITICAL',
|
||||
badgeColor: 'bg-rose-500/20 text-rose-300 border border-rose-500/30',
|
||||
},
|
||||
{
|
||||
icon: FileJson,
|
||||
title: 'CMS & Content Export',
|
||||
description:
|
||||
'Pages/Sections, Menus (Main + Footer), Articles, Store Settings',
|
||||
to: '/backup/cms',
|
||||
accentColor: 'text-amber-400 bg-amber-500/10 border-amber-500/20',
|
||||
badgeText: 'HIGH',
|
||||
badgeColor: 'bg-amber-500/20 text-amber-300 border border-amber-500/30',
|
||||
},
|
||||
{
|
||||
icon: FolderArchive,
|
||||
title: 'Media Files',
|
||||
description:
|
||||
'ไฟล์รูปภาพสินค้าและ Assets ทั้งหมดใน /uploads/ — download เป็น .zip',
|
||||
to: '/backup/media',
|
||||
accentColor: 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20',
|
||||
badgeText: 'HIGH',
|
||||
badgeColor: 'bg-amber-500/20 text-amber-300 border border-amber-500/30',
|
||||
note: 'ต้องการ backend endpoint (Phase 2)',
|
||||
},
|
||||
]
|
||||
|
||||
export default function BackupDashboardPage() {
|
||||
const [acknowledged, setAcknowledged] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl space-y-8 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 bg-cyan-500/10 rounded-xl border border-cyan-500/20">
|
||||
<DatabaseBackup className="w-6 h-6 text-cyan-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">Backup & Restore</h1>
|
||||
<p className="text-slate-400 text-sm mt-0.5">
|
||||
จัดการและดาวน์โหลดข้อมูลสำรองของระบบ HomAroma
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Warning Banner */}
|
||||
{!acknowledged && (
|
||||
<div className="admin-card p-5 border border-amber-500/30 bg-amber-500/5">
|
||||
<div className="flex gap-4">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-400 shrink-0 mt-0.5" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<p className="text-sm font-semibold text-amber-300">
|
||||
คำแนะนำก่อนใช้งาน Backup
|
||||
</p>
|
||||
<ul className="text-xs text-slate-400 space-y-1 list-disc list-inside">
|
||||
<li>ควร Backup ก่อนทุกครั้งที่จะทำการเปลี่ยนแปลงข้อมูลสำคัญ</li>
|
||||
<li>เก็บไฟล์ Backup ไว้ในที่ปลอดภัย — ไม่ควรเก็บไว้บนเครื่องเดียวกับ Production</li>
|
||||
<li>ทดสอบ Restore บน Staging ก่อนใช้ในกรณีฉุกเฉิน</li>
|
||||
<li>ข้อมูลบางส่วน (Database Full Backup, Media Files) ต้องการ Backend API ซึ่งจะเปิดใช้งานใน Phase 2</li>
|
||||
</ul>
|
||||
<button
|
||||
onClick={() => setAcknowledged(true)}
|
||||
className="mt-2 text-xs font-bold text-amber-400 hover:text-amber-300 transition-colors underline underline-offset-2"
|
||||
>
|
||||
รับทราบและปิดข้อความนี้
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Bar */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="admin-card p-4 flex items-center gap-3">
|
||||
<div className="p-2 bg-emerald-500/10 rounded-lg">
|
||||
<Shield className="w-4 h-4 text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Status</p>
|
||||
<p className="text-sm font-semibold text-emerald-400">ระบบพร้อมใช้งาน</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-card p-4 flex items-center gap-3">
|
||||
<div className="p-2 bg-slate-500/10 rounded-lg">
|
||||
<Clock className="w-4 h-4 text-slate-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Last Backup</p>
|
||||
<p className="text-sm font-semibold text-slate-300">ยังไม่มีประวัติ</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-card p-4 flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-500/10 rounded-lg">
|
||||
<Info className="w-4 h-4 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Phase</p>
|
||||
<p className="text-sm font-semibold text-blue-400">1 — Manual Export</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Backup Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{BACKUP_CARDS.map((card) => (
|
||||
<NavLink
|
||||
key={card.to}
|
||||
to={card.to}
|
||||
className="admin-card p-6 flex flex-col gap-4 group hover:border-white/15 transition-all duration-200 cursor-pointer"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2.5 rounded-xl border ${card.accentColor}`}>
|
||||
<card.icon className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-white text-sm">{card.title}</p>
|
||||
{card.badgeText && (
|
||||
<span className={`inline-block mt-1 text-[10px] font-bold px-2 py-0.5 rounded-full uppercase tracking-widest ${card.badgeColor}`}>
|
||||
{card.badgeText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-slate-600 group-hover:text-slate-400 transition-colors shrink-0 mt-1" />
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-slate-400 leading-relaxed">
|
||||
{card.description}
|
||||
</p>
|
||||
|
||||
{card.note && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-slate-600">
|
||||
<Info className="w-3 h-3 shrink-0" />
|
||||
<span>{card.note}</span>
|
||||
</div>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer Note */}
|
||||
<div className="text-xs text-slate-600 text-center pb-4">
|
||||
Backup & Restore — Phase 1 (Manual Export) · Phase 2 จะเพิ่ม Scheduled Backup และ Server-side Storage
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { DatabaseBackup, AlertTriangle, CheckCircle2, Info, Terminal, ChevronLeft, Loader2, RefreshCw } from 'lucide-react'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import { API_BASE } from '@homaroma/types'
|
||||
|
||||
interface DbStats {
|
||||
scannedAt: string
|
||||
tableCounts: Record<string, number>
|
||||
pgDumpNote: string
|
||||
}
|
||||
|
||||
export default function BackupDatabasePage() {
|
||||
const [stats, setStats] = useState<DbStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const loadStats = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const token = localStorage.getItem('homaroma_admin_token')
|
||||
const res = await fetch(`${API_BASE}/backup/database`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (!res.ok) {
|
||||
setError('ไม่สามารถดึงข้อมูลได้')
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
setStats(data)
|
||||
} catch {
|
||||
setError('Network error — ไม่สามารถเชื่อมต่อ backend')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { loadStats() }, [])
|
||||
|
||||
const totalRows = stats
|
||||
? Object.values(stats.tableCounts).reduce((a, b) => a + b, 0)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-8 animate-fade-in">
|
||||
{/* Breadcrumb */}
|
||||
<NavLink
|
||||
to="/backup"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-300 transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Backup & Restore
|
||||
</NavLink>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-rose-500/10 rounded-xl border border-rose-500/20">
|
||||
<DatabaseBackup className="w-7 h-7 text-rose-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">Full Database Backup</h1>
|
||||
<p className="text-slate-400 text-sm mt-0.5">
|
||||
ภาพรวมข้อมูลในฐานข้อมูล + คำแนะนำการ Backup ผ่าน SSH
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live DB Stats */}
|
||||
<div className="admin-card p-6 space-y-5">
|
||||
<div className="flex items-center justify-between border-b border-white/5 pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
||||
<h2 className="font-bold text-white uppercase tracking-widest text-sm">
|
||||
Database Overview
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={loadStats}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 text-xs text-slate-500 hover:text-slate-300 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8 gap-3 text-slate-500">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span className="text-sm">กำลังโหลดข้อมูล...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-sm text-red-400 p-3 bg-red-500/10 rounded-lg border border-red-500/20">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats && !loading && (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-3 mb-2">
|
||||
<div className="bg-white/3 rounded-xl border border-white/5 p-3 text-center">
|
||||
<p className="text-2xl font-bold text-white">{totalRows.toLocaleString()}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">Total Rows</p>
|
||||
</div>
|
||||
<div className="bg-white/3 rounded-xl border border-white/5 p-3 text-center">
|
||||
<p className="text-2xl font-bold text-white">{Object.keys(stats.tableCounts).length}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">Tables Scanned</p>
|
||||
</div>
|
||||
<div className="bg-white/3 rounded-xl border border-white/5 p-3 text-center">
|
||||
<p className="text-xs font-mono text-emerald-400 break-all">{new Date(stats.scannedAt).toLocaleTimeString('th-TH')}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">Last Scanned</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{Object.entries(stats.tableCounts).map(([table, count]) => (
|
||||
<div key={table} className="flex items-center justify-between py-2 px-3 bg-white/3 rounded-lg border border-white/5">
|
||||
<span className="text-xs text-slate-400 capitalize">{table.replace(/([A-Z])/g, ' $1').trim()}</span>
|
||||
<span className="text-sm font-bold text-white">{count.toLocaleString()}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-slate-600 text-right">
|
||||
Scanned at {new Date(stats.scannedAt).toLocaleString('th-TH')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* pg_dump instructions */}
|
||||
<div className="admin-card p-6 space-y-4">
|
||||
<div className="flex items-center gap-3 border-b border-white/5 pb-4">
|
||||
<Terminal className="w-5 h-5 text-amber-400" />
|
||||
<h2 className="font-bold text-white uppercase tracking-widest text-sm">
|
||||
Manual Backup (ผ่าน SSH)
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-slate-400">
|
||||
สำหรับ Full Database Backup ให้ใช้วิธีนี้ผ่าน SSH บน Production Server:
|
||||
</p>
|
||||
<div className="bg-night-950 rounded-xl border border-white/5 p-4 font-mono text-xs text-emerald-300 space-y-2">
|
||||
<p className="text-slate-500"># SSH เข้า server</p>
|
||||
<p>ssh ti@kitizone.com</p>
|
||||
<p className="text-slate-500 mt-3"># ตรวจสอบชื่อ database จาก .env</p>
|
||||
<p>{'cat /home/ti/projects/homaroma-api/.env | grep DATABASE_URL'}</p>
|
||||
<p className="text-slate-500 mt-3"># สร้าง pg_dump (แทนที่ <dbname> ด้วยชื่อจริง)</p>
|
||||
<p>{'pg_dump -U postgres <dbname> > ~/backup_$(date +%Y%m%d_%H%M).sql'}</p>
|
||||
<p className="text-slate-500 mt-3"># Download กลับมาเครื่อง local</p>
|
||||
<p>{'scp ti@kitizone.com:~/backup_*.sql ./'}</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 text-xs text-amber-400/70">
|
||||
<AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-0.5" />
|
||||
<p>Full pg_dump ต้องการ shell access — ไม่สามารถทำผ่าน Admin UI ได้ในขณะนี้</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phase 2 notice */}
|
||||
<div className="admin-card p-4 border border-blue-500/20 bg-blue-500/5 flex items-start gap-3">
|
||||
<Info className="w-4 h-4 text-blue-400 shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-slate-400">
|
||||
ข้อมูลสรุปด้านบนดึงมาจาก{' '}
|
||||
<code className="text-cyan-300 bg-white/5 px-1 py-0.5 rounded text-[11px]">GET /api/backup/database</code>{' '}
|
||||
— สำหรับ pg_dump ผ่าน UI จะต้องการ shell execution capability บน backend ซึ่งอยู่ใน roadmap ถัดไป
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useState } from 'react'
|
||||
import { FolderArchive, Download, ChevronLeft, Info, AlertTriangle, Loader2, CheckCircle2, Image, FileVideo, File } from 'lucide-react'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import { API_BASE } from '@homaroma/types'
|
||||
|
||||
export default function BackupMediaPage() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [result, setResult] = useState<{ success: boolean; message: string } | null>(null)
|
||||
|
||||
const handleDownload = async () => {
|
||||
setLoading(true)
|
||||
setResult(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/backup/media/zip`)
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: 'Download failed' }))
|
||||
setResult({ success: false, message: err.error || 'Download failed' })
|
||||
return
|
||||
}
|
||||
const blob = await res.blob()
|
||||
const filename = `homaroma_media_${new Date().toISOString().slice(0, 10)}.zip`
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
setResult({ success: true, message: `ดาวน์โหลดสำเร็จ: ${filename}` })
|
||||
} catch {
|
||||
setResult({ success: false, message: 'Network error — ไม่สามารถเชื่อมต่อ backend ได้' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-8 animate-fade-in">
|
||||
{/* Breadcrumb */}
|
||||
<NavLink
|
||||
to="/backup"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-300 transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Backup & Restore
|
||||
</NavLink>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-emerald-500/10 rounded-xl border border-emerald-500/20">
|
||||
<FolderArchive className="w-7 h-7 text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">Media Files Backup</h1>
|
||||
<p className="text-slate-400 text-sm mt-0.5">
|
||||
Download ไฟล์รูปภาพสินค้าและ Assets ทั้งหมดเป็น .zip
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phase 2 Notice */}
|
||||
<div className="admin-card p-6 border border-blue-500/20 bg-blue-500/5 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Info className="w-5 h-5 text-blue-400 shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold text-blue-300 text-sm">Phase 2 Feature</p>
|
||||
<p className="text-xs text-slate-500 mt-0.5">
|
||||
Media Backup ต้องการ Backend API endpoint ที่จะพัฒนาใน Phase 2
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-slate-400 leading-relaxed">
|
||||
เมื่อ Backend endpoint{' '}
|
||||
<code className="text-xs bg-white/5 px-1.5 py-0.5 rounded text-cyan-300">GET /backup/media/zip</code>{' '}
|
||||
พร้อม ระบบจะ zip โฟลเดอร์{' '}
|
||||
<code className="text-xs bg-white/5 px-1.5 py-0.5 rounded text-cyan-300">public/uploads/</code>{' '}
|
||||
และ stream ไฟล์กลับมา download โดยตรง
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* What's included */}
|
||||
<div className="admin-card p-6 space-y-5">
|
||||
<h2 className="text-sm font-bold text-slate-400 uppercase tracking-widest border-b border-white/5 pb-3">
|
||||
ไฟล์ที่จะถูก Backup
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ icon: Image, label: 'Product Images', desc: 'รูปภาพสินค้าทุกตัวที่อัปโหลดผ่าน Admin', color: 'text-cyan-400' },
|
||||
{ icon: Image, label: 'Category Images', desc: 'รูป Thumbnail ของแต่ละหมวดหมู่สินค้า', color: 'text-violet-400' },
|
||||
{ icon: Image, label: 'Article Images', desc: 'รูปประกอบบทความ Scent Guide', color: 'text-amber-400' },
|
||||
{ icon: Image, label: 'CMS Media', desc: 'รูปที่ใช้ใน Page Builder sections', color: 'text-emerald-400' },
|
||||
{ icon: FileVideo, label: 'Video Assets', desc: 'วิดีโอหรือ GIF ที่ใช้ใน hero sections', color: 'text-rose-400' },
|
||||
{ icon: File, label: 'Other Assets', desc: 'ไฟล์อื่นๆ ใน public/uploads/', color: 'text-slate-400' },
|
||||
].map(({ icon: Icon, label, desc, color }) => (
|
||||
<div key={label} className="flex items-center gap-3 py-2 border-b border-white/5 last:border-0">
|
||||
<Icon className={`w-4 h-4 shrink-0 ${color}`} />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">{label}</p>
|
||||
<p className="text-xs text-slate-500">{desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Size Warning */}
|
||||
<div className="flex items-start gap-3 text-sm text-amber-400/80">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<p className="text-xs">
|
||||
ไฟล์ Media อาจมีขนาดใหญ่มาก (หลาย GB) — ใช้ SCP หรือ rsync ผ่าน SSH สำหรับ Production Server เพื่อประสิทธิภาพที่ดีกว่า
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Manual SCP */}
|
||||
<div className="admin-card p-5 space-y-3">
|
||||
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest">Manual Download (ผ่าน SSH/SCP)</p>
|
||||
<div className="bg-night-950 rounded-xl border border-white/5 p-4 font-mono text-xs text-emerald-300 space-y-2">
|
||||
<p className="text-slate-500"># rsync media folder จาก production</p>
|
||||
<p>{'rsync -avz ti@kitizone.com:/home/ti/projects/homaroma-api/public/uploads/ ./uploads-backup/'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Download Button */}
|
||||
<div className="admin-card p-6 space-y-4">
|
||||
<p className="text-sm text-slate-400">
|
||||
เมื่อ Phase 2 พร้อม ปุ่มด้านล่างจะ trigger download .zip ของ /uploads/ ทั้งหมด
|
||||
</p>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={loading}
|
||||
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-400 text-sm font-bold uppercase tracking-widest hover:bg-emerald-500/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
กำลัง Download...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-4 h-4" />
|
||||
Download Media Files (.zip)
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{result && (
|
||||
<div
|
||||
className={`rounded-xl p-4 flex items-center gap-3 text-sm ${
|
||||
result.success
|
||||
? 'bg-emerald-500/10 border border-emerald-500/20 text-emerald-300'
|
||||
: 'bg-red-500/10 border border-red-500/20 text-red-300'
|
||||
}`}
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4 shrink-0" />
|
||||
{result.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { useState } from 'react'
|
||||
import { ShoppingCart, Download, CheckCircle2, ChevronLeft, Loader2, Info, AlertCircle } from 'lucide-react'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import { API_BASE } from '@homaroma/types'
|
||||
|
||||
type ExportFormat = 'json' | 'csv'
|
||||
type ExportSection = 'all' | 'orders' | 'customers' | 'coupons' | 'promotions'
|
||||
|
||||
const SECTIONS: { key: ExportSection; label: string; description: string }[] = [
|
||||
{ key: 'all', label: 'ทั้งหมด (Full Export)', description: 'Orders + Customers + Coupons + Promotions' },
|
||||
{ key: 'orders', label: 'Orders', description: 'Orders พร้อม OrderItems และ Tracking' },
|
||||
{ key: 'customers', label: 'Customers & Addresses', description: 'ข้อมูลลูกค้าและที่อยู่' },
|
||||
{ key: 'coupons', label: 'Coupons', description: 'Coupon codes และ usage stats' },
|
||||
{ key: 'promotions', label: 'Promotions', description: 'Promotion rules ทั้งหมด' },
|
||||
]
|
||||
|
||||
function formatDate(d: Date) {
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export default function BackupOrdersPage() {
|
||||
const [selectedSection, setSelectedSection] = useState<ExportSection>('all')
|
||||
const [format, setFormat] = useState<ExportFormat>('json')
|
||||
const [useDateRange, setUseDateRange] = useState(false)
|
||||
const [dateFrom, setDateFrom] = useState(formatDate(new Date(Date.now() - 30 * 86400_000)))
|
||||
const [dateTo, setDateTo] = useState(formatDate(new Date()))
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [result, setResult] = useState<{ success: boolean; message: string } | null>(null)
|
||||
|
||||
const handleExport = async () => {
|
||||
setLoading(true)
|
||||
setResult(null)
|
||||
try {
|
||||
const params = new URLSearchParams({ section: selectedSection, format })
|
||||
if (useDateRange) {
|
||||
params.set('from', dateFrom)
|
||||
params.set('to', dateTo)
|
||||
}
|
||||
const res = await fetch(`${API_BASE}/backup/orders?${params}`)
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: 'Export failed' }))
|
||||
setResult({ success: false, message: err.error || 'Export failed' })
|
||||
return
|
||||
}
|
||||
const blob = await res.blob()
|
||||
const ext = format === 'json' ? 'json' : 'csv'
|
||||
const suffix = useDateRange ? `${dateFrom}_to_${dateTo}` : 'all_time'
|
||||
const filename = `homaroma_orders_${selectedSection}_${suffix}.${ext}`
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
setResult({ success: true, message: `ดาวน์โหลดสำเร็จ: ${filename}` })
|
||||
} catch {
|
||||
setResult({ success: false, message: 'Network error — ไม่สามารถเชื่อมต่อ backend ได้' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-8 animate-fade-in">
|
||||
{/* Breadcrumb */}
|
||||
<NavLink
|
||||
to="/backup"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-300 transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Backup & Restore
|
||||
</NavLink>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-violet-500/10 rounded-xl border border-violet-500/20">
|
||||
<ShoppingCart className="w-7 h-7 text-violet-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">Orders & Customers Export</h1>
|
||||
<p className="text-slate-400 text-sm mt-0.5">
|
||||
Export Orders, Customers, Coupons และ Promotions พร้อม Date Range Filter
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phase 2 notice */}
|
||||
<div className="admin-card p-4 border border-blue-500/20 bg-blue-500/5 flex items-start gap-3">
|
||||
<Info className="w-4 h-4 text-blue-400 shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-slate-400">
|
||||
ปุ่ม Export จะทำงานเมื่อ Backend endpoint{' '}
|
||||
<code className="text-cyan-300 bg-white/5 px-1 py-0.5 rounded text-[11px]">GET /backup/orders</code>{' '}
|
||||
ถูก implement แล้ว (Phase 2) — ปัจจุบัน UI พร้อมรอ API
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-card p-6 space-y-6">
|
||||
{/* Section Selector */}
|
||||
<div className="space-y-3">
|
||||
<label className="text-xs font-bold text-slate-400 uppercase tracking-widest">
|
||||
เลือกข้อมูลที่ต้องการ Export
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{SECTIONS.map((s) => (
|
||||
<button
|
||||
key={s.key}
|
||||
onClick={() => setSelectedSection(s.key)}
|
||||
className={`w-full flex items-center gap-3 p-3 rounded-xl border text-left transition-all ${
|
||||
selectedSection === s.key
|
||||
? 'border-violet-500/40 bg-violet-500/10'
|
||||
: 'border-white/5 hover:border-white/10'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-4 h-4 rounded-full border-2 flex items-center justify-center shrink-0 ${
|
||||
selectedSection === s.key ? 'border-violet-400 bg-violet-400' : 'border-slate-600'
|
||||
}`}
|
||||
>
|
||||
{selectedSection === s.key && (
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-night-900" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">{s.label}</p>
|
||||
<p className="text-xs text-slate-500 mt-0.5">{s.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date Range */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-bold text-slate-400 uppercase tracking-widest">
|
||||
Date Range Filter
|
||||
</label>
|
||||
<button
|
||||
onClick={() => setUseDateRange(v => !v)}
|
||||
className={`text-xs font-semibold px-3 py-1 rounded-lg border transition-all ${
|
||||
useDateRange
|
||||
? 'border-violet-500/40 bg-violet-500/10 text-violet-400'
|
||||
: 'border-white/10 text-slate-500 hover:text-slate-300'
|
||||
}`}
|
||||
>
|
||||
{useDateRange ? 'เปิดใช้งาน' : 'ปิดใช้งาน'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{useDateRange && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-slate-500">จากวันที่</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-violet-500/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-slate-500">ถึงวันที่</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-violet-500/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!useDateRange && (
|
||||
<p className="text-xs text-slate-600">Export ข้อมูลทุกช่วงเวลา (ไม่จำกัดวันที่)</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Format Selector */}
|
||||
<div className="space-y-3">
|
||||
<label className="text-xs font-bold text-slate-400 uppercase tracking-widest">Format</label>
|
||||
<div className="flex gap-3">
|
||||
{(['json', 'csv'] as ExportFormat[]).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFormat(f)}
|
||||
className={`flex-1 py-2.5 rounded-xl border text-sm font-semibold uppercase tracking-wider transition-all ${
|
||||
format === f
|
||||
? 'border-violet-500/40 bg-violet-500/10 text-violet-400'
|
||||
: 'border-white/5 text-slate-500 hover:border-white/10 hover:text-slate-300'
|
||||
}`}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Privacy note */}
|
||||
<div className="flex items-start gap-2 text-xs text-amber-400/70">
|
||||
<AlertCircle className="w-3.5 h-3.5 shrink-0 mt-0.5" />
|
||||
<p>ข้อมูล Customer มีข้อมูลส่วนตัว — เก็บไฟล์ที่ Export ไว้ในที่ปลอดภัยและไม่แชร์โดยไม่จำเป็น</p>
|
||||
</div>
|
||||
|
||||
{/* Export Button */}
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={loading}
|
||||
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-violet-500/10 border border-violet-500/30 text-violet-400 text-sm font-bold uppercase tracking-widest hover:bg-violet-500/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
กำลัง Export...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-4 h-4" />
|
||||
Export {SECTIONS.find(s => s.key === selectedSection)?.label}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Result */}
|
||||
{result && (
|
||||
<div
|
||||
className={`rounded-xl p-4 flex items-center gap-3 text-sm ${
|
||||
result.success
|
||||
? 'bg-emerald-500/10 border border-emerald-500/20 text-emerald-300'
|
||||
: 'bg-red-500/10 border border-red-500/20 text-red-300'
|
||||
}`}
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4 shrink-0" />
|
||||
{result.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { newsletterRoute } from "./routes/newsletter";
|
||||
import { mediaRoute } from "./routes/media";
|
||||
import { contactsRoute } from "./routes/contacts";
|
||||
import { productAttributesRoute } from "./routes/product-attributes";
|
||||
import { backupRoute } from "./routes/backup";
|
||||
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3051;
|
||||
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
|
||||
const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_brand";
|
||||
@@ -135,7 +136,7 @@ const app = new Elysia()
|
||||
return file;
|
||||
})
|
||||
.group("/api", (app) =>
|
||||
app.use(authRoute).use(categoriesRoute).use(productsRoute).use(productAttributesRoute).use(uploadRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute)
|
||||
app.use(authRoute).use(categoriesRoute).use(productsRoute).use(productAttributesRoute).use(uploadRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute).use(backupRoute)
|
||||
)
|
||||
.listen({ port: PORT, hostname: "0.0.0.0" });
|
||||
|
||||
|
||||
@@ -0,0 +1,590 @@
|
||||
import { Elysia } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
import { adminOnly } from "../middleware/auth";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
// ─── Helper: stream JSON as file download ────────────────────────────────────
|
||||
|
||||
function jsonResponse(data: unknown, filename: string) {
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
const bytes = Buffer.from(json, "utf-8");
|
||||
return new Response(bytes, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
"Content-Length": String(bytes.length),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function csvResponse(rows: Record<string, unknown>[], filename: string) {
|
||||
if (!rows || rows.length === 0) {
|
||||
return new Response("", {
|
||||
headers: {
|
||||
"Content-Type": "text/csv",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
const firstRow = rows[0];
|
||||
if (!firstRow) {
|
||||
return new Response("", {
|
||||
headers: {
|
||||
"Content-Type": "text/csv",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
const headers = Object.keys(firstRow);
|
||||
const csvLines = [
|
||||
headers.join(","),
|
||||
...rows.map((row) =>
|
||||
headers
|
||||
.map((h) => {
|
||||
const val = (row as any)[h];
|
||||
if (val === null || val === undefined) return "";
|
||||
const str = typeof val === "object" ? JSON.stringify(val) : String(val);
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
})
|
||||
.join(",")
|
||||
),
|
||||
];
|
||||
const csv = csvLines.join("\r\n");
|
||||
const bytes = Buffer.from(csv, "utf-8");
|
||||
return new Response(bytes, {
|
||||
headers: {
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
"Content-Length": String(bytes.length),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function today() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
// ─── Route ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const backupRoute = new Elysia({ prefix: "/backup" })
|
||||
.use(adminOnly)
|
||||
|
||||
// ── 1. Catalog Export ──────────────────────────────────────────────────────
|
||||
/**
|
||||
* GET /api/backup/catalog?section=all|products|categories|attributes|brands&format=json|csv
|
||||
* Exports product catalog data as JSON or CSV download.
|
||||
*/
|
||||
.get("/catalog", async ({ query, set }: { query: any; set: any }) => {
|
||||
try {
|
||||
const section = (query.section as string) || "all";
|
||||
const format = (query.format as string) === "csv" ? "csv" : "json";
|
||||
const dt = today();
|
||||
|
||||
if (section === "products" || section === "all") {
|
||||
const products = await prisma.product.findMany({
|
||||
include: {
|
||||
variants: true,
|
||||
images: true,
|
||||
translations: true,
|
||||
attributes: { include: { attributeDefinition: true, translations: true } },
|
||||
brand: { include: { translations: true } },
|
||||
category: { select: { id: true, name: true } },
|
||||
},
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
|
||||
if (section === "products") {
|
||||
if (format === "csv") {
|
||||
const rows = products.map((p: any) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
categoryId: p.categoryId,
|
||||
brandId: p.brandId,
|
||||
variantCount: p.variants.length,
|
||||
imageCount: p.images.length,
|
||||
createdAt: p.createdAt,
|
||||
}));
|
||||
return csvResponse(rows, `homaroma_products_${dt}.csv`);
|
||||
}
|
||||
return jsonResponse({ exportedAt: new Date().toISOString(), count: products.length, data: products }, `homaroma_products_${dt}.json`);
|
||||
}
|
||||
|
||||
// full catalog
|
||||
const categories = await prisma.category.findMany({
|
||||
where: { deletedAt: null },
|
||||
include: { translations: true },
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
const attributes = await prisma.attributeDefinition.findMany({
|
||||
include: { translations: true },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
});
|
||||
const brands = await prisma.productBrand.findMany({
|
||||
include: { translations: true },
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
|
||||
if (format === "csv") {
|
||||
// For CSV full export, flatten products only (complex nested data → JSON)
|
||||
const rows = products.map((p: any) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
categoryId: p.categoryId,
|
||||
categoryName: p.category?.name,
|
||||
brandId: p.brandId,
|
||||
brandName: p.brand?.name,
|
||||
variantCount: p.variants.length,
|
||||
imageCount: p.images.length,
|
||||
createdAt: p.createdAt,
|
||||
}));
|
||||
return csvResponse(rows, `homaroma_catalog_${dt}.csv`);
|
||||
}
|
||||
|
||||
return jsonResponse(
|
||||
{
|
||||
exportedAt: new Date().toISOString(),
|
||||
summary: {
|
||||
products: products.length,
|
||||
categories: categories.length,
|
||||
attributes: attributes.length,
|
||||
brands: brands.length,
|
||||
},
|
||||
data: { products, categories, attributes, brands },
|
||||
},
|
||||
`homaroma_catalog_all_${dt}.json`
|
||||
);
|
||||
}
|
||||
|
||||
if (section === "categories") {
|
||||
const categories = await prisma.category.findMany({
|
||||
where: { deletedAt: null },
|
||||
include: { translations: true },
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
if (format === "csv") {
|
||||
const rows = categories.map((c: any) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
imageUrl: c.imageUrl,
|
||||
createdAt: c.createdAt,
|
||||
}));
|
||||
return csvResponse(rows, `homaroma_categories_${dt}.csv`);
|
||||
}
|
||||
return jsonResponse({ exportedAt: new Date().toISOString(), count: categories.length, data: categories }, `homaroma_categories_${dt}.json`);
|
||||
}
|
||||
|
||||
if (section === "attributes") {
|
||||
const attributes = await prisma.attributeDefinition.findMany({
|
||||
include: { translations: true },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
});
|
||||
if (format === "csv") {
|
||||
const rows = attributes.map((a: any) => ({
|
||||
id: a.id,
|
||||
code: a.code,
|
||||
label: a.label,
|
||||
type: a.type,
|
||||
unit: a.unit,
|
||||
isRequired: a.isRequired,
|
||||
isFilterable: a.isFilterable,
|
||||
sortOrder: a.sortOrder,
|
||||
}));
|
||||
return csvResponse(rows, `homaroma_attributes_${dt}.csv`);
|
||||
}
|
||||
return jsonResponse({ exportedAt: new Date().toISOString(), count: attributes.length, data: attributes }, `homaroma_attributes_${dt}.json`);
|
||||
}
|
||||
|
||||
if (section === "brands") {
|
||||
const brands = await prisma.productBrand.findMany({
|
||||
include: { translations: true },
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
if (format === "csv") {
|
||||
const rows = brands.map((b: any) => ({ id: b.id, name: b.name, createdAt: b.createdAt }));
|
||||
return csvResponse(rows, `homaroma_brands_${dt}.csv`);
|
||||
}
|
||||
return jsonResponse({ exportedAt: new Date().toISOString(), count: brands.length, data: brands }, `homaroma_brands_${dt}.json`);
|
||||
}
|
||||
|
||||
set.status = 400;
|
||||
return { error: `Unknown section: ${section}. Valid values: all, products, categories, attributes, brands` };
|
||||
} catch (err: any) {
|
||||
console.error("[Backup] Catalog export error:", err);
|
||||
set.status = 500;
|
||||
return { error: "Catalog export failed", detail: err?.message };
|
||||
}
|
||||
})
|
||||
|
||||
// ── 2. Orders & Customers Export ──────────────────────────────────────────
|
||||
/**
|
||||
* GET /api/backup/orders?section=all|orders|customers|coupons|promotions&format=json|csv&from=YYYY-MM-DD&to=YYYY-MM-DD
|
||||
*/
|
||||
.get("/orders", async ({ query, set }: { query: any; set: any }) => {
|
||||
try {
|
||||
const section = (query.section as string) || "all";
|
||||
const format = (query.format as string) === "csv" ? "csv" : "json";
|
||||
const from = query.from as string | undefined;
|
||||
const to = query.to as string | undefined;
|
||||
const dt = today();
|
||||
|
||||
const dateFilter =
|
||||
from || to
|
||||
? {
|
||||
createdAt: {
|
||||
...(from ? { gte: new Date(`${from}T00:00:00.000Z`) } : {}),
|
||||
...(to ? { lte: new Date(`${to}T23:59:59.999Z`) } : {}),
|
||||
},
|
||||
}
|
||||
: {};
|
||||
|
||||
if (section === "orders" || section === "all") {
|
||||
const orders = await prisma.order.findMany({
|
||||
where: dateFilter,
|
||||
include: { items: true, customer: { select: { id: true, displayName: true, lineUserId: true } } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
if (section === "orders") {
|
||||
if (format === "csv") {
|
||||
const rows = orders.map((o: any) => ({
|
||||
id: o.id,
|
||||
customerName: o.customerName,
|
||||
customerEmail: o.customerEmail,
|
||||
customerPhone: o.customerPhone,
|
||||
totalAmount: o.totalAmount,
|
||||
status: o.status,
|
||||
trackingNo: o.trackingNo,
|
||||
createdAt: o.createdAt,
|
||||
}));
|
||||
return csvResponse(rows, `homaroma_orders_${dt}.csv`);
|
||||
}
|
||||
return jsonResponse({ exportedAt: new Date().toISOString(), count: orders.length, data: orders }, `homaroma_orders_${dt}.json`);
|
||||
}
|
||||
|
||||
// full
|
||||
const customers = await prisma.customer.findMany({
|
||||
include: { addresses: true },
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
const coupons = await prisma.coupon.findMany({ orderBy: { id: "asc" } });
|
||||
const promotions = await prisma.promotion.findMany({ orderBy: { level: "asc" } });
|
||||
|
||||
if (format === "csv") {
|
||||
const rows = orders.map((o: any) => ({
|
||||
id: o.id,
|
||||
customerName: o.customerName,
|
||||
customerEmail: o.customerEmail,
|
||||
totalAmount: o.totalAmount,
|
||||
status: o.status,
|
||||
createdAt: o.createdAt,
|
||||
}));
|
||||
return csvResponse(rows, `homaroma_orders_all_${dt}.csv`);
|
||||
}
|
||||
|
||||
return jsonResponse(
|
||||
{
|
||||
exportedAt: new Date().toISOString(),
|
||||
dateRange: from || to ? { from: from ?? "all", to: to ?? "all" } : "all",
|
||||
summary: {
|
||||
orders: orders.length,
|
||||
customers: customers.length,
|
||||
coupons: coupons.length,
|
||||
promotions: promotions.length,
|
||||
},
|
||||
data: { orders, customers, coupons, promotions },
|
||||
},
|
||||
`homaroma_orders_full_${dt}.json`
|
||||
);
|
||||
}
|
||||
|
||||
if (section === "customers") {
|
||||
const customers = await prisma.customer.findMany({
|
||||
include: { addresses: true },
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
if (format === "csv") {
|
||||
const rows = customers.map((c: any) => ({
|
||||
id: c.id,
|
||||
displayName: c.displayName,
|
||||
email: c.email,
|
||||
phone: c.phone,
|
||||
points: c.points,
|
||||
createdAt: c.createdAt,
|
||||
}));
|
||||
return csvResponse(rows, `homaroma_customers_${dt}.csv`);
|
||||
}
|
||||
return jsonResponse({ exportedAt: new Date().toISOString(), count: customers.length, data: customers }, `homaroma_customers_${dt}.json`);
|
||||
}
|
||||
|
||||
if (section === "coupons") {
|
||||
const coupons = await prisma.coupon.findMany({ orderBy: { id: "asc" } });
|
||||
if (format === "csv") {
|
||||
const rows = coupons.map((c: any) => ({
|
||||
id: c.id,
|
||||
code: c.code,
|
||||
type: c.type,
|
||||
value: c.value,
|
||||
usageLimit: c.usageLimit,
|
||||
usedCount: c.usedCount,
|
||||
isActive: c.isActive,
|
||||
expiresAt: c.expiresAt,
|
||||
}));
|
||||
return csvResponse(rows, `homaroma_coupons_${dt}.csv`);
|
||||
}
|
||||
return jsonResponse({ exportedAt: new Date().toISOString(), count: coupons.length, data: coupons }, `homaroma_coupons_${dt}.json`);
|
||||
}
|
||||
|
||||
if (section === "promotions") {
|
||||
const promotions = await prisma.promotion.findMany({ orderBy: { level: "asc" } });
|
||||
if (format === "csv") {
|
||||
const rows = promotions.map((p: any) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
level: p.level,
|
||||
type: p.type,
|
||||
value: p.value,
|
||||
minPurchase: p.minPurchase,
|
||||
isActive: p.isActive,
|
||||
}));
|
||||
return csvResponse(rows, `homaroma_promotions_${dt}.csv`);
|
||||
}
|
||||
return jsonResponse({ exportedAt: new Date().toISOString(), count: promotions.length, data: promotions }, `homaroma_promotions_${dt}.json`);
|
||||
}
|
||||
|
||||
set.status = 400;
|
||||
return { error: `Unknown section: ${section}. Valid values: all, orders, customers, coupons, promotions` };
|
||||
} catch (err: any) {
|
||||
console.error("[Backup] Orders export error:", err);
|
||||
set.status = 500;
|
||||
return { error: "Orders export failed", detail: err?.message };
|
||||
}
|
||||
})
|
||||
|
||||
// ── 3. CMS & Content Export ────────────────────────────────────────────────
|
||||
/**
|
||||
* GET /api/backup/cms?section=all|pages|menus|articles|settings
|
||||
* Always returns JSON (CMS data is structured — CSV not useful here).
|
||||
*/
|
||||
.get("/cms", async ({ query, set }: { query: any; set: any }) => {
|
||||
try {
|
||||
const section = (query.section as string) || "all";
|
||||
const dt = today();
|
||||
|
||||
if (section === "pages" || section === "all") {
|
||||
const pages = await prisma.page.findMany({
|
||||
include: { sections: true, translations: true },
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
|
||||
if (section === "pages") {
|
||||
return jsonResponse({ exportedAt: new Date().toISOString(), count: pages.length, data: pages }, `homaroma_cms_pages_${dt}.json`);
|
||||
}
|
||||
|
||||
// fetch rest for full export
|
||||
const menus = await prisma.menu.findMany({
|
||||
include: { items: { include: { translations: true } } },
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
const articles = await prisma.article.findMany({
|
||||
include: { translations: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
const settings = await prisma.storeSettings.findFirst({
|
||||
include: { translations: true },
|
||||
});
|
||||
const footerMenus = await prisma.footerMenu.findMany({
|
||||
include: { items: true },
|
||||
orderBy: { id: "asc" },
|
||||
}).catch(() => []); // graceful if table not present
|
||||
|
||||
return jsonResponse(
|
||||
{
|
||||
exportedAt: new Date().toISOString(),
|
||||
summary: {
|
||||
pages: pages.length,
|
||||
menus: menus.length,
|
||||
articles: articles.length,
|
||||
hasSettings: !!settings,
|
||||
},
|
||||
data: { pages, menus, footerMenus, articles, settings },
|
||||
},
|
||||
`homaroma_cms_all_${dt}.json`
|
||||
);
|
||||
}
|
||||
|
||||
if (section === "menus") {
|
||||
const menus = await prisma.menu.findMany({
|
||||
include: { items: { include: { translations: true } } },
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
const footerMenus = await prisma.footerMenu.findMany({
|
||||
include: { items: true },
|
||||
orderBy: { id: "asc" },
|
||||
}).catch(() => []);
|
||||
return jsonResponse(
|
||||
{ exportedAt: new Date().toISOString(), data: { menus, footerMenus } },
|
||||
`homaroma_cms_menus_${dt}.json`
|
||||
);
|
||||
}
|
||||
|
||||
if (section === "articles") {
|
||||
const articles = await prisma.article.findMany({
|
||||
include: { translations: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
return jsonResponse({ exportedAt: new Date().toISOString(), count: articles.length, data: articles }, `homaroma_cms_articles_${dt}.json`);
|
||||
}
|
||||
|
||||
if (section === "settings") {
|
||||
const settings = await prisma.storeSettings.findFirst({ include: { translations: true } });
|
||||
return jsonResponse({ exportedAt: new Date().toISOString(), data: settings }, `homaroma_cms_settings_${dt}.json`);
|
||||
}
|
||||
|
||||
set.status = 400;
|
||||
return { error: `Unknown section: ${section}. Valid values: all, pages, menus, articles, settings` };
|
||||
} catch (err: any) {
|
||||
console.error("[Backup] CMS export error:", err);
|
||||
set.status = 500;
|
||||
return { error: "CMS export failed", detail: err?.message };
|
||||
}
|
||||
})
|
||||
|
||||
// ── 4. Media Files — list ──────────────────────────────────────────────────
|
||||
/**
|
||||
* GET /api/backup/media
|
||||
* Returns JSON list of all media file records + filesystem info.
|
||||
*/
|
||||
.get("/media", async ({ set }: { set: any }) => {
|
||||
try {
|
||||
const media = await prisma.media.findMany({ orderBy: { createdAt: "desc" } });
|
||||
const uploadsDir = path.resolve("public/uploads");
|
||||
|
||||
let fsFiles: string[] = [];
|
||||
try {
|
||||
fsFiles = fs.readdirSync(uploadsDir);
|
||||
} catch {
|
||||
// uploads dir may not exist yet
|
||||
}
|
||||
|
||||
return {
|
||||
exportedAt: new Date().toISOString(),
|
||||
dbRecords: media.length,
|
||||
fsFiles: fsFiles.length,
|
||||
uploadsPath: uploadsDir,
|
||||
media,
|
||||
};
|
||||
} catch (err: any) {
|
||||
console.error("[Backup] Media list error:", err);
|
||||
set.status = 500;
|
||||
return { error: "Media list failed", detail: err?.message };
|
||||
}
|
||||
})
|
||||
|
||||
// ── 5. Media Files — zip download ─────────────────────────────────────────
|
||||
/**
|
||||
* GET /api/backup/media/zip
|
||||
* Zips the public/uploads/ directory and streams the .zip file.
|
||||
* Uses Bun's native spawn to call system zip command.
|
||||
*/
|
||||
.get("/media/zip", async ({ set }: { set: any }) => {
|
||||
try {
|
||||
const uploadsDir = path.resolve("public/uploads");
|
||||
if (!fs.existsSync(uploadsDir)) {
|
||||
set.status = 404;
|
||||
return { error: "uploads directory not found", path: uploadsDir };
|
||||
}
|
||||
|
||||
const dt = today();
|
||||
const tmpZip = path.join("/tmp", `homaroma_media_${dt}_${Date.now()}.zip`);
|
||||
|
||||
// Use Bun.spawn to call system zip
|
||||
const proc = Bun.spawn(["zip", "-r", tmpZip, "."], {
|
||||
cwd: uploadsDir,
|
||||
stderr: "pipe",
|
||||
});
|
||||
await proc.exited;
|
||||
|
||||
if (!fs.existsSync(tmpZip)) {
|
||||
set.status = 500;
|
||||
return { error: "zip creation failed — ensure `zip` is installed on the server" };
|
||||
}
|
||||
|
||||
const fileData = await Bun.file(tmpZip).arrayBuffer();
|
||||
|
||||
// Clean up temp file
|
||||
try { fs.unlinkSync(tmpZip); } catch { /* ignore */ }
|
||||
|
||||
return new Response(fileData, {
|
||||
headers: {
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Disposition": `attachment; filename="homaroma_media_${dt}.zip"`,
|
||||
"Content-Length": String(fileData.byteLength),
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error("[Backup] Media zip error:", err);
|
||||
set.status = 500;
|
||||
return { error: "Media zip failed", detail: err?.message };
|
||||
}
|
||||
})
|
||||
|
||||
// ── 6. Database info ──────────────────────────────────────────────────────
|
||||
/**
|
||||
* GET /api/backup/database
|
||||
* Returns table row counts for dashboard display.
|
||||
* Full pg_dump requires shell access — provide instructions.
|
||||
*/
|
||||
.get("/database", async ({ set }: { set: any }) => {
|
||||
try {
|
||||
const [
|
||||
products, categories, customers, orders,
|
||||
articles, pages, menus, coupons, promotions,
|
||||
media, subscribers, rawMaterials, tiktokOrders, shopeeOrders, lazadaOrders,
|
||||
] = await Promise.all([
|
||||
prisma.product.count(),
|
||||
prisma.category.count({ where: { deletedAt: null } }),
|
||||
prisma.customer.count(),
|
||||
prisma.order.count(),
|
||||
prisma.article.count(),
|
||||
prisma.page.count(),
|
||||
prisma.menu.count(),
|
||||
prisma.coupon.count(),
|
||||
prisma.promotion.count(),
|
||||
prisma.media.count(),
|
||||
prisma.newsletterSubscription.count().catch(() => 0),
|
||||
prisma.rawMaterial.count(),
|
||||
prisma.tiktokOrder.count(),
|
||||
prisma.shopeeOrder.count(),
|
||||
prisma.lazadaOrder.count(),
|
||||
]);
|
||||
|
||||
return {
|
||||
scannedAt: new Date().toISOString(),
|
||||
tableCounts: {
|
||||
products,
|
||||
categories,
|
||||
customers,
|
||||
orders,
|
||||
articles,
|
||||
pages,
|
||||
menus,
|
||||
coupons,
|
||||
promotions,
|
||||
media,
|
||||
subscribers,
|
||||
rawMaterials,
|
||||
tiktokOrders,
|
||||
shopeeOrders,
|
||||
lazadaOrders,
|
||||
},
|
||||
pgDumpNote: "Full pg_dump requires shell access. Use SSH: pg_dump -U postgres <dbname> > backup.sql",
|
||||
};
|
||||
} catch (err: any) {
|
||||
console.error("[Backup] Database info error:", err);
|
||||
set.status = 500;
|
||||
return { error: "Database info failed", detail: err?.message };
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user