feat: enhance staff management and payment gateway features
- Fix national code handling in staff creation and updates to support Persian digits. - Update ClinicStaff entity to allow longer national codes (up to 15 characters). - Implement support for clinic secretaries in SecretaryController, allowing creation without a doctor UUID. - Add a new endpoint to retrieve doctors associated with a clinic for secretary management. - Improve appointment management by ensuring doctors are selectable even when no appointments exist. - Extend PatientController to allow secretaries to create patient records if they have the appropriate permissions. - Introduce a PriceInput component for better price formatting in forms, supporting Persian digits. - Add a MockGateway for testing payment processes without real transactions. - Enhance SMS settings management with an approval flow for post-visit text messages, including new fields for pending text and status. - Update migrations to reflect changes in database schema for national codes and SMS settings.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
@@ -19,6 +19,17 @@ const schema = z.object({
|
||||
}, 'درصد باید بین ۰ تا ۱۰۰ باشد'),
|
||||
max_cancel_hours_before: z.string(),
|
||||
appointment_reminder_hours: z.string(),
|
||||
// payment gateways
|
||||
payment_test_mode: z.string(),
|
||||
mellat_terminal_id: z.string(),
|
||||
mellat_username: z.string(),
|
||||
mellat_password: z.string(),
|
||||
sep_terminal_id: z.string(),
|
||||
// sms
|
||||
sms_provider: z.string(),
|
||||
kavenegar_api_key: z.string(),
|
||||
kavenegar_sender: z.string(),
|
||||
sms_price_rials: z.string(),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
@@ -30,12 +41,23 @@ interface Settings {
|
||||
commission_percent: string;
|
||||
max_cancel_hours_before: string;
|
||||
appointment_reminder_hours: string;
|
||||
payment_test_mode: string;
|
||||
mellat_terminal_id: string;
|
||||
mellat_username: string;
|
||||
mellat_password: string;
|
||||
sep_terminal_id: string;
|
||||
sms_provider: string;
|
||||
kavenegar_api_key: string;
|
||||
kavenegar_sender: string;
|
||||
sms_price_rials: string;
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function SettingsPage() {
|
||||
const qc = useQueryClient();
|
||||
const [showMellatPassword, setShowMellatPassword] = useState(false);
|
||||
const [showKavenegarKey, setShowKavenegarKey] = useState(false);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin-settings'],
|
||||
@@ -51,6 +73,7 @@ export default function SettingsPage() {
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors, isDirty },
|
||||
} = useForm<FormValues>({ resolver: zodResolver(schema) });
|
||||
|
||||
@@ -63,6 +86,15 @@ export default function SettingsPage() {
|
||||
commission_percent: settings.commission_percent ?? '0',
|
||||
max_cancel_hours_before: settings.max_cancel_hours_before ?? '24',
|
||||
appointment_reminder_hours: settings.appointment_reminder_hours ?? '2',
|
||||
payment_test_mode: settings.payment_test_mode ?? '0',
|
||||
mellat_terminal_id: settings.mellat_terminal_id ?? '',
|
||||
mellat_username: settings.mellat_username ?? '',
|
||||
mellat_password: settings.mellat_password ?? '',
|
||||
sep_terminal_id: settings.sep_terminal_id ?? '',
|
||||
sms_provider: settings.sms_provider ?? 'kavenegar',
|
||||
kavenegar_api_key: settings.kavenegar_api_key ?? '',
|
||||
kavenegar_sender: settings.kavenegar_sender ?? '',
|
||||
sms_price_rials: settings.sms_price_rials ?? '500',
|
||||
});
|
||||
}
|
||||
}, [settings, reset]);
|
||||
@@ -75,7 +107,8 @@ export default function SettingsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const commissionEnabled = watch('commission_enabled') === '1';
|
||||
const commissionEnabled = watch('commission_enabled') === '1';
|
||||
const paymentTestMode = watch('payment_test_mode') === '1';
|
||||
|
||||
const onSubmit = (values: FormValues) => {
|
||||
mutation.mutate(values);
|
||||
@@ -167,22 +200,11 @@ export default function SettingsPage() {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
{/* toggle فعال/غیرفعال */}
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 12, cursor: 'pointer' }}>
|
||||
<div style={{ position: 'relative', width: 44, height: 24, flexShrink: 0 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={commissionEnabled}
|
||||
onChange={(e) => {
|
||||
const target = e.target;
|
||||
const input = document.querySelector<HTMLInputElement>('input[name="commission_enabled"]');
|
||||
if (input) {
|
||||
input.value = target.checked ? '1' : '0';
|
||||
// Trigger react-hook-form change
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
}}
|
||||
style={{ opacity: 0, width: 0, height: 0, position: 'absolute' }}
|
||||
/>
|
||||
<input type="hidden" {...register('commission_enabled')} />
|
||||
<input type="hidden" {...register('commission_enabled')} />
|
||||
<div
|
||||
style={{ position: 'relative', width: 44, height: 24, flexShrink: 0, cursor: 'pointer' }}
|
||||
onClick={() => setValue('commission_enabled', commissionEnabled ? '0' : '1', { shouldDirty: true })}
|
||||
>
|
||||
<div style={{
|
||||
width: 44, height: 24, borderRadius: 12,
|
||||
background: commissionEnabled ? 'var(--primary)' : 'var(--border)',
|
||||
@@ -281,6 +303,136 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* درگاه پرداخت */}
|
||||
<div className="card card-pad">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: '1.25rem' }}>
|
||||
<div className="ico" style={{ background: '#fef3c7', color: '#d97706', 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 ? '#fef9c3' : 'var(--surface)', border: `1px solid ${paymentTestMode ? '#fbbf24' : '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 ? '#f59e0b' : 'var(--border)', transition: 'background .2s', position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', top: 2, width: 20, height: 20, borderRadius: '50%', background: '#fff', transition: 'right .2s', right: paymentTestMode ? 2 : 22, boxShadow: '0 1px 3px rgba(0,0,0,.2)' }} />
|
||||
</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' }}>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 600, marginBottom: '0.75rem', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#6366f1', display: 'inline-block' }} />
|
||||
درگاه ملت (Mellat)
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SEP */}
|
||||
<div>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 600, marginBottom: '0.75rem', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#10b981', display: 'inline-block' }} />
|
||||
درگاه سپ (SEP)
|
||||
</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: '#ede9fe', color: '#7c3aed', width: 36, height: 36, borderRadius: 10 }}>
|
||||
<span style={{ fontSize: 16 }}>📱</span>
|
||||
</div>
|
||||
<h3 style={{ fontSize: 15 }}>تنظیمات پیامک (SMS)</h3>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 12.5, fontWeight: 500, display: 'block', marginBottom: 4 }}>سرویس پیامک</label>
|
||||
<select {...register('sms_provider')}
|
||||
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 }}>
|
||||
<option value="kavenegar">کاوهنگار</option>
|
||||
<option value="rangineh">رنگینه</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12.5, fontWeight: 500, display: 'block', marginBottom: 4 }}>قیمت هر پیامک (ریال)</label>
|
||||
<input {...register('sms_price_rials')} type="number" min={0} dir="ltr" placeholder="500"
|
||||
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 style={{ marginTop: '1rem' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: '0.75rem' }}>تنظیمات کاوهنگار</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 12.5, fontWeight: 500, display: 'block', marginBottom: 4 }}>API Key</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input {...register('kavenegar_api_key')} type={showKavenegarKey ? '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={() => setShowKavenegarKey(p => !p)}
|
||||
style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--muted)', fontSize: 12 }}>
|
||||
{showKavenegarKey ? 'پنهان' : 'نمایش'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12.5, fontWeight: 500, display: 'block', marginBottom: 4 }}>شماره فرستنده</label>
|
||||
<input {...register('kavenegar_sender')} dir="ltr" placeholder="10008664"
|
||||
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>
|
||||
|
||||
{/* دکمه ذخیره */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
|
||||
<button
|
||||
@@ -293,6 +445,15 @@ export default function SettingsPage() {
|
||||
commission_percent: settings.commission_percent,
|
||||
max_cancel_hours_before: settings.max_cancel_hours_before,
|
||||
appointment_reminder_hours: settings.appointment_reminder_hours,
|
||||
payment_test_mode: settings.payment_test_mode,
|
||||
mellat_terminal_id: settings.mellat_terminal_id,
|
||||
mellat_username: settings.mellat_username,
|
||||
mellat_password: settings.mellat_password,
|
||||
sep_terminal_id: settings.sep_terminal_id,
|
||||
sms_provider: settings.sms_provider,
|
||||
kavenegar_api_key: settings.kavenegar_api_key,
|
||||
kavenegar_sender: settings.kavenegar_sender,
|
||||
sms_price_rials: settings.sms_price_rials,
|
||||
})}
|
||||
disabled={!isDirty || mutation.isPending}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user