Files
clinicpro/assets/admin/pages/SettingsPage.tsx
T

496 lines
26 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 } from '../lib/utils';
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(),
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_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 ?? '',
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',
sms_price_rials: s.sms_price_rials ?? '500',
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;
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);
// 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 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>
)}
{/* نوبت‌دهی */}
{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>
{/* پورسانت نوبت */}
<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 {...register('upgrade_commission_percent')} type="number" min={0} max={100} 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 {...register('tax_percent')} type="number" min={0} max={100} 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 {...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>
</>
)}
{/* درگاه پرداخت */}
{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>
{/* سپ */}
<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 {...register('sms_price_rials')} type="number" min={0} dir="ltr" className="input" style={{ maxWidth: 200 }} placeholder="500" />
</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>
);
}