Update SettingsPage.tsx metadata in manifest.json with new mtime and ast_hash
This commit is contained in:
+360
-420
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
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';
|
||||
@@ -6,7 +6,10 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import { formatDateTime } from '../lib/utils';
|
||||
import { Cog6ToothIcon, CheckCircleIcon } from '@heroicons/react/24/outline';
|
||||
import {
|
||||
Cog6ToothIcon, ClockIcon, CalculatorIcon, CreditCardIcon, ChatBubbleLeftRightIcon,
|
||||
MagnifyingGlassIcon, CheckCircleIcon, ExclamationTriangleIcon, EyeIcon, EyeSlashIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
interface TaxHistoryRow {
|
||||
tax_percent: number;
|
||||
@@ -42,40 +45,107 @@ const schema = z.object({
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
interface Settings {
|
||||
site_name: string;
|
||||
support_phone: string;
|
||||
max_cancel_hours_before: string;
|
||||
appointment_reminder_hours: string;
|
||||
appointment_commission_enabled: string;
|
||||
upgrade_commission_enabled: string;
|
||||
upgrade_commission_percent: string;
|
||||
tax_enabled: string;
|
||||
tax_percent: string;
|
||||
sms_panel_fee_rials: string;
|
||||
appointment_fee_rials: string;
|
||||
payment_test_mode: string;
|
||||
mellat_enabled: string;
|
||||
mellat_terminal_id: string;
|
||||
mellat_username: string;
|
||||
mellat_password: string;
|
||||
sep_enabled: string;
|
||||
sep_terminal_id: string;
|
||||
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 ?? '',
|
||||
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: s.sms_panel_fee_rials ?? '1500000',
|
||||
appointment_fee_rials: s.appointment_fee_rials ?? '150000',
|
||||
payment_test_mode: s.payment_test_mode ?? '0',
|
||||
mellat_enabled: s.mellat_enabled ?? '1',
|
||||
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' },
|
||||
{ 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;
|
||||
|
||||
@@ -88,462 +158,332 @@ export default function SettingsPage() {
|
||||
const taxHistory: TaxHistoryRow[] = (taxHistoryQ.data?.data as any)?.data ?? taxHistoryQ.data?.data ?? [];
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
setValue,
|
||||
register, handleSubmit, reset, watch, setValue,
|
||||
formState: { errors, isDirty },
|
||||
} = useForm<FormValues>({ resolver: zodResolver(schema) });
|
||||
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
reset({
|
||||
site_name: settings.site_name ?? 'ClinicPro',
|
||||
support_phone: settings.support_phone ?? '',
|
||||
max_cancel_hours_before: settings.max_cancel_hours_before ?? '24',
|
||||
appointment_reminder_hours: settings.appointment_reminder_hours ?? '2',
|
||||
appointment_commission_enabled: settings.appointment_commission_enabled ?? '0',
|
||||
upgrade_commission_enabled: settings.upgrade_commission_enabled ?? '0',
|
||||
upgrade_commission_percent: settings.upgrade_commission_percent ?? '20',
|
||||
tax_enabled: settings.tax_enabled ?? '0',
|
||||
tax_percent: settings.tax_percent ?? '10',
|
||||
sms_panel_fee_rials: settings.sms_panel_fee_rials ?? '1500000',
|
||||
appointment_fee_rials: settings.appointment_fee_rials ?? '150000',
|
||||
payment_test_mode: settings.payment_test_mode ?? '0',
|
||||
mellat_enabled: settings.mellat_enabled ?? '1',
|
||||
mellat_terminal_id: settings.mellat_terminal_id ?? '',
|
||||
mellat_username: settings.mellat_username ?? '',
|
||||
mellat_password: settings.mellat_password ?? '',
|
||||
sep_enabled: settings.sep_enabled ?? '1',
|
||||
sep_terminal_id: settings.sep_terminal_id ?? '',
|
||||
});
|
||||
}
|
||||
}, [settings, reset]);
|
||||
useEffect(() => { if (settings) reset(toForm(settings)); }, [settings, reset]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (values: FormValues) =>
|
||||
api.patch<ApiResponse<Settings>>('/api/v1/admin/settings', values),
|
||||
onSuccess: () => {
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
const paymentTestMode = watch('payment_test_mode') === '1';
|
||||
const mellatEnabled = watch('mellat_enabled') === '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';
|
||||
// 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);
|
||||
};
|
||||
const onSubmit = (values: FormValues) => mutation.mutate(values);
|
||||
|
||||
// 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 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 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">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--gap)' }}>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="card card-pad">
|
||||
<div className="skeleton" style={{ height: 200, borderRadius: 'var(--r)' }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<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 className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
||||
<div>
|
||||
<h1 className="section-title">تنظیمات سایت</h1>
|
||||
<div className="muted" style={{ fontSize: 13, marginTop: 3 }}>پیکربندی کلی پلتفرم</div>
|
||||
</div>
|
||||
{mutation.isSuccess && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'var(--success)', fontSize: 13.5 }}>
|
||||
<CheckCircleIcon style={{ width: 18, height: 18 }} />
|
||||
تنظیمات ذخیره شد
|
||||
</div>
|
||||
)}
|
||||
<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 style={{ display: 'flex', flexDirection: 'column', gap: 'var(--gap)' }}>
|
||||
<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">
|
||||
<div className="card-title-row" style={{ marginBottom: '1.25rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div className="ico" style={{ background: 'var(--primary-soft)', color: 'var(--primary)', width: 36, height: 36, borderRadius: 10 }}>
|
||||
<Cog6ToothIcon style={{ width: 18, height: 18 }} />
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* نوبتدهی */}
|
||||
{current.id === 'appointments' && (
|
||||
<div className="settings-grid">
|
||||
<Field label="مهلت مجاز لغو نوبت" hint="بیمار تا این تعداد ساعت پیش از نوبت اجازه لغو دارد.">
|
||||
<div className="input-suffix">
|
||||
<input {...register('max_cancel_hours_before')} type="number" min={0} className="input" style={{ maxWidth: 130 }} />
|
||||
<span className="suf">ساعت قبل</span>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="ارسال یادآور نوبت" hint="پیامک یادآوری این تعداد ساعت پیش از نوبت ارسال میشود.">
|
||||
<div className="input-suffix">
|
||||
<input {...register('appointment_reminder_hours')} type="number" min={0} 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>
|
||||
<h3 style={{ fontSize: 15 }}>اطلاعات پایه</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 13, fontWeight: 500, display: 'block', marginBottom: 6 }}>نام سایت</label>
|
||||
<input
|
||||
{...register('site_name')}
|
||||
style={{
|
||||
width: '100%', height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)',
|
||||
border: `1px solid ${errors.site_name ? 'var(--danger)' : 'var(--border)'}`,
|
||||
background: 'var(--surface)', color: 'var(--text)', fontSize: 13.5,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
{errors.site_name && <p style={{ color: 'var(--danger)', fontSize: 12, marginTop: 4 }}>{errors.site_name.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 13, fontWeight: 500, display: 'block', marginBottom: 6 }}>شماره پشتیبانی</label>
|
||||
<input
|
||||
{...register('support_phone')}
|
||||
dir="ltr"
|
||||
placeholder="021-12345678"
|
||||
style={{
|
||||
width: '100%', height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)',
|
||||
border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13.5,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* موتور مالی نمایندگی */}
|
||||
<div className="card card-pad">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: '1.25rem' }}>
|
||||
<div className="ico" style={{ background: 'var(--info-bg)', color: 'var(--info)', width: 36, height: 36, borderRadius: 10 }}>
|
||||
<span style={{ fontSize: 16 }}>🧮</span>
|
||||
</div>
|
||||
<h3 style={{ fontSize: 15 }}>موتور مالی نمایندگی</h3>
|
||||
</div>
|
||||
|
||||
<p className="muted" style={{ fontSize: 12, marginBottom: '1rem', lineHeight: 1.7 }}>
|
||||
ترتیب کسرها: ابتدا هزینه پنل پیامک، سپس مالیات بر ارزش افزوده (استخراجی از مبلغ شامل مالیات)،
|
||||
و در نهایت پورسانت نماینده از مبلغِ خالصِ پس از مالیات محاسبه میشود.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
||||
|
||||
{/* پورسانت نوبت */}
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 12, cursor: 'pointer' }}>
|
||||
<input type="hidden" {...register('appointment_commission_enabled')} />
|
||||
<div style={{ position: 'relative', width: 44, height: 24, flexShrink: 0 }}
|
||||
onClick={() => setValue('appointment_commission_enabled', apptCommissionEnabled ? '0' : '1', { shouldDirty: true })}>
|
||||
<div style={{ width: 44, height: 24, borderRadius: 12, background: apptCommissionEnabled ? 'var(--primary)' : 'var(--border)', transition: 'background .2s', position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', top: 2, width: 20, height: 20, borderRadius: '50%', background: 'var(--surface)', transition: 'right .2s', right: apptCommissionEnabled ? 2 : 22, boxShadow: '0 1px 3px rgba(0,0,0,.2)' }} />
|
||||
{/* پورسانت نوبت */}
|
||||
<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>
|
||||
<span style={{ fontSize: 14 }}>
|
||||
پورسانت نوبت نمایندگان {apptCommissionEnabled ? 'فعال' : 'غیرفعال'} است
|
||||
<span className="muted" style={{ fontSize: 12, marginRight: 6 }}>(درصد از پروفایل هر نماینده خوانده میشود)</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* پورسانت ارتقاء اشتراک */}
|
||||
<div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 12, cursor: 'pointer', marginBottom: upgradeCommissionEnabled ? 12 : 0 }}>
|
||||
<input type="hidden" {...register('upgrade_commission_enabled')} />
|
||||
<div style={{ position: 'relative', width: 44, height: 24, flexShrink: 0 }}
|
||||
onClick={() => setValue('upgrade_commission_enabled', upgradeCommissionEnabled ? '0' : '1', { shouldDirty: true })}>
|
||||
<div style={{ width: 44, height: 24, borderRadius: 12, background: upgradeCommissionEnabled ? 'var(--primary)' : 'var(--border)', transition: 'background .2s', position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', top: 2, width: 20, height: 20, borderRadius: '50%', background: 'var(--surface)', transition: 'right .2s', right: upgradeCommissionEnabled ? 2 : 22, boxShadow: '0 1px 3px rgba(0,0,0,.2)' }} />
|
||||
</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 {...register('upgrade_commission_percent')} type="number" min={0} max={100} className="input" style={{ maxWidth: 120 }} />
|
||||
<span className="suf">درصد (۰–۱۰۰)</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span style={{ fontSize: 14 }}>پورسانت ارتقاء اشتراک {upgradeCommissionEnabled ? 'فعال' : 'غیرفعال'} است</span>
|
||||
</label>
|
||||
{upgradeCommissionEnabled && (
|
||||
<div style={{ maxWidth: 280, paddingRight: 56 }}>
|
||||
<label style={{ fontSize: 13, fontWeight: 500, display: 'block', marginBottom: 6 }}>درصد پورسانت ارتقاء (۰–۱۰۰)</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input {...register('upgrade_commission_percent')} type="number" min={0} max={100}
|
||||
style={{ width: 120, height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13.5, boxSizing: 'border-box' }} />
|
||||
<span className="muted" style={{ fontSize: 13 }}>درصد</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Toggle checked={upgradeCommissionEnabled} onChange={() => toggle('upgrade_commission_enabled', upgradeCommissionEnabled)} label="پورسانت ارتقاء اشتراک" />
|
||||
</div>
|
||||
|
||||
{/* مالیات بر ارزش افزوده */}
|
||||
<div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 12, cursor: 'pointer', marginBottom: taxEnabled ? 12 : 0 }}>
|
||||
<input type="hidden" {...register('tax_enabled')} />
|
||||
<div style={{ position: 'relative', width: 44, height: 24, flexShrink: 0 }}
|
||||
onClick={() => setValue('tax_enabled', taxEnabled ? '0' : '1', { shouldDirty: true })}>
|
||||
<div style={{ width: 44, height: 24, borderRadius: 12, background: taxEnabled ? 'var(--primary)' : 'var(--border)', transition: 'background .2s', position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', top: 2, width: 20, height: 20, borderRadius: '50%', background: 'var(--surface)', transition: 'right .2s', right: taxEnabled ? 2 : 22, boxShadow: '0 1px 3px rgba(0,0,0,.2)' }} />
|
||||
</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 {...register('tax_percent')} type="number" min={0} max={100} className="input" style={{ maxWidth: 120 }} />
|
||||
<span className="suf">درصد (۰–۱۰۰)</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span style={{ fontSize: 14 }}>مالیات بر ارزش افزوده {taxEnabled ? 'فعال' : 'غیرفعال'} است</span>
|
||||
</label>
|
||||
{taxEnabled && (
|
||||
<div style={{ maxWidth: 280, paddingRight: 56 }}>
|
||||
<label style={{ fontSize: 13, fontWeight: 500, display: 'block', marginBottom: 6 }}>درصد مالیات (۰–۱۰۰)</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input {...register('tax_percent')} type="number" min={0} max={100}
|
||||
style={{ width: 120, height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13.5, boxSizing: 'border-box' }} />
|
||||
<span className="muted" style={{ fontSize: 13 }}>درصد</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Toggle checked={taxEnabled} onChange={() => toggle('tax_enabled', taxEnabled)} label="مالیات بر ارزش افزوده" />
|
||||
</div>
|
||||
|
||||
{taxHistory.length > 0 && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<div style={{ fontSize: 12.5, fontWeight: 600, marginBottom: 8 }}>تاریخچه تغییرات مالیات</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, maxWidth: 460 }}>
|
||||
<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: '6px 10px', borderRadius: 'var(--r-sm)', background: 'var(--surface-3)' }}>
|
||||
<span>
|
||||
{h.enabled ? `${h.tax_percent}٪` : 'غیرفعال'}
|
||||
{h.changed_by_name && <span className="muted"> — {h.changed_by_name}</span>}
|
||||
</span>
|
||||
<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>
|
||||
|
||||
{/* مبلغ هر نوبت */}
|
||||
<div style={{ maxWidth: 320 }}>
|
||||
<label style={{ fontSize: 13, fontWeight: 500, display: 'block', marginBottom: 6 }}>مبلغ هر نوبت (ریال)</label>
|
||||
<input {...register('appointment_fee_rials')} type="number" min={0} dir="ltr" placeholder="150000"
|
||||
style={{ width: 200, height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13.5, boxSizing: 'border-box' }} />
|
||||
<p className="muted" style={{ fontSize: 12, marginTop: 6 }}>
|
||||
مبلغی که بیمار هنگام رزرو نوبت آنلاین پرداخت میکند. ۱۵۰٬۰۰۰ ریال = ۱۵٬۰۰۰ تومان.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* هزینه پنل پیامک */}
|
||||
<div style={{ maxWidth: 320 }}>
|
||||
<label style={{ fontSize: 13, fontWeight: 500, display: 'block', marginBottom: 6 }}>هزینه ثابت پنل پیامک (ریال)</label>
|
||||
<input {...register('sms_panel_fee_rials')} type="number" min={0} dir="ltr" placeholder="1500000"
|
||||
style={{ width: 200, height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13.5, boxSizing: 'border-box' }} />
|
||||
<p className="muted" style={{ fontSize: 12, marginTop: 6 }}>
|
||||
از مبلغِ هر تراکنش (نوبت و اشتراک) کسر میشود. ۱٬۵۰۰٬۰۰۰ ریال = ۱۵۰٬۰۰۰ تومان.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* تنظیمات نوبتدهی */}
|
||||
<div className="card card-pad">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: '1.25rem' }}>
|
||||
<div className="ico" style={{ background: 'var(--warning-bg)', color: 'var(--warning)', width: 36, height: 36, borderRadius: 10 }}>
|
||||
<span style={{ fontSize: 16 }}>⏱</span>
|
||||
</div>
|
||||
<h3 style={{ fontSize: 15 }}>تنظیمات نوبتدهی</h3>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 13, fontWeight: 500, display: 'block', marginBottom: 6 }}>
|
||||
حداکثر ساعت مجاز برای لغو نوبت
|
||||
</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input
|
||||
{...register('max_cancel_hours_before')}
|
||||
type="number"
|
||||
min={0}
|
||||
style={{
|
||||
width: 100, height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)',
|
||||
border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13.5,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
<span className="muted" style={{ fontSize: 13 }}>ساعت قبل از نوبت</span>
|
||||
<div className="settings-grid" style={{ marginTop: 18 }}>
|
||||
<Field label="مبلغ هر نوبت" hint="مبلغی که بیمار هنگام رزرو آنلاین پرداخت میکند. ۱۵۰٬۰۰۰ ریال = ۱۵٬۰۰۰ تومان.">
|
||||
<div className="input-suffix">
|
||||
<input {...register('appointment_fee_rials')} type="number" min={0} dir="ltr" className="input" style={{ maxWidth: 200 }} placeholder="150000" />
|
||||
<span className="suf">ریال</span>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="هزینه ثابت پنل پیامک" hint="از مبلغ هر تراکنش کسر میشود. ۱٬۵۰۰٬۰۰۰ ریال = ۱۵۰٬۰۰۰ تومان.">
|
||||
<div className="input-suffix">
|
||||
<input {...register('sms_panel_fee_rials')} type="number" min={0} dir="ltr" className="input" style={{ maxWidth: 200 }} placeholder="1500000" />
|
||||
<span className="suf">ریال</span>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 13, fontWeight: 500, display: 'block', marginBottom: 6 }}>
|
||||
ارسال یادآور قبل از نوبت
|
||||
</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input
|
||||
{...register('appointment_reminder_hours')}
|
||||
type="number"
|
||||
min={0}
|
||||
style={{
|
||||
width: 100, height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)',
|
||||
border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13.5,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
<span className="muted" style={{ fontSize: 13 }}>ساعت قبل از نوبت</span>
|
||||
{/* درگاه پرداخت */}
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* درگاه پرداخت */}
|
||||
<div className="card card-pad">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: '1.25rem' }}>
|
||||
<div className="ico" style={{ background: 'var(--warning-bg)', color: 'var(--warning)', width: 36, height: 36, borderRadius: 10 }}>
|
||||
<span style={{ fontSize: 16 }}>💳</span>
|
||||
</div>
|
||||
<h3 style={{ fontSize: 15 }}>درگاه پرداخت</h3>
|
||||
</div>
|
||||
|
||||
{/* حالت تست */}
|
||||
<div style={{ marginBottom: '1.25rem', padding: '12px 16px', borderRadius: 'var(--r-sm)', background: paymentTestMode ? 'var(--warning-bg)' : 'var(--surface)', border: `1px solid ${paymentTestMode ? 'var(--warning)' : 'var(--border)'}` }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 12, cursor: 'pointer' }}>
|
||||
<input type="hidden" {...register('payment_test_mode')} />
|
||||
<div
|
||||
style={{ position: 'relative', width: 44, height: 24, flexShrink: 0, cursor: 'pointer' }}
|
||||
onClick={() => setValue('payment_test_mode', paymentTestMode ? '0' : '1', { shouldDirty: true })}
|
||||
>
|
||||
<div style={{ width: 44, height: 24, borderRadius: 12, background: paymentTestMode ? 'var(--warning)' : 'var(--border)', transition: 'background .2s', position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', top: 2, width: 20, height: 20, borderRadius: '50%', background: 'var(--surface)', transition: 'right .2s', right: paymentTestMode ? 2 : 22, boxShadow: '0 1px 3px rgba(0,0,0,.2)' }} />
|
||||
{/* ملت */}
|
||||
<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>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600 }}>{paymentTestMode ? 'حالت تست فعال' : 'حالت تست غیرفعال'}</div>
|
||||
<div className="muted" style={{ fontSize: 12, marginTop: 2 }}>
|
||||
{paymentTestMode ? 'همه پرداختها از درگاه آزمایشی رد میشوند (پول واقعی کسر نمیشود)' : 'پرداختها از درگاه واقعی انجام میشوند'}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Mellat */}
|
||||
<div style={{ marginBottom: '1.25rem', opacity: mellatEnabled ? 1 : 0.55 }}>
|
||||
<div style={{ marginBottom: '0.75rem', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 6 }}>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 600, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: 'var(--violet)', display: 'inline-block' }} />
|
||||
درگاه ملت (Mellat)
|
||||
</div>
|
||||
<input type="hidden" {...register('mellat_enabled')} />
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}
|
||||
onClick={() => setValue('mellat_enabled', mellatEnabled ? '0' : '1', { shouldDirty: true })}>
|
||||
<span style={{ fontSize: 12.5, fontWeight: 500, color: mellatEnabled ? 'var(--success)' : 'var(--muted)' }}>{mellatEnabled ? 'فعال' : 'غیرفعال'}</span>
|
||||
<div style={{ width: 44, height: 24, borderRadius: 12, background: mellatEnabled ? 'var(--success)' : 'var(--border)', transition: 'background .2s', position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', top: 2, width: 20, height: 20, borderRadius: '50%', background: 'var(--surface)', transition: 'right .2s', right: mellatEnabled ? 2 : 22, boxShadow: '0 1px 3px rgba(0,0,0,.2)' }} />
|
||||
{/* سپ */}
|
||||
<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>
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '0.75rem' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 12.5, fontWeight: 500, display: 'block', marginBottom: 4 }}>شناسه پایانه</label>
|
||||
<input {...register('mellat_terminal_id')} dir="ltr" placeholder="12345678"
|
||||
style={{ width: '100%', height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13, boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12.5, fontWeight: 500, display: 'block', marginBottom: 4 }}>نام کاربری</label>
|
||||
<input {...register('mellat_username')} dir="ltr" placeholder="username"
|
||||
style={{ width: '100%', height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13, boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12.5, fontWeight: 500, display: 'block', marginBottom: 4 }}>رمز عبور</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input {...register('mellat_password')} type={showMellatPassword ? 'text' : 'password'} dir="ltr" placeholder="••••••••"
|
||||
style={{ width: '100%', height: 38, padding: '0 36px 0 12px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13, boxSizing: 'border-box' }}
|
||||
/>
|
||||
<button type="button" onClick={() => setShowMellatPassword(p => !p)}
|
||||
style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--muted)', fontSize: 12 }}>
|
||||
{showMellatPassword ? 'پنهان' : 'نمایش'}
|
||||
</button>
|
||||
<div className="settings-grid one" style={{ maxWidth: 260 }}>
|
||||
<Field label="شناسه پایانه">
|
||||
<input {...register('sep_terminal_id')} className="input" dir="ltr" placeholder="12345678" />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* SEP */}
|
||||
<div style={{ opacity: sepEnabled ? 1 : 0.55 }}>
|
||||
<div style={{ marginBottom: '0.75rem', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 6 }}>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 600, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: 'var(--success)', display: 'inline-block' }} />
|
||||
درگاه سپ (SEP)
|
||||
</div>
|
||||
<input type="hidden" {...register('sep_enabled')} />
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}
|
||||
onClick={() => setValue('sep_enabled', sepEnabled ? '0' : '1', { shouldDirty: true })}>
|
||||
<span style={{ fontSize: 12.5, fontWeight: 500, color: sepEnabled ? 'var(--success)' : 'var(--muted)' }}>{sepEnabled ? 'فعال' : 'غیرفعال'}</span>
|
||||
<div style={{ width: 44, height: 24, borderRadius: 12, background: sepEnabled ? 'var(--success)' : 'var(--border)', transition: 'background .2s', position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', top: 2, width: 20, height: 20, borderRadius: '50%', background: 'var(--surface)', transition: 'right .2s', right: sepEnabled ? 2 : 22, boxShadow: '0 1px 3px rgba(0,0,0,.2)' }} />
|
||||
{/* پیامک */}
|
||||
{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>
|
||||
</label>
|
||||
</Field>
|
||||
</div>
|
||||
<div style={{ maxWidth: 240 }}>
|
||||
<label style={{ fontSize: 12.5, fontWeight: 500, display: 'block', marginBottom: 4 }}>شناسه پایانه</label>
|
||||
<input {...register('sep_terminal_id')} dir="ltr" placeholder="12345678"
|
||||
style={{ width: '100%', height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13, boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* تنظیمات پیامک */}
|
||||
<div className="card card-pad">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: '1.25rem' }}>
|
||||
<div className="ico" style={{ background: 'var(--violet-bg)', color: 'var(--violet)', width: 36, height: 36, borderRadius: 10 }}>
|
||||
<span style={{ fontSize: 16 }}>📱</span>
|
||||
</div>
|
||||
<h3 style={{ fontSize: 15 }}>تنظیمات پیامک (SMS)</h3>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 12.5, fontWeight: 500, display: 'block', marginBottom: 4 }}>کلید API کاوهنگار</label>
|
||||
<div style={{ fontSize: 13 }}>
|
||||
{settings?.sms_api_key_configured
|
||||
? <span style={{ color: 'var(--success)' }}>✓ تنظیمشده (از متغیر محیطی KAVENEGAR_API_KEY)</span>
|
||||
: <span style={{ color: 'var(--danger)' }}>✗ تنظیمنشده — مقدار KAVENEGAR_API_KEY را در فایل env قرار دهید</span>}
|
||||
</div>
|
||||
<p style={{ fontSize: 11.5, color: 'var(--muted)', marginTop: 8 }}>
|
||||
کلید API فقط از طریق متغیر محیطی سرور مدیریت میشود و در این پنل قابل ویرایش نیست.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* دکمه ذخیره */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
onClick={() => settings && reset({
|
||||
site_name: settings.site_name,
|
||||
support_phone: settings.support_phone,
|
||||
max_cancel_hours_before: settings.max_cancel_hours_before,
|
||||
appointment_reminder_hours: settings.appointment_reminder_hours,
|
||||
appointment_commission_enabled: settings.appointment_commission_enabled,
|
||||
upgrade_commission_enabled: settings.upgrade_commission_enabled,
|
||||
upgrade_commission_percent: settings.upgrade_commission_percent,
|
||||
tax_enabled: settings.tax_enabled,
|
||||
tax_percent: settings.tax_percent,
|
||||
sms_panel_fee_rials: settings.sms_panel_fee_rials,
|
||||
appointment_fee_rials: settings.appointment_fee_rials,
|
||||
payment_test_mode: settings.payment_test_mode,
|
||||
mellat_enabled: settings.mellat_enabled ?? '1',
|
||||
mellat_terminal_id: settings.mellat_terminal_id,
|
||||
mellat_username: settings.mellat_username,
|
||||
mellat_password: settings.mellat_password,
|
||||
sep_enabled: settings.sep_enabled ?? '1',
|
||||
sep_terminal_id: settings.sep_terminal_id,
|
||||
})}
|
||||
disabled={!isDirty || mutation.isPending}
|
||||
>
|
||||
بازگشت
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn primary"
|
||||
disabled={mutation.isPending || !isDirty}
|
||||
>
|
||||
{mutation.isPending ? 'در حال ذخیره...' : 'ذخیره تنظیمات'}
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -693,3 +693,98 @@ table.t tbody tr:last-child td { border-bottom: none; }
|
||||
@media (hover: none) {
|
||||
.srv-section-actions { opacity: 1; }
|
||||
}
|
||||
|
||||
/* ═══ صفحه تنظیمات (Settings redesign) ═══════════════════════════ */
|
||||
.settings-layout {
|
||||
display: grid; grid-template-columns: 250px minmax(0, 1fr);
|
||||
gap: var(--gap); align-items: start;
|
||||
}
|
||||
|
||||
/* نوار ناوبری کناری (Sticky) */
|
||||
.settings-nav { position: sticky; top: var(--gap); display: flex; flex-direction: column; gap: 12px; }
|
||||
.settings-search {
|
||||
display: flex; align-items: center; gap: 8px; height: 40px; padding: 0 12px;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-sm);
|
||||
color: var(--text-3); transition: .15s;
|
||||
}
|
||||
.settings-search:focus-within { border-color: var(--primary); box-shadow: 0 0 0 4px var(--ring); color: var(--primary); }
|
||||
.settings-search input {
|
||||
border: none; outline: none; background: none; flex: 1; min-width: 0;
|
||||
color: var(--text); font-family: inherit; font-size: 13.5px;
|
||||
}
|
||||
.settings-nav-list { display: flex; flex-direction: column; gap: 3px; }
|
||||
.settings-nav-item {
|
||||
display: flex; align-items: center; gap: 10px; width: 100%; text-align: right;
|
||||
padding: 9px 12px; border-radius: var(--r-sm); border: 1px solid transparent;
|
||||
background: none; color: var(--text-2); font-family: inherit; font-size: 13.5px; font-weight: 600;
|
||||
cursor: pointer; transition: .14s;
|
||||
}
|
||||
.settings-nav-item:hover { background: var(--surface-2); color: var(--text); }
|
||||
.settings-nav-item.active { background: var(--primary-soft); color: var(--primary-700); border-color: transparent; }
|
||||
.settings-nav-item svg { width: 17px; height: 17px; flex-shrink: 0; }
|
||||
.settings-nav-item .dot { width: 7px; height: 7px; border-radius: 50%; margin-inline-start: auto; flex-shrink: 0; }
|
||||
.settings-nav-empty { padding: 14px 12px; font-size: 12.5px; color: var(--text-3); text-align: center; }
|
||||
|
||||
/* هدر بخش */
|
||||
.settings-sec-head { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 20px; }
|
||||
.settings-sec-head .ico { width: 40px; height: 40px; border-radius: 11px; flex-shrink: 0; display: grid; place-items: center; }
|
||||
.settings-sec-head h2 { font-size: 16.5px; font-weight: 800; color: var(--text); letter-spacing: -.2px; }
|
||||
.settings-sec-head p { font-size: 12.5px; color: var(--text-3); margin-top: 3px; line-height: 1.6; }
|
||||
|
||||
/* گروه فیلدها */
|
||||
.settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px 18px; }
|
||||
.settings-grid.one { grid-template-columns: 1fr; }
|
||||
.field-block { display: flex; flex-direction: column; }
|
||||
.field-block.span2 { grid-column: 1 / -1; }
|
||||
.field-block > label { font-size: 13px; font-weight: 600; color: var(--text-2); margin-bottom: 6px; display: flex; align-items: center; gap: 5px; }
|
||||
.field-block .req { color: var(--danger); font-weight: 700; }
|
||||
.field-block .opt { color: var(--text-3); font-weight: 500; font-size: 11.5px; }
|
||||
.field-hint { font-size: 11.5px; color: var(--text-3); margin-top: 6px; line-height: 1.65; }
|
||||
.field-err { font-size: 12px; color: var(--danger); margin-top: 5px; }
|
||||
.input-suffix { display: flex; align-items: center; gap: 8px; }
|
||||
.input-suffix .suf { font-size: 13px; color: var(--text-3); white-space: nowrap; }
|
||||
|
||||
/* ردیف سوییچ (Toggle row) */
|
||||
.toggle-row {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 14px;
|
||||
padding: 13px 16px; border-radius: var(--r-sm);
|
||||
border: 1px solid var(--border); background: var(--surface-2); transition: .15s;
|
||||
}
|
||||
.toggle-row + .toggle-row { margin-top: 10px; }
|
||||
.toggle-row.on { border-color: color-mix(in oklch, var(--primary) 40%, var(--border)); background: var(--primary-soft); }
|
||||
.toggle-row.warn.on { border-color: var(--warning); background: var(--warning-bg); }
|
||||
.toggle-row .tr-title { font-size: 13.5px; font-weight: 600; color: var(--text); }
|
||||
.toggle-row .tr-desc { font-size: 12px; color: var(--text-3); margin-top: 3px; line-height: 1.6; }
|
||||
.toggle-sub { margin-top: 12px; padding-inline-start: 4px; max-width: 320px; }
|
||||
|
||||
/* کارت درگاه پرداخت */
|
||||
.gw-card { border: 1px solid var(--border); border-radius: var(--r); padding: 16px; transition: .15s; }
|
||||
.gw-card + .gw-card { margin-top: 14px; }
|
||||
.gw-card.off { opacity: .6; }
|
||||
.gw-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 14px; }
|
||||
.gw-name { display: flex; align-items: center; gap: 8px; font-size: 13.5px; font-weight: 700; color: var(--text); }
|
||||
.gw-name .dot { width: 9px; height: 9px; border-radius: 50%; }
|
||||
.gw-state { display: flex; align-items: center; gap: 8px; font-size: 12.5px; font-weight: 600; }
|
||||
|
||||
/* نوار ذخیره چسبان (Sticky save bar) */
|
||||
.save-bar {
|
||||
position: sticky; bottom: 16px; z-index: 20; margin-top: 4px;
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||
padding: 12px 16px 12px 20px; border-radius: var(--r);
|
||||
background: var(--surface); border: 1px solid var(--border-2);
|
||||
box-shadow: var(--shadow-lg); animation: pop .22s var(--ease);
|
||||
}
|
||||
.save-bar .sb-msg { display: flex; align-items: center; gap: 8px; font-size: 13.5px; font-weight: 600; color: var(--text-2); }
|
||||
.save-bar .sb-actions { display: flex; gap: 10px; }
|
||||
.save-bar.ok { border-color: color-mix(in oklch, var(--success) 45%, var(--border)); }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.settings-layout { grid-template-columns: 1fr; }
|
||||
.settings-nav { position: static; }
|
||||
.settings-nav-list { flex-direction: row; overflow-x: auto; padding-bottom: 4px; -webkit-overflow-scrolling: touch; }
|
||||
.settings-nav-item { flex-shrink: 0; width: auto; }
|
||||
.settings-nav-item .dot { display: none; }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.settings-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user