x
This commit is contained in:
1 parent
f5893c3fad
commit
570810acdb
8 files changed
+65875
-6
No files matched your search
@@ -6,6 +6,7 @@ import ProductEditPage from './pages/ProductEditPage'
|
||||
import ProductBomPage from './pages/ProductBomPage'
|
||||
import ProductCmsPage from './pages/ProductCmsPage'
|
||||
import CategoriesPage from './pages/CategoriesPage'
|
||||
import BrandsPage from './pages/BrandsPage'
|
||||
import CategoryEditPage from './pages/CategoryEditPage'
|
||||
import CategoryCmsPage from './pages/CategoryCmsPage'
|
||||
import CustomersPage from './pages/CustomersPage'
|
||||
@@ -106,6 +107,7 @@ function App() {
|
||||
<Route path="/products/:id/cms" element={<ProductCmsPage />} />
|
||||
<Route path="/products/:id/bom" element={<ProductBomPage />} />
|
||||
<Route path="/categories" element={<CategoriesPage />} />
|
||||
<Route path="/brands" element={<BrandsPage />} />
|
||||
<Route path="/categories/new" element={<CategoryEditPage />} />
|
||||
<Route path="/categories/:id" element={<CategoryEditPage />} />
|
||||
<Route path="/categories/:id/edit" element={<CategoryEditPage />} />
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Package,
|
||||
PackageOpen,
|
||||
Ruler,
|
||||
Award,
|
||||
Settings,
|
||||
LogOut,
|
||||
ChevronRight,
|
||||
@@ -55,7 +56,7 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
const isInProduct = ['/categories', '/products', '/product-attributes', '/raw-materials'].some(p => location.pathname.startsWith(p))
|
||||
const isInProduct = ['/categories', '/brands', '/products', '/product-attributes', '/raw-materials'].some(p => location.pathname.startsWith(p))
|
||||
const isInSales = ['/orders', '/promotions', '/coupons', '/global-analytics'].some(p => location.pathname.startsWith(p))
|
||||
const isRepeatCustomers = location.pathname.startsWith('/business-analytics/repeat-customer')
|
||||
const isInBusinessAnalytics = location.pathname.startsWith('/business-analytics') && !isRepeatCustomers
|
||||
@@ -92,6 +93,7 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
|
||||
|
||||
const PRODUCT_MENU = [
|
||||
{ icon: Tags, label: t('nav.categories', 'Categories'), to: '/categories' },
|
||||
{ icon: Award, label: t('nav.brands', 'Brands'), to: '/brands' },
|
||||
{ icon: Package, label: t('nav.products', 'Products'), to: '/products' },
|
||||
{ icon: Ruler, label: t('nav.productAttributes', 'Product Attributes'), to: '/product-attributes' },
|
||||
{ icon: PackageOpen, label: t('nav.rawMaterials', 'Raw Materials'), to: '/raw-materials' },
|
||||
|
||||
@@ -0,0 +1,754 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { API_BASE, type Brand } from '@homaroma/types'
|
||||
import {
|
||||
Award,
|
||||
Plus,
|
||||
Search,
|
||||
List,
|
||||
LayoutGrid,
|
||||
Edit3,
|
||||
Trash2,
|
||||
Globe,
|
||||
Package,
|
||||
X,
|
||||
Check,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Sparkles
|
||||
} from 'lucide-react'
|
||||
|
||||
const SUPPORTED_LANGUAGES = [
|
||||
{ code: 'th', label: 'ไทย (Thai)', flag: '🇹🇭' },
|
||||
{ code: 'en', label: 'English', flag: '🇺🇸' },
|
||||
{ code: 'ja', label: '日本語 (Japanese)', flag: '🇯🇵' },
|
||||
{ code: 'zh', label: '简体中文 (Chinese)', flag: '🇨🇳' },
|
||||
{ code: 'vi', label: 'Tiếng Việt (Vietnamese)', flag: '🇻🇳' },
|
||||
{ code: 'ko', label: '한국어 (Korean)', flag: '🇰🇷' },
|
||||
{ code: 'lo', label: 'ພາສາລາວ (Lao)', flag: '🇱🇦' },
|
||||
{ code: 'it', label: 'Italiano (Italian)', flag: '🇮🇹' },
|
||||
]
|
||||
|
||||
export default function BrandsPage() {
|
||||
const { t } = useTranslation()
|
||||
const [brands, setBrands] = useState<Brand[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [viewMode, setViewMode] = useState<'list' | 'grid'>('list')
|
||||
|
||||
// Modals state
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
|
||||
const [newBrandName, setNewBrandName] = useState('')
|
||||
const [createLoading, setCreateLoading] = useState(false)
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
|
||||
const [editingBrand, setEditingBrand] = useState<Brand | null>(null)
|
||||
const [editBrandName, setEditBrandName] = useState('')
|
||||
const [editLoading, setEditLoading] = useState(false)
|
||||
const [editError, setEditError] = useState<string | null>(null)
|
||||
|
||||
const [translatingBrand, setTranslatingBrand] = useState<Brand | null>(null)
|
||||
const [translationsMap, setTranslationsMap] = useState<Record<string, string>>({})
|
||||
const [translationLoading, setTranslationLoading] = useState(false)
|
||||
const [translationError, setTranslationError] = useState<string | null>(null)
|
||||
const [translationSuccess, setTranslationSuccess] = useState(false)
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/products/brands`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (Array.isArray(data)) {
|
||||
setBrands(data)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch brands:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
// Filtered brands
|
||||
const filteredBrands = useMemo(() => {
|
||||
const q = searchQuery.trim().toLowerCase()
|
||||
if (!q) return brands
|
||||
|
||||
return brands.filter(b => {
|
||||
if (String(b.id).includes(q)) return true
|
||||
if (b.name.toLowerCase().includes(q)) return true
|
||||
if (b.translations) {
|
||||
return b.translations.some(tr => tr.name.toLowerCase().includes(q))
|
||||
}
|
||||
return false
|
||||
})
|
||||
}, [brands, searchQuery])
|
||||
|
||||
// Total products across all brands
|
||||
const totalProducts = useMemo(() => {
|
||||
return brands.reduce((sum, b) => sum + (b._count?.products || 0), 0)
|
||||
}, [brands])
|
||||
|
||||
// Create Brand handler
|
||||
const handleCreateBrand = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!newBrandName.trim()) return
|
||||
setCreateLoading(true)
|
||||
setCreateError(null)
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/products/brands`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: newBrandName.trim() })
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok && data.success) {
|
||||
setIsCreateModalOpen(false)
|
||||
setNewBrandName('')
|
||||
fetchData()
|
||||
} else {
|
||||
setCreateError(data.message || 'Failed to create brand')
|
||||
}
|
||||
} catch (err: any) {
|
||||
setCreateError(err.message || 'Error creating brand')
|
||||
} finally {
|
||||
setCreateLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Edit Brand handler
|
||||
const handleEditBrand = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!editingBrand || !editBrandName.trim()) return
|
||||
setEditLoading(true)
|
||||
setEditError(null)
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/products/brands/${editingBrand.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: editBrandName.trim() })
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok && data.success) {
|
||||
setEditingBrand(null)
|
||||
setEditBrandName('')
|
||||
fetchData()
|
||||
} else {
|
||||
setEditError(data.message || 'Failed to update brand')
|
||||
}
|
||||
} catch (err: any) {
|
||||
setEditError(err.message || 'Error updating brand')
|
||||
} finally {
|
||||
setEditLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete Brand handler
|
||||
const handleDeleteBrand = async (brand: Brand) => {
|
||||
const productCount = brand._count?.products || 0
|
||||
if (productCount > 0) {
|
||||
alert(`ไม่สามารถลบแบรนด์ "${brand.name}" ได้ เนื่องจากมีสินค้าผูกอยู่ ${productCount} รายการ กรุณาย้ายสินค้าไปแบรนด์อื่นก่อน`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!confirm(`คุณแน่ใจหรือไม่ว่าต้องการลบแบรนด์ "${brand.name}"?`)) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/products/brands/${brand.id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok && data.success) {
|
||||
fetchData()
|
||||
} else {
|
||||
alert(data.message || 'Failed to delete brand')
|
||||
}
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Error deleting brand')
|
||||
}
|
||||
}
|
||||
|
||||
// Open Translation Modal
|
||||
const openTranslationModal = (brand: Brand) => {
|
||||
setTranslatingBrand(brand)
|
||||
const map: Record<string, string> = { th: brand.name }
|
||||
if (brand.translations) {
|
||||
for (const tr of brand.translations) {
|
||||
map[tr.langCode] = tr.name
|
||||
}
|
||||
}
|
||||
setTranslationsMap(map)
|
||||
setTranslationError(null)
|
||||
setTranslationSuccess(false)
|
||||
}
|
||||
|
||||
// Save Translations
|
||||
const handleSaveTranslations = async () => {
|
||||
if (!translatingBrand) return
|
||||
setTranslationLoading(true)
|
||||
setTranslationError(null)
|
||||
setTranslationSuccess(false)
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/products/brands/${translatingBrand.id}/translations`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: translationsMap })
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok && data.success) {
|
||||
setTranslationSuccess(true)
|
||||
fetchData()
|
||||
setTimeout(() => {
|
||||
setTranslatingBrand(null)
|
||||
setTranslationSuccess(false)
|
||||
}, 1200)
|
||||
} else {
|
||||
setTranslationError(data.message || 'Failed to save translations')
|
||||
}
|
||||
} catch (err: any) {
|
||||
setTranslationError(err.message || 'Error saving translations')
|
||||
} finally {
|
||||
setTranslationLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in relative pb-16">
|
||||
{/* Header Bar */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold text-white flex items-center gap-2.5">
|
||||
<Award className="w-8 h-8 text-cyan-glow" />
|
||||
<span>{t('brands.title', 'Brand Management')}</span>
|
||||
</h1>
|
||||
<span className="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-cyan-glow/10 text-cyan-glow border border-cyan-glow/20">
|
||||
{brands.length} แบรนด์
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-slate-500 text-sm">
|
||||
จัดการแบรนด์สินค้า ยี่ห้อ และคำแปลชื่อแบรนด์หลายภาษา • ผูกสินค้าทั้งหมด {totalProducts} รายการ
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* View Switcher Toggle */}
|
||||
<div className="flex items-center p-1 bg-night-900 border border-white/10 rounded-xl shadow-inner">
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-all cursor-pointer ${
|
||||
viewMode === 'list'
|
||||
? 'bg-cyan-glow text-night-950 font-bold shadow-sm'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
title="List / Table View"
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
<span>List</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-all cursor-pointer ${
|
||||
viewMode === 'grid'
|
||||
? 'bg-cyan-glow text-night-950 font-bold shadow-sm'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
title="Grid / Card View"
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
<span>Grid</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setNewBrandName('')
|
||||
setCreateError(null)
|
||||
setIsCreateModalOpen(true)
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-night-950 font-bold rounded-xl text-sm transition-all shadow-lg shadow-cyan-500/20 active:scale-95 cursor-pointer"
|
||||
>
|
||||
<Plus className="w-4 h-4 stroke-[3]" />
|
||||
<span>เพิ่มแบรนด์ใหม่</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Filter Bar */}
|
||||
<div className="relative">
|
||||
<Search className="w-5 h-5 absolute left-4 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="ค้นหาตามชื่อแบรนด์, ID หรือคำแปลภาษา..."
|
||||
className="w-full pl-12 pr-4 py-3 bg-night-900/80 border border-white/10 rounded-2xl text-sm text-white placeholder-slate-500 focus:outline-none focus:border-cyan-glow/50 transition-all shadow-inner"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-slate-500 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20 text-slate-500 gap-3">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-cyan-glow" />
|
||||
<span className="text-sm">กำลังโหลดข้อมูลแบรนด์...</span>
|
||||
</div>
|
||||
) : filteredBrands.length === 0 ? (
|
||||
<div className="text-center py-16 bg-night-900/50 border border-white/10 rounded-3xl p-8 space-y-4">
|
||||
<div className="w-14 h-14 mx-auto rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center text-slate-500">
|
||||
<Award className="w-7 h-7" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-bold text-white">
|
||||
{searchQuery ? 'ไม่พบแบรนด์ที่ตรงกับคำค้นหา' : 'ยังไม่มีแบรนด์ในระบบ'}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500 max-w-md mx-auto">
|
||||
{searchQuery ? `ลองค้นหาด้วยคำอื่น หรือกดล้างคำค้นหา` : `เริ่มต้นด้วยการสร้างแบรนด์สินค้าใหม่เพื่อใช้จัดกลุ่มสินค้า`}
|
||||
</p>
|
||||
</div>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="text-xs text-cyan-glow hover:underline"
|
||||
>
|
||||
ล้างการค้นหา
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : viewMode === 'list' ? (
|
||||
/* TABLE / LIST VIEW */
|
||||
<div className="bg-night-900 border border-white/10 rounded-2xl overflow-hidden shadow-xl">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm text-slate-300">
|
||||
<thead className="bg-white/[0.02] border-b border-white/10 text-[10px] uppercase font-bold tracking-widest text-slate-400">
|
||||
<tr>
|
||||
<th className="py-4 px-6 w-20">ID</th>
|
||||
<th className="py-4 px-6">ชื่อแบรนด์ (Brand Name)</th>
|
||||
<th className="py-4 px-6 w-36">สินค้าที่ผูก</th>
|
||||
<th className="py-4 px-6">คำแปลภาษา (Translations)</th>
|
||||
<th className="py-4 px-6 text-right w-48">จัดการ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5 font-medium">
|
||||
{filteredBrands.map((brand) => {
|
||||
const prodCount = brand._count?.products || 0
|
||||
const translations = brand.translations || []
|
||||
|
||||
return (
|
||||
<tr key={brand.id} className="hover:bg-white/[0.02] transition-colors group">
|
||||
<td className="py-4 px-6 font-mono text-xs text-slate-500">
|
||||
#{brand.id}
|
||||
</td>
|
||||
<td className="py-4 px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-cyan-500/20 via-blue-500/10 to-transparent border border-cyan-500/30 flex items-center justify-center text-cyan-glow shrink-0">
|
||||
<Award className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-bold text-white text-base group-hover:text-cyan-glow transition-colors">
|
||||
{brand.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 px-6">
|
||||
<Link
|
||||
to={`/products?brandId=${brand.id}`}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-xs text-slate-300 hover:text-white transition-all"
|
||||
>
|
||||
<Package className="w-3.5 h-3.5 text-cyan-glow" />
|
||||
<span className="font-semibold">{prodCount}</span>
|
||||
<span className="text-[10px] text-slate-500">รายการ</span>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-4 px-6">
|
||||
<div className="flex items-center flex-wrap gap-1.5">
|
||||
{translations.length > 0 ? (
|
||||
translations.map((tr) => (
|
||||
<span
|
||||
key={tr.langCode}
|
||||
className="px-2 py-0.5 rounded-lg text-[10px] font-bold uppercase bg-white/5 border border-white/10 text-slate-300"
|
||||
title={`${tr.langCode}: ${tr.name}`}
|
||||
>
|
||||
{tr.langCode}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-xs text-slate-600 italic">ไม่มีคำแปล</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 px-6 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => openTranslationModal(brand)}
|
||||
className="p-2 rounded-xl bg-white/5 hover:bg-cyan-glow/10 hover:text-cyan-glow border border-white/10 text-slate-400 transition-colors"
|
||||
title="จัดการคำแปลภาษา"
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingBrand(brand)
|
||||
setEditBrandName(brand.name)
|
||||
setEditError(null)
|
||||
}}
|
||||
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 hover:text-white border border-white/10 text-slate-400 transition-colors"
|
||||
title="แก้ไขชื่อแบรนด์"
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteBrand(brand)}
|
||||
className="p-2 rounded-xl bg-white/5 hover:bg-rose-500/20 hover:text-rose-400 border border-white/10 text-slate-400 transition-colors"
|
||||
title="ลบแบรนด์"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* GRID / CARDS VIEW */
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{filteredBrands.map((brand) => {
|
||||
const prodCount = brand._count?.products || 0
|
||||
const translations = brand.translations || []
|
||||
|
||||
return (
|
||||
<div
|
||||
key={brand.id}
|
||||
className="bg-night-900 border border-white/10 hover:border-cyan-glow/40 rounded-3xl p-6 space-y-5 transition-all shadow-xl hover:shadow-cyan-glow/5 group flex flex-col justify-between"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-cyan-500/20 via-blue-500/10 to-transparent border border-cyan-500/30 flex items-center justify-center text-cyan-glow shadow-inner">
|
||||
<Award className="w-6 h-6" />
|
||||
</div>
|
||||
<span className="font-mono text-xs text-slate-500">#{brand.id}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white group-hover:text-cyan-glow transition-colors">
|
||||
{brand.name}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 mt-1 flex items-center gap-1.5">
|
||||
<Package className="w-3.5 h-3.5 text-cyan-glow" />
|
||||
<span>{prodCount} สินค้าผูกอยู่</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Translations Tags */}
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-500">
|
||||
ภาษาที่รองรับ:
|
||||
</span>
|
||||
<div className="flex items-center flex-wrap gap-1.5">
|
||||
{translations.length > 0 ? (
|
||||
translations.map((tr) => (
|
||||
<span
|
||||
key={tr.langCode}
|
||||
className="px-2 py-0.5 rounded-lg text-[10px] font-bold uppercase bg-white/5 border border-white/10 text-slate-300"
|
||||
title={`${tr.langCode}: ${tr.name}`}
|
||||
>
|
||||
{tr.langCode}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-xs text-slate-600 italic">ไม่มีคำแปล</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card Actions */}
|
||||
<div className="pt-4 border-t border-white/5 flex items-center justify-between gap-2">
|
||||
<button
|
||||
onClick={() => openTranslationModal(brand)}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-xl bg-white/5 hover:bg-cyan-glow/10 hover:text-cyan-glow border border-white/10 text-xs font-semibold text-slate-300 transition-colors"
|
||||
>
|
||||
<Globe className="w-3.5 h-3.5" />
|
||||
<span>แปลภาษา</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingBrand(brand)
|
||||
setEditBrandName(brand.name)
|
||||
setEditError(null)
|
||||
}}
|
||||
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 hover:text-white border border-white/10 text-slate-400 transition-colors"
|
||||
title="แก้ไขชื่อแบรนด์"
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteBrand(brand)}
|
||||
className="p-2 rounded-xl bg-white/5 hover:bg-rose-500/20 hover:text-rose-400 border border-white/10 text-slate-400 transition-colors"
|
||||
title="ลบแบรนด์"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CREATE BRAND MODAL */}
|
||||
{isCreateModalOpen && (
|
||||
<div className="fixed inset-0 z-50 bg-night-950/80 backdrop-blur-md flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md bg-night-900 border border-white/10 rounded-3xl shadow-2xl overflow-hidden animate-zoom-in">
|
||||
<div className="p-6 border-b border-white/10 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-cyan-glow/10 border border-cyan-glow/20 flex items-center justify-center text-cyan-glow">
|
||||
<Plus className="w-5 h-5 stroke-[2.5]" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-white text-base">เพิ่มแบรนด์ใหม่</h3>
|
||||
<p className="text-xs text-slate-500">สร้างชื่อยี่ห้อสินค้าสำหรับใช้ในระบบ</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsCreateModalOpen(false)}
|
||||
className="text-slate-500 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCreateBrand} className="p-6 space-y-5">
|
||||
{createError && (
|
||||
<div className="p-3 bg-rose-500/10 border border-rose-500/20 rounded-xl text-rose-400 text-xs flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
<span>{createError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">
|
||||
ชื่อแบรนด์ (Brand Name) *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={newBrandName}
|
||||
onChange={(e) => setNewBrandName(e.target.value)}
|
||||
placeholder="เช่น TOBAKI, Brembo, RCB, Ohlins"
|
||||
className="w-full px-4 py-3 bg-night-950 border border-white/10 focus:border-cyan-glow/50 rounded-xl outline-none text-sm text-white placeholder-slate-600 transition-all shadow-inner"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 flex items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCreateModalOpen(false)}
|
||||
className="px-4 py-2.5 rounded-xl border border-white/10 hover:bg-white/5 text-sm font-semibold text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
ยกเลิก
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createLoading || !newBrandName.trim()}
|
||||
className="px-5 py-2.5 rounded-xl bg-cyan-glow hover:bg-cyan-glow/80 text-night-950 text-sm font-bold transition-all disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{createLoading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
<span>บันทึกแบรนด์</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* EDIT BRAND MODAL */}
|
||||
{editingBrand && (
|
||||
<div className="fixed inset-0 z-50 bg-night-950/80 backdrop-blur-md flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md bg-night-900 border border-white/10 rounded-3xl shadow-2xl overflow-hidden animate-zoom-in">
|
||||
<div className="p-6 border-b border-white/10 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-white/5 border border-white/10 flex items-center justify-center text-slate-300">
|
||||
<Edit3 className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-white text-base">แก้ไขชื่อแบรนด์</h3>
|
||||
<p className="text-xs text-slate-500">เปลี่ยนชื่อหลักของแบรนด์ #{editingBrand.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setEditingBrand(null)}
|
||||
className="text-slate-500 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleEditBrand} className="p-6 space-y-5">
|
||||
{editError && (
|
||||
<div className="p-3 bg-rose-500/10 border border-rose-500/20 rounded-xl text-rose-400 text-xs flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
<span>{editError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">
|
||||
ชื่อแบรนด์ *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={editBrandName}
|
||||
onChange={(e) => setEditBrandName(e.target.value)}
|
||||
className="w-full px-4 py-3 bg-night-950 border border-white/10 focus:border-cyan-glow/50 rounded-xl outline-none text-sm text-white transition-all shadow-inner"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 flex items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingBrand(null)}
|
||||
className="px-4 py-2.5 rounded-xl border border-white/10 hover:bg-white/5 text-sm font-semibold text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
ยกเลิก
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={editLoading || !editBrandName.trim()}
|
||||
className="px-5 py-2.5 rounded-xl bg-cyan-glow hover:bg-cyan-glow/80 text-night-950 text-sm font-bold transition-all disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{editLoading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
<span>บันทึกการแก้ไข</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TRANSLATIONS MODAL */}
|
||||
{translatingBrand && (
|
||||
<div className="fixed inset-0 z-50 bg-night-950/80 backdrop-blur-md flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-2xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl overflow-hidden animate-zoom-in max-h-[90vh] flex flex-col">
|
||||
<div className="p-6 border-b border-white/10 flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-cyan-glow/10 border border-cyan-glow/20 flex items-center justify-center text-cyan-glow">
|
||||
<Globe className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-white text-base">แปลชื่อแบรนด์: {translatingBrand.name}</h3>
|
||||
<p className="text-xs text-slate-500">กำหนดชื่อแบรนด์ในแต่ละภาษาให้แสดงผลถูกต้องในหน้า Storefront</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setTranslatingBrand(null)}
|
||||
className="text-slate-500 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 overflow-y-auto space-y-5 flex-1">
|
||||
{translationError && (
|
||||
<div className="p-3 bg-rose-500/10 border border-rose-500/20 rounded-xl text-rose-400 text-xs flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
<span>{translationError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{translationSuccess && (
|
||||
<div className="p-3 bg-emerald-500/10 border border-emerald-500/20 rounded-xl text-emerald-400 text-xs flex items-center gap-2">
|
||||
<Check className="w-4 h-4 shrink-0" />
|
||||
<span>บันทึกคำแปลสำเร็จเรียบร้อย!</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{SUPPORTED_LANGUAGES.map((lang) => (
|
||||
<div key={lang.code} className="space-y-1.5 p-3 rounded-2xl bg-white/[0.02] border border-white/5">
|
||||
<label className="text-xs font-semibold text-slate-300 flex items-center gap-1.5">
|
||||
<span>{lang.flag}</span>
|
||||
<span>{lang.label}</span>
|
||||
<span className="text-[10px] text-slate-500 font-mono uppercase">({lang.code})</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={translationsMap[lang.code] || ''}
|
||||
onChange={(e) => setTranslationsMap({ ...translationsMap, [lang.code]: e.target.value })}
|
||||
placeholder={`ชื่อแบรนด์ (${lang.label})...`}
|
||||
className="w-full px-3 py-2 bg-night-950 border border-white/10 focus:border-cyan-glow/50 rounded-xl outline-none text-sm text-white placeholder-slate-600 transition-all shadow-inner"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-white/10 shrink-0 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// Autofill empty with primary name
|
||||
const primary = translatingBrand.name
|
||||
const updated: Record<string, string> = { ...translationsMap }
|
||||
for (const lang of SUPPORTED_LANGUAGES) {
|
||||
if (!updated[lang.code]?.trim()) {
|
||||
updated[lang.code] = primary
|
||||
}
|
||||
}
|
||||
setTranslationsMap(updated)
|
||||
}}
|
||||
className="text-xs text-cyan-glow hover:underline flex items-center gap-1"
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
<span>เติมชื่อหลักลงทุกภาษาที่ยังว่าง</span>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTranslatingBrand(null)}
|
||||
className="px-4 py-2.5 rounded-xl border border-white/10 hover:bg-white/5 text-sm font-semibold text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
ยกเลิก
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveTranslations}
|
||||
disabled={translationLoading}
|
||||
className="px-5 py-2.5 rounded-xl bg-cyan-glow hover:bg-cyan-glow/80 text-night-950 text-sm font-bold transition-all disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{translationLoading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
<span>บันทึกคำแปล</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
Download,
|
||||
Upload
|
||||
} from 'lucide-react'
|
||||
import { API_BASE, type AttributeDefinition, type Category, type Product, type ProductAttribute, type ProductImage, type Variant } from '@homaroma/types'
|
||||
import { API_BASE, type AttributeDefinition, type Brand, type Category, type Product, type ProductAttribute, type ProductImage, type Variant } from '@homaroma/types'
|
||||
import MDEditor from '@uiw/react-md-editor'
|
||||
import MediaLibraryModal from '../components/MediaLibraryModal'
|
||||
import ContentLanguageTabs from '../components/ContentLanguageTabs'
|
||||
@@ -127,6 +127,8 @@ export default function ProductEditPage() {
|
||||
const [translationModalTab, setTranslationModalTab] = useState<'export' | 'import'>('export')
|
||||
|
||||
const [categoryId, setCategoryId] = useState<number>(0)
|
||||
const [brands, setBrands] = useState<Brand[]>([])
|
||||
const [brandId, setBrandId] = useState<number | ''>('')
|
||||
const [name, setName] = useState<Record<string, string>>({})
|
||||
const [description, setDescription] = useState<Record<string, string>>({})
|
||||
const [details, setDetails] = useState<Record<string, string>>({})
|
||||
@@ -189,9 +191,14 @@ export default function ProductEditPage() {
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const catRes = await fetch(`${API_BASE}/categories`)
|
||||
const [catRes, brandRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/categories`),
|
||||
fetch(`${API_BASE}/products/brands`)
|
||||
])
|
||||
const catData: Category[] = await catRes.json()
|
||||
const brandData: Brand[] = brandRes.ok ? await brandRes.json() : []
|
||||
setCategories(catData)
|
||||
setBrands(brandData)
|
||||
|
||||
if (id) {
|
||||
const prodRes = await fetch(`${API_BASE}/products/${id}?lang=all`)
|
||||
@@ -200,6 +207,7 @@ export default function ProductEditPage() {
|
||||
const prodData: Product = await prodRes.json()
|
||||
setProduct(prodData)
|
||||
setCategoryId(prodData.categoryId)
|
||||
setBrandId(prodData.brandId ?? prodData.brand?.id ?? (brandData[0]?.id || ''))
|
||||
|
||||
const parseTranslatable = (val: any) => typeof val === 'object' && val !== null ? val : { th: val || '' }
|
||||
setName(parseTranslatable(prodData.name))
|
||||
@@ -224,6 +232,7 @@ export default function ProductEditPage() {
|
||||
} else {
|
||||
setProduct(null)
|
||||
if (catData.length > 0) setCategoryId(catData[0].id)
|
||||
if (brandData.length > 0) setBrandId(brandData[0].id)
|
||||
setName({ th: '', en: '' })
|
||||
setDescription({ th: '', en: '' })
|
||||
setDetails({ th: '', en: '' })
|
||||
@@ -402,6 +411,7 @@ export default function ProductEditPage() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
categoryId,
|
||||
brandId: brandId ? Number(brandId) : undefined,
|
||||
name,
|
||||
titleGraphicUrl: titleGraphicUrl.trim() || null,
|
||||
description,
|
||||
@@ -570,7 +580,7 @@ export default function ProductEditPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Product Name ({activeLang.toUpperCase()})</label>
|
||||
<input
|
||||
@@ -580,7 +590,6 @@ export default function ProductEditPage() {
|
||||
placeholder={`e.g. Scented Candle (${activeLang})`}
|
||||
className="w-full px-4 py-3 bg-night-950 border border-white/10 focus:border-cyan-glow/40 rounded-xl outline-none text-sm text-white transition-all shadow-inner"
|
||||
/>
|
||||
<p className="text-[10px] text-slate-600 px-1 mt-1">Brand Name: <span className="italic">{product?.brand.name}</span></p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -596,6 +605,24 @@ export default function ProductEditPage() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Brand</label>
|
||||
<Link to="/brands" className="text-[10px] text-cyan-glow hover:underline">จัดการแบรนด์</Link>
|
||||
</div>
|
||||
<select
|
||||
value={brandId}
|
||||
onChange={(e) => setBrandId(e.target.value ? Number(e.target.value) : '')}
|
||||
className="w-full px-4 py-3 bg-night-950 border border-white/10 focus:border-cyan-glow/40 rounded-xl outline-none text-sm text-white transition-all appearance-none cursor-pointer"
|
||||
required
|
||||
>
|
||||
<option value="">เลือกแบรนด์...</option>
|
||||
{brands.map(b => (
|
||||
<option key={b.id} value={b.id}>{b.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 space-y-2 p-4 rounded-xl border border-white/10 bg-white/[0.02]">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
Reference in new issue
Block a user