Users typing on a Persian keyboard produced two distinct failures. Fields with type="number" silently returned an empty string — the browser rejects Persian digits, so the value was lost and saved as empty or zero. Text fields passed the Persian characters straight through to the database, where a mobile stored as ۰۹۱۲… never matches 09… again. The secretary form hit the second case with no validation at all. Frontend: - Adds digitsOnly() and the national-code schemas to lib/utils, plus lib/forms with numericField()/latinDigitsField() wrappers for React Hook Form fields. - Converts every type="number" input to type="text" inputMode="numeric" with digit normalization; none remain. Fields that legitimately carry non-digits (sheba, landline) only get the digits translated, keeping IR and separators. - Points the patient national-code and mobile schemas at the shared normalizing schemas, which accept Persian input instead of rejecting it. - Drops two duplicate local digit converters in favour of the shared helper. Backend: - Adds NumericFieldNormalizerSubscriber, translating digits in whitelisted numeric keys of JSON request bodies under /api/v1/ before controllers run, so nobat724_front and clinic-pro-tauri are covered too. Translation only — no characters are stripped, non-string values and other keys are untouched. Three component tests asserted on role="spinbutton" and numeric input values; both are properties of type="number", so they were updated to match the new text inputs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
534 lines
28 KiB
TypeScript
534 lines
28 KiB
TypeScript
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<typeof schema>;
|
|
|
|
interface Settings extends FormValues {
|
|
sms_api_key_configured: boolean;
|
|
}
|
|
|
|
const toForm = (s: Partial<Settings>): 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 (
|
|
<div className="settings-sec-head">
|
|
<div className="ico" style={{ background: s.bg, color: s.fg }}><s.Icon style={{ width: 19, height: 19 }} /></div>
|
|
<div>
|
|
<h2>{s.label}</h2>
|
|
<p>{s.desc}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Toggle({ checked, onChange, label }: { checked: boolean; onChange: () => void; label: string }) {
|
|
return (
|
|
<label className="switch" title={label}>
|
|
<input type="checkbox" checked={checked} onChange={onChange} aria-label={label} />
|
|
<span className="switch-track"><span className="switch-thumb" /></span>
|
|
</label>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className={`field-block${span2 ? ' span2' : ''}`}>
|
|
<label>
|
|
{label}
|
|
{required && <span className="req">*</span>}
|
|
{optional && <span className="opt">(اختیاری)</span>}
|
|
</label>
|
|
{children}
|
|
{hint && <p className="field-hint">{hint}</p>}
|
|
{error && <p className="field-err">{error}</p>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Component ─────────────────────────────────────────────────────────────
|
|
|
|
export default function SettingsPage() {
|
|
const qc = useQueryClient();
|
|
const [showMellatPassword, setShowMellatPassword] = useState(false);
|
|
const [active, setActive] = useState<SectionId>('general');
|
|
const [search, setSearch] = useState('');
|
|
const [savedFlash, setSavedFlash] = useState(false);
|
|
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: ['admin-settings'],
|
|
queryFn: () => api.get<ApiResponse<Settings>>('/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<ApiResponse<TaxHistoryRow[]>>('/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<FormValues>({ resolver: zodResolver(schema) });
|
|
|
|
useEffect(() => { if (settings) reset(toForm(settings)); }, [settings, reset]);
|
|
|
|
const mutation = useMutation({
|
|
mutationFn: (values: FormValues) => api.patch<ApiResponse<Settings>>('/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 (
|
|
<div className="fade-in settings-layout">
|
|
<div className="card card-pad"><div className="skeleton" style={{ height: 220, borderRadius: 'var(--r)' }} /></div>
|
|
<div className="card card-pad"><div className="skeleton" style={{ height: 360, borderRadius: 'var(--r)' }} /></div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="fade-in">
|
|
<div style={{ marginBottom: 'var(--gap)' }}>
|
|
<h1 className="section-title">تنظیمات</h1>
|
|
<div className="muted" style={{ fontSize: 13, marginTop: 3 }}>پیکربندی کلی پلتفرم — تغییرات پس از ذخیره اعمال میشوند</div>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit(onSubmit)}>
|
|
<div className="settings-layout">
|
|
|
|
{/* ── نوار ناوبری کناری ── */}
|
|
<aside className="settings-nav">
|
|
<div className="settings-search">
|
|
<MagnifyingGlassIcon style={{ width: 16, height: 16 }} />
|
|
<input
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder="جستجوی تنظیمات..."
|
|
aria-label="جستجوی تنظیمات"
|
|
/>
|
|
</div>
|
|
<nav className="settings-nav-list" aria-label="بخشهای تنظیمات">
|
|
{filtered.map((s) => (
|
|
<button
|
|
key={s.id}
|
|
type="button"
|
|
className={`settings-nav-item${current.id === s.id ? ' active' : ''}`}
|
|
aria-current={current.id === s.id ? 'page' : undefined}
|
|
onClick={() => setActive(s.id)}
|
|
>
|
|
<s.Icon />
|
|
{s.label}
|
|
<span className="dot" style={{ background: s.fg }} />
|
|
</button>
|
|
))}
|
|
{filtered.length === 0 && <div className="settings-nav-empty">موردی یافت نشد</div>}
|
|
</nav>
|
|
</aside>
|
|
|
|
{/* ── محتوای بخش فعال ── */}
|
|
<div className="card card-pad">
|
|
<SectionHead s={current} />
|
|
|
|
{/* عمومی */}
|
|
{current.id === 'general' && (
|
|
<>
|
|
<div className="settings-grid">
|
|
<Field label="نام سایت" required error={errors.site_name?.message}
|
|
hint="عنوانی که در سربرگ سایت و پیامکها استفاده میشود.">
|
|
<input {...register('site_name')} className={`input${errors.site_name ? ' err' : ''}`} placeholder="نام سایت" />
|
|
</Field>
|
|
<Field label="شماره پشتیبانی" optional hint="برای نمایش به کاربران در بخش تماس.">
|
|
<input {...register('support_phone')} className="input" dir="ltr" placeholder="021-12345678" />
|
|
</Field>
|
|
</div>
|
|
|
|
{/* کپچا (ALTCHA) */}
|
|
<div className={`toggle-row${altchaEnabled ? ' on' : ''}`} style={{ marginTop: 18 }}>
|
|
<div>
|
|
<div className="tr-title">کپچای امنیتی (ALTCHA)</div>
|
|
<div className="tr-desc">
|
|
محافظت فرمهای ورود/ثبتنام در برابر بات. پیشفرض از متغیر محیطی سرور خوانده میشود؛ این کلید آن را override میکند.
|
|
</div>
|
|
</div>
|
|
<Toggle checked={altchaEnabled} onChange={() => toggle('altcha_enabled', altchaEnabled)} label="کپچای امنیتی ALTCHA" />
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* نوبتدهی */}
|
|
{current.id === 'appointments' && (
|
|
<div className="settings-grid">
|
|
<Field label="مهلت مجاز لغو نوبت" hint="بیمار تا این تعداد ساعت پیش از نوبت اجازه لغو دارد.">
|
|
<div className="input-suffix">
|
|
<input {...numericField(register('max_cancel_hours_before'))} className="input" style={{ maxWidth: 130 }} />
|
|
<span className="suf">ساعت قبل</span>
|
|
</div>
|
|
</Field>
|
|
<Field label="ارسال یادآور نوبت" hint="پیامک یادآوری این تعداد ساعت پیش از نوبت ارسال میشود.">
|
|
<div className="input-suffix">
|
|
<input {...numericField(register('appointment_reminder_hours'))} className="input" style={{ maxWidth: 130 }} />
|
|
<span className="suf">ساعت قبل</span>
|
|
</div>
|
|
</Field>
|
|
</div>
|
|
)}
|
|
|
|
{/* مالی */}
|
|
{current.id === 'financial' && (
|
|
<>
|
|
<div className="field-hint" style={{ padding: '12px 14px', background: 'var(--info-bg)', borderRadius: 'var(--r-sm)', color: 'var(--text-2)', marginBottom: 18 }}>
|
|
ترتیب کسرها: ابتدا هزینه پنل پیامک، سپس مالیات بر ارزش افزوده (استخراجی از مبلغ شامل مالیات)، و در نهایت پورسانت نماینده از مبلغِ خالصِ پس از مالیات.
|
|
</div>
|
|
|
|
{/* پورسانت نوبت */}
|
|
<div className={`toggle-row${apptCommissionEnabled ? ' on' : ''}`}>
|
|
<div>
|
|
<div className="tr-title">پورسانت نوبت نمایندگان</div>
|
|
<div className="tr-desc">درصد از پروفایل هر نماینده خوانده میشود.</div>
|
|
</div>
|
|
<Toggle checked={apptCommissionEnabled} onChange={() => toggle('appointment_commission_enabled', apptCommissionEnabled)} label="پورسانت نوبت نمایندگان" />
|
|
</div>
|
|
|
|
{/* پورسانت ارتقاء */}
|
|
<div className={`toggle-row${upgradeCommissionEnabled ? ' on' : ''}`}>
|
|
<div style={{ flex: 1 }}>
|
|
<div className="tr-title">پورسانت ارتقاء اشتراک</div>
|
|
<div className="tr-desc">درصد پورسانت هنگام ارتقاء اشتراک نمایندگان.</div>
|
|
{upgradeCommissionEnabled && (
|
|
<div className="toggle-sub">
|
|
<div className="input-suffix">
|
|
<input {...numericField(register('upgrade_commission_percent'), 3)} className="input" style={{ maxWidth: 120 }} />
|
|
<span className="suf">درصد (۰–۱۰۰)</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<Toggle checked={upgradeCommissionEnabled} onChange={() => toggle('upgrade_commission_enabled', upgradeCommissionEnabled)} label="پورسانت ارتقاء اشتراک" />
|
|
</div>
|
|
|
|
{/* مالیات */}
|
|
<div className={`toggle-row${taxEnabled ? ' on' : ''}`}>
|
|
<div style={{ flex: 1 }}>
|
|
<div className="tr-title">مالیات بر ارزش افزوده</div>
|
|
<div className="tr-desc">از مبلغ تراکنشها کسر و در تاریخچه ثبت میشود.</div>
|
|
{taxEnabled && (
|
|
<div className="toggle-sub">
|
|
<div className="input-suffix">
|
|
<input {...numericField(register('tax_percent'), 3)} className="input" style={{ maxWidth: 120 }} />
|
|
<span className="suf">درصد (۰–۱۰۰)</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<Toggle checked={taxEnabled} onChange={() => toggle('tax_enabled', taxEnabled)} label="مالیات بر ارزش افزوده" />
|
|
</div>
|
|
|
|
{taxHistory.length > 0 && (
|
|
<div style={{ marginTop: 16 }}>
|
|
<div style={{ fontSize: 12.5, fontWeight: 700, marginBottom: 8, color: 'var(--text-2)' }}>تاریخچه تغییرات مالیات</div>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, maxWidth: 480 }}>
|
|
{taxHistory.map((h, i) => (
|
|
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, fontSize: 12.5, padding: '7px 11px', borderRadius: 'var(--r-sm)', background: 'var(--surface-2)' }}>
|
|
<span>{h.enabled ? `${h.tax_percent}٪` : 'غیرفعال'}{h.changed_by_name && <span className="muted"> — {h.changed_by_name}</span>}</span>
|
|
<span className="muted">{formatDateTime(h.changed_at)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="settings-grid" style={{ marginTop: 18 }}>
|
|
<Field label="مبلغ هر نوبت" hint="مبلغی که بیمار هنگام رزرو آنلاین پرداخت میکند (تومان).">
|
|
<div className="input-suffix">
|
|
<input {...numericField(register('appointment_fee_rials'))} className="input" style={{ maxWidth: 200 }} placeholder="15000" />
|
|
<span className="suf">تومان</span>
|
|
</div>
|
|
</Field>
|
|
<Field label="هزینه ثابت پنل پیامک" hint="از مبلغ هر تراکنش کسر میشود (تومان).">
|
|
<div className="input-suffix">
|
|
<input {...numericField(register('sms_panel_fee_rials'))} className="input" style={{ maxWidth: 200 }} placeholder="150000" />
|
|
<span className="suf">تومان</span>
|
|
</div>
|
|
</Field>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* درگاه پرداخت */}
|
|
{current.id === 'payment' && (
|
|
<>
|
|
<div className={`toggle-row warn${paymentTestMode ? ' on' : ''}`} style={{ marginBottom: 18 }}>
|
|
<div>
|
|
<div className="tr-title">{paymentTestMode ? 'حالت تست فعال' : 'حالت تست غیرفعال'}</div>
|
|
<div className="tr-desc">
|
|
{paymentTestMode
|
|
? 'همه پرداختها از درگاه آزمایشی رد میشوند (پول واقعی کسر نمیشود).'
|
|
: 'پرداختها از درگاه واقعی انجام میشوند.'}
|
|
</div>
|
|
</div>
|
|
<Toggle checked={paymentTestMode} onChange={() => toggle('payment_test_mode', paymentTestMode)} label="حالت تست پرداخت" />
|
|
</div>
|
|
|
|
{/* ملت */}
|
|
<div className={`gw-card${mellatEnabled ? '' : ' off'}`}>
|
|
<div className="gw-head">
|
|
<div className="gw-name"><span className="dot" style={{ background: 'var(--violet)' }} />درگاه ملت (Mellat)</div>
|
|
<div className="gw-state" style={{ color: mellatEnabled ? 'var(--success)' : 'var(--text-3)' }}>
|
|
{mellatEnabled ? 'فعال' : 'غیرفعال'}
|
|
<Toggle checked={mellatEnabled} onChange={() => toggle('mellat_enabled', mellatEnabled)} label="فعالسازی درگاه ملت" />
|
|
</div>
|
|
</div>
|
|
<div className="settings-grid" style={{ gridTemplateColumns: '1fr 1fr 1fr' }}>
|
|
<Field label="شناسه پایانه">
|
|
<input {...register('mellat_terminal_id')} className="input" dir="ltr" placeholder="12345678" />
|
|
</Field>
|
|
<Field label="نام کاربری">
|
|
<input {...register('mellat_username')} className="input" dir="ltr" placeholder="username" />
|
|
</Field>
|
|
<Field label="رمز عبور">
|
|
<div style={{ position: 'relative' }}>
|
|
<input {...register('mellat_password')} type={showMellatPassword ? 'text' : 'password'} className="input" dir="ltr" placeholder="••••••••" style={{ paddingLeft: 44 }} />
|
|
<button type="button" onClick={() => setShowMellatPassword(p => !p)} aria-label={showMellatPassword ? 'پنهان کردن رمز' : 'نمایش رمز'}
|
|
style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-3)', display: 'grid', placeItems: 'center' }}>
|
|
{showMellatPassword ? <EyeSlashIcon style={{ width: 17, height: 17 }} /> : <EyeIcon style={{ width: 17, height: 17 }} />}
|
|
</button>
|
|
</div>
|
|
</Field>
|
|
</div>
|
|
<div className="settings-grid" style={{ gridTemplateColumns: '1fr auto', alignItems: 'end', marginTop: 12 }}>
|
|
<Field label="آدرس WSDL (اختیاری — خالی = عملیاتی)">
|
|
<input {...register('mellat_wsdl_url')} className="input" dir="ltr" placeholder="https://bpm.shaparak.ir/pgwchannel/services/pgw?wsdl" />
|
|
</Field>
|
|
<div className="gw-state" style={{ color: mellatSandbox ? 'var(--warning)' : 'var(--text-3)' }}>
|
|
{mellatSandbox ? 'Sandbox (آزمایشگاه)' : 'واقعی'}
|
|
<Toggle checked={mellatSandbox} onChange={() => toggle('mellat_sandbox', mellatSandbox)} label="حالت Sandbox ملت" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* سپ */}
|
|
<div className={`gw-card${sepEnabled ? '' : ' off'}`}>
|
|
<div className="gw-head">
|
|
<div className="gw-name"><span className="dot" style={{ background: 'var(--success)' }} />درگاه سپ (SEP)</div>
|
|
<div className="gw-state" style={{ color: sepEnabled ? 'var(--success)' : 'var(--text-3)' }}>
|
|
{sepEnabled ? 'فعال' : 'غیرفعال'}
|
|
<Toggle checked={sepEnabled} onChange={() => toggle('sep_enabled', sepEnabled)} label="فعالسازی درگاه سپ" />
|
|
</div>
|
|
</div>
|
|
<div className="settings-grid one" style={{ maxWidth: 260 }}>
|
|
<Field label="شناسه پایانه">
|
|
<input {...register('sep_terminal_id')} className="input" dir="ltr" placeholder="12345678" />
|
|
</Field>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* پیامک */}
|
|
{current.id === 'sms' && (
|
|
<div className="settings-grid one">
|
|
<Field label="کلید API کاوهنگار"
|
|
hint="کلید API فقط از طریق متغیر محیطی سرور (KAVENEGAR_API_KEY) مدیریت میشود و در این پنل قابل ویرایش نیست.">
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, fontWeight: 600 }}>
|
|
{settings?.sms_api_key_configured
|
|
? <><CheckCircleIcon style={{ width: 18, height: 18, color: 'var(--success)' }} /><span style={{ color: 'var(--success)' }}>تنظیمشده</span></>
|
|
: <><ExclamationTriangleIcon style={{ width: 18, height: 18, color: 'var(--danger)' }} /><span style={{ color: 'var(--danger)' }}>تنظیمنشده — مقدار KAVENEGAR_API_KEY را در فایل env قرار دهید</span></>}
|
|
</div>
|
|
</Field>
|
|
<Field label="هزینه هر پیامک" hint="مبلغ کسرشده از کیف پول به ازای هر پیامک ارسالی (تومان). مبنای محاسبهٔ تعداد پیامک از موجودی.">
|
|
<input {...numericField(register('sms_price_rials'))} className="input" style={{ maxWidth: 200 }} placeholder="50" />
|
|
</Field>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── نوار ذخیره چسبان ── */}
|
|
{(isDirty || savedFlash) && (
|
|
<div className={`save-bar${!isDirty && savedFlash ? ' ok' : ''}`}>
|
|
{!isDirty && savedFlash ? (
|
|
<div className="sb-msg" style={{ color: 'var(--success)' }}>
|
|
<CheckCircleIcon style={{ width: 18, height: 18 }} />
|
|
تنظیمات با موفقیت ذخیره شد
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="sb-msg">
|
|
<ExclamationTriangleIcon style={{ width: 17, height: 17, color: 'var(--warning)' }} />
|
|
تغییرات ذخیرهنشده دارید
|
|
</div>
|
|
<div className="sb-actions">
|
|
<button type="button" className="btn ghost sm" disabled={mutation.isPending}
|
|
onClick={() => settings && reset(toForm(settings))}>
|
|
بازگردانی
|
|
</button>
|
|
<button type="submit" className="btn primary sm" disabled={mutation.isPending}>
|
|
{mutation.isPending ? 'در حال ذخیره...' : 'ذخیره تغییرات'}
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|