feat: add Settlements, SMS, User detail, and Users management pages
- Implement SettlementsPage for managing settlement requests with approval and rejection functionalities. - Create SmsPage for handling SMS templates, including creation, approval, rejection, and logging. - Add UserDetailPage to display detailed information about users. - Develop UsersPage for listing users with search, view, edit, and delete options. - Introduce new types for User, SmsTemplate, SmsLog, and Settlement to support the new features.
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
danger?: boolean;
|
||||
loading?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = 'تأیید',
|
||||
cancelLabel = 'لغو',
|
||||
danger = false,
|
||||
loading = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: Props) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/40" onClick={onCancel} />
|
||||
<div
|
||||
className="relative bg-white rounded-2xl shadow-2xl w-full max-w-sm mx-4 p-6"
|
||||
style={{ animation: 'scale-in 200ms ease' }}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`shrink-0 w-10 h-10 rounded-full flex items-center justify-center ${danger ? 'bg-red-100' : 'bg-yellow-100'}`}>
|
||||
<ExclamationTriangleIcon className={`w-5 h-5 ${danger ? 'text-red-600' : 'text-yellow-600'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 text-base">{title}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 mt-6 justify-end">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 rounded-[10px] border border-gray-300 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={loading}
|
||||
className={`px-4 py-2 rounded-[10px] text-sm text-white font-medium disabled:opacity-50 transition-colors ${
|
||||
danger
|
||||
? 'bg-red-600 hover:bg-red-700'
|
||||
: 'bg-primary-600 hover:bg-primary-700'
|
||||
}`}
|
||||
>
|
||||
{loading ? 'در حال انجام...' : confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import React from 'react';
|
||||
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
export interface Column<T> {
|
||||
key: string;
|
||||
header: string;
|
||||
render?: (row: T) => React.ReactNode;
|
||||
sortable?: boolean;
|
||||
}
|
||||
|
||||
interface Props<T> {
|
||||
columns: Column<T>[];
|
||||
data: T[];
|
||||
loading?: boolean;
|
||||
searchValue?: string;
|
||||
onSearchChange?: (v: string) => void;
|
||||
searchPlaceholder?: string;
|
||||
actions?: (row: T) => React.ReactNode;
|
||||
emptyMessage?: string;
|
||||
emptyAction?: React.ReactNode;
|
||||
}
|
||||
|
||||
function SkeletonRow({ cols }: { cols: number }) {
|
||||
return (
|
||||
<tr>
|
||||
{Array.from({ length: cols }).map((_, i) => (
|
||||
<td key={i} className="px-4 py-3">
|
||||
<div className="h-4 bg-gray-200 rounded animate-pulse w-full" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DataTable<T extends object>({
|
||||
columns,
|
||||
data,
|
||||
loading,
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
searchPlaceholder = 'جستجو...',
|
||||
actions,
|
||||
emptyMessage = 'هیچ موردی یافت نشد',
|
||||
emptyAction,
|
||||
}: Props<T>) {
|
||||
const allColumns = actions
|
||||
? [...columns, { key: '__actions', header: 'اقدامات' }]
|
||||
: columns;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{onSearchChange !== undefined && (
|
||||
<div className="mb-4">
|
||||
<div className="relative max-w-xs">
|
||||
<MagnifyingGlassIcon className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchValue}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
className="w-full h-10 pr-9 pl-3 border border-gray-300 rounded-[10px] text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border border-gray-200">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
{allColumns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className="px-4 py-3 text-right text-xs font-semibold text-gray-500 uppercase tracking-wide whitespace-nowrap"
|
||||
>
|
||||
{col.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{loading ? (
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<SkeletonRow key={i} cols={allColumns.length} />
|
||||
))
|
||||
) : data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={allColumns.length} className="text-center py-16">
|
||||
<div className="flex flex-col items-center gap-3 text-gray-400">
|
||||
<svg className="w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
<p className="text-sm">{emptyMessage}</p>
|
||||
{emptyAction}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((row, rowIdx) => (
|
||||
<tr key={rowIdx} className="hover:bg-gray-50 transition-colors">
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className="px-4 py-3 text-gray-700 whitespace-nowrap">
|
||||
{col.render
|
||||
? col.render(row)
|
||||
: ((row as Record<string, unknown>)[col.key] as React.ReactNode) ?? '—'}
|
||||
</td>
|
||||
))}
|
||||
{actions && (
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">{actions(row)}</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
type ModalSize = 'sm' | 'md' | 'lg' | 'xl';
|
||||
|
||||
const sizeMap: Record<ModalSize, string> = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-4xl',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
title: string;
|
||||
size?: ModalSize;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function Modal({ open, title, size = 'md', onClose, children, footer }: Props) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
|
||||
<div
|
||||
className={`relative bg-white rounded-2xl shadow-2xl w-full ${sizeMap[size]} flex flex-col max-h-[90vh]`}
|
||||
style={{ animation: 'scale-in 200ms ease' }}
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 shrink-0">
|
||||
<h2 className="font-semibold text-gray-900 text-base">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded-md hover:bg-gray-100"
|
||||
>
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">{children}</div>
|
||||
{footer && (
|
||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 shrink-0">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
interface Crumb {
|
||||
label: string;
|
||||
to?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
breadcrumbs?: Crumb[];
|
||||
action?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function PageHeader({ title, breadcrumbs, action }: Props) {
|
||||
return (
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900">{title}</h1>
|
||||
{breadcrumbs && breadcrumbs.length > 0 && (
|
||||
<nav className="flex items-center gap-1 mt-1 text-sm text-gray-500">
|
||||
{breadcrumbs.map((crumb, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && <span className="text-gray-300">/</span>}
|
||||
{crumb.to ? (
|
||||
<Link to={crumb.to} className="hover:text-primary-600 transition-colors">
|
||||
{crumb.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-gray-700">{crumb.label}</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
{action && <div>{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import { ChevronRightIcon, ChevronLeftIcon } from '@heroicons/react/24/outline';
|
||||
import { formatNumber } from '../../lib/utils';
|
||||
|
||||
interface Props {
|
||||
page: number;
|
||||
total: number;
|
||||
limit: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export default function Pagination({ page, total, limit, onPageChange }: Props) {
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
const from = (page - 1) * limit + 1;
|
||||
const to = Math.min(page * limit, total);
|
||||
|
||||
const pages: (number | '...')[] = [];
|
||||
if (totalPages <= 7) {
|
||||
for (let i = 1; i <= totalPages; i++) pages.push(i);
|
||||
} else {
|
||||
pages.push(1);
|
||||
if (page > 3) pages.push('...');
|
||||
for (let i = Math.max(2, page - 1); i <= Math.min(totalPages - 1, page + 1); i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
if (page < totalPages - 2) pages.push('...');
|
||||
pages.push(totalPages);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between mt-4 text-sm">
|
||||
<span className="text-gray-500">
|
||||
نمایش {formatNumber(from)}–{formatNumber(to)} از {formatNumber(total)}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={page === 1}
|
||||
className="p-1.5 rounded-lg hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronRightIcon className="w-4 h-4" />
|
||||
</button>
|
||||
{pages.map((p, i) =>
|
||||
p === '...' ? (
|
||||
<span key={`dots-${i}`} className="px-2 text-gray-400">...</span>
|
||||
) : (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => onPageChange(p as number)}
|
||||
className={`w-8 h-8 rounded-lg text-sm transition-colors ${
|
||||
p === page
|
||||
? 'bg-primary-600 text-white font-semibold'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{formatNumber(p as number)}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<button
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page === totalPages}
|
||||
className="p-1.5 rounded-lg hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronLeftIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import React from 'react';
|
||||
import type { AppointmentStatus, PaymentStatus, SmsTemplateStatus, SettlementStatus } from '../../types';
|
||||
|
||||
const variants: Record<string, string> = {
|
||||
yellow: 'bg-yellow-100 text-yellow-800',
|
||||
blue: 'bg-blue-100 text-blue-800',
|
||||
purple: 'bg-purple-100 text-purple-800',
|
||||
orange: 'bg-orange-100 text-orange-800',
|
||||
indigo: 'bg-indigo-100 text-indigo-800',
|
||||
green: 'bg-green-100 text-green-800',
|
||||
red: 'bg-red-100 text-red-800',
|
||||
gray: 'bg-gray-100 text-gray-700',
|
||||
rose: 'bg-rose-100 text-rose-800',
|
||||
};
|
||||
|
||||
const dots: Record<string, string> = {
|
||||
yellow: 'bg-yellow-500',
|
||||
blue: 'bg-blue-500',
|
||||
purple: 'bg-purple-500',
|
||||
orange: 'bg-orange-500',
|
||||
indigo: 'bg-indigo-500',
|
||||
green: 'bg-green-500',
|
||||
red: 'bg-red-500',
|
||||
gray: 'bg-gray-400',
|
||||
rose: 'bg-rose-500',
|
||||
};
|
||||
|
||||
const appointmentMap: Record<AppointmentStatus, { color: string; label: string }> = {
|
||||
waiting_for_payment: { color: 'yellow', label: 'در انتظار پرداخت' },
|
||||
reserved: { color: 'blue', label: 'رزرو شده' },
|
||||
checked_in: { color: 'purple', label: 'ورود به مطب' },
|
||||
waiting: { color: 'orange', label: 'در صف انتظار' },
|
||||
in_progress: { color: 'indigo', label: 'در حال ویزیت' },
|
||||
visited: { color: 'green', label: 'ویزیت شده' },
|
||||
completed: { color: 'green', label: 'تکمیل شده' },
|
||||
cancelled_by_user: { color: 'red', label: 'لغو توسط بیمار' },
|
||||
cancelled_by_doctor: { color: 'red', label: 'لغو توسط پزشک' },
|
||||
cancelled_by_admin: { color: 'red', label: 'لغو توسط ادمین' },
|
||||
auto_cancel_unpaid: { color: 'gray', label: 'لغو خودکار' },
|
||||
no_show: { color: 'rose', label: 'غیبت' },
|
||||
};
|
||||
|
||||
const paymentMap: Record<PaymentStatus, { color: string; label: string }> = {
|
||||
pending: { color: 'yellow', label: 'در انتظار' },
|
||||
received: { color: 'green', label: 'موفق' },
|
||||
canceled: { color: 'red', label: 'لغو شده' },
|
||||
refund: { color: 'blue', label: 'استرداد' },
|
||||
};
|
||||
|
||||
const smsMap: Record<SmsTemplateStatus, { color: string; label: string }> = {
|
||||
draft: { color: 'gray', label: 'پیشنویس' },
|
||||
pending_approval: { color: 'yellow', label: 'در انتظار تأیید' },
|
||||
approved: { color: 'green', label: 'تأیید شده' },
|
||||
rejected: { color: 'red', label: 'رد شده' },
|
||||
};
|
||||
|
||||
const settlementMap: Record<SettlementStatus, { color: string; label: string }> = {
|
||||
pending: { color: 'yellow', label: 'در انتظار' },
|
||||
approved: { color: 'green', label: 'تأیید شده' },
|
||||
rejected: { color: 'red', label: 'رد شده' },
|
||||
};
|
||||
|
||||
interface Props {
|
||||
type: 'appointment' | 'payment' | 'sms' | 'settlement' | 'active';
|
||||
value: string;
|
||||
}
|
||||
|
||||
export default function StatusBadge({ type, value }: Props) {
|
||||
let color = 'gray';
|
||||
let label = value;
|
||||
|
||||
if (type === 'appointment') {
|
||||
const m = appointmentMap[value as AppointmentStatus];
|
||||
if (m) { color = m.color; label = m.label; }
|
||||
} else if (type === 'payment') {
|
||||
const m = paymentMap[value as PaymentStatus];
|
||||
if (m) { color = m.color; label = m.label; }
|
||||
} else if (type === 'sms') {
|
||||
const m = smsMap[value as SmsTemplateStatus];
|
||||
if (m) { color = m.color; label = m.label; }
|
||||
} else if (type === 'settlement') {
|
||||
const m = settlementMap[value as SettlementStatus];
|
||||
if (m) { color = m.color; label = m.label; }
|
||||
} else if (type === 'active') {
|
||||
color = value === 'true' || value === 'active' ? 'green' : 'gray';
|
||||
label = value === 'true' || value === 'active' ? 'فعال' : 'غیرفعال';
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium ${variants[color] ?? variants.gray}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${dots[color] ?? dots.gray}`} />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActiveBadge({ active }: { active: boolean }) {
|
||||
return (
|
||||
<StatusBadge type="active" value={active ? 'active' : 'inactive'} />
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user