feat: add Staff, Subscription, ClinicServices, SmsWallet pages (TASK-10,11,13,14)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
7ea3523830
commit
ed18c260ca
@@ -0,0 +1,231 @@
|
||||
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 { 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 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),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="کیف پول پیامک" description="مدیریت موجودی و تنظیمات ارسال پیامک" />
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 20 }}>
|
||||
{/* کارت موجودی */}
|
||||
<div className="card">
|
||||
<div style={{ fontSize: 13, color: 'var(--text-3)', marginBottom: 4 }}>موجودی کیف پول</div>
|
||||
{balanceLoading ? (
|
||||
<div style={{ height: 40 }} />
|
||||
) : (
|
||||
<>
|
||||
<div style={{ fontSize: 28, fontWeight: 700, marginBottom: 4 }}>
|
||||
{formatRial(balance?.balance_rials ?? 0)}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-3)', marginBottom: 16 }}>
|
||||
معادل {formatNumber(balance?.estimated_sms_count ?? 0)} پیامک
|
||||
{balance?.sms_price_rials ? ` (هر پیامک ${formatRial(balance.sms_price_rials)})` : ''}
|
||||
</div>
|
||||
<button className="btn primary sm" onClick={() => setChargeOpen(true)}>
|
||||
شارژ کیف پول
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* تنظیمات پیامک */}
|
||||
<div className="card">
|
||||
<div style={{ fontWeight: 600, marginBottom: 12 }}>تنظیمات ارسال</div>
|
||||
{currentSettings ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 14, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={currentSettings.reminder_enabled}
|
||||
onChange={(e) => setLocalSettings({ ...currentSettings, reminder_enabled: e.target.checked })}
|
||||
/>
|
||||
یادآوری قبل از نوبت
|
||||
</label>
|
||||
{currentSettings.reminder_enabled && (
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<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: 80 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 14, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={currentSettings.post_visit_enabled}
|
||||
onChange={(e) => setLocalSettings({ ...currentSettings, post_visit_enabled: e.target.checked })}
|
||||
/>
|
||||
پیامک بعد از ویزیت
|
||||
</label>
|
||||
{currentSettings.post_visit_enabled && (
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label style={{ fontSize: 13 }}>متن پیامک</label>
|
||||
<textarea
|
||||
value={currentSettings.post_visit_text ?? ''}
|
||||
onChange={(e) => setLocalSettings({ ...currentSettings, post_visit_text: e.target.value })}
|
||||
rows={3}
|
||||
placeholder="ممنون از مراجعه شما..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="btn primary sm"
|
||||
style={{ alignSelf: 'flex-start' }}
|
||||
disabled={saveMutation.isPending}
|
||||
onClick={() => currentSettings && saveMutation.mutate(currentSettings)}
|
||||
>
|
||||
{saveMutation.isPending ? 'در حال ذخیره...' : 'ذخیره تنظیمات'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* تاریخچه تراکنشها */}
|
||||
<div className="card">
|
||||
<div style={{ fontWeight: 600, marginBottom: 12 }}>تاریخچه تراکنشها</div>
|
||||
{logsLoading ? (
|
||||
<div style={{ color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div style={{ color: 'var(--text-3)', fontSize: 13 }}>تراکنشی ثبت نشده است</div>
|
||||
) : (
|
||||
<>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<th style={{ textAlign: 'right', padding: '8px 4px' }}>نوع</th>
|
||||
<th style={{ textAlign: 'right', padding: '8px 4px' }}>مبلغ</th>
|
||||
<th style={{ textAlign: 'right', padding: '8px 4px' }}>توضیحات</th>
|
||||
<th style={{ textAlign: 'right', padding: '8px 4px' }}>تاریخ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map((log) => (
|
||||
<tr key={log.uuid} style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: '8px 4px' }}>
|
||||
<span className={`badge ${log.type === 'credit' ? 'green' : 'red'}`}>
|
||||
{log.type === 'credit' ? 'شارژ' : 'کسر'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '8px 4px' }}>{formatRial(log.amount_rials)}</td>
|
||||
<td style={{ padding: '8px 4px', color: 'var(--text-2)' }}>{log.description}</td>
|
||||
<td style={{ padding: '8px 4px', color: 'var(--text-3)' }}>{formatDateTime(log.created_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<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: 12 }}>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{(['mellat', 'sep'] as const).map((gw) => (
|
||||
<button
|
||||
key={gw}
|
||||
type="button"
|
||||
className={`btn ${gateway === gw ? 'primary' : ''}`}
|
||||
onClick={() => setGateway(gw)}
|
||||
>
|
||||
{gw === 'mellat' ? 'ملت' : 'سپ'}
|
||||
</button>
|
||||
))}
|
||||
</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>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user