feat: add clinic management and financial reporting features
- Implemented ClinicFormPage for adding new clinics with validation. - Created MyFinancialPage to display financial summaries and charts. - Developed MyPatientsPage for managing patient data with search and pagination. - Added PreRegistrationsPage for handling pre-registration requests with approval and rejection functionalities. - Introduced database migration for pre_registrations table. - Built PreRegistrationController for managing pre-registration logic, including submission, approval, and rejection. - Created PreRegistration entity and repository for handling pre-registration data.
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { CheckIcon, XMarkIcon, PhoneIcon } from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { PaginatedResponse } from '../lib/api';
|
||||
import { formatDate } from '../lib/utils';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
|
||||
interface PreRegistration {
|
||||
uuid: string;
|
||||
type: 'independent_doctor' | 'doctor_with_clinic' | 'clinic_manager';
|
||||
name: string;
|
||||
mobile: string;
|
||||
info: string | null;
|
||||
status: 'pending' | 'approved' | 'rejected';
|
||||
admin_note: string | null;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
const EMPTY: PreRegistration[] = [];
|
||||
|
||||
const TYPE_META: Record<string, { label: string; color: string }> = {
|
||||
independent_doctor: { label: 'دکتر', color: 'blue' },
|
||||
doctor_with_clinic: { label: 'دکتر + کلینیک', color: 'purple' },
|
||||
clinic_manager: { label: 'مدیر کلینیک', color: 'orange' },
|
||||
};
|
||||
|
||||
const STATUS_META: Record<string, { label: string; color: string }> = {
|
||||
pending: { label: 'در انتظار', color: 'yellow' },
|
||||
approved: { label: 'تأیید شده', color: 'green' },
|
||||
rejected: { label: 'رد شده', color: 'red' },
|
||||
};
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ value: 'pending', label: 'در انتظار' },
|
||||
{ value: 'approved', label: 'تأیید شده' },
|
||||
{ value: 'rejected', label: 'رد شده' },
|
||||
{ value: 'all', label: 'همه' },
|
||||
];
|
||||
|
||||
export default function PreRegistrationsPage() {
|
||||
const qc = useQueryClient();
|
||||
const [page, setPage] = useState(1);
|
||||
const [statusFilter, setStatusFilter] = useState('pending');
|
||||
const [approveTarget, setApproveTarget] = useState<PreRegistration | null>(null);
|
||||
const [rejectTarget, setRejectTarget] = useState<PreRegistration | null>(null);
|
||||
const [rejectNote, setRejectNote] = useState('');
|
||||
const limit = 20;
|
||||
|
||||
const { data, isLoading } = useQuery<PaginatedResponse<PreRegistration>>({
|
||||
queryKey: ['pre-registrations', page, statusFilter],
|
||||
queryFn: () => api.get(`/api/v1/admin/pre-registrations?page=${page}&limit=${limit}&status=${statusFilter}`),
|
||||
});
|
||||
|
||||
const items = data?.data ?? EMPTY;
|
||||
const total = data?.meta?.totalRecords ?? 0;
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: (uuid: string) => api.post(`/api/v1/admin/pre-registrations/${uuid}/approve`, {}),
|
||||
onSuccess: () => {
|
||||
toast.success('درخواست تأیید شد و اطلاعات ورود ارسال گردید');
|
||||
setApproveTarget(null);
|
||||
qc.invalidateQueries({ queryKey: ['pre-registrations'] });
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: ({ uuid, note }: { uuid: string; note: string }) =>
|
||||
api.post(`/api/v1/admin/pre-registrations/${uuid}/reject`, { note: note || undefined }),
|
||||
onSuccess: () => {
|
||||
toast.success('درخواست رد شد');
|
||||
setRejectTarget(null);
|
||||
setRejectNote('');
|
||||
qc.invalidateQueries({ queryKey: ['pre-registrations'] });
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<PageHeader title="درخواستهای ثبتنام" description={`${total} درخواست`} />
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="seg" style={{ marginBottom: 20, width: 'fit-content' }}>
|
||||
{STATUS_TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.value}
|
||||
className={statusFilter === tab.value ? 'active' : ''}
|
||||
onClick={() => { setStatusFilter(tab.value); setPage(1); }}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||
<table className="t">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>نام</th>
|
||||
<th>موبایل</th>
|
||||
<th>نوع حساب</th>
|
||||
<th>توضیحات</th>
|
||||
<th>وضعیت</th>
|
||||
<th>تاریخ درخواست</th>
|
||||
{statusFilter === 'pending' && <th />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading
|
||||
? Array.from({ length: 8 }).map((_, i) => (
|
||||
<tr key={i}>
|
||||
{Array.from({ length: 6 }).map((_, j) => (
|
||||
<td key={j}><div className="skeleton" style={{ height: 16, borderRadius: 6 }} /></td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
: items.length === 0
|
||||
? (
|
||||
<tr>
|
||||
<td colSpan={7} style={{ textAlign: 'center', padding: 48, color: 'var(--text-3)' }}>
|
||||
درخواستی یافت نشد
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
: items.map((item) => {
|
||||
const typeMeta = TYPE_META[item.type] ?? { label: item.type, color: 'gray' };
|
||||
const statusMeta = STATUS_META[item.status] ?? { label: item.status, color: 'gray' };
|
||||
return (
|
||||
<tr key={item.uuid}>
|
||||
<td>
|
||||
<div style={{ fontWeight: 600 }}>{item.name}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, direction: 'ltr' }}>
|
||||
<PhoneIcon style={{ width: 13, height: 13, color: 'var(--text-3)' }} />
|
||||
<span style={{ fontSize: 13 }}>{item.mobile}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${typeMeta.color}`}>
|
||||
<span className="bdot" />{typeMeta.label}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ maxWidth: 200 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-2)', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{item.info || '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${statusMeta.color}`}>
|
||||
<span className="bdot" />{statusMeta.label}
|
||||
</span>
|
||||
{item.admin_note && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 3 }}>{item.admin_note}</div>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ fontSize: 13 }}>{formatDate(String(item.created_at))}</td>
|
||||
{statusFilter === 'pending' && (
|
||||
<td>
|
||||
<div className="row-actions">
|
||||
<button
|
||||
className="mini-btn"
|
||||
title="تأیید"
|
||||
style={{ color: 'var(--green)' }}
|
||||
onClick={() => setApproveTarget(item)}
|
||||
>
|
||||
<CheckIcon style={{ width: 16, height: 16 }} />
|
||||
</button>
|
||||
<button
|
||||
className="mini-btn"
|
||||
title="رد"
|
||||
style={{ color: 'var(--red)' }}
|
||||
onClick={() => { setRejectTarget(item); setRejectNote(''); }}
|
||||
>
|
||||
<XMarkIcon style={{ width: 16, height: 16 }} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
|
||||
|
||||
{/* Approve Dialog */}
|
||||
<ConfirmDialog
|
||||
open={!!approveTarget}
|
||||
title="تأیید درخواست"
|
||||
message={`آیا درخواست "${approveTarget?.name}" را تأیید میکنید؟ اطلاعات ورود از طریق SMS ارسال خواهد شد.`}
|
||||
confirmLabel="تأیید و ارسال SMS"
|
||||
loading={approveMutation.isPending}
|
||||
onConfirm={() => approveTarget && approveMutation.mutate(approveTarget.uuid)}
|
||||
onCancel={() => setApproveTarget(null)}
|
||||
/>
|
||||
|
||||
{/* Reject Dialog */}
|
||||
{rejectTarget && (
|
||||
<div className="overlay" onClick={() => setRejectTarget(null)}>
|
||||
<div className="modal" style={{ maxWidth: 420 }} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-head">
|
||||
<b>رد درخواست — {rejectTarget.name}</b>
|
||||
</div>
|
||||
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<p style={{ margin: 0, fontSize: 13, color: 'var(--text-2)' }}>در صورت تمایل دلیل رد را وارد کنید (اختیاری)</p>
|
||||
<textarea
|
||||
className="field"
|
||||
rows={3}
|
||||
placeholder="دلیل رد..."
|
||||
value={rejectNote}
|
||||
onChange={(e) => setRejectNote(e.target.value)}
|
||||
style={{ resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="modal-foot">
|
||||
<button className="btn ghost sm" onClick={() => setRejectTarget(null)}>انصراف</button>
|
||||
<button
|
||||
className="btn danger sm"
|
||||
disabled={rejectMutation.isPending}
|
||||
onClick={() => rejectMutation.mutate({ uuid: rejectTarget.uuid, note: rejectNote })}
|
||||
>
|
||||
{rejectMutation.isPending ? 'در حال رد...' : 'رد درخواست'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user