feat: enhance various pages with improved UI elements and search functionality

This commit is contained in:
hamed
2026-06-10 13:20:21 +03:30
parent 06ab875693
commit 4b8504df91
8 changed files with 86 additions and 34 deletions
+1 -1
View File
@@ -56,7 +56,7 @@ export default function DataTable<T extends object>({
{(onSearchChange !== undefined || headerExtra) && ( {(onSearchChange !== undefined || headerExtra) && (
<div className="flex items-center justify-between gap-3 mb-4 flex-wrap"> <div className="flex items-center justify-between gap-3 mb-4 flex-wrap">
{onSearchChange !== undefined && ( {onSearchChange !== undefined && (
<div className="relative w-72 max-w-full"> <div className="relative w-full sm:w-72">
<MagnifyingGlassIcon className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 dark:text-slate-500 pointer-events-none" /> <MagnifyingGlassIcon className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 dark:text-slate-500 pointer-events-none" />
<input <input
type="text" type="text"
+2 -2
View File
@@ -48,7 +48,7 @@ export default function AppointmentsPage() {
render: (a) => ( render: (a) => (
<div> <div>
<p className="font-medium text-slate-800 dark:text-slate-100">{a.patient_name || '—'}</p> <p className="font-medium text-slate-800 dark:text-slate-100">{a.patient_name || '—'}</p>
<p className="text-xs text-gray-400 dir-ltr">{maskMobile(a.patient_mobile)}</p> <p className="text-xs text-gray-400" dir="ltr">{maskMobile(a.patient_mobile)}</p>
</div> </div>
), ),
}, },
@@ -88,7 +88,7 @@ export default function AppointmentsPage() {
/> />
<div className="cp-card p-6"> <div className="cp-card p-6">
<div className="flex items-center gap-3 mb-4 flex-wrap"> <div className="flex items-center gap-3 mb-4 overflow-x-auto pb-1 flex-nowrap sm:flex-wrap">
{STATUS_FILTERS.map((f) => ( {STATUS_FILTERS.map((f) => (
<button <button
key={f.value} key={f.value}
+2 -2
View File
@@ -151,11 +151,11 @@ export default function BlogFormPage() {
<div className="pt-4 space-y-3"> <div className="pt-4 space-y-3">
<button type="submit" disabled={isSubmitting} <button type="submit" disabled={isSubmitting}
className="w-full py-2.5 bg-primary-600 text-white text-sm font-medium rounded-[10px] hover:bg-primary-700 disabled:opacity-50 transition-colors"> className="cp-btn-primary w-full justify-center py-2.5">
{isSubmitting ? 'در حال ذخیره...' : isEdit ? 'بروزرسانی' : 'ذخیره'} {isSubmitting ? 'در حال ذخیره...' : isEdit ? 'بروزرسانی' : 'ذخیره'}
</button> </button>
<button type="button" onClick={() => navigate('/admin/blogs')} <button type="button" onClick={() => navigate('/admin/blogs')}
className="w-full py-2.5 border border-gray-300 text-sm text-gray-700 rounded-[10px] hover:bg-gray-50 transition-colors"> className="cp-btn-secondary w-full justify-center py-2.5">
لغو لغو
</button> </button>
</div> </div>
+19 -4
View File
@@ -23,6 +23,7 @@ export default function CommentsPage() {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [approvedFilter, setApprovedFilter] = useState('pending'); const [approvedFilter, setApprovedFilter] = useState('pending');
const [approveTarget, setApproveTarget] = useState<Comment | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Comment | null>(null); const [deleteTarget, setDeleteTarget] = useState<Comment | null>(null);
const limit = 15; const limit = 15;
@@ -40,6 +41,7 @@ export default function CommentsPage() {
mutationFn: (c: Comment) => api.post<ApiResponse<null>>(`/api/v1/admin/comment/${c.uuid}/approve`, {}), mutationFn: (c: Comment) => api.post<ApiResponse<null>>(`/api/v1/admin/comment/${c.uuid}/approve`, {}),
onSuccess: () => { onSuccess: () => {
toast.success('نظر تأیید شد'); toast.success('نظر تأیید شد');
setApproveTarget(null);
qc.invalidateQueries({ queryKey: ['comments'] }); qc.invalidateQueries({ queryKey: ['comments'] });
}, },
onError: (err: Error) => toast.error(err.message), onError: (err: Error) => toast.error(err.message),
@@ -67,7 +69,7 @@ export default function CommentsPage() {
key: 'body', key: 'body',
header: 'متن', header: 'متن',
render: (c) => ( render: (c) => (
<span className="text-gray-500 text-xs line-clamp-2 max-w-xs">{c.body}</span> <span className="text-slate-500 dark:text-slate-400 text-xs line-clamp-2 max-w-xs block">{c.body}</span>
), ),
}, },
{ {
@@ -111,8 +113,11 @@ export default function CommentsPage() {
actions={(comment) => ( actions={(comment) => (
<> <>
{!comment.is_approved && ( {!comment.is_approved && (
<button onClick={() => approveMutation.mutate(comment)} <button
className="p-1.5 text-gray-400 hover:text-green-600 hover:bg-green-50 rounded-lg transition-colors" title="تأیید"> onClick={() => setApproveTarget(comment)}
className="cp-action-approve"
title="تأیید"
>
<CheckIcon className="w-4 h-4" /> <CheckIcon className="w-4 h-4" />
</button> </button>
)} )}
@@ -126,10 +131,20 @@ export default function CommentsPage() {
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} /> <Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
</div> </div>
<ConfirmDialog
open={!!approveTarget}
title="تأیید نظر"
message={`نظر "${approveTarget?.title}" از ${approveTarget?.patient_name} را تأیید می‌کنید؟`}
confirmLabel="تأیید"
loading={approveMutation.isPending}
onConfirm={() => approveTarget && approveMutation.mutate(approveTarget)}
onCancel={() => setApproveTarget(null)}
/>
<ConfirmDialog <ConfirmDialog
open={!!deleteTarget} open={!!deleteTarget}
title="حذف نظر" title="حذف نظر"
message="آیا از حذف این نظر اطمینان دارید؟" message={`آیا از حذف نظر "${deleteTarget?.title}" از ${deleteTarget?.patient_name} اطمینان دارید؟`}
confirmLabel="حذف" confirmLabel="حذف"
danger danger
loading={deleteMutation.isPending} loading={deleteMutation.isPending}
+17 -5
View File
@@ -54,10 +54,22 @@ export default function RatingsPage() {
const columns: Column<Rating>[] = [ const columns: Column<Rating>[] = [
{ key: 'patient_name', header: 'بیمار', render: (r) => <span className="font-medium">{r.patient_name}</span> }, { key: 'patient_name', header: 'بیمار', render: (r) => <span className="font-medium">{r.patient_name}</span> },
{ key: 'doctor_name', header: 'پزشک', render: (r) => `دکتر ${r.doctor_name}` }, { key: 'doctor_name', header: 'پزشک', render: (r) => `دکتر ${r.doctor_name}` },
{ key: 'overall', header: 'کلی', render: (r) => <Stars value={r.overall} /> }, {
{ key: 'diagnosis_accuracy', header: 'صحت تشخیص', render: (r) => <Stars value={r.diagnosis_accuracy} /> }, key: 'overall',
{ key: 'skill', header: 'مهارت', render: (r) => <Stars value={r.skill} /> }, header: 'کلی',
{ key: 'behavior', header: 'رفتار', render: (r) => <Stars value={r.behavior} /> }, render: (r) => <Stars value={r.overall} />,
},
{
key: 'ratings_detail',
header: 'جزئیات امتیاز',
render: (r) => (
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-slate-500 dark:text-slate-400 min-w-[160px]">
<span>تشخیص:</span><Stars value={r.diagnosis_accuracy} />
<span>مهارت:</span><Stars value={r.skill} />
<span>رفتار:</span><Stars value={r.behavior} />
</div>
),
},
{ key: 'created_at', header: 'تاریخ', render: (r) => formatDate(r.created_at) }, { key: 'created_at', header: 'تاریخ', render: (r) => formatDate(r.created_at) },
]; ];
@@ -93,7 +105,7 @@ export default function RatingsPage() {
<ConfirmDialog <ConfirmDialog
open={!!deleteTarget} open={!!deleteTarget}
title="حذف امتیاز" title="حذف امتیاز"
message="آیا از حذف این امتیاز اطمینان دارید؟" message={`آیا از حذف امتیاز ${deleteTarget?.patient_name} برای دکتر ${deleteTarget?.doctor_name} اطمینان دارید؟`}
confirmLabel="حذف" confirmLabel="حذف"
danger danger
loading={deleteMutation.isPending} loading={deleteMutation.isPending}
+13 -5
View File
@@ -25,6 +25,7 @@ export default function SettlementsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const qc = useQueryClient(); const qc = useQueryClient();
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('pending'); const [statusFilter, setStatusFilter] = useState('pending');
const [approveTarget, setApproveTarget] = useState<Settlement | null>(null); const [approveTarget, setApproveTarget] = useState<Settlement | null>(null);
const [rejectTarget, setRejectTarget] = useState<Settlement | null>(null); const [rejectTarget, setRejectTarget] = useState<Settlement | null>(null);
@@ -32,10 +33,11 @@ export default function SettlementsPage() {
const limit = 15; const limit = 15;
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['settlements', page, statusFilter], queryKey: ['settlements', page, search, statusFilter],
queryFn: () => { queryFn: () => {
const params = new URLSearchParams({ page: String(page), limit: String(limit) }); const params = new URLSearchParams({ page: String(page), limit: String(limit) });
if (statusFilter) params.set('status', statusFilter); if (statusFilter) params.set('status', statusFilter);
if (search) params.set('search', search);
return api.get<PaginatedResponse<Settlement>>(`/api/v1/admin/settlements?${params}`); return api.get<PaginatedResponse<Settlement>>(`/api/v1/admin/settlements?${params}`);
}, },
}); });
@@ -83,7 +85,7 @@ export default function SettlementsPage() {
/> />
<div className="cp-card p-6"> <div className="cp-card p-6">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4 flex-wrap">
{STATUS_FILTERS.map((f) => ( {STATUS_FILTERS.map((f) => (
<button key={f.value} onClick={() => { setStatusFilter(f.value); setPage(1); }} <button key={f.value} onClick={() => { setStatusFilter(f.value); setPage(1); }}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${ className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
@@ -98,6 +100,9 @@ export default function SettlementsPage() {
columns={columns} columns={columns}
data={items} data={items}
loading={isLoading} loading={isLoading}
searchValue={search}
onSearchChange={(v) => { setSearch(v); setPage(1); }}
searchPlaceholder="جستجو بر اساس نام نماینده..."
emptyMessage="هیچ درخواست تسویه‌ای یافت نشد" emptyMessage="هیچ درخواست تسویه‌ای یافت نشد"
actions={(s) => ( actions={(s) => (
<> <>
@@ -107,8 +112,11 @@ export default function SettlementsPage() {
</button> </button>
{s.status === 'pending' && ( {s.status === 'pending' && (
<> <>
<button onClick={() => setApproveTarget(s)} <button
className="p-1.5 text-gray-400 hover:text-green-600 hover:bg-green-50 rounded-lg transition-colors" title="تأیید"> onClick={() => setApproveTarget(s)}
className="cp-action-approve"
title="تأیید"
>
<CheckIcon className="w-4 h-4" /> <CheckIcon className="w-4 h-4" />
</button> </button>
<button onClick={() => setRejectTarget(s)} <button onClick={() => setRejectTarget(s)}
@@ -126,7 +134,7 @@ export default function SettlementsPage() {
<ConfirmDialog <ConfirmDialog
open={!!approveTarget} open={!!approveTarget}
title="تأیید تسویه" title="تأیید تسویه"
message={`آیا از تأیید درخواست تسویه ${approveTarget?.representation_name} به مبلغ ${approveTarget ? formatRial(approveTarget.amount) : ''} اطمینان دارید؟`} message={`تسویه ${approveTarget?.representation_name} به مبلغ ${approveTarget ? formatRial(approveTarget.amount) : ''} را تأیید می‌کنید؟`}
confirmLabel="تأیید" confirmLabel="تأیید"
loading={approveMutation.isPending} loading={approveMutation.isPending}
onConfirm={() => approveTarget && approveMutation.mutate(approveTarget)} onConfirm={() => approveTarget && approveMutation.mutate(approveTarget)}
+28 -12
View File
@@ -28,6 +28,7 @@ export default function SmsPage() {
const qc = useQueryClient(); const qc = useQueryClient();
const [activeTab, setActiveTab] = useState<Tab>('samples'); const [activeTab, setActiveTab] = useState<Tab>('samples');
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [approveTarget, setApproveTarget] = useState<SmsTemplate | null>(null);
const [rejectTarget, setRejectTarget] = useState<SmsTemplate | null>(null); const [rejectTarget, setRejectTarget] = useState<SmsTemplate | null>(null);
const [rejectReason, setRejectReason] = useState(''); const [rejectReason, setRejectReason] = useState('');
const [addOpen, setAddOpen] = useState(false); const [addOpen, setAddOpen] = useState(false);
@@ -44,12 +45,11 @@ export default function SmsPage() {
}); });
const pendingTemplatesQuery = useQuery({ const pendingTemplatesQuery = useQuery({
queryKey: ['sms-pending', page], queryKey: ['sms-pending', activeTab === 'pending' ? page : 1],
queryFn: () => queryFn: () =>
api.get<PaginatedResponse<SmsTemplate>>( api.get<PaginatedResponse<SmsTemplate>>(
`/api/v1/admin/sms/sample-templates?status=pending&page=${page}&limit=${limit}`, `/api/v1/admin/sms/sample-templates?status=pending&page=${activeTab === 'pending' ? page : 1}&limit=${limit}`,
), ),
enabled: activeTab === 'pending',
}); });
const logsQuery = useQuery({ const logsQuery = useQuery({
@@ -80,6 +80,7 @@ export default function SmsPage() {
api.post<ApiResponse<null>>(`/api/v1/admin/sms/template/${t.uuid}/approve`, {}), api.post<ApiResponse<null>>(`/api/v1/admin/sms/template/${t.uuid}/approve`, {}),
onSuccess: () => { onSuccess: () => {
toast.success('قالب تأیید شد'); toast.success('قالب تأیید شد');
setApproveTarget(null);
qc.invalidateQueries({ queryKey: ['sms-pending'] }); qc.invalidateQueries({ queryKey: ['sms-pending'] });
}, },
onError: (err: Error) => toast.error(err.message), onError: (err: Error) => toast.error(err.message),
@@ -145,11 +146,7 @@ export default function SmsPage() {
{ key: 'sent_at', header: 'زمان ارسال', render: (l) => formatDateTime(l.sent_at) }, { key: 'sent_at', header: 'زمان ارسال', render: (l) => formatDateTime(l.sent_at) },
]; ];
const tabs: { key: Tab; label: string }[] = [ const pendingCount = pendingTemplatesQuery.data?.meta?.totalRecords ?? 0;
{ key: 'samples', label: 'قالب‌های نمونه' },
{ key: 'pending', label: 'در انتظار تأیید' },
{ key: 'logs', label: 'لاگ‌های ارسال' },
];
const handleTabChange = (tab: Tab) => { const handleTabChange = (tab: Tab) => {
setActiveTab(tab); setActiveTab(tab);
@@ -174,14 +171,23 @@ export default function SmsPage() {
<div className="cp-card"> <div className="cp-card">
<div className="flex border-b border-slate-200 dark:border-gray-700 px-6 pt-4"> <div className="flex border-b border-slate-200 dark:border-gray-700 px-6 pt-4">
{tabs.map((t) => ( {([
{ key: 'samples' as Tab, label: 'قالب‌های نمونه' },
{ key: 'pending' as Tab, label: 'در انتظار تأیید', badge: pendingCount },
{ key: 'logs' as Tab, label: 'لاگ‌های ارسال' },
]).map((t) => (
<button key={t.key} onClick={() => handleTabChange(t.key)} <button key={t.key} onClick={() => handleTabChange(t.key)}
className={`pb-3 px-4 text-sm font-medium border-b-2 transition-colors -mb-px ${ className={`pb-3 px-4 text-sm font-medium border-b-2 transition-colors -mb-px flex items-center gap-1.5 ${
activeTab === t.key activeTab === t.key
? 'border-primary-600 text-primary-600' ? 'border-primary-600 text-primary-600'
: 'border-transparent text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200' : 'border-transparent text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200'
}`}> }`}>
{t.label} {t.label}
{t.badge != null && t.badge > 0 && (
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full text-[10px] font-bold bg-orange-500 text-white leading-none">
{t.badge}
</span>
)}
</button> </button>
))} ))}
</div> </div>
@@ -219,8 +225,8 @@ export default function SmsPage() {
emptyMessage="هیچ قالبی در انتظار تأیید نیست" emptyMessage="هیچ قالبی در انتظار تأیید نیست"
actions={(t) => ( actions={(t) => (
<> <>
<button onClick={() => approveMutation.mutate(t)} <button onClick={() => setApproveTarget(t)}
className="p-1.5 text-gray-400 hover:text-green-600 hover:bg-green-50 rounded-lg transition-colors" title="تأیید"> className="cp-action-approve" title="تأیید">
<CheckIcon className="w-4 h-4" /> <CheckIcon className="w-4 h-4" />
</button> </button>
<button onClick={() => setRejectTarget(t)} <button onClick={() => setRejectTarget(t)}
@@ -311,6 +317,16 @@ export default function SmsPage() {
className="cp-textarea resize-none" /> className="cp-textarea resize-none" />
</Modal> </Modal>
<ConfirmDialog
open={!!approveTarget}
title="تأیید قالب پیامک"
message={`قالب "${approveTarget?.name}" را تأیید می‌کنید؟`}
confirmLabel="تأیید"
loading={approveMutation.isPending}
onConfirm={() => approveTarget && approveMutation.mutate(approveTarget)}
onCancel={() => setApproveTarget(null)}
/>
<ConfirmDialog <ConfirmDialog
open={!!deleteTarget} open={!!deleteTarget}
title="حذف قالب" title="حذف قالب"
+4 -3
View File
@@ -126,9 +126,10 @@ body {
} }
/* Table action buttons */ /* Table action buttons */
.cp-action-view { @apply p-1.5 rounded-lg text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-500/10 dark:hover:text-blue-400 transition-colors; } .cp-action-view { @apply p-1.5 rounded-lg text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-500/10 dark:hover:text-blue-400 transition-colors; }
.cp-action-edit { @apply p-1.5 rounded-lg text-slate-400 hover:text-primary-600 hover:bg-primary-50 dark:hover:bg-primary-500/10 dark:hover:text-primary-400 transition-colors; } .cp-action-edit { @apply p-1.5 rounded-lg text-slate-400 hover:text-primary-600 hover:bg-primary-50 dark:hover:bg-primary-500/10 dark:hover:text-primary-400 transition-colors; }
.cp-action-delete { @apply p-1.5 rounded-lg text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-500/10 dark:hover:text-red-400 transition-colors; } .cp-action-delete { @apply p-1.5 rounded-lg text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-500/10 dark:hover:text-red-400 transition-colors; }
.cp-action-approve { @apply p-1.5 rounded-lg text-slate-400 hover:text-green-600 hover:bg-green-50 dark:hover:bg-green-500/10 dark:hover:text-green-400 transition-colors; }
/* Stat card — cp-card inlined to avoid @apply on component class */ /* Stat card — cp-card inlined to avoid @apply on component class */
.cp-stat { .cp-stat {