- 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.
410 lines
20 KiB
TypeScript
410 lines
20 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { useForm } from 'react-hook-form';
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
import { z } from 'zod';
|
|
import { toast } from 'sonner';
|
|
import {
|
|
ArrowUpCircleIcon, ArrowDownCircleIcon, DevicePhoneMobileIcon,
|
|
} from '@heroicons/react/24/outline';
|
|
import { api } from '../lib/api';
|
|
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
|
import type { SmsWalletBalance, SmsWalletLog, SmsSettings } from '../types';
|
|
import { formatRial, formatNumber, formatDateTime } from '../lib/utils';
|
|
import Modal from '../components/ui/Modal';
|
|
import Pagination from '../components/ui/Pagination';
|
|
import PageHeader from '../components/ui/PageHeader';
|
|
|
|
const chargeSchema = z.object({
|
|
amount_rials: z.coerce.number().min(10000, 'حداقل مبلغ ۱۰,۰۰۰ ریال است'),
|
|
});
|
|
type ChargeForm = z.infer<typeof chargeSchema>;
|
|
|
|
const EMPTY_LOGS: SmsWalletLog[] = [];
|
|
|
|
export default function SmsWalletPage() {
|
|
const qc = useQueryClient();
|
|
const [chargeOpen, setChargeOpen] = useState(false);
|
|
const [gateway, setGateway] = useState<'mellat' | 'sep'>('mellat');
|
|
const [logPage, setLogPage] = useState(1);
|
|
|
|
const { data: balanceData, isLoading: balanceLoading } = useQuery<ApiResponse<SmsWalletBalance>>({
|
|
queryKey: ['sms-wallet-balance'],
|
|
queryFn: () => api.get('/api/v1/sms/wallet/balance'),
|
|
});
|
|
|
|
const { data: logsData, isLoading: logsLoading } = useQuery<PaginatedResponse<SmsWalletLog>>({
|
|
queryKey: ['sms-wallet-logs', logPage],
|
|
queryFn: () => api.get(`/api/v1/sms/wallet/logs?page=${logPage}&limit=20`),
|
|
});
|
|
|
|
const { data: settingsData } = useQuery<ApiResponse<SmsSettings>>({
|
|
queryKey: ['sms-settings'],
|
|
queryFn: () => api.get('/api/v1/sms/settings'),
|
|
});
|
|
|
|
const balance = balanceData?.data;
|
|
const logs = logsData?.data ?? EMPTY_LOGS;
|
|
const total = logsData?.meta?.totalRecords ?? 0;
|
|
const settings = settingsData?.data;
|
|
|
|
const chargeForm = useForm<ChargeForm>({ resolver: zodResolver(chargeSchema) });
|
|
const watchAmount = chargeForm.watch('amount_rials');
|
|
|
|
const chargeMutation = useMutation({
|
|
mutationFn: ({ amount_rials }: ChargeForm) =>
|
|
api.post<{ data: { payment_url: string } }>('/api/v1/sms/wallet/charge', { gateway, amount_rials }),
|
|
onSuccess: (res: any) => {
|
|
const url = res?.data?.payment_url;
|
|
if (url) window.location.href = url;
|
|
else toast.error('خطا در دریافت لینک پرداخت');
|
|
},
|
|
onError: (e: any) => toast.error(e.message),
|
|
});
|
|
|
|
const [localSettings, setLocalSettings] = useState<SmsSettings | null>(null);
|
|
const currentSettings = localSettings ?? settings;
|
|
|
|
const saveMutation = useMutation({
|
|
mutationFn: (body: SmsSettings) => api.patch('/api/v1/sms/settings', body),
|
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ['sms-settings'] }); toast.success('تنظیمات ذخیره شد'); },
|
|
onError: (e: any) => toast.error(e.message),
|
|
});
|
|
|
|
const smsCount = balance?.estimated_sms_count ?? 0;
|
|
const isLowBalance = smsCount < 10;
|
|
|
|
return (
|
|
<>
|
|
<PageHeader title="کیف پول پیامک" description="مدیریت موجودی و تنظیمات ارسال پیامک" />
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 20 }}>
|
|
{/* کارت موجودی — gradient */}
|
|
<div style={{
|
|
background: 'linear-gradient(135deg, oklch(0.52 0.22 256), oklch(0.40 0.18 256))',
|
|
borderRadius: 'var(--r)',
|
|
padding: '24px 24px 20px',
|
|
color: '#fff',
|
|
position: 'relative',
|
|
overflow: 'hidden',
|
|
}}>
|
|
{/* دایره تزئینی */}
|
|
<div style={{
|
|
position: 'absolute', top: -30, left: -30,
|
|
width: 140, height: 140, borderRadius: '50%',
|
|
background: 'rgba(255,255,255,0.06)',
|
|
}} />
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
|
<div style={{ fontSize: 13, opacity: 0.85 }}>موجودی کیف پول</div>
|
|
<span style={{
|
|
fontSize: 11.5, fontWeight: 600, padding: '3px 10px', borderRadius: 20,
|
|
background: isLowBalance ? 'rgba(239,68,68,0.3)' : 'rgba(34,197,94,0.3)',
|
|
border: `1px solid ${isLowBalance ? 'rgba(239,68,68,0.5)' : 'rgba(34,197,94,0.5)'}`,
|
|
}}>
|
|
{isLowBalance ? 'موجودی کم' : 'فعال'}
|
|
</span>
|
|
</div>
|
|
|
|
{balanceLoading ? (
|
|
<div style={{ height: 60 }} />
|
|
) : (
|
|
<>
|
|
<div style={{ fontSize: 36, fontWeight: 800, letterSpacing: '-0.5px', marginBottom: 4 }}>
|
|
{formatRial(balance?.balance_rials ?? 0)}
|
|
</div>
|
|
<div style={{ fontSize: 13, opacity: 0.8, marginBottom: 16 }}>
|
|
معادل {formatNumber(smsCount)} پیامک
|
|
{balance?.sms_price_rials ? ` (هر پیامک ${formatRial(balance.sms_price_rials)})` : ''}
|
|
</div>
|
|
|
|
{/* نوار پیشرفت */}
|
|
<div style={{
|
|
background: 'rgba(255,255,255,0.2)', borderRadius: 8, height: 6, marginBottom: 16, overflow: 'hidden',
|
|
}}>
|
|
<div style={{
|
|
height: '100%', borderRadius: 8,
|
|
width: `${Math.min(100, (smsCount / 500) * 100)}%`,
|
|
background: isLowBalance ? '#ef4444' : 'rgba(255,255,255,0.85)',
|
|
transition: 'width 0.4s ease',
|
|
}} />
|
|
</div>
|
|
|
|
<button
|
|
style={{
|
|
background: '#fff', color: 'var(--primary)',
|
|
border: 'none', borderRadius: 8, padding: '8px 18px',
|
|
fontWeight: 700, fontSize: 13.5, cursor: 'pointer',
|
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
|
}}
|
|
onClick={() => setChargeOpen(true)}
|
|
>
|
|
<DevicePhoneMobileIcon style={{ width: 16 }} />
|
|
شارژ کیف پول
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* تنظیمات پیامک */}
|
|
<div className="card">
|
|
<div style={{
|
|
fontWeight: 600, padding: '14px 16px', borderBottom: '1px solid var(--border)',
|
|
display: 'flex', alignItems: 'center', gap: 8,
|
|
}}>
|
|
<DevicePhoneMobileIcon style={{ width: 16, color: 'var(--primary)' }} />
|
|
تنظیمات ارسال
|
|
</div>
|
|
<div style={{ padding: '16px' }}>
|
|
{currentSettings ? (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
{/* toggle یادآوری */}
|
|
<div style={{
|
|
background: currentSettings.reminder_enabled ? 'var(--primary-subtle)' : 'oklch(0.97 0 0)',
|
|
border: `1px solid ${currentSettings.reminder_enabled ? 'oklch(0.85 0.06 256)' : 'var(--border)'}`,
|
|
borderRadius: 8, padding: '10px 14px',
|
|
display: 'flex', alignItems: 'flex-start', gap: 10, cursor: 'pointer',
|
|
transition: 'all 0.15s',
|
|
}}
|
|
onClick={() => setLocalSettings({ ...currentSettings, reminder_enabled: !currentSettings.reminder_enabled })}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={currentSettings.reminder_enabled}
|
|
onChange={(e) => setLocalSettings({ ...currentSettings, reminder_enabled: e.target.checked })}
|
|
style={{ width: 16, height: 16, accentColor: 'var(--primary)', marginTop: 2, cursor: 'pointer', flexShrink: 0 }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
/>
|
|
<div>
|
|
<div style={{ fontWeight: 600, fontSize: 13.5 }}>یادآوری قبل از نوبت</div>
|
|
<div style={{ color: 'var(--text-3)', fontSize: 12, marginTop: 2 }}>ارسال خودکار چند ساعت قبل از نوبت</div>
|
|
</div>
|
|
</div>
|
|
|
|
{currentSettings.reminder_enabled && (
|
|
<div className="field" style={{ marginBottom: 0, marginRight: 4 }}>
|
|
<label style={{ fontSize: 13 }}>چند ساعت قبل از نوبت</label>
|
|
<input
|
|
type="number" min={1} max={72}
|
|
value={currentSettings.reminder_hours_before}
|
|
onChange={(e) => setLocalSettings({ ...currentSettings, reminder_hours_before: +e.target.value })}
|
|
dir="ltr"
|
|
style={{ width: 90 }}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* toggle بعد از ویزیت */}
|
|
<div style={{
|
|
background: currentSettings.post_visit_enabled ? 'var(--primary-subtle)' : 'oklch(0.97 0 0)',
|
|
border: `1px solid ${currentSettings.post_visit_enabled ? 'oklch(0.85 0.06 256)' : 'var(--border)'}`,
|
|
borderRadius: 8, padding: '10px 14px',
|
|
display: 'flex', alignItems: 'flex-start', gap: 10, cursor: 'pointer',
|
|
transition: 'all 0.15s',
|
|
}}
|
|
onClick={() => setLocalSettings({ ...currentSettings, post_visit_enabled: !currentSettings.post_visit_enabled })}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={currentSettings.post_visit_enabled}
|
|
onChange={(e) => setLocalSettings({ ...currentSettings, post_visit_enabled: e.target.checked })}
|
|
style={{ width: 16, height: 16, accentColor: 'var(--primary)', marginTop: 2, cursor: 'pointer', flexShrink: 0 }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
/>
|
|
<div>
|
|
<div style={{ fontWeight: 600, fontSize: 13.5 }}>پیامک بعد از ویزیت</div>
|
|
<div style={{ color: 'var(--text-3)', fontSize: 12, marginTop: 2 }}>ارسال متن تشکر پس از پایان مراجعه</div>
|
|
</div>
|
|
</div>
|
|
|
|
{currentSettings.post_visit_enabled && (
|
|
<div style={{ marginRight: 4 }}>
|
|
<div className="field" style={{ marginBottom: 8 }}>
|
|
<label style={{ fontSize: 13 }}>متن پیامک</label>
|
|
<textarea
|
|
value={currentSettings.post_visit_text_pending ?? currentSettings.post_visit_text ?? ''}
|
|
onChange={(e) => setLocalSettings({ ...currentSettings, post_visit_text: e.target.value })}
|
|
rows={3}
|
|
placeholder="ممنون از مراجعه شما..."
|
|
/>
|
|
</div>
|
|
{/* وضعیت تأیید */}
|
|
{currentSettings.post_visit_text_status === 'pending' && (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', borderRadius: 8, background: '#fef9c3', border: '1px solid #fbbf24', fontSize: 12.5, marginBottom: 8 }}>
|
|
<span style={{ fontSize: 14 }}>⏳</span>
|
|
<span>متن پیامک در انتظار تأیید ادمین است</span>
|
|
</div>
|
|
)}
|
|
{currentSettings.post_visit_text_status === 'approved' && (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', borderRadius: 8, background: '#dcfce7', border: '1px solid #86efac', fontSize: 12.5, marginBottom: 8 }}>
|
|
<span style={{ fontSize: 14 }}>✅</span>
|
|
<span>متن پیامک تأیید شده و فعال است</span>
|
|
</div>
|
|
)}
|
|
{currentSettings.post_visit_text_status === 'rejected' && (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', borderRadius: 8, background: '#fee2e2', border: '1px solid #fca5a5', fontSize: 12.5, marginBottom: 8 }}>
|
|
<span style={{ fontSize: 14 }}>❌</span>
|
|
<div>
|
|
<div>متن پیامک رد شد</div>
|
|
{currentSettings.post_visit_text_reject_reason && (
|
|
<div style={{ color: '#dc2626', marginTop: 2 }}>دلیل: {currentSettings.post_visit_text_reject_reason}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
className="btn primary sm"
|
|
style={{ alignSelf: 'flex-start', marginTop: 4 }}
|
|
disabled={saveMutation.isPending}
|
|
onClick={() => currentSettings && saveMutation.mutate(currentSettings)}
|
|
>
|
|
{saveMutation.isPending ? 'در حال ذخیره...' : 'ذخیره تنظیمات'}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div style={{ color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* تاریخچه تراکنشها */}
|
|
<div className="card">
|
|
<div style={{
|
|
fontWeight: 600, padding: '14px 16px', borderBottom: '1px solid var(--border)',
|
|
display: 'flex', alignItems: 'center', gap: 8,
|
|
}}>
|
|
تاریخچه تراکنشها
|
|
{!logsLoading && total > 0 && (
|
|
<span className="badge gray" style={{ fontSize: 11 }}>{total} تراکنش</span>
|
|
)}
|
|
</div>
|
|
|
|
{logsLoading ? (
|
|
<div style={{ color: 'var(--text-3)', fontSize: 13, padding: '16px' }}>در حال بارگذاری...</div>
|
|
) : logs.length === 0 ? (
|
|
<div style={{ color: 'var(--text-3)', fontSize: 13, padding: '32px 16px', textAlign: 'center' }}>
|
|
تراکنشی ثبت نشده است
|
|
</div>
|
|
) : (
|
|
<>
|
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
|
<thead>
|
|
<tr style={{ background: 'oklch(0.97 0.01 256)', borderBottom: '1px solid var(--border)' }}>
|
|
<th style={{ textAlign: 'right', padding: '10px 16px', fontWeight: 600, color: 'var(--text-2)' }}>نوع</th>
|
|
<th style={{ textAlign: 'right', padding: '10px 16px', fontWeight: 600, color: 'var(--text-2)' }}>مبلغ</th>
|
|
<th style={{ textAlign: 'right', padding: '10px 16px', fontWeight: 600, color: 'var(--text-2)' }}>توضیحات</th>
|
|
<th style={{ textAlign: 'right', padding: '10px 16px', fontWeight: 600, color: 'var(--text-2)' }}>تاریخ</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{logs.map((log, idx) => (
|
|
<tr
|
|
key={log.uuid}
|
|
style={{
|
|
borderBottom: '1px solid var(--border)',
|
|
background: idx % 2 === 1 ? 'oklch(0.985 0.005 256)' : 'transparent',
|
|
}}
|
|
>
|
|
<td style={{ padding: '11px 16px' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
{log.type === 'credit' ? (
|
|
<ArrowUpCircleIcon style={{ width: 18, color: 'oklch(0.52 0.17 162)', flexShrink: 0 }} />
|
|
) : (
|
|
<ArrowDownCircleIcon style={{ width: 18, color: 'oklch(0.52 0.2 25)', flexShrink: 0 }} />
|
|
)}
|
|
<span className={`badge ${log.type === 'credit' ? 'green' : 'red'}`}>
|
|
{log.type === 'credit' ? 'شارژ' : 'کسر'}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td style={{
|
|
padding: '11px 16px',
|
|
fontWeight: 600,
|
|
color: log.type === 'credit' ? 'oklch(0.45 0.17 162)' : 'oklch(0.45 0.2 25)',
|
|
}}>
|
|
{formatRial(log.amount_rials)}
|
|
</td>
|
|
<td style={{ padding: '11px 16px', color: 'var(--text-2)' }}>{log.description}</td>
|
|
<td style={{ padding: '11px 16px', color: 'var(--text-3)' }}>{formatDateTime(log.created_at)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
<div style={{ padding: '12px 16px' }}>
|
|
<Pagination page={logPage} total={total} limit={20} onPageChange={setLogPage} />
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Modal شارژ */}
|
|
<Modal open={chargeOpen} onClose={() => setChargeOpen(false)} title="شارژ کیف پول پیامک">
|
|
<form onSubmit={chargeForm.handleSubmit((d) => chargeMutation.mutate(d))}>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
|
{/* انتخاب درگاه — radio cards */}
|
|
<div>
|
|
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8, color: 'var(--text-2)' }}>انتخاب درگاه پرداخت</div>
|
|
<div style={{ display: 'flex', gap: 10 }}>
|
|
{(['mellat', 'sep'] as const).map((gw) => (
|
|
<div
|
|
key={gw}
|
|
onClick={() => setGateway(gw)}
|
|
style={{
|
|
flex: 1, textAlign: 'center', padding: '12px',
|
|
border: `2px solid ${gateway === gw ? 'var(--primary)' : 'var(--border)'}`,
|
|
borderRadius: 10, cursor: 'pointer',
|
|
background: gateway === gw ? 'var(--primary-subtle)' : 'transparent',
|
|
transition: 'all 0.15s',
|
|
}}
|
|
>
|
|
<div style={{ fontWeight: 700, fontSize: 15, color: gateway === gw ? 'var(--primary)' : 'var(--text-1)' }}>
|
|
{gw === 'mellat' ? 'ملت' : 'سپ'}
|
|
</div>
|
|
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 3 }}>
|
|
{gw === 'mellat' ? 'بانک ملت' : 'بانک صادرات'}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="field">
|
|
<label>مبلغ (ریال)</label>
|
|
<input
|
|
{...chargeForm.register('amount_rials')}
|
|
type="number" min={10000}
|
|
placeholder="500000" dir="ltr"
|
|
/>
|
|
{chargeForm.formState.errors.amount_rials && (
|
|
<span className="field-error">{chargeForm.formState.errors.amount_rials.message}</span>
|
|
)}
|
|
</div>
|
|
|
|
{watchAmount && Number(watchAmount) >= 10000 && (
|
|
<div style={{
|
|
background: 'var(--primary-subtle)',
|
|
border: '1px solid oklch(0.85 0.06 256)',
|
|
borderRadius: 8, padding: '10px 14px',
|
|
fontSize: 13, color: 'var(--primary)', fontWeight: 500,
|
|
}}>
|
|
پرداخت {formatRial(Number(watchAmount))} از طریق {gateway === 'mellat' ? 'بانک ملت' : 'سپ'}
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<button type="submit" className="btn primary" disabled={chargeMutation.isPending}>
|
|
{chargeMutation.isPending ? 'در حال انتقال...' : 'پرداخت'}
|
|
</button>
|
|
<button type="button" className="btn" onClick={() => setChargeOpen(false)}>انصراف</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
</>
|
|
);
|
|
}
|