Update SettingsPage.tsx metadata in manifest.json with new mtime and ast_hash

This commit is contained in:
hamed
2026-07-02 10:27:41 +03:30
parent 949fddc57a
commit 6d594822bf
6 changed files with 1149 additions and 869 deletions
+360 -420
View File
@@ -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>
);
+95
View File
@@ -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; }
}
+16 -1
View File
@@ -659,8 +659,12 @@
"657": "Community 657",
"658": "Community 658",
"659": "Community 659",
"660": "Community 660",
"661": "Community 661",
"662": "Community 662",
"663": "Community 663",
"664": "Community 664",
"665": "Community 665",
"666": "Community 666",
"667": "Community 667",
"668": "Community 668",
@@ -668,5 +672,16 @@
"670": "Community 670",
"671": "Community 671",
"672": "Community 672",
"679": "Community 679"
"673": "Community 673",
"674": "Community 674",
"675": "Community 675",
"676": "Community 676",
"677": "Community 677",
"678": "Community 678",
"679": "Community 679",
"680": "Community 680",
"681": "Community 681",
"682": "Community 682",
"683": "Community 683",
"684": "Community 684"
}
+166 -87
View File
@@ -1,16 +1,16 @@
# Graph Report - clinicpro (2026-07-02)
## Corpus Check
- 673 files · ~473,575 words
- 673 files · ~472,927 words
- Verdict: corpus is large enough that graph structure adds value.
## Summary
- 8466 nodes · 11667 edges · 670 communities (536 shown, 134 thin omitted)
- 8473 nodes · 11675 edges · 685 communities (552 shown, 133 thin omitted)
- Extraction: 98% EXTRACTED · 2% INFERRED · 0% AMBIGUOUS · INFERRED: 265 edges (avg confidence: 0.8)
- Token cost: 0 input · 0 output
## Graph Freshness
- Built from commit: `70d28007`
- Built from commit: `949fddc5`
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
- Run `graphify update .` after code changes (no API cost).
@@ -661,15 +661,30 @@
- [[_COMMUNITY_Community 657|Community 657]]
- [[_COMMUNITY_Community 658|Community 658]]
- [[_COMMUNITY_Community 659|Community 659]]
- [[_COMMUNITY_Community 660|Community 660]]
- [[_COMMUNITY_Community 661|Community 661]]
- [[_COMMUNITY_Community 662|Community 662]]
- [[_COMMUNITY_Community 663|Community 663]]
- [[_COMMUNITY_Community 664|Community 664]]
- [[_COMMUNITY_Community 665|Community 665]]
- [[_COMMUNITY_Community 666|Community 666]]
- [[_COMMUNITY_Community 667|Community 667]]
- [[_COMMUNITY_Community 668|Community 668]]
- [[_COMMUNITY_Community 669|Community 669]]
- [[_COMMUNITY_Community 671|Community 671]]
- [[_COMMUNITY_Community 672|Community 672]]
- [[_COMMUNITY_Community 673|Community 673]]
- [[_COMMUNITY_Community 674|Community 674]]
- [[_COMMUNITY_Community 675|Community 675]]
- [[_COMMUNITY_Community 676|Community 676]]
- [[_COMMUNITY_Community 677|Community 677]]
- [[_COMMUNITY_Community 678|Community 678]]
- [[_COMMUNITY_Community 679|Community 679]]
- [[_COMMUNITY_Community 680|Community 680]]
- [[_COMMUNITY_Community 681|Community 681]]
- [[_COMMUNITY_Community 682|Community 682]]
- [[_COMMUNITY_Community 683|Community 683]]
- [[_COMMUNITY_Community 684|Community 684]]
## God Nodes (most connected - your core abstractions)
1. `BaseController` - 76 edges
@@ -688,8 +703,8 @@
assets/admin/components/ServiceTariffModal.tsx → assets/admin/lib/utils.ts
- `PersianDatePicker()` --calls--> `formatDate()` [EXTRACTED]
assets/admin/components/ui/PersianDatePicker.tsx → assets/admin/lib/utils.ts
- `SmsPage()` --calls--> `formatDateTime()` [EXTRACTED]
assets/admin/pages/SmsPage.tsx → assets/admin/lib/utils.ts
- `LogsPage()` --calls--> `formatDateTime()` [EXTRACTED]
assets/admin/pages/LogsPage.tsx → assets/admin/lib/utils.ts
- `NewAppointmentModal()` --calls--> `useAuthStore` [EXTRACTED]
assets/admin/pages/AppointmentsPage.tsx → assets/admin/stores/authStore.ts
- `LogoUploadField()` --calls--> `useAuthStore` [EXTRACTED]
@@ -698,35 +713,35 @@
## Import Cycles
- None detected.
## Communities (670 total, 134 thin omitted)
## Communities (685 total, 133 thin omitted)
### Community 0 - "Community 0"
Cohesion: 0.05
Nodes (37): ALLOWED_ROLES, PrivateRoute(), PublicRoute(), RoleRoute(), queryClient, toastStyle, AddForm, addSchema (+29 more)
Nodes (42): ALLOWED_ROLES, PrivateRoute(), PublicRoute(), RoleRoute(), queryClient, toastStyle, AddForm, addSchema (+34 more)
### Community 1 - "Community 1"
Cohesion: 0.03
Nodes (41): AddressData, AddrForm, addrSchema, AVATAR_COLORS, BookingMeta, CityOpt, DateOverrideData, DEFAULT_BOOKING_META (+33 more)
### Community 2 - "Community 2"
Cohesion: 0.07
Nodes (26): get, PaymentConfig, PaymentGatewayInfo, api, ApiError, getToken(), refreshOnce(), request() (+18 more)
Cohesion: 0.08
Nodes (23): get, api, getToken(), refreshOnce(), request(), FormData, schema, post (+15 more)
### Community 3 - "Community 3"
Cohesion: 0.03
Nodes (80): ApiResponse, PaginatedResponse, formatDate(), ALL_STATUSES, AppointmentDetailPage(), STATUS_FILTERS, FILTERS, Breakdown (+72 more)
Cohesion: 0.06
Nodes (40): ApiResponse, PaginatedResponse, formatDate(), STATUS_FILTERS, FILTERS, Breakdown, SOURCE_LABEL, Summary (+32 more)
### Community 4 - "Community 4"
Cohesion: 0.05
Nodes (11): DoctorServiceController, DoctorService, Doctor, DoctorServiceRepository, Collection, self, User, WeeklySchedule (+3 more)
Cohesion: 0.08
Nodes (5): Doctor, Collection, self, User, WeeklySchedule
### Community 5 - "Community 5"
Cohesion: 0.07
Nodes (3): UserProfile, self, User
### Community 6 - "Community 6"
Cohesion: 0.09
Nodes (15): SettlementController, SettlementRepository, WalletTransactionRepository, CommissionService, Settlement, JsonResponse, Request, User (+7 more)
Cohesion: 0.12
Nodes (12): SettlementController, SettlementRepository, WalletTransactionRepository, Settlement, JsonResponse, Request, User, ManagerRegistry (+4 more)
### Community 7 - "Community 7"
Cohesion: 0.07
@@ -753,16 +768,16 @@ Cohesion: 0.05
Nodes (44): Clinic Doctor Invitation API, DELETE `/api/v1/admin/clinic/invitation/{invUuid}`, Errors, Errors, Errors, Errors, Errors, Errors (+36 more)
### Community 13 - "Community 13"
Cohesion: 0.06
Nodes (35): FreeVisitPrice(), Pricing, usePaymentConfig(), formatDateTime(), formatRial(), InsurancePricingPage(), LogsPage(), FinancialSummary (+27 more)
Cohesion: 0.04
Nodes (47): FreeVisitPrice(), Pricing, PaymentConfig, PaymentGatewayInfo, usePaymentConfig(), formatRial(), emptyFeatures(), FEATURE_KEYS (+39 more)
### Community 14 - "Community 14"
Cohesion: 0.15
Nodes (8): AppointmentSettingsController, Holiday, HolidayRepository, JsonResponse, Request, User, Doctor, ManagerRegistry
Cohesion: 0.10
Nodes (12): AppointmentSettingsController, DateOverride, Holiday, DateOverrideRepository, HolidayRepository, JsonResponse, Request, User (+4 more)
### Community 15 - "Community 15"
Cohesion: 0.09
Nodes (13): PaymentController, MockGateway, SepGateway, JsonResponse, Payment, PaymentGatewayInterface, Request, Response (+5 more)
Cohesion: 0.13
Nodes (10): PaymentController, MockGateway, JsonResponse, Payment, PaymentGatewayInterface, Request, Response, User (+2 more)
### Community 16 - "Community 16"
Cohesion: 0.06
@@ -777,8 +792,8 @@ Cohesion: 0.05
Nodes (38): API, API, API, API, API, Route, Route, Route (+30 more)
### Community 19 - "Community 19"
Cohesion: 0.20
Nodes (8): AdminUser, ChangeRoleModal(), getPrimaryRole(), HUES_LIST, ROLE_META, ROLE_TABS, RoleBadge(), UserStats
Cohesion: 0.06
Nodes (37): formatDateTime(), ALL_STATUSES, AppointmentDetailPage(), SessionRow(), PaymentDetailPage(), SmsPage(), STATUS_LOG_META, Tab (+29 more)
### Community 20 - "Community 20"
Cohesion: 0.14
@@ -786,11 +801,11 @@ Nodes (3): AdminApiController, JsonResponse, Request
### Community 21 - "Community 21"
Cohesion: 0.05
Nodes (37): formatNumber(), ClinicDetailPage(), AdminCharts, AdminDashboard(), AdminRecent, AdminStats, APPT_CLS, APPT_COLOR (+29 more)
Nodes (35): formatNumber(), ClinicDetailPage(), AdminCharts, AdminDashboard(), AdminRecent, AdminStats, APPT_CLS, APPT_COLOR (+27 more)
### Community 22 - "Community 22"
Cohesion: 0.29
Nodes (4): RatingController, JsonResponse, Request, User
Cohesion: 0.15
Nodes (9): RatingController, Like, LikeRepository, JsonResponse, Request, User, Comment, ManagerRegistry (+1 more)
### Community 23 - "Community 23"
Cohesion: 0.05
@@ -809,8 +824,8 @@ Cohesion: 0.13
Nodes (8): InsuranceController, EntityInsurancePricing, EntityInsurancePricingRepository, TenantInsuranceCleanupService, JsonResponse, Request, User, ManagerRegistry
### Community 27 - "Community 27"
Cohesion: 0.05
Nodes (40): 55. 🟢 `GET` all tag, 56. 🟢 `GET` supplementary_insurance, 57. 🟢 `GET` categories list, 58. 🟢 `GET` all state, 59. 🟢 `GET` all city, 60. 🟢 `GET` all specially doctor, 61. 🔵 `POST` post, 62. 🟡 `PATCH` patch (+32 more)
Cohesion: 0.06
Nodes (35): 55. 🟢 `GET` all tag, 56. 🟢 `GET` supplementary_insurance, 57. 🟢 `GET` categories list, 58. 🟢 `GET` all state, 59. 🟢 `GET` all city, 60. 🟢 `GET` all specially doctor, 62. 🟡 `PATCH` patch, 63. 🔴 `DELETE` DELETE (+27 more)
### Community 28 - "Community 28"
Cohesion: 0.09
@@ -865,8 +880,8 @@ Cohesion: 0.09
Nodes (4): User, PasswordAuthenticatedUserInterface, self, UserInterface
### Community 41 - "Community 41"
Cohesion: 0.05
Nodes (29): Contract, InsuranceOption, KIND_LABEL, CityForm, citySchema, ImportError, InsuranceForm, insuranceSchema (+21 more)
Cohesion: 0.06
Nodes (24): CityForm, citySchema, ImportError, InsuranceForm, insuranceSchema, LogoUploadField(), ProvinceForm, provinceSchema (+16 more)
### Community 42 - "Community 42"
Cohesion: 0.07
@@ -913,8 +928,8 @@ Cohesion: 0.07
Nodes (26): الزامات UI, باگ‌فیکس صفحه نوبت‌ها, باگ ۱ — کرش تقویم, باگ ۲ — روز هفته در DateNavigator, باگ ۳ — پیام «slot نیست», باگ ۴ — نوبت جدید: نام اجباری + find-or-create patient, باگ ۵ — patient_mobile نشان می‌دهد موبایل پزشک, باگ ۶ — نوبت‌های رزرو شده در نمایش زمانبندی (+18 more)
### Community 53 - "Community 53"
Cohesion: 0.09
Nodes (13): AppLogRepository, ClaimItemRepository, PreRegistrationRepository, SmsLogRepository, TariffRepository, ServiceEntityRepository, SmsLog, ManagerRegistry (+5 more)
Cohesion: 0.08
Nodes (16): AppLogRepository, ClaimItemRepository, InvoiceItemRepository, PreRegistrationRepository, SessionServiceRepository, SmsSettingsRepository, ServiceEntityRepository, SmsSettings (+8 more)
### Community 54 - "Community 54"
Cohesion: 0.10
@@ -1001,8 +1016,8 @@ Cohesion: 0.16
Nodes (5): DoctorSecretary, Clinic, Doctor, self, User
### Community 77 - "Community 77"
Cohesion: 0.10
Nodes (13): Claim, ClaimItem, DebtRow, InsuranceOption, KIND_LABEL, STATUS_FILTERS, STATUS_META, IbanItem (+5 more)
Cohesion: 0.07
Nodes (21): Contract, InsuranceOption, KIND_LABEL, Claim, ClaimItem, DebtRow, InsuranceOption, KIND_LABEL (+13 more)
### Community 78 - "Community 78"
Cohesion: 0.12
@@ -1030,7 +1045,7 @@ Nodes (29): devDependencies, @babel/core, @babel/preset-env, @babel/preset-react
### Community 86 - "Community 86"
Cohesion: 0.07
Nodes (13): AppointmentExpiryServiceTest, LowTierFixesTest, SendCodeMobileRateLimitTest, ClaimsListPaginationTest, ServiceItemDeleteCleanupTest, ServiceItemStaffOwnershipTest, KernelBrowser, CommentPaginationTest (+5 more)
Nodes (13): AppointmentExpiryServiceTest, DateOverrideOwnershipTest, LowTierFixesTest, SendCodeMobileRateLimitTest, ClaimsListPaginationTest, ServiceItemStaffOwnershipTest, KernelBrowser, CommentPaginationTest (+5 more)
### Community 87 - "Community 87"
Cohesion: 0.10
@@ -1053,7 +1068,7 @@ Cohesion: 0.10
Nodes (20): DELETE `/api/v1/admin/users/{uuid}`, Errors, Errors, GET `/api/v1/admin/users`, GET `/api/v1/admin/users/stats`, GET `/api/v1/admin/users/{uuid}`, POST `/api/v1/admin/users/{uuid}/status`, PUT `/api/v1/admin/users/{uuid}` (+12 more)
### Community 92 - "Community 92"
Cohesion: 0.09
Cohesion: 0.10
Nodes (21): Bulk import / export, DELETE `/api/v1/admin/doctor-service/{id}`, Doctor Service API, Errors, Errors, Errors, Errors, GET `/api/v1/admin/doctor-services` (+13 more)
### Community 93 - "Community 93"
@@ -1117,8 +1132,8 @@ Cohesion: 0.32
Nodes (6): AppointmentController, Appointment, Doctor, JsonResponse, Request, User
### Community 108 - "Community 108"
Cohesion: 0.13
Nodes (10): BaseController, CategoryController, CategoryImportController, SmsMessageController, JsonResponse, JsonResponse, Request, JsonResponse (+2 more)
Cohesion: 0.17
Nodes (8): BaseController, CategoryController, SiteConfigController, JsonResponse, JsonResponse, Request, User, JsonResponse
### Community 109 - "Community 109"
Cohesion: 0.29
@@ -1318,7 +1333,7 @@ Nodes (15): Date Overrides, DELETE `/api/v1/appointment-settings/date-override/{
### Community 161 - "Community 161"
Cohesion: 0.24
Nodes (5): ClaimsListNPlusOneTest, ClaimItem, ClaimService, Claim, Invoice
Nodes (7): ClaimSubmitterInterface, ClaimService, ManualClaimSubmitter, Claim, Invoice, Claim, ClaimSubmissionResult
### Community 162 - "Community 162"
Cohesion: 0.13
@@ -1469,8 +1484,8 @@ Cohesion: 0.20
Nodes (10): Admin API, Clinic Invitation Management, GET `/api/v1/admin/comments`, GET `/api/v1/admin/rates`, GET /api/v1/admin/settings, PATCH /api/v1/admin/settings, Query Parameters, Query Parameters (+2 more)
### Community 202 - "Community 202"
Cohesion: 0.10
Nodes (19): Appointment Settings API, Available Locations, DELETE `/api/v1/appointment-settings/holidays/{uuid}`, Errors, Errors, `GET /api/v1/appointment-settings/available-locations/{doctorUuid}`, GET `/api/v1/appointment-settings/holidays/list/{doctorUuid}`, GET `/api/v1/appointment-settings/holidays/{uuid}` (+11 more)
Cohesion: 0.15
Nodes (13): DELETE `/api/v1/appointment-settings/holidays/{uuid}`, Errors, GET `/api/v1/appointment-settings/holidays/list/{doctorUuid}`, GET `/api/v1/appointment-settings/holidays/{uuid}`, Holidays, PATCH `/api/v1/appointment-settings/holidays/{uuid}`, POST `/api/v1/appointment-settings/holidays`, Request Body (+5 more)
### Community 203 - "Community 203"
Cohesion: 0.15
@@ -1557,8 +1572,8 @@ Cohesion: 0.30
Nodes (6): AbstractAuthenticator, Passport, PasswordAuthenticator, Request, Response, TokenInterface
### Community 228 - "Community 228"
Cohesion: 0.04
Nodes (47): Bulk import / export, DELETE `/api/v1/admin/insurance/{id}`, DELETE `/api/v1/billing/tenant-insurances/{uuid}`, DELETE `/api/v1/insurance/{id}`, EntityInsurancePricing — قیمت‌گذاری ویزیت بر اساس بیمه, Errors, Errors, Errors (+39 more)
Cohesion: 0.05
Nodes (43): Bulk import / export, DELETE `/api/v1/admin/insurance/{id}`, DELETE `/api/v1/billing/tenant-insurances/{uuid}`, DELETE `/api/v1/insurance/{id}`, EntityInsurancePricing — قیمت‌گذاری ویزیت بر اساس بیمه, Errors, Errors, Errors (+35 more)
### Community 229 - "Community 229"
Cohesion: 0.15
@@ -1644,6 +1659,10 @@ Nodes (11): Endpoint ها, تسک ۱۸: ماژول تسویه نماینده (Se
Cohesion: 0.18
Nodes (11): Appointment Management, Error Responses, GET `/api/v1/admin/appointments`, GET `/api/v1/admin/appointments/today-stats`, POST `/api/v1/admin/appointment`, Query Parameters, Query Parameters, Request Body (+3 more)
### Community 251 - "Community 251"
Cohesion: 0.21
Nodes (4): SmsLogRepository, OtpService, SmsLog, ManagerRegistry
### Community 252 - "Community 252"
Cohesion: 0.45
Nodes (4): NotificationMobileController, JsonResponse, Request, User
@@ -1725,7 +1744,7 @@ Cohesion: 0.18
Nodes (10): Endpoint های موجود که تغییر می‌کنند, GET /api/v1/admin/dashboard/charts?from=UNIX&to=UNIX, GET /api/v1/dashboard/clinic, GET /api/v1/dashboard/doctor, تسک ۱۶: داشبورد هوشمند — چارت + فیلتر زمانی, توضیح, زمان تخمینی, فیلتر بازه زمانی (+2 more)
### Community 274 - "Community 274"
Cohesion: 0.20
Cohesion: 0.18
Nodes (5): Authentication, ClinicPro — API Documentation Index, Error Code Reference, Modules, Standard Response Envelope
### Community 275 - "Community 275"
@@ -1829,8 +1848,8 @@ Cohesion: 0.19
Nodes (6): HealthController, PreRegistrationController, EntityManagerInterface, JsonResponse, Request, JsonResponse
### Community 301 - "Community 301"
Cohesion: 0.36
Nodes (4): SiteConfigController, JsonResponse, Request, User
Cohesion: 0.17
Nodes (7): FormValues, schema, SectionDef, SectionId, SECTIONS, Settings, TaxHistoryRow
### Community 302 - "Community 302"
Cohesion: 0.12
@@ -1841,8 +1860,8 @@ Cohesion: 0.12
Nodes (16): آماده‌سازی پروژه ClinicPro برای دیپلوی روی Coolify با Docker Compose, زمینه, فایل‌های مرتبط, نکات مهم (محدودیت‌ها و edge caseها), هدف, وظایف, ۱. ساخت `Dockerfile` چندمرحله‌ای, ۱۰. ساخت راهنمای `docs/deploy/coolify.md` (+8 more)
### Community 304 - "Community 304"
Cohesion: 0.04
Nodes (46): 29. 🟢 `GET` clinic list 🆕, 30. 🟢 `GET` get 🆕, 33. 🔵 `POST` image_clinic, 34. 🔵 `POST` image logo, 36. 🟢 `GET` get my rate, 37. 🔵 `POST` post, 39. 🟡 `PATCH` Comment confirmation, 40. 🔵 `POST` post (+38 more)
Cohesion: 0.22
Nodes (9): 29. 🟢 `GET` clinic list 🆕, 30. 🟢 `GET` get 🆕, 36. 🟢 `GET` get my rate, 6. کلینیک (Clinic), هدرهای اضافی, پارامترهای Query, پاسخ‌ها, پاسخ‌ها (+1 more)
### Community 306 - "Community 306"
Cohesion: 0.07
@@ -1889,8 +1908,8 @@ Cohesion: 0.36
Nodes (3): SubscriptionPlanRepository, ManagerRegistry, SubscriptionPlan
### Community 317 - "Community 317"
Cohesion: 0.43
Nodes (3): InvoiceItemRepository, InvoiceItem, ManagerRegistry
Cohesion: 0.36
Nodes (3): DoctorServiceController, JsonResponse, Request
### Community 319 - "Community 319"
Cohesion: 0.39
@@ -2009,8 +2028,8 @@ Cohesion: 0.26
Nodes (3): MellatGateway, PaymentInitResult, PaymentVerifyResult
### Community 352 - "Community 352"
Cohesion: 0.48
Nodes (3): SmsSettingsRepository, SmsSettings, ManagerRegistry
Cohesion: 0.31
Nodes (3): SepGateway, PaymentInitResult, PaymentVerifyResult
### Community 354 - "Community 354"
Cohesion: 0.36
@@ -2069,8 +2088,8 @@ Cohesion: 0.29
Nodes (7): Dashboard, GET `/api/v1/admin/dashboard/charts`, GET `/api/v1/admin/dashboard/recent`, GET `/api/v1/admin/dashboard/stats`, Response `200`, Response `200`, Response `200`
### Community 368 - "Community 368"
Cohesion: 0.21
Nodes (5): DateOverrideOwnershipTest, DateOverride, DateOverrideRepository, Doctor, ManagerRegistry
Cohesion: 0.36
Nodes (3): TariffRepository, ManagerRegistry, Tariff
### Community 369 - "Community 369"
Cohesion: 0.29
@@ -2173,20 +2192,16 @@ Cohesion: 0.33
Nodes (6): Refactoring Plan, فاز ۰ — مستندسازی (۳ تا ۵ روز، قبل از هر کدنویسی), فاز ۱ — زیرساخت پایه (task-01), فاز ۲ — پیاده‌سازی ماژول‌ها (به ترتیب dependency), فاز ۳ — بهینه‌سازی (بعد از پیاده‌سازی), فاز ۴ — آماده‌سازی تولید
### Community 397 - "Community 397"
Cohesion: 0.36
Nodes (4): ClaimAmountBoundsTest, Claim, Doctor, User
Cohesion: 0.21
Nodes (6): ClaimAmountBoundsTest, ClaimsListNPlusOneTest, ClaimItem, Claim, Doctor, User
### Community 398 - "Community 398"
Cohesion: 0.29
Nodes (4): initiate(), verify(), PaymentInitResult, PaymentVerifyResult
### Community 400 - "Community 400"
Cohesion: 0.23
Nodes (5): AbstractMigration, Schema, Version20260609131553, Schema, Version20260609134741
### Community 405 - "Community 405"
Cohesion: 0.53
Nodes (4): ClaimSubmitterInterface, ManualClaimSubmitter, Claim, ClaimSubmissionResult
Cohesion: 0.47
Nodes (3): CommissionService, Payment, Representation
### Community 407 - "Community 407"
Cohesion: 0.36
@@ -2196,17 +2211,25 @@ Nodes (3): SmsService, SendSmsMessage, SmsProviderInterface
Cohesion: 0.17
Nodes (11): دیپلوی ClinicPro روی Coolify (Docker Compose), رفع اشکال, مراحل دیپلوی, معماری دیپلوی, نکات عملیاتی, چند دامنه فرانت‌اند (مهم), ۱. ساخت Resource در Coolify, ۲. اختصاص دامنه (+3 more)
### Community 426 - "Community 426"
Cohesion: 0.23
Nodes (5): AbstractMigration, Schema, Version20260611084046, Schema, Version20260614181629
### Community 430 - "Community 430"
Cohesion: 0.39
Nodes (3): DoctorService, DoctorServiceRepository, ManagerRegistry
### Community 439 - "Community 439"
Cohesion: 0.33
Nodes (5): Like, LikeRepository, Comment, ManagerRegistry, User
Cohesion: 0.29
Nodes (6): Appointment Settings API, Available Locations, Errors, `GET /api/v1/appointment-settings/available-locations/{doctorUuid}`, Response `200`, Slot Calculation Logic (Reference)
### Community 440 - "Community 440"
Cohesion: 0.11
Nodes (18): [F10] راهنمای کهنه در `CLAUDE.md`: endpoint `categorys/{bundle}` منتقل شده, [F11] داشبورد دکتر `GET /api/v1/dashboard/doctor` همیشه 500 (فیلد ناموجود در DQL) — ✅ رفع شد, [F1] phpstan: مقایسهٔ همیشه‌درست در محاسبهٔ estimated SMS — ✅ رفع شد, [F2] تست‌های PHPUnit به API خارجی Kavenegar درخواست واقعی می‌زنند, [F3] دیتابیس تست seed نشده — فقط کاربر ادمین وجود دارد, [F4] اسکریپت seeder `create_test_users.php` وجود ندارد, [F5] ادمین با JWT معتبر به `/api/doc` (Swagger UI) دسترسی ندارد (401), [F6] ناسازگاری کدهای خطا بین دامنه‌ها (+10 more)
### Community 452 - "Community 452"
Cohesion: 0.05
Nodes (34): cn(), iranMobileOptionalSchema, iranMobileSchema, isValidIranMobile(), maskMobile(), sanitizeMobileInput(), toDate(), toEnglishDigits() (+26 more)
Cohesion: 0.06
Nodes (31): cn(), iranMobileOptionalSchema, iranMobileSchema, isValidIranMobile(), maskMobile(), sanitizeMobileInput(), toDate(), toEnglishDigits() (+23 more)
### Community 456 - "Community 456"
Cohesion: 0.38
@@ -2217,8 +2240,8 @@ Cohesion: 0.47
Nodes (6): formatPersianDate(), gToJ(), jFirstDayOfWeek(), PersianDateInput(), todayGregorian(), toPersianNums()
### Community 459 - "Community 459"
Cohesion: 0.33
Nodes (6): API موجود (نیاز به تغییر ندارند), اپیک‌ها, اپیک ۳ — منشی (Secretary) — تکمیل, تغییرات مورد نیاز, توضیح, نیازمندی‌های کارکردی
Cohesion: 0.40
Nodes (5): API موجود (نیاز به تغییر ندارند), اپیک ۳ — منشی (Secretary) — تکمیل, تغییرات مورد نیاز, توضیح, نیازمندی‌های کارکردی
### Community 460 - "Community 460"
Cohesion: 0.33
@@ -2321,8 +2344,8 @@ Cohesion: 0.40
Nodes (5): Admin Endpoints, GET /api/v1/admin/sms/settings/review, GET /api/v1/admin/sms/wallet-report, POST /api/v1/admin/sms/settings/{id}/approve, POST /api/v1/admin/sms/settings/{id}/reject
### Community 491 - "Community 491"
Cohesion: 0.53
Nodes (3): SessionServiceRepository, ManagerRegistry, SessionService
Cohesion: 0.43
Nodes (3): CategoryImportController, JsonResponse, Request
### Community 493 - "Community 493"
Cohesion: 0.20
@@ -2353,8 +2376,8 @@ Cohesion: 0.40
Nodes (5): addMinutes(), calcSlotCount(), hasOverlap(), parseMinutes(), SessionEditor()
### Community 500 - "Community 500"
Cohesion: 0.40
Nodes (5): API موجود (نیاز به endpoint جدید ندارد), اپیک ۷ — داشبورد هوشمند (Smart Dashboard), تغییر مورد نیاز, توضیح, نیازمندی‌های کارکردی
Cohesion: 0.33
Nodes (6): API موجود (نیاز به endpoint جدید ندارد), اپیک‌ها, اپیک ۷ — داشبورد هوشمند (Smart Dashboard), تغییر مورد نیاز, توضیح, نیازمندی‌های کارکردی
### Community 501 - "Community 501"
Cohesion: 0.40
@@ -2481,8 +2504,8 @@ Cohesion: 0.67
Nodes (3): Errors, GET `/api/v1/notification-mobile/{target}`, Response `200`
### Community 542 - "Community 542"
Cohesion: 0.15
Nodes (11): emptyFeatures(), FEATURE_KEYS, FEATURE_LABELS, PeriodForm, periodSchema, PLAN_DISPLAY, PlanForm, planSchema (+3 more)
Cohesion: 0.43
Nodes (3): SmsMessageController, JsonResponse, Request
### Community 543 - "Community 543"
Cohesion: 0.53
@@ -2681,8 +2704,8 @@ Cohesion: 0.67
Nodes (3): بک‌اند, فرانت‌اند, وضعیت فعلی کد (مهم — قبل از تغییر بخوان)
### Community 618 - "Community 618"
Cohesion: 0.15
Nodes (5): ServiceCoverageNPlusOneTest, TenantInsuranceCleanupTest, TenantServiceCoverageRepository, ManagerRegistry, TenantServiceCoverage
Cohesion: 0.12
Nodes (6): ServiceItemDeleteCleanupTest, ServiceCoverageNPlusOneTest, TenantInsuranceCleanupTest, TenantServiceCoverageRepository, ManagerRegistry, TenantServiceCoverage
### Community 631 - "Community 631"
Cohesion: 0.50
@@ -2745,8 +2768,8 @@ Cohesion: 0.50
Nodes (4): 38. 🟢 `GET` Unapproved comments, هدرهای اضافی, پارامترهای Query, پاسخ‌ها
### Community 654 - "Community 654"
Cohesion: 0.50
Nodes (4): Errors, PATCH `/api/v1/insurance/{id}`, Request Body, Response `200`
Cohesion: 0.29
Nodes (3): ApiError, { refreshMock, logoutMock }, replaceMock
### Community 656 - "Community 656"
Cohesion: 0.40
@@ -2764,10 +2787,22 @@ Nodes (3): GET `/api/v1/clinic-pro/doctor-addresses/{doctorId}`, Path Parameters
Cohesion: 0.50
Nodes (4): GET `/api/v1/admin/secretaries`, Query Parameters, Response `200`, Secretary Management
### Community 661 - "Community 661"
Cohesion: 0.40
Nodes (5): 37. 🔵 `POST` post, Request Body, مثال Request, هدرهای اضافی, پاسخ‌ها
### Community 662 - "Community 662"
Cohesion: 0.29
Nodes (7): Application Logs, DELETE `/api/v1/admin/logs`, GET `/api/v1/admin/logs`, Log Retention, Query Parameters, Response `200`, Response `200`
### Community 664 - "Community 664"
Cohesion: 0.40
Nodes (5): 61. 🔵 `POST` post, Request Body, مثال Request, هدرهای اضافی, پاسخ‌ها
### Community 665 - "Community 665"
Cohesion: 0.40
Nodes (3): Props, StatTone, TONE
### Community 666 - "Community 666"
Cohesion: 0.50
Nodes (4): GET /api/v1/appointment-settings/slots — دریافت اسلات‌های خالی, POST /api/v1/appointment-settings/holidays — ثبت تعطیلی (فقط ادمین), POST /api/v1/appointment-settings/overrides — ثبت Override توسط دکتر, Task-09: API تنظیمات نوبت
@@ -2784,25 +2819,69 @@ Nodes (4): ۲.۱۳ نظرات، لایک و امتیازدهی, ۲.۱۳.۱ نظ
Cohesion: 0.50
Nodes (4): GET `/api/v1/admin/settlements`, Query Parameters, Response `200`, Settlement Management
### Community 673 - "Community 673"
Cohesion: 0.50
Nodes (4): Errors, GET `/api/v1/admin/insurances`, Query Parameters, Response `200`
### Community 674 - "Community 674"
Cohesion: 0.50
Nodes (4): Errors, POST `/api/v1/insurance/`, Request Body (`application/json`), Response `201`
### Community 675 - "Community 675"
Cohesion: 0.50
Nodes (4): 39. 🟡 `PATCH` Comment confirmation, مثال Request, هدرهای اضافی, پاسخ‌ها
### Community 676 - "Community 676"
Cohesion: 0.50
Nodes (4): 40. 🔵 `POST` post, مثال Request, هدرهای اضافی, پاسخ‌ها
### Community 677 - "Community 677"
Cohesion: 0.50
Nodes (4): 44. 🟢 `GET` list comment, هدرهای اضافی, پارامترهای Query, پاسخ‌ها
### Community 678 - "Community 678"
Cohesion: 0.50
Nodes (4): 45. 🔵 `POST` post, مثال Request, هدرهای اضافی, پاسخ‌ها
### Community 680 - "Community 680"
Cohesion: 0.50
Nodes (4): 46. 🟡 `PATCH` patch, مثال Request, هدرهای اضافی, پاسخ‌ها
### Community 681 - "Community 681"
Cohesion: 0.67
Nodes (3): 33. 🔵 `POST` image_clinic, هدرهای اضافی, پاسخ‌ها
### Community 682 - "Community 682"
Cohesion: 0.67
Nodes (3): 34. 🔵 `POST` image logo, هدرهای اضافی, پاسخ‌ها
### Community 683 - "Community 683"
Cohesion: 0.67
Nodes (3): 42. 🔴 `DELETE` delete, هدرهای اضافی, پاسخ‌ها
### Community 684 - "Community 684"
Cohesion: 0.67
Nodes (3): 43. 🟢 `GET` get, هدرهای اضافی, پاسخ‌ها
## Knowledge Gaps
- **3644 isolated node(s):** `ALLOWED_ROLES`, `Pricing`, `TenantInsurance`, `CoverageRow`, `Draft` (+3639 more)
- **3645 isolated node(s):** `ALLOWED_ROLES`, `Pricing`, `TenantInsurance`, `CoverageRow`, `Draft` (+3640 more)
These have ≤1 connection - possible missing edges or undocumented components.
- **134 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
- **133 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
## Suggested Questions
_Questions this graph is uniquely positioned to answer:_
- **Why does `BaseController` connect `Community 108` to `Community 4`, `Community 6`, `Community 138`, `Community 139`, `Community 14`, `Community 15`, `Community 275`, `Community 20`, `Community 22`, `Community 26`, `Community 164`, `Community 295`, `Community 300`, `Community 301`, `Community 175`, `Community 176`, `Community 177`, `Community 58`, `Community 59`, `Community 63`, `Community 64`, `Community 70`, `Community 71`, `Community 75`, `Community 206`, `Community 230`, `Community 103`, `Community 104`, `Community 107`, `Community 109`, `Community 121`, `Community 122`, `Community 252`?**
_High betweenness centrality (0.032) - this node is a cross-community bridge._
- **Why does `BaseController` connect `Community 108` to `Community 6`, `Community 138`, `Community 139`, `Community 14`, `Community 15`, `Community 275`, `Community 20`, `Community 22`, `Community 26`, `Community 542`, `Community 164`, `Community 295`, `Community 300`, `Community 175`, `Community 176`, `Community 177`, `Community 58`, `Community 59`, `Community 317`, `Community 63`, `Community 64`, `Community 70`, `Community 71`, `Community 75`, `Community 206`, `Community 230`, `Community 103`, `Community 104`, `Community 107`, `Community 491`, `Community 109`, `Community 121`, `Community 122`, `Community 252`?**
_High betweenness centrality (0.033) - this node is a cross-community bridge._
- **Why does `AppointmentRepository` connect `Community 119` to `Community 53`?**
_High betweenness centrality (0.021) - this node is a cross-community bridge._
- **Why does `Version20260614181657` connect `Community 431` to `Community 400`?**
_High betweenness centrality (0.019) - this node is a cross-community bridge._
- **Why does `Version20260614181657` connect `Community 431` to `Community 426`?**
_High betweenness centrality (0.015) - this node is a cross-community bridge._
- **What connects `ALLOWED_ROLES`, `Pricing`, `TenantInsurance` to the rest of the system?**
_3644 weakly-connected nodes found - possible documentation gaps or missing edges._
_3645 weakly-connected nodes found - possible documentation gaps or missing edges._
- **Should `Community 0` be split into smaller, more focused modules?**
_Cohesion score 0.054426705370101594 - nodes in this community are weakly interconnected._
_Cohesion score 0.048633879781420766 - nodes in this community are weakly interconnected._
- **Should `Community 1` be split into smaller, more focused modules?**
_Cohesion score 0.028985507246376812 - nodes in this community are weakly interconnected._
- **Should `Community 2` be split into smaller, more focused modules?**
_Cohesion score 0.06565656565656566 - nodes in this community are weakly interconnected._
_Cohesion score 0.07823613086770982 - nodes in this community are weakly interconnected._
+510 -359
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -345,8 +345,8 @@
"semantic_hash": ""
},
"assets/admin/pages/SettingsPage.tsx": {
"mtime": 1782974557.7457983,
"ast_hash": "e0b1c6e11b841e8c8bc367c2380663af",
"mtime": 1782975327.2297547,
"ast_hash": "b20c69938bd37c5db0e5b66a1ba3080f",
"semantic_hash": ""
},
"assets/admin/pages/SettlementDetailPage.tsx": {