Files
clinicpro/assets/admin/pages/SettlementsPage.tsx
T
hamed 684cf1f783 feat: implement useUrlState hook for managing URL-based state in admin pages
- Refactor multiple admin pages (BlogsPage, ClinicsPage, DoctorsPage, etc.) to utilize the new useUrlState hook for managing pagination, search, and filter states via URL.
- Ensure that the state persists in the URL, allowing users to return to the same state when navigating back from detail pages.
- Update relevant components to handle state changes appropriately and maintain clean URLs by removing default values.
- Add SlotPicker component for selecting appointment slots based on availability.
- Create tests for useUrlState to validate its functionality and ensure correct behavior when interacting with the URL.
- Update API documentation to reflect changes in appointment creation and slot selection processes.
2026-07-29 21:04:40 +03:30

188 lines
7.8 KiB
TypeScript

import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { EyeIcon, CheckIcon, XMarkIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import { useUrlState, pageOf } from '../hooks/useUrlState';
import type { ApiResponse, PaginatedResponse } from '../lib/api';
import type { Settlement } from '../types';
import { formatDate, formatRial } from '../lib/utils';
import DataTable, { Column } from '../components/ui/DataTable';
import StatusBadge from '../components/ui/StatusBadge';
import Pagination from '../components/ui/Pagination';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import Modal from '../components/ui/Modal';
const STATUS_FILTERS = [
{ value: '', label: 'همه' },
{ value: 'pending', label: 'در انتظار' },
{ value: 'approved', label: 'تأیید شده' },
{ value: 'paid', label: 'پرداخت شده' },
{ value: 'rejected', label: 'رد شده' },
];
export default function SettlementsPage() {
const navigate = useNavigate();
const qc = useQueryClient();
// وضعیت لیست در URL می‌ماند تا «بازگشت» از صفحهٔ جزئیات، همین فیلترها و صفحه را برگرداند.
const [urlState, setUrlState] = useUrlState({ page: '1', search: '', status: 'pending' });
const page = pageOf(urlState.page);
const search = urlState.search;
const statusFilter = urlState.status;
const setPage = (p: number) => setUrlState({ page: String(p) });
const setSearch = (v: string) => setUrlState({ search: v, page: '1' });
const setStatusFilter = (v: string) => setUrlState({ status: v, page: '1' });
const [approveTarget, setApproveTarget] = useState<Settlement | null>(null);
const [rejectTarget, setRejectTarget] = useState<Settlement | null>(null);
const [rejectReason, setRejectReason] = useState('');
const limit = 15;
const { data, isLoading } = useQuery({
queryKey: ['settlements', page, search, statusFilter],
queryFn: () => {
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
if (statusFilter) params.set('status', statusFilter);
if (search) params.set('search', search);
return api.get<PaginatedResponse<Settlement>>(`/api/v1/admin/settlements?${params}`);
},
});
const approveMutation = useMutation({
mutationFn: (s: Settlement) =>
api.post<ApiResponse<null>>(`/api/v1/settlement/${s.uuid}/approve`, {}),
onSuccess: () => {
toast.success('تسویه تأیید شد');
setApproveTarget(null);
qc.invalidateQueries({ queryKey: ['settlements'] });
},
onError: (err: Error) => toast.error(err.message),
});
const rejectMutation = useMutation({
mutationFn: ({ s, reason }: { s: Settlement; reason: string }) =>
api.post<ApiResponse<null>>(`/api/v1/settlement/${s.uuid}/reject`, { reason }),
onSuccess: () => {
toast.success('تسویه رد شد');
setRejectTarget(null);
setRejectReason('');
qc.invalidateQueries({ queryKey: ['settlements'] });
},
onError: (err: Error) => toast.error(err.message),
});
const columns: Column<Settlement>[] = [
{ key: 'representation_name', header: 'نماینده', render: (s) => <b>{s.representation_name}</b> },
{ key: 'amount', header: 'مبلغ', render: (s) => formatRial(s.amount) },
{ key: 'bank_card', header: 'شماره کارت', render: (s) => s.bank_card ? <span dir="ltr" style={{ fontFamily: 'monospace', fontSize: 12 }}>{s.bank_card}</span> : '—' },
{ key: 'bank_name', header: 'بانک' },
{ key: 'status', header: 'وضعیت', render: (s) => <StatusBadge type="settlement" value={s.status} /> },
{ key: 'requested_at', header: 'تاریخ درخواست', render: (s) => formatDate(s.requested_at) },
];
const items = data?.data ?? [];
const total = data?.meta?.totalRecords ?? 0;
return (
<div className="fade-in">
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
<div>
<h1 className="section-title">تسویه‌حساب</h1>
<div className="muted">{total} درخواست تسویه</div>
</div>
</div>
<div className="card">
<div className="card-pad" style={{ paddingBottom: 0 }}>
<div className="toolbar">
<div className="field" style={{ minWidth: 240 }}>
<MagnifyingGlassIcon style={{ width: 17, height: 17 }} />
<input
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
placeholder="جستجو بر اساس نام نماینده..."
/>
</div>
<div className="seg">
{STATUS_FILTERS.map((f) => (
<button
key={f.value}
className={statusFilter === f.value ? 'on' : ''}
onClick={() => { setStatusFilter(f.value); setPage(1); }}
>
{f.label}
</button>
))}
</div>
</div>
</div>
<DataTable<Settlement>
columns={columns}
data={items}
loading={isLoading}
emptyMessage="هیچ درخواست تسویه‌ای یافت نشد"
actions={(s) => (
<>
<button onClick={() => navigate(`/admin/settlements/${s.uuid}`)}
className="mini-btn" title="مشاهده">
<EyeIcon style={{ width: 15, height: 15 }} />
</button>
{s.status === 'pending' && (
<>
<button onClick={() => setApproveTarget(s)} className="mini-btn" title="تأیید">
<CheckIcon style={{ width: 15, height: 15 }} />
</button>
<button onClick={() => setRejectTarget(s)} className="mini-btn danger" title="رد">
<XMarkIcon style={{ width: 15, height: 15 }} />
</button>
</>
)}
</>
)}
/>
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
</div>
<ConfirmDialog
open={!!approveTarget}
title="تأیید تسویه"
message={`تسویه ${approveTarget?.representation_name} به مبلغ ${approveTarget ? formatRial(approveTarget.amount) : ''} را تأیید می‌کنید؟`}
confirmLabel="تأیید"
loading={approveMutation.isPending}
onConfirm={() => approveTarget && approveMutation.mutate(approveTarget)}
onCancel={() => setApproveTarget(null)}
/>
<Modal
open={!!rejectTarget}
title="رد درخواست تسویه"
onClose={() => { setRejectTarget(null); setRejectReason(''); }}
footer={
<>
<button onClick={() => { setRejectTarget(null); setRejectReason(''); }}
className="btn ghost sm">لغو</button>
<button
onClick={() => rejectTarget && rejectMutation.mutate({ s: rejectTarget, reason: rejectReason })}
disabled={!rejectReason || rejectMutation.isPending}
className="btn danger sm">
{rejectMutation.isPending ? 'در حال ارسال...' : 'رد کردن'}
</button>
</>
}
>
<div className="form-row">
<label>دلیل رد</label>
<textarea
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
rows={4}
placeholder="دلیل رد درخواست را بنویسید..."
className="input"
style={{ resize: 'none', height: 'auto' }}
/>
</div>
</Modal>
</div>
);
}