canina/frontend/admin-panel/src/pages/B2BManager.tsx
parsa aghaei 00e0d61d96
Some checks failed
Deploy Canina / deploy (push) Failing after 29s
fix(admin-panel): fix partner accounts API endpoint URL in B2BManager.tsx to /b2b/partners
2026-08-08 19:08:44 +03:30

526 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 { Building2, CheckCircle2, XCircle, Clock, PhoneCall, Filter, X, Plus, ShieldCheck } from 'lucide-react';
import { toast } from 'react-hot-toast';
import api from '../services/api';
import Spinner from '../components/ui/Spinner';
import type { B2BInquiry, PartnerAccount } from '../types/admin';
export default function B2BManager() {
const [activeTab, setActiveTab] = useState<'inquiries' | 'partners'>('inquiries');
// Inquiries State
const [inquiries, setInquiries] = useState<B2BInquiry[]>([]);
const [isInquiriesLoading, setIsInquiriesLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState<string>('ALL');
// Inquiry Review Modal State
const [selectedInquiry, setSelectedInquiry] = useState<B2BInquiry | null>(null);
const [inquiryStatus, setInquiryStatus] = useState<'PENDING' | 'CONTACTED' | 'APPROVED' | 'REJECTED'>('CONTACTED');
const [inquiryAdminNotes, setInquiryAdminNotes] = useState('');
const [isInquirySubmitting, setIsInquirySubmitting] = useState(false);
// Partners State
const [partners, setPartners] = useState<PartnerAccount[]>([]);
const [isPartnersLoading, setIsPartnersLoading] = useState(false);
const [isPartnerModalOpen, setIsPartnerModalOpen] = useState(false);
const [partnerFormData, setPartnerFormData] = useState({
userId: '',
companyName: '',
taxId: '',
creditLimit: '100000000',
discountTier: 'tier1',
status: 'ACTIVE',
});
const [isPartnerSubmitting, setIsPartnerSubmitting] = useState(false);
const fetchInquiries = async () => {
try {
const res = await api.get('/b2b/inquiries');
const data = Array.isArray(res.data) ? res.data : (res.data?.data || []);
setInquiries(data);
} catch (err) {
console.error('Failed to fetch B2B inquiries:', err);
toast.error('خطا در دریافت لیست درخواست‌های B2B');
} finally {
setIsInquiriesLoading(false);
}
};
const fetchPartners = async () => {
try {
const res = await api.get('/b2b/partners');
const data = Array.isArray(res.data) ? res.data : (res.data?.data || []);
setPartners(data);
} catch (err) {
console.warn('Failed to fetch partner accounts:', err);
// Fallback empty if backend endpoint not populated
setPartners([]);
} finally {
setIsPartnersLoading(false);
}
};
useEffect(() => {
let isMounted = true;
if (activeTab === 'inquiries') {
api.get('/b2b/inquiries')
.then(res => {
if (!isMounted) return;
const data = Array.isArray(res.data) ? res.data : (res.data?.data || []);
setInquiries(data);
})
.catch(err => {
console.error('Failed to fetch B2B inquiries:', err);
toast.error('خطا در دریافت لیست درخواست‌های B2B');
})
.finally(() => {
if (isMounted) setIsInquiriesLoading(false);
});
} else {
api.get('/b2b/partners')
.then(res => {
if (!isMounted) return;
const data = Array.isArray(res.data) ? res.data : (res.data?.data || []);
setPartners(data);
})
.catch(err => {
console.warn('Failed to fetch partner accounts:', err);
if (isMounted) setPartners([]);
})
.finally(() => {
if (isMounted) setIsPartnersLoading(false);
});
}
return () => {
isMounted = false;
};
}, [activeTab]);
const openInquiryReview = (inquiry: B2BInquiry) => {
setSelectedInquiry(inquiry);
setInquiryStatus(inquiry.status || 'CONTACTED');
setInquiryAdminNotes(inquiry.adminNotes || '');
};
const handleInquiryReviewSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedInquiry) return;
try {
setIsInquirySubmitting(true);
await api.patch(`/b2b/inquiries/${selectedInquiry.id}`, {
status: inquiryStatus,
adminNotes: inquiryAdminNotes.trim() || undefined,
});
toast.success('وضعیت درخواست B2B بروزرسانی شد');
setSelectedInquiry(null);
fetchInquiries();
} catch (err) {
console.error('Failed to review B2B inquiry:', err);
toast.error('خطا در بروزرسانی وضعیت درخواست B2B');
} finally {
setIsInquirySubmitting(false);
}
};
const handlePartnerSave = async (e: React.FormEvent) => {
e.preventDefault();
if (!partnerFormData.userId.trim() || !partnerFormData.companyName.trim()) {
toast.error('شناسه کاربر و نام شرکت اجباری هستند');
return;
}
try {
setIsPartnerSubmitting(true);
await api.post('/admin/b2b/partners', partnerFormData);
toast.success('حساب همکار B2B جدید ایجاد شد');
setIsPartnerModalOpen(false);
fetchPartners();
} catch (err) {
console.error('Failed to create partner account:', err);
toast.error('خطا در ثبت حساب همکار B2B');
} finally {
setIsPartnerSubmitting(false);
}
};
const filteredInquiries = inquiries.filter(inq => {
if (statusFilter === 'ALL') return true;
return inq.status === statusFilter;
});
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<h2 className="text-2xl font-black text-gray-900 flex items-center gap-2">
<Building2 className="w-6 h-6 text-purple-600" />
مدیریت همکاران عمده‌فروشی و B2B
</h2>
<p className="text-gray-500 font-medium mt-1">بررسی درخواست‌های نمایندگی، تنظیم سقف اعتبار و اعطای کدهای تخفیف همکار</p>
</div>
{/* Tab Toggle */}
<div className="flex items-center gap-2 bg-white p-1.5 rounded-2xl border border-gray-200 shadow-sm">
<button
onClick={() => setActiveTab('inquiries')}
className={`px-4 py-2 rounded-xl text-xs font-bold transition-all ${
activeTab === 'inquiries' ? 'bg-purple-600 text-white shadow-sm' : 'text-gray-600 hover:bg-gray-50'
}`}
>
درخواست‌های دریافت نمایندگی ({inquiries.length})
</button>
<button
onClick={() => setActiveTab('partners')}
className={`px-4 py-2 rounded-xl text-xs font-bold transition-all ${
activeTab === 'partners' ? 'bg-purple-600 text-white shadow-sm' : 'text-gray-600 hover:bg-gray-50'
}`}
>
حساب‌های تاییدشده همکار (Partners)
</button>
</div>
</div>
{activeTab === 'inquiries' ? (
<div className="space-y-4">
<div className="flex justify-end">
<div className="flex items-center gap-2 bg-white p-1.5 rounded-2xl border border-gray-200 shadow-sm">
<Filter className="w-4 h-4 text-gray-400 mr-2" />
{[
{ id: 'ALL', label: 'همه' },
{ id: 'PENDING', label: 'جدید' },
{ id: 'CONTACTED', label: 'تماس گرفته شده' },
{ id: 'APPROVED', label: 'تایید شده' },
{ id: 'REJECTED', label: 'رد شده' },
].map(f => (
<button
key={f.id}
onClick={() => setStatusFilter(f.id)}
className={`px-3 py-1.5 rounded-xl text-xs font-bold transition-all ${
statusFilter === f.id ? 'bg-purple-50 text-purple-700 border border-purple-200' : 'text-gray-600 hover:bg-gray-50'
}`}
>
{f.label}
</button>
))}
</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">اطلاعات تماس</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-28">عملیات</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{isInquiriesLoading ? (
<tr>
<td colSpan={6} className="py-12 text-center">
<Spinner size="lg" className="mx-auto text-purple-600" />
</td>
</tr>
) : filteredInquiries.length === 0 ? (
<tr>
<td colSpan={6} className="py-12 text-center text-gray-500 font-medium">درخواستی یافت نشد</td>
</tr>
) : (
filteredInquiries.map((inq) => (
<tr key={inq.id} className="hover:bg-gray-50/50 transition-colors">
<td className="py-4 px-6">
<div className="flex flex-col">
<span className="font-bold text-gray-900">{inq.companyName}</span>
<span className="text-xs text-gray-500">{inq.contactName}</span>
</div>
</td>
<td className="py-4 px-6">
<div className="flex flex-col font-mono text-xs text-gray-600" dir="ltr">
<span>{inq.phone}</span>
<span>{inq.email}</span>
</div>
</td>
<td className="py-4 px-6">
<div className="flex flex-col">
<span className="inline-block px-2.5 py-0.5 bg-purple-50 text-purple-700 text-xs font-bold rounded-lg border border-purple-100 w-max">
{inq.businessType}
</span>
{inq.estimatedVolume && <span className="text-[11px] text-gray-400 mt-1">{inq.estimatedVolume}</span>}
</div>
</td>
<td className="py-4 px-6 text-gray-600 text-xs max-w-xs line-clamp-2">
{inq.message}
</td>
<td className="py-4 px-6">
{inq.status === 'APPROVED' ? (
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-green-50 text-green-700 text-xs font-bold rounded-full border border-green-200">
<CheckCircle2 className="w-3.5 h-3.5" /> تایید شده
</span>
) : inq.status === 'CONTACTED' ? (
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-blue-50 text-blue-700 text-xs font-bold rounded-full border border-blue-200">
<PhoneCall className="w-3.5 h-3.5" /> تماس گرفته شده
</span>
) : inq.status === 'REJECTED' ? (
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-red-50 text-red-700 text-xs font-bold rounded-full border border-red-200">
<XCircle className="w-3.5 h-3.5" /> رد شده
</span>
) : (
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-amber-50 text-amber-700 text-xs font-bold rounded-full border border-amber-200">
<Clock className="w-3.5 h-3.5" /> در انتظار بررسی
</span>
)}
</td>
<td className="py-4 px-6">
<button
onClick={() => openInquiryReview(inq)}
className="px-3 py-1.5 bg-purple-600 text-white hover:bg-purple-700 rounded-lg text-xs font-bold transition-all shadow-sm"
>
تغییر وضعیت
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
) : (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="font-bold text-gray-800">فهرست حساب‌های تایید شده همکار</h3>
<button
onClick={() => setIsPartnerModalOpen(true)}
className="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-xl flex items-center gap-2 font-bold text-xs shadow-md shadow-purple-200"
>
<Plus className="w-4 h-4" />
افزودن همکار B2B جدید
</button>
</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">User ID</th>
<th className="py-4 px-6 text-sm font-bold text-gray-500">کد اقتصادی (Tax ID)</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>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{isPartnersLoading ? (
<tr>
<td colSpan={6} className="py-12 text-center">
<Spinner size="lg" className="mx-auto text-purple-600" />
</td>
</tr>
) : partners.length === 0 ? (
<tr>
<td colSpan={6} className="py-12 text-center text-gray-500 font-medium">هیچ حساب همکار B2B به صورت دستی تعریف نشده است</td>
</tr>
) : (
partners.map((partner) => (
<tr key={partner.id} className="hover:bg-gray-50/50 transition-colors">
<td className="py-4 px-6 font-bold text-gray-900">{partner.companyName}</td>
<td className="py-4 px-6 font-mono text-xs text-gray-500">{partner.userId}</td>
<td className="py-4 px-6 font-mono text-xs text-gray-500">{partner.taxId || '-'}</td>
<td className="py-4 px-6 font-bold text-purple-700">
{Number(partner.creditLimit).toLocaleString()} تومان
</td>
<td className="py-4 px-6">
<span className="px-2.5 py-1 bg-purple-50 text-purple-700 text-xs font-bold rounded-lg border border-purple-100">
{partner.discountTier}
</span>
</td>
<td className="py-4 px-6">
<span className="inline-flex items-center gap-1 text-green-600 text-xs font-bold">
<ShieldCheck className="w-4 h-4" /> {partner.status}
</span>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* Inquiry Review Modal */}
{selectedInquiry && (
<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-lg shadow-2xl overflow-hidden 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-lg font-bold text-gray-900">
بررسی درخواست B2B ({selectedInquiry.companyName})
</h3>
<button onClick={() => setSelectedInquiry(null)} className="text-gray-400 hover:text-red-500 transition-colors">
<X className="w-6 h-6" />
</button>
</div>
<form onSubmit={handleInquiryReviewSubmit} className="p-6 space-y-4">
<div className="p-4 bg-purple-50/50 rounded-xl border border-purple-100 space-y-1 text-xs">
<div><span className="font-bold text-gray-600">شخص رابط: </span>{selectedInquiry.contactName} ({selectedInquiry.phone})</div>
<div><span className="font-bold text-gray-600">پیام درخواست: </span>{selectedInquiry.message}</div>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-2">تغییر وضعیت درخواست *</label>
<select
value={inquiryStatus}
onChange={(e) => setInquiryStatus(e.target.value as 'PENDING' | 'CONTACTED' | 'APPROVED' | 'REJECTED')}
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-sm font-bold bg-white"
>
<option value="PENDING">در انتظار بررسی (PENDING)</option>
<option value="CONTACTED">تماس حاصل شد (CONTACTED)</option>
<option value="APPROVED">تایید شده (APPROVED)</option>
<option value="REJECTED">رد شده (REJECTED)</option>
</select>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">یادداشت ادمین (adminNotes)</label>
<textarea
rows={4}
value={inquiryAdminNotes}
onChange={(e) => setInquiryAdminNotes(e.target.value)}
placeholder="مذاکره انجام شد. لیست قیمت عمده ارسال گردید."
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-sm"
/>
</div>
<div className="pt-4 flex justify-end gap-3 border-t border-gray-100">
<button
type="button"
onClick={() => setSelectedInquiry(null)}
className="px-5 py-2.5 rounded-xl border border-gray-200 text-gray-600 font-bold hover:bg-gray-50 transition-colors"
>
انصراف
</button>
<button
type="submit"
disabled={isInquirySubmitting}
className="px-6 py-2.5 rounded-xl bg-purple-600 text-white font-bold hover:bg-purple-700 transition-colors shadow-md shadow-purple-200 flex items-center gap-2"
>
{isInquirySubmitting && <Spinner size="sm" />}
بروزرسانی وضعیت
</button>
</div>
</form>
</div>
</div>
)}
{/* Partner Account Setup Modal */}
{isPartnerModalOpen && (
<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-lg shadow-2xl overflow-hidden 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-lg font-bold text-gray-900">
تعریف حساب همکار B2B جدید
</h3>
<button onClick={() => setIsPartnerModalOpen(false)} className="text-gray-400 hover:text-red-500 transition-colors">
<X className="w-6 h-6" />
</button>
</div>
<form onSubmit={handlePartnerSave} className="p-6 space-y-4">
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">شناسه کاربر (User ID) *</label>
<input
required
type="text"
value={partnerFormData.userId}
onChange={(e) => setPartnerFormData({ ...partnerFormData, userId: e.target.value })}
placeholder="uuid-string-of-user"
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-xs font-mono"
dir="ltr"
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">نام شرکت / سازمان *</label>
<input
required
type="text"
value={partnerFormData.companyName}
onChange={(e) => setPartnerFormData({ ...partnerFormData, companyName: e.target.value })}
placeholder="کلینیک دامپزشکی پارت"
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">کد اقتصادی (Tax ID)</label>
<input
type="text"
value={partnerFormData.taxId}
onChange={(e) => setPartnerFormData({ ...partnerFormData, taxId: e.target.value })}
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-xs font-mono"
dir="ltr"
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">سقف اعتبار (تومان)</label>
<input
type="number"
value={partnerFormData.creditLimit}
onChange={(e) => setPartnerFormData({ ...partnerFormData, creditLimit: e.target.value })}
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-sm font-bold"
/>
</div>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">سطح تخفیف (Discount Tier)</label>
<select
value={partnerFormData.discountTier}
onChange={(e) => setPartnerFormData({ ...partnerFormData, discountTier: e.target.value })}
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-sm font-bold bg-white"
>
<option value="tier1">سطح ۱ (۱۵٪ تخفیف)</option>
<option value="tier2">سطح ۲ (۲۵٪ تخفیف)</option>
<option value="tier3">سطح ۳ (۳۵٪ تخفیف)</option>
</select>
</div>
<div className="pt-4 flex justify-end gap-3 border-t border-gray-100">
<button
type="button"
onClick={() => setIsPartnerModalOpen(false)}
className="px-5 py-2.5 rounded-xl border border-gray-200 text-gray-600 font-bold hover:bg-gray-50 transition-colors"
>
انصراف
</button>
<button
type="submit"
disabled={isPartnerSubmitting}
className="px-6 py-2.5 rounded-xl bg-purple-600 text-white font-bold hover:bg-purple-700 transition-colors shadow-md shadow-purple-200 flex items-center gap-2"
>
{isPartnerSubmitting && <Spinner size="sm" />}
ایجاد حساب همکار
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}