This commit is contained in:
Your Name committed 2026-10-15 06:19:05 +07:00
1 parent f5893c3fad
commit 570810acdb
8 files changed
+65875 -6

No files matched your search

+2
View File
@@ -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 />} />
+3 -1
View File
@@ -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' },
+754
View File
@@ -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>
)
}
+31 -4
View File
@@ -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">