canina/frontend/admin-panel/src/pages/Products.tsx
parsa aghaei 3aebb9e176 fix(admin): show original filename and selected state in image gallery
- Add filename label and upload date under each thumbnail in MediaSelector
- Highlight currently selected image with purple border and badge
- Add selectedUrl prop to MediaSelector for visual indication
- Show image filename in product form Media tab
- Add helper text for clipboard paste and gallery selection
2026-07-11 15:45:17 +03:30

443 lines
25 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect } from 'react';
import { Search, Filter, Box, Plus, Edit2, Trash2, Image as ImageIcon, X } from 'lucide-react';
import { toast } from 'react-hot-toast';
import api from '../services/api';
import Spinner from '../components/ui/Spinner';
import Pagination from '../components/ui/Pagination';
import MediaSelector from '../components/ui/MediaSelector';
export default function Products() {
const [products, setProducts] = useState<any[]>([]);
const [categories, setCategories] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
// Filters
const [search, setSearch] = useState('');
const [categoryFilter, setCategoryFilter] = useState('');
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const limit = 10;
// Modal State
const [isModalOpen, setIsModalOpen] = useState(false);
const [isMediaSelectorOpen, setIsMediaSelectorOpen] = useState(false);
const [editingProduct, setEditingProduct] = useState<any>(null);
const [activeTab, setActiveTab] = useState('general');
const [formData, setFormData] = useState({
artNo: '',
name: '',
scientificTagline: '',
description: '',
shortDescription: '',
categoryId: '',
priceValue: 0,
priceDisplay: '',
unit: '',
packageSize: 0,
dosageLogic: '',
suitableFor: 'سگ و گربه',
imageUrl: '',
metaTitle: '',
metaDescription: '',
keywords: '',
canonicalUrl: '',
slug: ''
});
const fetchData = async () => {
try {
setIsLoading(true);
// Fetch products first
const prodRes = await api.get('/admin/products', { params: { page, limit, search, categoryId: categoryFilter } });
if (prodRes.data?.data) {
setProducts(prodRes.data.data);
setTotalPages(prodRes.data.meta?.lastPage || 1);
}
// Fetch categories separately so it doesn't break products
try {
const catRes = await api.get('/admin/categories');
if (catRes.data?.data) {
setCategories(catRes.data.data);
} else if (Array.isArray(catRes.data)) {
setCategories(catRes.data);
}
} catch (err) {
console.warn('Could not fetch categories', err);
}
} catch (err: any) {
console.error(err);
toast.error('خطا در دریافت لیست محصولات: ' + (err.response?.data?.message || err.message));
} finally {
setIsLoading(false);
}
};
useEffect(() => {
const timer = setTimeout(() => {
fetchData();
}, 500);
return () => clearTimeout(timer);
}, [search, categoryFilter, page]);
const openModal = (product: any = null) => {
if (product) {
setEditingProduct(product);
setFormData({
artNo: product.artNo,
name: product.name,
scientificTagline: product.scientificTagline || '',
description: product.description || '',
shortDescription: product.shortDescription || '',
categoryId: product.categoryId || '',
priceValue: Number(product.priceValue),
priceDisplay: product.priceDisplay || '',
unit: product.unit || '',
packageSize: Number(product.packageSize),
dosageLogic: product.dosageLogic || '',
suitableFor: product.suitableFor || 'سگ و گربه',
imageUrl: product.imageUrl || '',
metaTitle: product.metaTitle || '',
metaDescription: product.metaDescription || '',
keywords: product.keywords || '',
canonicalUrl: product.canonicalUrl || '',
slug: product.slug || ''
});
} else {
setEditingProduct(null);
setFormData({
artNo: '', name: '', scientificTagline: '', description: '', shortDescription: '', categoryId: '',
priceValue: 0, priceDisplay: '', unit: '', packageSize: 0, dosageLogic: '', suitableFor: 'سگ و گربه',
imageUrl: '', metaTitle: '', metaDescription: '', keywords: '', canonicalUrl: '', slug: ''
});
}
setActiveTab('general');
setIsModalOpen(true);
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingProduct) {
await api.put(`/admin/products/${editingProduct.id}`, formData);
} else {
await api.post('/admin/products', formData);
}
setIsModalOpen(false);
fetchData();
} catch (error) {
console.error('Save failed', error);
alert('خطا در ذخیره محصول');
}
};
const handleDelete = async (id: string) => {
if (!window.confirm('آیا از حذف این محصول مطمئن هستید؟')) return;
try {
await api.delete(`/admin/products/${id}`);
fetchData();
} catch (error) {
console.error('Delete failed', error);
}
};
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row justify-between gap-4">
<div>
<h2 className="text-2xl font-black text-gray-900 flex items-center gap-2">
<Box className="w-6 h-6 text-purple-600" />
مدیریت محصولات
</h2>
<p className="text-gray-500 font-medium mt-1">افزودن و ویرایش پیشرفته محصولات با تنظیمات سئو</p>
</div>
<button
onClick={() => openModal()}
className="bg-purple-600 hover:bg-purple-700 text-white px-5 py-2.5 rounded-xl flex items-center gap-2 font-bold transition-all shadow-md shadow-purple-200"
>
<Plus className="w-5 h-5" />
محصول جدید
</button>
</div>
<div className="bg-white p-4 rounded-2xl shadow-sm border border-gray-200 flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="w-5 h-5 absolute right-4 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="جستجو در نام و کد محصول..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
className="w-full pl-4 pr-12 py-3 rounded-xl border border-gray-200 focus:border-purple-500 focus:ring-2 focus:ring-purple-200 outline-none transition-all"
/>
</div>
<div className="relative w-full sm:w-64">
<Filter className="w-5 h-5 absolute right-4 top-1/2 -translate-y-1/2 text-gray-400" />
<select
value={categoryFilter}
onChange={(e) => { setCategoryFilter(e.target.value); setPage(1); }}
className="w-full pl-4 pr-12 py-3 rounded-xl border border-gray-200 focus:border-purple-500 focus:ring-2 focus:ring-purple-200 outline-none transition-all appearance-none"
>
<option value="">همه دسته‌بندی‌ها</option>
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
</div>
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-right">
<thead className="bg-gray-50 border-b border-gray-100">
<tr>
<th className="py-4 px-6 text-sm font-bold text-gray-500">تصویر</th>
<th className="py-4 px-6 text-sm font-bold text-gray-500">کد (Art No)</th>
<th className="py-4 px-6 text-sm font-bold text-gray-500">نام محصول</th>
<th className="py-4 px-6 text-sm font-bold text-gray-500">دسته‌بندی</th>
<th className="py-4 px-6 text-sm font-bold text-gray-500">موجودی / قیمت</th>
<th className="py-4 px-6 text-sm font-bold text-gray-500 w-24">عملیات</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{isLoading ? (
<tr>
<td colSpan={6} className="py-12 text-center">
<Spinner size="lg" className="mx-auto text-purple-600" />
</td>
</tr>
) : products.length === 0 ? (
<tr>
<td colSpan={6} className="py-12 text-center text-gray-500 font-medium">محصولی یافت نشد</td>
</tr>
) : (
products.map((product) => (
<tr key={product.id} className="hover:bg-gray-50/50 transition-colors">
<td className="py-3 px-6">
{product.imageUrl ? (
<img src={product.imageUrl.startsWith('http') ? product.imageUrl : `http://localhost:4001${product.imageUrl}`} alt={product.name} className="w-12 h-12 rounded-xl object-cover border border-gray-200" />
) : (
<div className="w-12 h-12 rounded-xl bg-gray-100 flex items-center justify-center text-gray-400">
<ImageIcon className="w-5 h-5" />
</div>
)}
</td>
<td className="py-4 px-6 text-gray-500 font-mono text-sm">{product.artNo}</td>
<td className="py-4 px-6 font-bold text-gray-900">{product.name}</td>
<td className="py-4 px-6 text-gray-600 text-sm">{product.category?.name || 'بدون دسته'}</td>
<td className="py-4 px-6">
<div className="flex flex-col">
<span className="font-bold text-gray-900">{Number(product.priceValue).toLocaleString()} تومان</span>
<span className="text-xs text-gray-500">{product.packageSize} موجودی</span>
</div>
</td>
<td className="py-4 px-6">
<div className="flex items-center gap-2">
<button onClick={() => openModal(product)} className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors">
<Edit2 className="w-4 h-4" />
</button>
<button onClick={() => handleDelete(product.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors">
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{!isLoading && totalPages > 1 && (
<div className="p-4 border-t border-gray-100 flex justify-center bg-gray-50/50">
<Pagination currentPage={page} totalPages={totalPages} onPageChange={setPage} />
</div>
)}
</div>
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-gray-900/60 backdrop-blur-sm">
<div className="bg-white rounded-2xl w-full max-w-4xl max-h-[90vh] flex flex-col shadow-2xl animate-in zoom-in duration-200">
<div className="px-6 py-4 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
<h3 className="text-xl font-bold text-gray-900">
{editingProduct ? 'ویرایش پیشرفته محصول' : 'افزودن محصول جدید'}
</h3>
<button onClick={() => setIsModalOpen(false)} className="text-gray-400 hover:text-red-500 transition-colors">
<X className="w-6 h-6" />
</button>
</div>
<div className="flex border-b border-gray-100 px-6 pt-2 gap-4">
{[
{ id: 'general', label: 'اطلاعات پایه' },
{ id: 'pricing', label: 'موجودی و قیمت' },
{ id: 'seo', label: 'سئو (SEO)' },
{ id: 'media', label: 'تصاویر' }
].map(tab => (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={`pb-3 px-2 text-sm font-bold border-b-2 transition-colors ${activeTab === tab.id ? 'border-purple-600 text-purple-700' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
>
{tab.label}
</button>
))}
</div>
<div className="p-6 overflow-y-auto flex-1 bg-gray-50/30">
<form id="productForm" onSubmit={handleSave} className="space-y-6">
{/* General Tab */}
<div className={activeTab === 'general' ? 'block space-y-4' : 'hidden'}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-bold text-gray-700">کد محصول (Art No) *</label>
<input required type="text" value={formData.artNo} onChange={(e) => setFormData({...formData, artNo: e.target.value})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-mono" dir="ltr" />
</div>
<div className="space-y-2">
<label className="text-sm font-bold text-gray-700">نام محصول *</label>
<input required type="text" value={formData.name} onChange={(e) => setFormData({...formData, name: e.target.value})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none" />
</div>
<div className="space-y-2 md:col-span-2">
<label className="text-sm font-bold text-gray-700">شعار علمی (Tagline)</label>
<input type="text" value={formData.scientificTagline} onChange={(e) => setFormData({...formData, scientificTagline: e.target.value})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none" />
</div>
<div className="space-y-2">
<label className="text-sm font-bold text-gray-700">دسته‌بندی *</label>
<select required value={formData.categoryId} onChange={(e) => setFormData({...formData, categoryId: e.target.value})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none bg-white">
<option value="">انتخاب کنید</option>
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-bold text-gray-700">مناسب برای</label>
<select value={formData.suitableFor} onChange={(e) => setFormData({...formData, suitableFor: e.target.value})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none bg-white">
<option value="سگ">سگ</option>
<option value="گربه">گربه</option>
<option value="سگ و گربه">سگ و گربه</option>
</select>
</div>
<div className="space-y-2 md:col-span-2">
<label className="text-sm font-bold text-gray-700">توضیح کوتاه</label>
<textarea rows={2} value={formData.shortDescription} onChange={(e) => setFormData({...formData, shortDescription: e.target.value})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none"></textarea>
</div>
<div className="space-y-2 md:col-span-2">
<label className="text-sm font-bold text-gray-700">توضیحات کامل</label>
<textarea rows={5} value={formData.description} onChange={(e) => setFormData({...formData, description: e.target.value})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none"></textarea>
</div>
<div className="space-y-2 md:col-span-2">
<label className="text-sm font-bold text-gray-700">منطق دوز مصرفی (Dosage Logic)</label>
<textarea rows={2} value={formData.dosageLogic} onChange={(e) => setFormData({...formData, dosageLogic: e.target.value})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none"></textarea>
</div>
</div>
</div>
{/* Pricing Tab */}
<div className={activeTab === 'pricing' ? 'block space-y-4' : 'hidden'}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-bold text-gray-700">قیمت (تومان) *</label>
<input required type="number" min="0" value={formData.priceValue} onChange={(e) => setFormData({...formData, priceValue: Number(e.target.value)})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none" dir="ltr" />
</div>
<div className="space-y-2">
<label className="text-sm font-bold text-gray-700">نمایش قیمت (متنی) *</label>
<input required type="text" placeholder="مثال: ۸۵۰,۰۰۰ تومان" value={formData.priceDisplay} onChange={(e) => setFormData({...formData, priceDisplay: e.target.value})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none" />
</div>
<div className="space-y-2">
<label className="text-sm font-bold text-gray-700">موجودی *</label>
<input required type="number" min="0" value={formData.packageSize} onChange={(e) => setFormData({...formData, packageSize: Number(e.target.value)})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none" dir="ltr" />
</div>
<div className="space-y-2">
<label className="text-sm font-bold text-gray-700">واحد (Unit) *</label>
<input required type="text" placeholder="عدد، گرم، میلی‌لیتر" value={formData.unit} onChange={(e) => setFormData({...formData, unit: e.target.value})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none" />
</div>
</div>
</div>
{/* SEO Tab */}
<div className={activeTab === 'seo' ? 'block space-y-4' : 'hidden'}>
<div className="grid grid-cols-1 gap-4">
<div className="space-y-2">
<label className="text-sm font-bold text-gray-700">نامک (Slug) محصول</label>
<input type="text" value={formData.slug} onChange={(e) => setFormData({...formData, slug: e.target.value})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none" dir="ltr" placeholder="مثال: canhydrox-gag" />
</div>
<div className="space-y-2">
<label className="text-sm font-bold text-gray-700">عنوان متا (Meta Title)</label>
<input type="text" value={formData.metaTitle} onChange={(e) => setFormData({...formData, metaTitle: e.target.value})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none" />
</div>
<div className="space-y-2">
<label className="text-sm font-bold text-gray-700">توضیحات متا (Meta Description)</label>
<textarea rows={3} value={formData.metaDescription} onChange={(e) => setFormData({...formData, metaDescription: e.target.value})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none"></textarea>
</div>
<div className="space-y-2">
<label className="text-sm font-bold text-gray-700">کلمات کلیدی (با کاما جدا کنید)</label>
<input type="text" value={formData.keywords} onChange={(e) => setFormData({...formData, keywords: e.target.value})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none" />
</div>
<div className="space-y-2">
<label className="text-sm font-bold text-gray-700">آدرس کانونی (Canonical URL)</label>
<input type="text" value={formData.canonicalUrl} onChange={(e) => setFormData({...formData, canonicalUrl: e.target.value})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-mono text-sm" dir="ltr" />
</div>
</div>
</div>
{/* Media Tab */}
<div className={activeTab === 'media' ? 'block space-y-4' : 'hidden'}>
<div className="flex flex-col items-center justify-center p-8 border-2 border-dashed border-gray-200 rounded-2xl bg-white">
{formData.imageUrl ? (
<div className="w-full flex flex-col items-center">
<div className="relative group">
<img src={formData.imageUrl.startsWith('http') ? formData.imageUrl : `http://localhost:4001${formData.imageUrl}`} alt="product" className="max-w-xs h-48 object-cover rounded-xl shadow-md" />
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded-xl flex items-center justify-center gap-3">
<button type="button" onClick={() => setIsMediaSelectorOpen(true)} className="bg-white text-gray-800 px-4 py-2 rounded-lg font-bold text-sm">تغییر عکس</button>
</div>
</div>
<div className="mt-4 text-center" dir="ltr">
<p className="text-xs text-gray-500 font-mono bg-gray-100 px-4 py-2 rounded-lg inline-block">
{formData.imageUrl.split('/').pop()}
</p>
</div>
<p className="text-[11px] text-gray-400 mt-2">برای تغییر عکس، روی تصویر کلیک کنید یا دکمه زیر را بزنید</p>
<button type="button" onClick={() => setIsMediaSelectorOpen(true)} className="mt-3 bg-purple-100 text-purple-700 hover:bg-purple-200 px-6 py-2 rounded-xl font-bold transition-colors text-sm">
انتخاب از گالری رسانه
</button>
</div>
) : (
<>
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center text-gray-400 mb-4">
<ImageIcon className="w-8 h-8" />
</div>
<p className="text-gray-500 font-medium mb-4">هیچ تصویری انتخاب نشده است</p>
<p className="text-xs text-gray-400 mb-4">از گالری رسانه تصویر انتخاب کنید یا با Ctrl+V از کلیپ‌بورد بچسبانید</p>
<button type="button" onClick={() => setIsMediaSelectorOpen(true)} className="bg-purple-100 text-purple-700 hover:bg-purple-200 px-6 py-2 rounded-xl font-bold transition-colors">
انتخاب از گالری رسانه
</button>
</>
)}
</div>
</div>
</form>
</div>
<div className="p-4 border-t border-gray-100 bg-gray-50 flex justify-end gap-3 rounded-b-2xl">
<button type="button" onClick={() => setIsModalOpen(false)} className="px-5 py-2.5 rounded-xl font-bold text-gray-600 hover:bg-gray-200 transition-colors">
انصراف
</button>
<button type="submit" form="productForm" className="bg-purple-600 hover:bg-purple-700 text-white px-8 py-2.5 rounded-xl font-bold transition-all shadow-md shadow-purple-200">
ذخیره محصول
</button>
</div>
</div>
</div>
)}
{/* Media Selector Modal */}
<MediaSelector
isOpen={isMediaSelectorOpen}
onClose={() => setIsMediaSelectorOpen(false)}
onSelect={(url) => setFormData({...formData, imageUrl: url})}
selectedUrl={formData.imageUrl.startsWith('http') ? formData.imageUrl : formData.imageUrl ? `http://localhost:4001${formData.imageUrl}` : undefined}
/>
</div>
);
}