import React, { useEffect, useMemo, useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { zodResolver } from '@hookform/resolvers/zod'; import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; import { formatDateTime, rialToToman, tomanToRial } from '../lib/utils'; import { numericField } from '../lib/forms'; import { Cog6ToothIcon, ClockIcon, CalculatorIcon, CreditCardIcon, ChatBubbleLeftRightIcon, MagnifyingGlassIcon, CheckCircleIcon, ExclamationTriangleIcon, EyeIcon, EyeSlashIcon, } from '@heroicons/react/24/outline'; interface TaxHistoryRow { tax_percent: number; enabled: boolean; changed_by_name: string | null; changed_at: number; } // ── Schema ──────────────────────────────────────────────────────────────── const schema = z.object({ site_name: z.string().min(1, 'نام سایت الزامی است'), support_phone: z.string(), altcha_enabled: z.string(), max_cancel_hours_before: z.string(), appointment_reminder_hours: z.string(), // financial engine appointment_commission_enabled: z.string(), upgrade_commission_enabled: z.string(), upgrade_commission_percent: z.string(), tax_enabled: z.string(), tax_percent: z.string(), sms_panel_fee_rials: z.string(), sms_price_rials: z.string(), appointment_fee_rials: z.string(), // payment gateways payment_test_mode: z.string(), mellat_enabled: z.string(), mellat_sandbox: z.string(), mellat_wsdl_url: z.string(), mellat_terminal_id: z.string(), mellat_username: z.string(), mellat_password: z.string(), sep_enabled: z.string(), sep_terminal_id: z.string(), }); type FormValues = z.infer; interface Settings extends FormValues { sms_api_key_configured: boolean; } const toForm = (s: Partial): FormValues => ({ site_name: s.site_name ?? 'ClinicPro', support_phone: s.support_phone ?? '', altcha_enabled: s.altcha_enabled ?? '0', max_cancel_hours_before: s.max_cancel_hours_before ?? '24', appointment_reminder_hours: s.appointment_reminder_hours ?? '2', appointment_commission_enabled: s.appointment_commission_enabled ?? '0', upgrade_commission_enabled: s.upgrade_commission_enabled ?? '0', upgrade_commission_percent: s.upgrade_commission_percent ?? '20', tax_enabled: s.tax_enabled ?? '0', tax_percent: s.tax_percent ?? '10', // ذخیره ریال است؛ در فرم به تومان نمایش/ویرایش می‌شود. sms_panel_fee_rials: String(rialToToman(Number(s.sms_panel_fee_rials ?? 1500000))), sms_price_rials: String(rialToToman(Number(s.sms_price_rials ?? 500))), appointment_fee_rials: String(rialToToman(Number(s.appointment_fee_rials ?? 150000))), payment_test_mode: s.payment_test_mode ?? '0', mellat_enabled: s.mellat_enabled ?? '1', mellat_sandbox: s.mellat_sandbox ?? '0', mellat_wsdl_url: s.mellat_wsdl_url ?? '', mellat_terminal_id: s.mellat_terminal_id ?? '', mellat_username: s.mellat_username ?? '', mellat_password: s.mellat_password ?? '', sep_enabled: s.sep_enabled ?? '1', sep_terminal_id: s.sep_terminal_id ?? '', }); // ── Section definitions (drive the nav rail + search) ─────────────────────── type SectionId = 'general' | 'appointments' | 'financial' | 'payment' | 'sms'; interface SectionDef { id: SectionId; label: string; desc: string; Icon: typeof Cog6ToothIcon; bg: string; fg: string; keywords: string; } const SECTIONS: SectionDef[] = [ { id: 'general', label: 'عمومی', desc: 'اطلاعات پایه پلتفرم', Icon: Cog6ToothIcon, bg: 'var(--primary-soft)', fg: 'var(--primary)', keywords: 'نام سایت پشتیبانی شماره تلفن brand کپچا امنیت altcha captcha بات' }, { id: 'appointments', label: 'نوبت‌دهی', desc: 'قوانین لغو و یادآوری', Icon: ClockIcon, bg: 'var(--warning-bg)', fg: 'var(--warning)', keywords: 'نوبت لغو یادآور reminder cancel ساعت' }, { id: 'financial', label: 'مالی', desc: 'پورسانت، مالیات و کارمزدها', Icon: CalculatorIcon, bg: 'var(--info-bg)', fg: 'var(--info)', keywords: 'پورسانت مالیات کارمزد پیامک نوبت مبلغ ریال tax commission' }, { id: 'payment', label: 'درگاه پرداخت', desc: 'ملت، سپ و حالت تست', Icon: CreditCardIcon, bg: 'var(--success-bg)', fg: 'var(--success)', keywords: 'درگاه پرداخت ملت سپ mellat sep terminal تست gateway' }, { id: 'sms', label: 'پیامک', desc: 'پیکربندی سرویس پیامک', Icon: ChatBubbleLeftRightIcon, bg: 'var(--violet-bg)', fg: 'var(--violet)', keywords: 'پیامک sms کاوه‌نگار kavenegar api' }, ]; // ── Small presentational helpers ──────────────────────────────────────────── function SectionHead({ s }: { s: SectionDef }) { return (

{s.label}

{s.desc}

); } function Toggle({ checked, onChange, label }: { checked: boolean; onChange: () => void; label: string }) { return ( ); } function Field({ label, hint, required, optional, error, children, span2 }: { label: string; hint?: string; required?: boolean; optional?: boolean; error?: string; children: React.ReactNode; span2?: boolean; }) { return (
{children} {hint &&

{hint}

} {error &&

{error}

}
); } // ── Component ───────────────────────────────────────────────────────────── export default function SettingsPage() { const qc = useQueryClient(); const [showMellatPassword, setShowMellatPassword] = useState(false); const [active, setActive] = useState('general'); const [search, setSearch] = useState(''); const [savedFlash, setSavedFlash] = useState(false); const { data, isLoading } = useQuery({ queryKey: ['admin-settings'], queryFn: () => api.get>('/api/v1/admin/settings'), staleTime: 30_000, }); // eslint-disable-next-line @typescript-eslint/no-explicit-any const settings: Settings | undefined = (data?.data as any)?.data ?? data?.data; const taxHistoryQ = useQuery({ queryKey: ['tax-history'], queryFn: () => api.get>('/api/v1/admin/settings/tax-history'), staleTime: 30_000, }); // eslint-disable-next-line @typescript-eslint/no-explicit-any const taxHistory: TaxHistoryRow[] = (taxHistoryQ.data?.data as any)?.data ?? taxHistoryQ.data?.data ?? []; const { register, handleSubmit, reset, watch, setValue, formState: { errors, isDirty }, } = useForm({ resolver: zodResolver(schema) }); useEffect(() => { if (settings) reset(toForm(settings)); }, [settings, reset]); const mutation = useMutation({ mutationFn: (values: FormValues) => api.patch>('/api/v1/admin/settings', values), onSuccess: (res) => { qc.invalidateQueries({ queryKey: ['admin-settings'] }); qc.invalidateQueries({ queryKey: ['tax-history'] }); // eslint-disable-next-line @typescript-eslint/no-explicit-any const fresh: Settings | undefined = (res?.data as any)?.data ?? res?.data; if (fresh) reset(toForm(fresh)); setSavedFlash(true); }, }); // auto-hide the "saved" flash useEffect(() => { if (!savedFlash) return; const t = setTimeout(() => setSavedFlash(false), 2600); return () => clearTimeout(t); }, [savedFlash]); const onSubmit = (values: FormValues) => mutation.mutate({ ...values, // فرم به تومان است؛ برای ذخیره به ریال تبدیل کن. sms_panel_fee_rials: String(tomanToRial(Number(values.sms_panel_fee_rials))), sms_price_rials: String(tomanToRial(Number(values.sms_price_rials))), appointment_fee_rials: String(tomanToRial(Number(values.appointment_fee_rials))), }); // Ctrl/Cmd+S saves useEffect(() => { const h = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') { e.preventDefault(); if (isDirty && !mutation.isPending) handleSubmit(onSubmit)(); } }; window.addEventListener('keydown', h); return () => window.removeEventListener('keydown', h); // eslint-disable-next-line react-hooks/exhaustive-deps }, [isDirty, mutation.isPending]); const paymentTestMode = watch('payment_test_mode') === '1'; const mellatEnabled = watch('mellat_enabled') === '1'; const mellatSandbox = watch('mellat_sandbox') === '1'; const sepEnabled = watch('sep_enabled') === '1'; const apptCommissionEnabled = watch('appointment_commission_enabled') === '1'; const upgradeCommissionEnabled = watch('upgrade_commission_enabled') === '1'; const taxEnabled = watch('tax_enabled') === '1'; const altchaEnabled = watch('altcha_enabled') === '1'; const toggle = (name: keyof FormValues, current: boolean) => setValue(name, current ? '0' : '1', { shouldDirty: true }); // search filters the nav rail const q = search.trim(); const filtered = useMemo( () => q === '' ? SECTIONS : SECTIONS.filter(s => (s.label + ' ' + s.desc + ' ' + s.keywords).includes(q)), [q], ); const current = filtered.find(s => s.id === active) ?? filtered[0] ?? SECTIONS[0]; if (isLoading) { return (
); } return (

تنظیمات

پیکربندی کلی پلتفرم — تغییرات پس از ذخیره اعمال می‌شوند
{/* ── نوار ناوبری کناری ── */} {/* ── محتوای بخش فعال ── */}
{/* عمومی */} {current.id === 'general' && ( <>
{/* کپچا (ALTCHA) */}
کپچای امنیتی (ALTCHA)
محافظت فرم‌های ورود/ثبت‌نام در برابر بات. پیش‌فرض از متغیر محیطی سرور خوانده می‌شود؛ این کلید آن را override می‌کند.
toggle('altcha_enabled', altchaEnabled)} label="کپچای امنیتی ALTCHA" />
)} {/* نوبت‌دهی */} {current.id === 'appointments' && (
ساعت قبل
ساعت قبل
)} {/* مالی */} {current.id === 'financial' && ( <>
ترتیب کسرها: ابتدا هزینه پنل پیامک، سپس مالیات بر ارزش افزوده (استخراجی از مبلغ شامل مالیات)، و در نهایت پورسانت نماینده از مبلغِ خالصِ پس از مالیات.
{/* پورسانت نوبت */}
پورسانت نوبت نمایندگان
درصد از پروفایل هر نماینده خوانده می‌شود.
toggle('appointment_commission_enabled', apptCommissionEnabled)} label="پورسانت نوبت نمایندگان" />
{/* پورسانت ارتقاء */}
پورسانت ارتقاء اشتراک
درصد پورسانت هنگام ارتقاء اشتراک نمایندگان.
{upgradeCommissionEnabled && (
درصد (۰–۱۰۰)
)}
toggle('upgrade_commission_enabled', upgradeCommissionEnabled)} label="پورسانت ارتقاء اشتراک" />
{/* مالیات */}
مالیات بر ارزش افزوده
از مبلغ تراکنش‌ها کسر و در تاریخچه ثبت می‌شود.
{taxEnabled && (
درصد (۰–۱۰۰)
)}
toggle('tax_enabled', taxEnabled)} label="مالیات بر ارزش افزوده" />
{taxHistory.length > 0 && (
تاریخچه تغییرات مالیات
{taxHistory.map((h, i) => (
{h.enabled ? `${h.tax_percent}٪` : 'غیرفعال'}{h.changed_by_name && — {h.changed_by_name}} {formatDateTime(h.changed_at)}
))}
)}
تومان
تومان
)} {/* درگاه پرداخت */} {current.id === 'payment' && ( <>
{paymentTestMode ? 'حالت تست فعال' : 'حالت تست غیرفعال'}
{paymentTestMode ? 'همه پرداخت‌ها از درگاه آزمایشی رد می‌شوند (پول واقعی کسر نمی‌شود).' : 'پرداخت‌ها از درگاه واقعی انجام می‌شوند.'}
toggle('payment_test_mode', paymentTestMode)} label="حالت تست پرداخت" />
{/* ملت */}
درگاه ملت (Mellat)
{mellatEnabled ? 'فعال' : 'غیرفعال'} toggle('mellat_enabled', mellatEnabled)} label="فعال‌سازی درگاه ملت" />
{mellatSandbox ? 'Sandbox (آزمایشگاه)' : 'واقعی'} toggle('mellat_sandbox', mellatSandbox)} label="حالت Sandbox ملت" />
{/* سپ */}
درگاه سپ (SEP)
{sepEnabled ? 'فعال' : 'غیرفعال'} toggle('sep_enabled', sepEnabled)} label="فعال‌سازی درگاه سپ" />
)} {/* پیامک */} {current.id === 'sms' && (
{settings?.sms_api_key_configured ? <>تنظیم‌شده : <>تنظیم‌نشده — مقدار KAVENEGAR_API_KEY را در فایل env قرار دهید}
)}
{/* ── نوار ذخیره چسبان ── */} {(isDirty || savedFlash) && (
{!isDirty && savedFlash ? (
تنظیمات با موفقیت ذخیره شد
) : ( <>
تغییرات ذخیره‌نشده دارید
)}
)}
); }