canina/frontend/admin-panel/src/pages/B2BManager.tsx

536 lines
24 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 Button from '../components/ui/Button';
import Modal from '../components/ui/Modal';
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
variant="primary"
size="xs"
onClick={() => openInquiryReview(inq)}
>
تغییر وضعیت
</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
variant="primary"
size="sm"
startIcon={Plus}
onClick={() => setIsPartnerModalOpen(true)}
>
افزودن همکار 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 && (
<Modal
isOpen={!!selectedInquiry}
onClose={() => setSelectedInquiry(null)}
title={`بررسی درخواست B2B (${selectedInquiry.companyName})`}
icon={Building2}
maxWidth="lg"
footer={
<div className="flex justify-end gap-2.5 w-full">
<Button
variant="secondary"
size="sm"
type="button"
onClick={() => setSelectedInquiry(null)}
>
انصراف
</Button>
<Button
variant="primary"
size="sm"
type="submit"
form="inquiryForm"
isLoading={isInquirySubmitting}
>
بروزرسانی وضعیت
</Button>
</div>
}
>
<div className="space-y-4 text-right font-vazir">
<form id="inquiryForm" onSubmit={handleInquiryReviewSubmit} className="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="text-gray-500">رابط: </span><span className="font-bold text-gray-800">{selectedInquiry.contactName}</span></div>
<div><span className="text-gray-500">تلفن / ایمیل: </span><span className="font-mono text-gray-800">{selectedInquiry.phone} - {selectedInquiry.email}</span></div>
<div><span className="text-gray-500">نوع کسب‌وکار: </span><span className="font-bold text-purple-700">{selectedInquiry.businessType}</span></div>
{selectedInquiry.message && <div className="mt-2 text-gray-700 bg-white p-2.5 rounded-lg border border-gray-200">{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>
</form>
</div>
</Modal>
)}
{/* Partner Account Setup Modal */}
{isPartnerModalOpen && (
<Modal
isOpen={isPartnerModalOpen}
onClose={() => setIsPartnerModalOpen(false)}
title="تعریف حساب همکار B2B جدید"
icon={Building2}
maxWidth="lg"
footer={
<div className="flex justify-end gap-2.5 w-full">
<Button
variant="secondary"
size="sm"
type="button"
onClick={() => setIsPartnerModalOpen(false)}
>
انصراف
</Button>
<Button
variant="primary"
size="sm"
type="submit"
form="partnerForm"
isLoading={isPartnerSubmitting}
>
ایجاد حساب همکار
</Button>
</div>
}
>
<div className="space-y-4 text-right font-vazir">
<form id="partnerForm" onSubmit={handlePartnerSave} className="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>
</form>
</div>
</Modal>
)}
</div>
);
}