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:
hamed
2026-06-15 11:03:56 +03:30
parent 55f646e2d4
commit 5cdcec23a9
32 changed files with 1487 additions and 128 deletions
+97 -1
View File
@@ -21,7 +21,7 @@ const templateSchema = z.object({
});
type TemplateFormData = z.infer<typeof templateSchema>;
type Tab = 'samples' | 'pending' | 'logs';
type Tab = 'samples' | 'pending' | 'logs' | 'post-visit-review';
const STATUS_LOG_META: Record<string, { label: string; cls: string }> = {
sent: { label: 'ارسال شده', cls: 'green' },
@@ -38,6 +38,8 @@ export default function SmsPage() {
const [rejectReason, setRejectReason] = useState('');
const [addOpen, setAddOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<SmsTemplate | null>(null);
const [postVisitRejectId, setPostVisitRejectId] = useState<number | null>(null);
const [postVisitRejectReason, setPostVisitRejectReason] = useState('');
const limit = 15;
const sampleTemplatesQuery = useQuery({
@@ -114,6 +116,33 @@ export default function SmsPage() {
onError: (err: Error) => toast.error(err.message),
});
const postVisitReviewQuery = useQuery<ApiResponse<{ data: Array<{ id: number; entity_type: string; entity_id: number; post_visit_text_pending: string; post_visit_text_status: string }> }>>({
queryKey: ['sms-post-visit-review'],
queryFn: () => api.get('/api/v1/admin/sms/settings/review'),
enabled: activeTab === 'post-visit-review',
});
const postVisitApproveMutation = useMutation({
mutationFn: (id: number) => api.post(`/api/v1/admin/sms/settings/${id}/approve`, {}),
onSuccess: () => {
toast.success('متن پیامک تأیید شد');
qc.invalidateQueries({ queryKey: ['sms-post-visit-review'] });
},
onError: (err: Error) => toast.error(err.message),
});
const postVisitRejectMutation = useMutation({
mutationFn: ({ id, reason }: { id: number; reason: string }) =>
api.post(`/api/v1/admin/sms/settings/${id}/reject`, { reason }),
onSuccess: () => {
toast.success('متن پیامک رد شد');
setPostVisitRejectId(null);
setPostVisitRejectReason('');
qc.invalidateQueries({ queryKey: ['sms-post-visit-review'] });
},
onError: (err: Error) => toast.error(err.message),
});
const templateColumns: Column<SmsTemplate>[] = [
{ key: 'name', header: 'نام', render: (t) => <b>{t.name}</b> },
{
@@ -153,9 +182,12 @@ export default function SmsPage() {
setPage(1);
};
const postVisitPendingCount = (postVisitReviewQuery.data?.data as any)?.data?.length ?? 0;
const TABS: { key: Tab; label: string; badge?: number }[] = [
{ key: 'samples', label: 'قالب‌های نمونه' },
{ key: 'pending', label: 'در انتظار تأیید', badge: pendingCount },
{ key: 'post-visit-review', label: 'متن پیامک ویزیت', badge: postVisitPendingCount },
{ key: 'logs', label: 'لاگ‌های ارسال' },
];
@@ -262,6 +294,48 @@ export default function SmsPage() {
</>
)}
{activeTab === 'post-visit-review' && (
<div style={{ padding: '16px' }}>
{postVisitReviewQuery.isLoading ? (
<div className="muted" style={{ fontSize: 13 }}>در حال بارگذاری...</div>
) : ((postVisitReviewQuery.data?.data as any)?.data ?? []).length === 0 ? (
<div style={{ textAlign: 'center', padding: '32px 16px', color: 'var(--text-3)', fontSize: 13 }}>
متنی برای بررسی وجود ندارد
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{((postVisitReviewQuery.data?.data as any)?.data ?? []).map((item: any) => (
<div key={item.id} className="card card-pad">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 10 }}>
<div>
<span className="badge gray" style={{ fontSize: 11 }}>{item.entity_type} #{item.entity_id}</span>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
className="btn primary sm"
disabled={postVisitApproveMutation.isPending}
onClick={() => postVisitApproveMutation.mutate(item.id)}
>
<CheckIcon style={{ width: 14 }} /> تأیید
</button>
<button
className="btn danger sm"
onClick={() => { setPostVisitRejectId(item.id); setPostVisitRejectReason(''); }}
>
<XMarkIcon style={{ width: 14 }} /> رد
</button>
</div>
</div>
<div style={{ fontSize: 13, lineHeight: 1.8, padding: '10px 14px', background: 'var(--surface)', borderRadius: 8, border: '1px solid var(--border)', direction: 'rtl' }}>
{item.post_visit_text_pending}
</div>
</div>
))}
</div>
)}
</div>
)}
{activeTab === 'logs' && (
<>
<DataTable<SmsLog>
@@ -328,6 +402,28 @@ export default function SmsPage() {
</div>
</Modal>
<Modal open={!!postVisitRejectId} title="رد متن پیامک ویزیت"
onClose={() => { setPostVisitRejectId(null); setPostVisitRejectReason(''); }}
footer={
<>
<button onClick={() => { setPostVisitRejectId(null); setPostVisitRejectReason(''); }} className="btn ghost sm">لغو</button>
<button
onClick={() => postVisitRejectId && postVisitRejectMutation.mutate({ id: postVisitRejectId, reason: postVisitRejectReason })}
disabled={!postVisitRejectReason || postVisitRejectMutation.isPending}
className="btn danger sm">
رد کردن
</button>
</>
}
>
<div className="form-row">
<label>دلیل رد</label>
<textarea value={postVisitRejectReason} onChange={(e) => setPostVisitRejectReason(e.target.value)}
rows={4} placeholder="دلیل رد را بنویسید..."
className="input" style={{ resize: 'none', height: 'auto' }} />
</div>
</Modal>
<ConfirmDialog
open={!!approveTarget}
title="تأیید قالب پیامک"