canina/frontend/admin-panel/src/pages/FinancialSettingsPage.tsx
parsa aghaei 204350eff8
Some checks failed
E2E Playwright Tests / Run Full E2E & Security Suites (push) Waiting to run
Deploy Canina / deploy (push) Has been cancelled
feat(financial): add enable/disable toggle for shelter animal charity and round-up donation
2026-09-13 09:41:56 +03:30

549 lines
29 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 { DollarSign, Save, Plus, X, Heart, Percent, Wallet, Calculator, ArrowUpRight, ArrowDownRight, Compass } from 'lucide-react';
import { toast } from 'react-hot-toast';
import api from '../services/api';
import Spinner from '../components/ui/Spinner';
import PriceInput from '../components/ui/PriceInput';
import Button from '../components/ui/Button';
import Input from '../components/ui/Input';
import type { FinancialSettings, PricingSettings } from '../types/admin';
import { calculateSellingPrice } from '../utils/pricing';
export default function FinancialSettingsPage() {
const [financial, setFinancial] = useState<FinancialSettings>({
taxPercentage: 10,
freeShippingThreshold: 2000000,
standardShippingFee: 85000,
charityDonationOptions: [10000, 20000, 50000],
walletWithdrawalEnabled: false,
});
const [charityStep, setCharityStep] = useState('10000');
const [charityEnabled, setCharityEnabled] = useState(true);
const [refillEnabled, setRefillEnabled] = useState(false);
const [refillPercent, setRefillPercent] = useState('5');
// Smart Pricing & Rounding Settings
const [pricing, setPricing] = useState<PricingSettings>({
roundingStep: 5000,
roundingMode: 'UP',
defaultRetailMarginPercent: 30,
defaultWholesaleMarginPercent: 15,
});
const [testBuyPrice, setTestBuyPrice] = useState('1000000');
const [applyToAllProductsNow, setApplyToAllProductsNow] = useState(true);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [donationInput, setDonationInput] = useState('');
useEffect(() => {
let isSubscribed = true;
Promise.all([
api.get('/settings/financial'),
api.get('/admin/settings'),
api.get('/admin/pricing/settings'),
])
.then(([resFin, resAdmin, resPricing]) => {
if (!isSubscribed) return;
const data = resFin.data?.data || resFin.data;
if (data) {
setFinancial({
taxPercentage: Number(data.taxPercentage ?? 10),
freeShippingThreshold: Number(data.freeShippingThreshold ?? 2000000),
standardShippingFee: Number(data.standardShippingFee ?? 85000),
charityDonationOptions: Array.isArray(data.charityDonationOptions)
? data.charityDonationOptions.map(Number)
: [10000, 20000, 50000],
charityDonationEnabled: Boolean(data.charityDonationEnabled ?? true),
walletWithdrawalEnabled: Boolean(data.walletWithdrawalEnabled ?? false),
});
if (data.charityDonationEnabled !== undefined) {
setCharityEnabled(Boolean(data.charityDonationEnabled));
}
}
if (resAdmin.data?.success) {
setCharityStep(resAdmin.data.data.CHARITY_ROUND_STEP || '10000');
if (resAdmin.data.data.CHARITY_DONATION_ENABLED !== undefined) {
setCharityEnabled(resAdmin.data.data.CHARITY_DONATION_ENABLED !== 'false');
}
setRefillEnabled(resAdmin.data.data.REFILL_SUBSCRIPTION_ENABLED === 'true');
setRefillPercent(resAdmin.data.data.REFILL_REWARD_PERCENT || '5');
}
if (resPricing.data?.success && resPricing.data.data) {
const p = resPricing.data.data;
setPricing({
roundingStep: Number(p.roundingStep ?? 5000),
roundingMode: p.roundingMode || 'UP',
defaultRetailMarginPercent: Number(p.defaultRetailMarginPercent ?? 30),
defaultWholesaleMarginPercent: Number(p.defaultWholesaleMarginPercent ?? 15),
});
}
})
.catch((err) => {
console.warn('Failed to fetch Financial settings', err);
})
.finally(() => {
if (isSubscribed) setIsLoading(false);
});
return () => {
isSubscribed = false;
};
}, []);
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
try {
setIsSaving(true);
const [, , resPricing] = await Promise.all([
api.patch('/settings/financial', {
...financial,
charityDonationEnabled: charityEnabled,
}),
api.put('/admin/settings', {
TAX_PERCENTAGE: String(financial.taxPercentage),
CHARITY_ROUND_STEP: charityStep,
CHARITY_DONATION_ENABLED: charityEnabled ? 'true' : 'false',
REFILL_SUBSCRIPTION_ENABLED: refillEnabled ? 'true' : 'false',
REFILL_REWARD_PERCENT: refillPercent,
}),
api.put('/admin/pricing/settings', {
...pricing,
applyToAllProducts: applyToAllProductsNow,
}),
]);
const pricingMsg = resPricing.data?.message;
toast.success(pricingMsg || 'تنظیمات مالی و قیمت‌گذاری با موفقیت ذخیره شد');
} catch (err) {
console.error('Failed to update financial settings:', err);
toast.error('خطا در بروزرسانی تنظیمات مالی');
} finally {
setIsSaving(false);
}
};
const addDonationOption = () => {
const val = Number(donationInput);
if (val > 0 && !financial.charityDonationOptions.includes(val)) {
setFinancial({
...financial,
charityDonationOptions: [...financial.charityDonationOptions, val].sort((a, b) => a - b),
});
setDonationInput('');
}
};
const removeDonationOption = (amount: number) => {
setFinancial({
...financial,
charityDonationOptions: financial.charityDonationOptions.filter((a) => a !== amount),
});
};
return (
<div className="space-y-6 font-vazir" dir="rtl">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h2 className="text-2xl font-black text-gray-900 flex items-center gap-2">
<DollarSign className="w-6 h-6 text-emerald-600" />
تنظیمات مالی، مالیات و خیریه (Financial Settings)
</h2>
<p className="text-gray-500 font-medium mt-1">
تعیین درصد مالیات بر ارزش افزوده، کمکهای خیریه ردپای مهربانی و تسویهحساب کیف پول
</p>
</div>
</div>
{isLoading ? (
<div className="flex justify-center p-12">
<Spinner size="lg" className="text-emerald-600" />
</div>
) : (
<form onSubmit={handleSave} className="bg-white p-6 rounded-2xl shadow-sm border border-gray-200 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Tax Percentage */}
<div>
<label className="block text-sm font-bold text-gray-700 mb-1.5 flex items-center gap-1.5">
<Percent className="w-4 h-4 text-emerald-600" />
<span>درصد مالیات بر ارزش افزوده (VAT) *</span>
</label>
<Input
required
type="number"
min="0"
max="100"
value={financial.taxPercentage}
onChange={(e) => setFinancial({ ...financial, taxPercentage: Number(e.target.value) })}
leftIcon={<span className="text-gray-400 font-bold text-xs">%</span>}
dir="ltr"
className="font-bold text-sm"
/>
<p className="text-xs text-gray-500 mt-1">درصد قانونی محاسبه مالیات بر ارزش افزوده در فاکتور نهایی سفارشات.</p>
</div>
{/* Charity Donation Toggle & Step */}
<div className="md:col-span-2 p-5 bg-rose-50/50 rounded-2xl border border-rose-100 space-y-4">
<label className="flex items-center justify-between cursor-pointer">
<div className="flex items-start gap-3">
<input
type="checkbox"
checked={charityEnabled}
onChange={(e) => setCharityEnabled(e.target.checked)}
className="w-5 h-5 mt-0.5 rounded-lg text-rose-600 focus:ring-rose-500 border-gray-300 cursor-pointer"
/>
<div>
<span className="text-sm font-black text-gray-900 block flex items-center gap-2">
<Heart className="w-4 h-4 text-rose-500 fill-rose-500" />
فعالسازی طرح ردپای مهربانی (کمک داوطلبانه به پناهگاه حیوانات)
</span>
<span className="text-xs text-gray-500 font-medium block mt-1">
در صورت غیرفعال بودن، بخش رند کردن فاکتور، مشارکت در خیریه و ردپای مهربانی به طور کامل از تسویهحساب و بخشهای مختلف سایت مخفی میشود.
</span>
</div>
</div>
</label>
{charityEnabled && (
<div className="pt-4 border-t border-rose-100/80 grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-bold text-gray-700 mb-1.5 flex items-center gap-1.5">
<Heart className="w-4 h-4 text-rose-500" />
<span>پله رند کردن کمک به خیریه ردپای مهربانی (تومان)</span>
</label>
<PriceInput
value={charityStep}
onChange={(val) => setCharityStep(val)}
placeholder="10000"
className="px-4 py-3 rounded-xl border border-gray-200 focus:border-rose-500 text-sm bg-white"
/>
<p className="text-xs text-gray-500 mt-1">مبلغ فاکتور به نزدیکترین ضریب این عدد (مثلاً ۱۰,۰۰۰ یا ۵۰,۰۰۰ تومان) رند میگردد.</p>
</div>
</div>
)}
</div>
{/* Charity Donation Options */}
{charityEnabled && (
<div className="md:col-span-2 space-y-2 pt-3 border-t border-gray-100">
<label className="block text-sm font-bold text-gray-700 mb-1 flex items-center gap-1.5">
<Heart className="w-4 h-4 text-rose-500" />
<span>گزینههای دلخواه کمک مستقیم به خیریه (charityDonationOptions)</span>
</label>
<div className="flex gap-2 max-w-md">
<PriceInput
placeholder="مبلغ جدید (مثال: ۱۰۰,۰۰۰)"
value={donationInput}
onChange={(val) => setDonationInput(val)}
className="px-4 py-2 rounded-xl border border-gray-200 text-xs"
/>
<Button
type="button"
variant="primary"
size="xs"
startIcon={Plus}
onClick={addDonationOption}
>
افزودن
</Button>
</div>
<div className="flex flex-wrap gap-2 pt-2">
{financial.charityDonationOptions.map((amount) => (
<span
key={amount}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-rose-50 text-rose-700 text-xs font-bold rounded-xl border border-rose-200"
>
{amount.toLocaleString('fa-IR')} تومان
<button
type="button"
onClick={() => removeDonationOption(amount)}
className="hover:text-red-500 cursor-pointer"
>
<X className="w-3.5 h-3.5" />
</button>
</span>
))}
</div>
</div>
)}
{/* Refill Subscription and Reward Cashback */}
<div className="md:col-span-2 pt-4 border-t border-gray-100 space-y-4">
<label className="flex items-center justify-between cursor-pointer p-4 bg-indigo-50/50 hover:bg-indigo-50 rounded-2xl border border-indigo-100 transition-colors">
<div className="flex items-start gap-3">
<input
type="checkbox"
checked={refillEnabled}
onChange={(e) => setRefillEnabled(e.target.checked)}
className="w-5 h-5 mt-0.5 rounded-lg text-indigo-600 focus:ring-indigo-500 border-gray-300 cursor-pointer"
/>
<div>
<span className="text-sm font-black text-gray-900 block flex items-center gap-2">
<Percent className="w-4 h-4 text-indigo-600" />
فعالسازی سرویس «رزرو هوشمند تمدید و پاداش خرید بعدی» (Refill Subscription)
</span>
<span className="text-xs text-gray-500 font-medium block mt-0.5">
در صورت فعال بودن، گزینهای در سبد خرید و تسویهحساب به مشتری پیشنهاد میشود تا با فعالسازی یادآور تمدید، درصد مشخصی پاداش (اعتبار خرید بعدی) رزرو کند.
</span>
</div>
</div>
</label>
{refillEnabled && (
<div className="p-4 bg-white rounded-2xl border border-indigo-100 grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-gray-700 mb-1.5">
درصد پاداش رزرو تمدید (Cashback Reward Percent)
</label>
<Input
type="number"
min="1"
max="100"
value={refillPercent}
onChange={(e) => setRefillPercent(e.target.value)}
leftIcon={<span className="text-gray-400 font-bold text-xs">%</span>}
dir="ltr"
inputSize="sm"
className="font-bold"
/>
<p className="text-[11px] text-gray-400 mt-1">پیش‌فرض: ۵ درصد</p>
</div>
</div>
)}
</div>
{/* Smart Pricing & Rounding Rules Section */}
<div className="md:col-span-2 pt-6 border-t-2 border-dashed border-gray-200 space-y-5">
<div className="flex items-center justify-between">
<div>
<h3 className="text-base font-black text-gray-900 flex items-center gap-2">
<Calculator className="w-5 h-5 text-indigo-600" />
تنظیمات هوشمند فرمول سود و گرد کردن قیمتها (Pricing & Rounding Rules)
</h3>
<p className="text-xs text-gray-500 mt-1">
تعیین گام و جهت رند شدن قیمتها و درصد سود پیشفرض برای فروش خرد و عمده. این تنظیمات در فرم ویرایش محصول و تغییرات گروهی قیمت اعمال میگردد.
</p>
</div>
</div>
<div className="bg-gradient-to-br from-indigo-50/60 via-purple-50/30 to-blue-50/50 p-5 rounded-2xl border border-indigo-100/80 space-y-5">
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Rounding Step */}
<div>
<label className="block text-xs font-bold text-gray-800 mb-1.5 flex items-center gap-1.5">
<Compass className="w-4 h-4 text-indigo-600" />
<span>گام گرد کردن قیمت (پله رند شدن به تومان)</span>
</label>
<PriceInput
value={String(pricing.roundingStep)}
onChange={(val) => setPricing({ ...pricing, roundingStep: Math.max(0, Number(val) || 0) })}
placeholder="5000"
className="px-4 py-2.5 bg-white rounded-xl border border-indigo-200 focus:border-indigo-600 text-sm font-bold"
/>
<div className="flex flex-wrap gap-1.5 mt-2">
{[1000, 5000, 10000, 50000, 100000].map((stepVal) => (
<button
key={stepVal}
type="button"
onClick={() => setPricing({ ...pricing, roundingStep: stepVal })}
className={`px-2.5 py-1 text-xs font-bold rounded-lg border transition-all cursor-pointer ${
pricing.roundingStep === stepVal
? 'bg-indigo-600 text-white border-indigo-600 shadow-sm'
: 'bg-white text-gray-600 border-gray-200 hover:border-indigo-300'
}`}
>
{stepVal.toLocaleString('fa-IR')} تومان
</button>
))}
</div>
</div>
{/* Rounding Mode / Direction */}
<div>
<label className="block text-xs font-bold text-gray-800 mb-1.5 flex items-center gap-1.5">
<span>جهت گرد کردن قیمت (Rounding Direction)</span>
</label>
<div className="grid grid-cols-3 gap-2">
<button
type="button"
onClick={() => setPricing({ ...pricing, roundingMode: 'UP' })}
className={`p-3 rounded-xl border text-center transition-all cursor-pointer ${
pricing.roundingMode === 'UP'
? 'bg-indigo-600 text-white border-indigo-600 shadow-md ring-2 ring-indigo-300'
: 'bg-white text-gray-700 border-gray-200 hover:border-indigo-300'
}`}
>
<ArrowUpRight className={`w-4 h-4 mx-auto mb-1 ${pricing.roundingMode === 'UP' ? 'text-white' : 'text-emerald-600'}`} />
<span className="block text-xs font-black">به سمت بالا</span>
<span className="block text-[10px] opacity-80 mt-0.5">سقف (Ceil)</span>
</button>
<button
type="button"
onClick={() => setPricing({ ...pricing, roundingMode: 'DOWN' })}
className={`p-3 rounded-xl border text-center transition-all cursor-pointer ${
pricing.roundingMode === 'DOWN'
? 'bg-indigo-600 text-white border-indigo-600 shadow-md ring-2 ring-indigo-300'
: 'bg-white text-gray-700 border-gray-200 hover:border-indigo-300'
}`}
>
<ArrowDownRight className={`w-4 h-4 mx-auto mb-1 ${pricing.roundingMode === 'DOWN' ? 'text-white' : 'text-rose-600'}`} />
<span className="block text-xs font-black">به سمت پایین</span>
<span className="block text-[10px] opacity-80 mt-0.5">کف (Floor)</span>
</button>
<button
type="button"
onClick={() => setPricing({ ...pricing, roundingMode: 'NEAREST' })}
className={`p-3 rounded-xl border text-center transition-all cursor-pointer ${
pricing.roundingMode === 'NEAREST'
? 'bg-indigo-600 text-white border-indigo-600 shadow-md ring-2 ring-indigo-300'
: 'bg-white text-gray-700 border-gray-200 hover:border-indigo-300'
}`}
>
<Compass className={`w-4 h-4 mx-auto mb-1 ${pricing.roundingMode === 'NEAREST' ? 'text-white' : 'text-indigo-600'}`} />
<span className="block text-xs font-black">نزدیکترین</span>
<span className="block text-[10px] opacity-80 mt-0.5">ریاضی (Round)</span>
</button>
</div>
</div>
{/* Default Retail Margin */}
<div>
<label className="block text-xs font-bold text-gray-800 mb-1.5">
درصد سود پیشفرض تکفروشی (Retail Margin %)
</label>
<Input
type="number"
min="0"
max="500"
step="0.5"
value={pricing.defaultRetailMarginPercent}
onChange={(e) => setPricing({ ...pricing, defaultRetailMarginPercent: Number(e.target.value) || 0 })}
leftIcon={<span className="text-gray-400 font-bold text-xs">%</span>}
dir="ltr"
className="font-bold text-sm"
/>
<p className="text-[11px] text-gray-500 mt-1">فرمول: قیمت خرید × (۱ + سود٪)</p>
</div>
{/* Default Wholesale Margin */}
<div>
<label className="block text-xs font-bold text-gray-800 mb-1.5">
درصد سود پیشفرض عمدهفروشی (Wholesale Margin %)
</label>
<Input
type="number"
min="0"
max="500"
step="0.5"
value={pricing.defaultWholesaleMarginPercent}
onChange={(e) => setPricing({ ...pricing, defaultWholesaleMarginPercent: Number(e.target.value) || 0 })}
leftIcon={<span className="text-gray-400 font-bold text-xs">%</span>}
dir="ltr"
className="font-bold text-sm"
/>
<p className="text-[11px] text-gray-500 mt-1">برای مشتریان همکار و داروخانههای دامپزشکی B2B</p>
</div>
</div>
{/* Live Simulation / Test Box */}
<div className="bg-white/90 p-4 rounded-xl border border-indigo-100 shadow-sm space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-black text-indigo-900 flex items-center gap-1.5">
<Calculator className="w-4 h-4 text-indigo-600" />
پیشنمایش زنده خروجی با تنظیمات فعلی:
</span>
<span className="text-[11px] text-indigo-600 font-medium">
گام: {pricing.roundingStep.toLocaleString('fa-IR')} تومان ({pricing.roundingMode === 'UP' ? 'گرد به بالا' : pricing.roundingMode === 'DOWN' ? 'گرد به پایین' : 'گرد به نزدیک‌ترین'})
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 items-center">
<div>
<span className="block text-[11px] text-gray-500 mb-1 font-bold">قیمت خرید فرضی (تست):</span>
<PriceInput
value={testBuyPrice}
onChange={(val) => setTestBuyPrice(val)}
placeholder="1,000,000"
className="px-3 py-2 bg-gray-50 rounded-lg border border-gray-200 text-xs font-bold"
/>
</div>
<div className="bg-emerald-50/70 p-3 rounded-xl border border-emerald-200">
<span className="block text-[10px] text-emerald-800 font-bold">قیمت تکفروشی محاسبهشده (+{pricing.defaultRetailMarginPercent}٪):</span>
<span className="text-sm font-black text-emerald-700 block mt-0.5">
{calculateSellingPrice(Number(testBuyPrice) || 0, pricing.defaultRetailMarginPercent, pricing.roundingStep, pricing.roundingMode).toLocaleString('fa-IR')} تومان
</span>
</div>
<div className="bg-blue-50/70 p-3 rounded-xl border border-blue-200">
<span className="block text-[10px] text-blue-800 font-bold">قیمت عمدهفروشی محاسبهشده (+{pricing.defaultWholesaleMarginPercent}٪):</span>
<span className="text-sm font-black text-blue-700 block mt-0.5">
{calculateSellingPrice(Number(testBuyPrice) || 0, pricing.defaultWholesaleMarginPercent, pricing.roundingStep, pricing.roundingMode).toLocaleString('fa-IR')} تومان
</span>
</div>
</div>
</div>
{/* Instant Recalculation Toggle */}
<div className="p-4 bg-gradient-to-r from-purple-100/90 to-indigo-100/80 border border-purple-200 rounded-2xl flex items-center justify-between">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={applyToAllProductsNow}
onChange={(e) => setApplyToAllProductsNow(e.target.checked)}
className="w-5 h-5 mt-0.5 rounded-lg text-purple-600 focus:ring-purple-500 border-purple-300 cursor-pointer shrink-0"
/>
<div>
<span className="text-xs font-black text-purple-950 block">
محاسبه و بروزرسانی فوری قیمت تمام کالاهای فروشگاه با این درصد سود و رند کردن جدید
</span>
<span className="text-[11px] text-purple-800 block mt-0.5 font-medium leading-relaxed">
با فعال بودن این گزینه هنگام زدن دکمه ذخیره، قیمت فروش تک و عمده تمامی محصولات بر اساس قیمت خرید، درصد سود جدید ({pricing.defaultRetailMarginPercent}٪ / {pricing.defaultWholesaleMarginPercent}٪) و پله رند کردن ({pricing.roundingStep.toLocaleString('fa-IR')} تومان {pricing.roundingMode === 'UP' ? 'به بالا' : pricing.roundingMode === 'DOWN' ? 'به پایین' : 'به نزدیک‌ترین'}) فوراً در دیتابیس مجدداً محاسبه و بهروزرسانی میشوند.
</span>
</div>
</label>
</div>
</div>
</div>
{/* Wallet Withdrawal Feature Toggle */}
<div className="md:col-span-2 pt-4 border-t border-gray-100">
<label className="flex items-center gap-3 cursor-pointer p-4 bg-emerald-50/50 hover:bg-emerald-50 rounded-2xl border border-emerald-100 transition-colors">
<input
type="checkbox"
checked={Boolean(financial.walletWithdrawalEnabled)}
onChange={(e) => setFinancial({ ...financial, walletWithdrawalEnabled: e.target.checked })}
className="w-5 h-5 rounded-lg text-emerald-600 focus:ring-emerald-500 border-gray-300 cursor-pointer"
/>
<div>
<span className="text-sm font-black text-gray-900 block flex items-center gap-2">
<Wallet className="w-4 h-4 text-emerald-600" />
فعالسازی امکان ثبت درخواست برداشت و تسویهحساب کیف پول
</span>
<span className="text-xs text-gray-500 font-medium block mt-0.5">
در صورت فعال بودن، کاربران میتوانند از طریق داشبورد خود درخواست تسویه و واریز وجه کیف پول به شماره شبا ثبت کنند.
</span>
</div>
</label>
</div>
</div>
<div className="flex justify-end pt-4 border-t border-gray-100">
<Button
type="submit"
variant="primary"
size="md"
startIcon={Save}
isLoading={isSaving}
>
ذخیره تنظیمات مالی و قیمتگذاری
</Button>
</div>
</form>
)}
</div>
);
}