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:
@@ -4,6 +4,25 @@ import { useAuthStore } from './stores/authStore';
|
||||
import AdminLayout from './components/layout/AdminLayout';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import UsersPage from './pages/UsersPage';
|
||||
import UserDetailPage from './pages/UserDetailPage';
|
||||
import DoctorsPage from './pages/DoctorsPage';
|
||||
import DoctorDetailPage from './pages/DoctorDetailPage';
|
||||
import ClinicsPage from './pages/ClinicsPage';
|
||||
import ClinicDetailPage from './pages/ClinicDetailPage';
|
||||
import AppointmentsPage from './pages/AppointmentsPage';
|
||||
import AppointmentDetailPage from './pages/AppointmentDetailPage';
|
||||
import PaymentsPage from './pages/PaymentsPage';
|
||||
import PaymentDetailPage from './pages/PaymentDetailPage';
|
||||
import SettlementsPage from './pages/SettlementsPage';
|
||||
import RepresentationsPage from './pages/RepresentationsPage';
|
||||
import CommentsPage from './pages/CommentsPage';
|
||||
import RatingsPage from './pages/RatingsPage';
|
||||
import SmsPage from './pages/SmsPage';
|
||||
import CategoriesPage from './pages/CategoriesPage';
|
||||
import BlogsPage from './pages/BlogsPage';
|
||||
import BlogFormPage from './pages/BlogFormPage';
|
||||
import SecretariesPage from './pages/SecretariesPage';
|
||||
|
||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
@@ -36,6 +55,50 @@ export default function App() {
|
||||
>
|
||||
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
|
||||
{/* Users */}
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="users/:uuid" element={<UserDetailPage />} />
|
||||
|
||||
{/* Doctors */}
|
||||
<Route path="doctors" element={<DoctorsPage />} />
|
||||
<Route path="doctors/:uuid" element={<DoctorDetailPage />} />
|
||||
|
||||
{/* Clinics */}
|
||||
<Route path="clinics" element={<ClinicsPage />} />
|
||||
<Route path="clinics/:uuid" element={<ClinicDetailPage />} />
|
||||
|
||||
{/* Appointments */}
|
||||
<Route path="appointments" element={<AppointmentsPage />} />
|
||||
<Route path="appointments/:uuid" element={<AppointmentDetailPage />} />
|
||||
|
||||
{/* Payments */}
|
||||
<Route path="payments" element={<PaymentsPage />} />
|
||||
<Route path="payments/:uuid" element={<PaymentDetailPage />} />
|
||||
|
||||
{/* Settlements */}
|
||||
<Route path="settlements" element={<SettlementsPage />} />
|
||||
|
||||
{/* Representations */}
|
||||
<Route path="representations" element={<RepresentationsPage />} />
|
||||
|
||||
{/* Comments & Ratings */}
|
||||
<Route path="comments" element={<CommentsPage />} />
|
||||
<Route path="ratings" element={<RatingsPage />} />
|
||||
|
||||
{/* SMS */}
|
||||
<Route path="sms" element={<SmsPage />} />
|
||||
|
||||
{/* Categories */}
|
||||
<Route path="categories" element={<CategoriesPage />} />
|
||||
|
||||
{/* Blogs */}
|
||||
<Route path="blogs" element={<BlogsPage />} />
|
||||
<Route path="blogs/new" element={<BlogFormPage />} />
|
||||
<Route path="blogs/:uuid/edit" element={<BlogFormPage />} />
|
||||
|
||||
{/* Secretaries */}
|
||||
<Route path="secretaries" element={<SecretariesPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/admin/dashboard" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -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'} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
const BASE_URL = '';
|
||||
|
||||
function getToken(): string | null {
|
||||
try {
|
||||
const raw = localStorage.getItem('clinicpro-auth');
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed?.state?.token ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const token = getToken();
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
const res = await fetch(`${BASE_URL}${path}`, { ...options, headers });
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
const firstErr = body?.errors?.[0];
|
||||
throw new ApiError(
|
||||
res.status,
|
||||
firstErr?.code ?? 'ERR_UNKNOWN',
|
||||
firstErr?.message ?? 'خطای ناشناخته',
|
||||
);
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
|
||||
patch: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||
put: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
errors: { code: string; message: string; field?: string }[];
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
success: boolean;
|
||||
data: {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
};
|
||||
errors: [];
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export function formatRial(amount: number): string {
|
||||
return new Intl.NumberFormat('fa-IR').format(amount) + ' تومان';
|
||||
}
|
||||
|
||||
export function formatNumber(n: number): string {
|
||||
return new Intl.NumberFormat('fa-IR').format(n);
|
||||
}
|
||||
|
||||
export function formatDate(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return '—';
|
||||
try {
|
||||
return new Intl.DateTimeFormat('fa-IR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(new Date(dateStr));
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDateTime(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return '—';
|
||||
try {
|
||||
return new Intl.DateTimeFormat('fa-IR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(dateStr));
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
export function maskMobile(mobile: string): string {
|
||||
if (mobile.length < 7) return mobile;
|
||||
return mobile.slice(0, 4) + '***' + mobile.slice(-3);
|
||||
}
|
||||
|
||||
export function cn(...classes: (string | undefined | null | false)[]): string {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ArrowRightIcon } from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import type { Appointment, AppointmentStatus } from '../types';
|
||||
import { formatDate, formatDateTime, formatRial } from '../lib/utils';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import StatusBadge from '../components/ui/StatusBadge';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
|
||||
const ALL_STATUSES: { value: AppointmentStatus; label: string }[] = [
|
||||
{ value: 'waiting_for_payment', label: 'در انتظار پرداخت' },
|
||||
{ value: 'reserved', label: 'رزرو شده' },
|
||||
{ value: 'checked_in', label: 'ورود به مطب' },
|
||||
{ value: 'waiting', label: 'در صف انتظار' },
|
||||
{ value: 'in_progress', label: 'در حال ویزیت' },
|
||||
{ value: 'visited', label: 'ویزیت شده' },
|
||||
{ value: 'completed', label: 'تکمیل شده' },
|
||||
{ value: 'cancelled_by_admin', label: 'لغو توسط ادمین' },
|
||||
{ value: 'no_show', label: 'غیبت' },
|
||||
];
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-3 border-b border-gray-100 last:border-0">
|
||||
<span className="text-sm text-gray-500">{label}</span>
|
||||
<span className="text-sm font-medium text-gray-900">{value ?? '—'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppointmentDetailPage() {
|
||||
const { uuid } = useParams<{ uuid: string }>();
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const [cancelOpen, setCancelOpen] = useState(false);
|
||||
const [newStatus, setNewStatus] = useState('');
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['appointment', uuid],
|
||||
queryFn: () => api.get<ApiResponse<Appointment>>(`/api/v1/appointment/${uuid}`),
|
||||
enabled: !!uuid,
|
||||
});
|
||||
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: (status: string) =>
|
||||
api.patch<ApiResponse<null>>(`/api/v1/appointment/${uuid}/status`, { status }),
|
||||
onSuccess: () => {
|
||||
toast.success('وضعیت نوبت بروزرسانی شد');
|
||||
qc.invalidateQueries({ queryKey: ['appointment', uuid] });
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: () => api.patch<ApiResponse<null>>(`/api/v1/appointment/${uuid}/cancel`, {}),
|
||||
onSuccess: () => {
|
||||
toast.success('نوبت لغو شد');
|
||||
setCancelOpen(false);
|
||||
qc.invalidateQueries({ queryKey: ['appointment', uuid] });
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const appt = data?.data;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="جزئیات نوبت"
|
||||
breadcrumbs={[
|
||||
{ label: 'داشبورد', to: '/admin/dashboard' },
|
||||
{ label: 'نوبتها', to: '/admin/appointments' },
|
||||
{ label: 'جزئیات' },
|
||||
]}
|
||||
action={
|
||||
<button onClick={() => navigate('/admin/appointments')}
|
||||
className="flex items-center gap-2 text-sm text-gray-500 hover:text-gray-800 transition-colors">
|
||||
<ArrowRightIcon className="w-4 h-4" />
|
||||
بازگشت
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 p-6 space-y-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="h-8 bg-gray-100 rounded-lg animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : appt ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||
<h3 className="font-semibold text-gray-800 mb-4">اطلاعات بیمار</h3>
|
||||
<InfoRow label="نام بیمار" value={appt.patient_name} />
|
||||
<InfoRow label="موبایل" value={<span dir="ltr">{appt.patient_mobile}</span>} />
|
||||
<InfoRow label="پزشک" value={`دکتر ${appt.doctor_name}`} />
|
||||
<InfoRow label="کلینیک" value={appt.clinic_name} />
|
||||
<InfoRow label="تاریخ نوبت" value={formatDate(appt.appointment_date)} />
|
||||
<InfoRow label="ساعت" value={appt.appointment_time} />
|
||||
<InfoRow label="مبلغ" value={formatRial(appt.amount)} />
|
||||
<InfoRow label="تاریخ ثبت" value={formatDateTime(appt.created_at)} />
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||
<h3 className="font-semibold text-gray-800 mb-4">وضعیت و اقدامات</h3>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-gray-500 mb-2">وضعیت فعلی:</p>
|
||||
<StatusBadge type="appointment" value={appt.status} />
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">تغییر وضعیت:</label>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={newStatus}
|
||||
onChange={(e) => setNewStatus(e.target.value)}
|
||||
className="flex-1 h-10 border border-gray-300 rounded-[10px] px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">انتخاب وضعیت...</option>
|
||||
{ALL_STATUSES.map((s) => (
|
||||
<option key={s.value} value={s.value}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => newStatus && statusMutation.mutate(newStatus)}
|
||||
disabled={!newStatus || statusMutation.isPending}
|
||||
className="px-4 py-2 bg-primary-600 text-white text-sm rounded-[10px] hover:bg-primary-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
اعمال
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-gray-100">
|
||||
<button
|
||||
onClick={() => setCancelOpen(true)}
|
||||
className="w-full py-2 border border-red-300 text-red-600 text-sm rounded-[10px] hover:bg-red-50 transition-colors"
|
||||
>
|
||||
لغو نوبت
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 p-16 text-center text-gray-400">
|
||||
نوبتی یافت نشد
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={cancelOpen}
|
||||
title="لغو نوبت"
|
||||
message="آیا از لغو این نوبت اطمینان دارید؟"
|
||||
confirmLabel="لغو نوبت"
|
||||
danger
|
||||
loading={cancelMutation.isPending}
|
||||
onConfirm={() => cancelMutation.mutate()}
|
||||
onCancel={() => setCancelOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { EyeIcon } from '@heroicons/react/24/outline';
|
||||
import { api } from '../lib/api';
|
||||
import type { PaginatedResponse } from '../lib/api';
|
||||
import type { Appointment, AppointmentStatus } from '../types';
|
||||
import { formatDate, formatRial, maskMobile } from '../lib/utils';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import DataTable, { Column } from '../components/ui/DataTable';
|
||||
import StatusBadge from '../components/ui/StatusBadge';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
|
||||
const STATUS_FILTERS: { value: string; label: string }[] = [
|
||||
{ value: '', label: 'همه' },
|
||||
{ value: 'waiting_for_payment', label: 'در انتظار پرداخت' },
|
||||
{ value: 'reserved', label: 'رزرو شده' },
|
||||
{ value: 'checked_in', label: 'ورود به مطب' },
|
||||
{ value: 'waiting', label: 'در صف انتظار' },
|
||||
{ value: 'in_progress', label: 'در حال ویزیت' },
|
||||
{ value: 'visited', label: 'ویزیت شده' },
|
||||
{ value: 'completed', label: 'تکمیل شده' },
|
||||
{ value: 'cancelled_by_user', label: 'لغو توسط بیمار' },
|
||||
{ value: 'no_show', label: 'غیبت' },
|
||||
];
|
||||
|
||||
export default function AppointmentsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const limit = 15;
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['appointments', page, search, statusFilter],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||
if (search) params.set('search', search);
|
||||
if (statusFilter) params.set('status', statusFilter);
|
||||
return api.get<PaginatedResponse<Appointment>>(`/api/v1/appointments?${params}`);
|
||||
},
|
||||
});
|
||||
|
||||
const columns: Column<Appointment>[] = [
|
||||
{
|
||||
key: 'patient',
|
||||
header: 'بیمار',
|
||||
render: (a) => (
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{a.patient_name || '—'}</p>
|
||||
<p className="text-xs text-gray-400 dir-ltr">{maskMobile(a.patient_mobile)}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'doctor_name', header: 'پزشک', render: (a) => `دکتر ${a.doctor_name}` },
|
||||
{ key: 'clinic_name', header: 'کلینیک', render: (a) => a.clinic_name ?? '—' },
|
||||
{
|
||||
key: 'appointment_date',
|
||||
header: 'تاریخ نوبت',
|
||||
render: (a) => (
|
||||
<div>
|
||||
<p>{formatDate(a.appointment_date)}</p>
|
||||
<p className="text-xs text-gray-400">{a.appointment_time}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'وضعیت',
|
||||
render: (a) => <StatusBadge type="appointment" value={a.status} />,
|
||||
},
|
||||
{
|
||||
key: 'amount',
|
||||
header: 'مبلغ',
|
||||
render: (a) => <span className="text-sm">{formatRial(a.amount)}</span>,
|
||||
},
|
||||
{ key: 'created_at', header: 'تاریخ ثبت', render: (a) => formatDate(a.created_at) },
|
||||
];
|
||||
|
||||
const items = data?.data?.items ?? [];
|
||||
const total = data?.data?.total ?? 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="نوبتها"
|
||||
breadcrumbs={[{ label: 'داشبورد', to: '/admin/dashboard' }, { label: 'نوبتها' }]}
|
||||
/>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
|
||||
<div className="flex items-center gap-3 mb-4 flex-wrap">
|
||||
{STATUS_FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.value}
|
||||
onClick={() => { setStatusFilter(f.value); setPage(1); }}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||
statusFilter === f.value
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DataTable<Appointment>
|
||||
columns={columns}
|
||||
data={items}
|
||||
loading={isLoading}
|
||||
searchValue={search}
|
||||
onSearchChange={(v) => { setSearch(v); setPage(1); }}
|
||||
searchPlaceholder="جستجو بر اساس موبایل یا نام پزشک..."
|
||||
emptyMessage="هیچ نوبتی یافت نشد"
|
||||
actions={(appt) => (
|
||||
<button
|
||||
onClick={() => navigate(`/admin/appointments/${appt.uuid}`)}
|
||||
className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
||||
title="مشاهده"
|
||||
>
|
||||
<EyeIcon className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import React from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import type { Blog } from '../types';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
|
||||
const schema = z.object({
|
||||
title: z.string().min(3, 'عنوان الزامی است'),
|
||||
summary: z.string().optional(),
|
||||
content: z.string().min(10, 'محتوا الزامی است'),
|
||||
tags: z.string().optional(),
|
||||
status: z.enum(['draft', 'published']),
|
||||
});
|
||||
type FormData = z.infer<typeof schema>;
|
||||
|
||||
export default function BlogFormPage() {
|
||||
const { uuid } = useParams<{ uuid: string }>();
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const isEdit = !!uuid;
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['blog', uuid],
|
||||
queryFn: () => api.get<ApiResponse<Blog>>(`/api/v1/blog/${uuid}`),
|
||||
enabled: isEdit,
|
||||
});
|
||||
|
||||
const blog = data?.data;
|
||||
|
||||
const { register, handleSubmit, control, reset, formState: { errors, isSubmitting } } = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { status: 'draft' },
|
||||
values: blog
|
||||
? {
|
||||
title: blog.title,
|
||||
summary: blog.summary ?? '',
|
||||
content: blog.content,
|
||||
tags: blog.tags?.join(', ') ?? '',
|
||||
status: blog.status,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (d: FormData) =>
|
||||
api.post<ApiResponse<Blog>>('/api/v1/blog', {
|
||||
...d,
|
||||
tags: d.tags ? d.tags.split(',').map((t) => t.trim()).filter(Boolean) : [],
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('مقاله ذخیره شد');
|
||||
qc.invalidateQueries({ queryKey: ['blogs'] });
|
||||
navigate('/admin/blogs');
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (d: FormData) =>
|
||||
api.patch<ApiResponse<Blog>>(`/api/v1/blog/${uuid}`, {
|
||||
...d,
|
||||
tags: d.tags ? d.tags.split(',').map((t) => t.trim()).filter(Boolean) : [],
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('مقاله بروزرسانی شد');
|
||||
qc.invalidateQueries({ queryKey: ['blogs'] });
|
||||
qc.invalidateQueries({ queryKey: ['blog', uuid] });
|
||||
navigate('/admin/blogs');
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const onSubmit = (d: FormData) => {
|
||||
if (isEdit) updateMutation.mutate(d);
|
||||
else createMutation.mutate(d);
|
||||
};
|
||||
|
||||
if (isEdit && isLoading) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 p-6 space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="h-10 bg-gray-100 rounded-lg animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title={isEdit ? 'ویرایش مقاله' : 'نوشتن مقاله جدید'}
|
||||
breadcrumbs={[
|
||||
{ label: 'داشبورد', to: '/admin/dashboard' },
|
||||
{ label: 'بلاگ', to: '/admin/blogs' },
|
||||
{ label: isEdit ? 'ویرایش' : 'جدید' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">عنوان مقاله *</label>
|
||||
<input {...register('title')} placeholder="عنوان جذاب بنویسید..."
|
||||
className="w-full h-11 border border-gray-300 rounded-[10px] px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" />
|
||||
{errors.title && <p className="text-red-500 text-xs mt-1">{errors.title.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">خلاصه</label>
|
||||
<textarea {...register('summary')} rows={2} placeholder="خلاصه کوتاه مقاله..."
|
||||
className="w-full border border-gray-300 rounded-[10px] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 resize-none" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">محتوا *</label>
|
||||
<textarea {...register('content')} rows={16} placeholder="محتوای مقاله را بنویسید..."
|
||||
className="w-full border border-gray-300 rounded-[10px] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 resize-none font-mono" />
|
||||
{errors.content && <p className="text-red-500 text-xs mt-1">{errors.content.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">وضعیت انتشار</label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="status"
|
||||
render={({ field }) => (
|
||||
<select {...field}
|
||||
className="w-full h-11 border border-gray-300 rounded-[10px] px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500">
|
||||
<option value="draft">پیشنویس</option>
|
||||
<option value="published">منتشر</option>
|
||||
</select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">تگها (با ویرگول جدا کنید)</label>
|
||||
<input {...register('tags')} dir="ltr" placeholder="tag1, tag2, tag3"
|
||||
className="w-full h-11 border border-gray-300 rounded-[10px] px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" />
|
||||
</div>
|
||||
|
||||
<div className="pt-4 space-y-3">
|
||||
<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">
|
||||
{isSubmitting ? 'در حال ذخیره...' : isEdit ? 'بروزرسانی' : 'ذخیره'}
|
||||
</button>
|
||||
<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">
|
||||
لغو
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { EyeIcon, PencilIcon, TrashIcon, PlusIcon } from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||||
import type { Blog } from '../types';
|
||||
import { formatDate, formatNumber } from '../lib/utils';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import DataTable, { Column } from '../components/ui/DataTable';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
|
||||
const STATUS_FILTERS = [
|
||||
{ value: '', label: 'همه' },
|
||||
{ value: 'draft', label: 'پیشنویس' },
|
||||
{ value: 'published', label: 'منتشرشده' },
|
||||
];
|
||||
|
||||
export default function BlogsPage() {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [deleteTarget, setDeleteTarget] = useState<Blog | null>(null);
|
||||
const limit = 15;
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['blogs', page, search, statusFilter],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||
if (search) params.set('search', search);
|
||||
if (statusFilter) params.set('status', statusFilter);
|
||||
return api.get<PaginatedResponse<Blog>>(`/api/v1/blogs?${params}`);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (b: Blog) => api.delete<ApiResponse<null>>(`/api/v1/blog/${b.uuid}`),
|
||||
onSuccess: () => {
|
||||
toast.success('مقاله حذف شد');
|
||||
setDeleteTarget(null);
|
||||
qc.invalidateQueries({ queryKey: ['blogs'] });
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const columns: Column<Blog>[] = [
|
||||
{
|
||||
key: 'title',
|
||||
header: 'عنوان',
|
||||
render: (b) => (
|
||||
<div className="flex items-center gap-3">
|
||||
{b.cover_image ? (
|
||||
<img src={b.cover_image} alt="" className="w-10 h-7 rounded object-cover shrink-0" />
|
||||
) : (
|
||||
<div className="w-10 h-7 rounded bg-gray-100 shrink-0" />
|
||||
)}
|
||||
<span className="font-medium text-gray-900 line-clamp-1">{b.title}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'author_name', header: 'نویسنده' },
|
||||
{
|
||||
key: 'status',
|
||||
header: 'وضعیت',
|
||||
render: (b) => (
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||
b.status === 'published'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{b.status === 'published' ? 'منتشرشده' : 'پیشنویس'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'tags',
|
||||
header: 'تگها',
|
||||
render: (b) => (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{b.tags?.slice(0, 3).map((tag, i) => (
|
||||
<span key={i} className="text-xs bg-blue-50 text-blue-600 px-2 py-0.5 rounded-full">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'views_count', header: 'بازدید', render: (b) => formatNumber(b.views_count) },
|
||||
{ key: 'published_at', header: 'انتشار', render: (b) => formatDate(b.published_at) },
|
||||
{ key: 'created_at', header: 'تاریخ ثبت', render: (b) => formatDate(b.created_at) },
|
||||
];
|
||||
|
||||
const items = data?.data?.items ?? [];
|
||||
const total = data?.data?.total ?? 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="بلاگ"
|
||||
breadcrumbs={[{ label: 'داشبورد', to: '/admin/dashboard' }, { label: 'بلاگ' }]}
|
||||
action={
|
||||
<button onClick={() => navigate('/admin/blogs/new')}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white text-sm rounded-[10px] hover:bg-primary-700 transition-colors">
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
نوشتن مقاله
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
{STATUS_FILTERS.map((f) => (
|
||||
<button key={f.value} onClick={() => { setStatusFilter(f.value); setPage(1); }}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||
statusFilter === f.value ? 'bg-primary-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DataTable<Blog>
|
||||
columns={columns}
|
||||
data={items}
|
||||
loading={isLoading}
|
||||
searchValue={search}
|
||||
onSearchChange={(v) => { setSearch(v); setPage(1); }}
|
||||
searchPlaceholder="جستجو بر اساس عنوان..."
|
||||
emptyMessage="هیچ مقالهای یافت نشد"
|
||||
emptyAction={
|
||||
<button onClick={() => navigate('/admin/blogs/new')}
|
||||
className="px-4 py-2 bg-primary-600 text-white text-sm rounded-[10px] hover:bg-primary-700 transition-colors">
|
||||
نوشتن اولین مقاله
|
||||
</button>
|
||||
}
|
||||
actions={(blog) => (
|
||||
<>
|
||||
<button onClick={() => navigate(`/admin/blogs/${blog.uuid}/edit`)}
|
||||
className="p-1.5 text-gray-400 hover:text-primary-600 hover:bg-primary-50 rounded-lg transition-colors" title="ویرایش">
|
||||
<PencilIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => setDeleteTarget(blog)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" title="حذف">
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="حذف مقاله"
|
||||
message={`آیا از حذف مقاله "${deleteTarget?.title}" اطمینان دارید؟`}
|
||||
confirmLabel="حذف"
|
||||
danger
|
||||
loading={deleteMutation.isPending}
|
||||
onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget)}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { TrashIcon, PlusIcon, PencilIcon } from '@heroicons/react/24/outline';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import type { Category, CategoryBundle } from '../types';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import DataTable, { Column } from '../components/ui/DataTable';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
import Modal from '../components/ui/Modal';
|
||||
|
||||
const TABS: { key: CategoryBundle; label: string }[] = [
|
||||
{ key: 'state', label: 'استانها' },
|
||||
{ key: 'city', label: 'شهرها' },
|
||||
{ key: 'specialty', label: 'تخصصها' },
|
||||
{ key: 'doctor_service', label: 'خدمات پزشک' },
|
||||
{ key: 'insurance_type', label: 'نوع بیمه' },
|
||||
{ key: 'supplementary_insurance', label: 'بیمه تکمیلی' },
|
||||
{ key: 'tag', label: 'تگهای بلاگ' },
|
||||
];
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1, 'نام الزامی است'),
|
||||
code: z.string().optional(),
|
||||
parent_uuid: z.string().optional(),
|
||||
});
|
||||
type FormData = z.infer<typeof schema>;
|
||||
|
||||
export default function CategoriesPage() {
|
||||
const qc = useQueryClient();
|
||||
const [activeTab, setActiveTab] = useState<CategoryBundle>('state');
|
||||
const [search, setSearch] = useState('');
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [editTarget, setEditTarget] = useState<Category | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Category | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['categories', activeTab],
|
||||
queryFn: () =>
|
||||
api.get<ApiResponse<Category[]>>(`/api/v1/categorys/${activeTab}`),
|
||||
});
|
||||
|
||||
const { register, handleSubmit, reset, setValue, formState: { errors, isSubmitting } } = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (d: FormData) =>
|
||||
api.post<ApiResponse<Category>>('/api/v1/category', { ...d, bundle: activeTab }),
|
||||
onSuccess: () => {
|
||||
toast.success('دستهبندی اضافه شد');
|
||||
setAddOpen(false);
|
||||
reset();
|
||||
qc.invalidateQueries({ queryKey: ['categories', activeTab] });
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ uuid, d }: { uuid: string; d: FormData }) =>
|
||||
api.patch<ApiResponse<Category>>(`/api/v1/category/${uuid}`, d),
|
||||
onSuccess: () => {
|
||||
toast.success('دستهبندی بروزرسانی شد');
|
||||
setEditTarget(null);
|
||||
reset();
|
||||
qc.invalidateQueries({ queryKey: ['categories', activeTab] });
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (c: Category) => api.delete<ApiResponse<null>>(`/api/v1/category/${c.uuid}`),
|
||||
onSuccess: () => {
|
||||
toast.success('دستهبندی حذف شد');
|
||||
setDeleteTarget(null);
|
||||
qc.invalidateQueries({ queryKey: ['categories', activeTab] });
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const openEdit = (c: Category) => {
|
||||
setEditTarget(c);
|
||||
setValue('name', c.name);
|
||||
setValue('code', c.code ?? '');
|
||||
setValue('parent_uuid', c.parent_uuid ?? '');
|
||||
};
|
||||
|
||||
const allItems = data?.data ?? [];
|
||||
const filtered = search
|
||||
? allItems.filter((c) => c.name.includes(search))
|
||||
: allItems;
|
||||
|
||||
const columns: Column<Category>[] = [
|
||||
{ key: 'name', header: 'نام', render: (c) => <span className="font-medium">{c.name}</span> },
|
||||
{ key: 'code', header: 'کد', render: (c) => c.code ? <span dir="ltr" className="font-mono text-xs">{c.code}</span> : '—' },
|
||||
{ key: 'parent_name', header: 'والد', render: (c) => c.parent_name ?? '—' },
|
||||
];
|
||||
|
||||
const handleTabChange = (tab: CategoryBundle) => {
|
||||
setActiveTab(tab);
|
||||
setSearch('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="دستهبندیها"
|
||||
breadcrumbs={[{ label: 'داشبورد', to: '/admin/dashboard' }, { label: 'دستهبندیها' }]}
|
||||
action={
|
||||
<button onClick={() => setAddOpen(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white text-sm rounded-[10px] hover:bg-primary-700 transition-colors">
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
افزودن
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100">
|
||||
<div className="flex flex-wrap border-b border-gray-200 px-6 pt-4 gap-1">
|
||||
{TABS.map((t) => (
|
||||
<button key={t.key} onClick={() => handleTabChange(t.key)}
|
||||
className={`pb-3 px-4 text-sm font-medium border-b-2 transition-colors -mb-px ${
|
||||
activeTab === t.key
|
||||
? 'border-primary-600 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<DataTable<Category>
|
||||
columns={columns}
|
||||
data={filtered}
|
||||
loading={isLoading}
|
||||
searchValue={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="جستجو..."
|
||||
emptyMessage="هیچ موردی یافت نشد"
|
||||
actions={(cat) => (
|
||||
<>
|
||||
<button onClick={() => openEdit(cat)}
|
||||
className="p-1.5 text-gray-400 hover:text-primary-600 hover:bg-primary-50 rounded-lg transition-colors" title="ویرایش">
|
||||
<PencilIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => setDeleteTarget(cat)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" title="حذف">
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal open={addOpen} title={`افزودن — ${TABS.find((t) => t.key === activeTab)?.label}`}
|
||||
onClose={() => { setAddOpen(false); reset(); }}
|
||||
footer={
|
||||
<>
|
||||
<button onClick={() => { setAddOpen(false); reset(); }}
|
||||
className="px-4 py-2 border border-gray-300 text-sm text-gray-700 rounded-[10px] hover:bg-gray-50 transition-colors">
|
||||
لغو
|
||||
</button>
|
||||
<button form="cat-form" type="submit" disabled={isSubmitting}
|
||||
className="px-4 py-2 bg-primary-600 text-white text-sm rounded-[10px] hover:bg-primary-700 disabled:opacity-50 transition-colors">
|
||||
ذخیره
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="cat-form" onSubmit={handleSubmit((d) => createMutation.mutate(d))} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">نام</label>
|
||||
<input {...register('name')}
|
||||
className="w-full h-11 border border-gray-300 rounded-[10px] px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" />
|
||||
{errors.name && <p className="text-red-500 text-xs mt-1">{errors.name.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">کد (اختیاری)</label>
|
||||
<input {...register('code')} dir="ltr"
|
||||
className="w-full h-11 border border-gray-300 rounded-[10px] px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" />
|
||||
</div>
|
||||
{(activeTab === 'city') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">استان (UUID)</label>
|
||||
<input {...register('parent_uuid')} dir="ltr" placeholder="uuid استان"
|
||||
className="w-full h-11 border border-gray-300 rounded-[10px] px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" />
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal open={!!editTarget} title="ویرایش دستهبندی"
|
||||
onClose={() => { setEditTarget(null); reset(); }}
|
||||
footer={
|
||||
<>
|
||||
<button onClick={() => { setEditTarget(null); reset(); }}
|
||||
className="px-4 py-2 border border-gray-300 text-sm text-gray-700 rounded-[10px] hover:bg-gray-50 transition-colors">
|
||||
لغو
|
||||
</button>
|
||||
<button form="edit-cat-form" type="submit" disabled={isSubmitting}
|
||||
className="px-4 py-2 bg-primary-600 text-white text-sm rounded-[10px] hover:bg-primary-700 disabled:opacity-50 transition-colors">
|
||||
ذخیره
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="edit-cat-form"
|
||||
onSubmit={handleSubmit((d) => editTarget && updateMutation.mutate({ uuid: editTarget.uuid, d }))}
|
||||
className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">نام</label>
|
||||
<input {...register('name')}
|
||||
className="w-full h-11 border border-gray-300 rounded-[10px] px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" />
|
||||
{errors.name && <p className="text-red-500 text-xs mt-1">{errors.name.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">کد (اختیاری)</label>
|
||||
<input {...register('code')} dir="ltr"
|
||||
className="w-full h-11 border border-gray-300 rounded-[10px] px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" />
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="حذف دستهبندی"
|
||||
message={`آیا از حذف "${deleteTarget?.name}" اطمینان دارید؟`}
|
||||
confirmLabel="حذف"
|
||||
danger
|
||||
loading={deleteMutation.isPending}
|
||||
onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget)}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import React from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ArrowRightIcon } from '@heroicons/react/24/outline';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import type { Clinic } from '../types';
|
||||
import { formatDate } from '../lib/utils';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import { ActiveBadge } from '../components/ui/StatusBadge';
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-3 border-b border-gray-100 last:border-0">
|
||||
<span className="text-sm text-gray-500">{label}</span>
|
||||
<span className="text-sm font-medium text-gray-900">{value ?? '—'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ClinicDetailPage() {
|
||||
const { uuid } = useParams<{ uuid: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['clinic', uuid],
|
||||
queryFn: () => api.get<ApiResponse<Clinic>>(`/api/v1/clinic/${uuid}`),
|
||||
enabled: !!uuid,
|
||||
});
|
||||
|
||||
const clinic = data?.data;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="جزئیات کلینیک"
|
||||
breadcrumbs={[
|
||||
{ label: 'داشبورد', to: '/admin/dashboard' },
|
||||
{ label: 'کلینیکها', to: '/admin/clinics' },
|
||||
{ label: 'جزئیات' },
|
||||
]}
|
||||
action={
|
||||
<button onClick={() => navigate('/admin/clinics')}
|
||||
className="flex items-center gap-2 text-sm text-gray-500 hover:text-gray-800 transition-colors">
|
||||
<ArrowRightIcon className="w-4 h-4" />
|
||||
بازگشت
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 p-6 space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="h-8 bg-gray-100 rounded-lg animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : clinic ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
{clinic.logo ? (
|
||||
<img src={clinic.logo} alt="" className="w-16 h-16 rounded-xl object-cover" />
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded-xl bg-blue-100 flex items-center justify-center text-blue-700 text-2xl font-bold">
|
||||
{clinic.name?.[0]}
|
||||
</div>
|
||||
)}
|
||||
<h2 className="font-bold text-gray-900 text-lg">{clinic.name}</h2>
|
||||
</div>
|
||||
<InfoRow label="تلفن" value={clinic.phone ? <span dir="ltr">{clinic.phone}</span> : null} />
|
||||
<InfoRow label="وضعیت" value={<ActiveBadge active={clinic.is_active} />} />
|
||||
<InfoRow label="تاریخ ثبت" value={formatDate(clinic.created_at)} />
|
||||
</div>
|
||||
|
||||
{clinic.description && (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||
<h3 className="font-semibold text-gray-800 mb-3">توضیحات</h3>
|
||||
<p className="text-sm text-gray-600 leading-relaxed">{clinic.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 p-16 text-center text-gray-400">
|
||||
کلینیکی یافت نشد
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { EyeIcon, TrashIcon, PencilIcon } from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||||
import type { Clinic } from '../types';
|
||||
import { formatDate, formatNumber } from '../lib/utils';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import DataTable, { Column } from '../components/ui/DataTable';
|
||||
import { ActiveBadge } from '../components/ui/StatusBadge';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
|
||||
export default function ClinicsPage() {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [deleteTarget, setDeleteTarget] = useState<Clinic | null>(null);
|
||||
const limit = 15;
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['clinics', page, search],
|
||||
queryFn: () =>
|
||||
api.get<PaginatedResponse<Clinic>>(
|
||||
`/api/v1/clinics?page=${page}&limit=${limit}${search ? `&search=${encodeURIComponent(search)}` : ''}`,
|
||||
),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (c: Clinic) => api.delete<ApiResponse<null>>(`/api/v1/clinic/${c.uuid}`),
|
||||
onSuccess: () => {
|
||||
toast.success('کلینیک حذف شد');
|
||||
setDeleteTarget(null);
|
||||
qc.invalidateQueries({ queryKey: ['clinics'] });
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const columns: Column<Clinic>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'نام کلینیک',
|
||||
render: (c) => (
|
||||
<div className="flex items-center gap-3">
|
||||
{c.logo ? (
|
||||
<img src={c.logo} alt="" className="w-8 h-8 rounded-lg object-cover" />
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-lg bg-blue-100 flex items-center justify-center text-blue-700 text-xs font-bold">
|
||||
{c.name?.[0]}
|
||||
</div>
|
||||
)}
|
||||
<span className="font-medium text-gray-900">{c.name}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'phone', header: 'تلفن', render: (c) => c.phone ? <span dir="ltr">{c.phone}</span> : '—' },
|
||||
{
|
||||
key: 'doctors_count',
|
||||
header: 'پزشکان',
|
||||
render: (c) => (
|
||||
<span className="text-xs bg-indigo-50 text-indigo-700 px-2 py-0.5 rounded-full">
|
||||
{formatNumber(c.doctors_count ?? 0)} پزشک
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ key: 'is_active', header: 'وضعیت', render: (c) => <ActiveBadge active={c.is_active} /> },
|
||||
{ key: 'created_at', header: 'تاریخ ثبت', render: (c) => formatDate(c.created_at) },
|
||||
];
|
||||
|
||||
const items = data?.data?.items ?? [];
|
||||
const total = data?.data?.total ?? 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="کلینیکها"
|
||||
breadcrumbs={[{ label: 'داشبورد', to: '/admin/dashboard' }, { label: 'کلینیکها' }]}
|
||||
/>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
|
||||
<DataTable<Clinic>
|
||||
columns={columns}
|
||||
data={items}
|
||||
loading={isLoading}
|
||||
searchValue={search}
|
||||
onSearchChange={(v) => { setSearch(v); setPage(1); }}
|
||||
searchPlaceholder="جستجو بر اساس نام کلینیک..."
|
||||
emptyMessage="هیچ کلینیکی یافت نشد"
|
||||
actions={(clinic) => (
|
||||
<>
|
||||
<button onClick={() => navigate(`/admin/clinics/${clinic.uuid}`)}
|
||||
className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors" title="مشاهده">
|
||||
<EyeIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => navigate(`/admin/clinics/${clinic.uuid}?edit=1`)}
|
||||
className="p-1.5 text-gray-400 hover:text-primary-600 hover:bg-primary-50 rounded-lg transition-colors" title="ویرایش">
|
||||
<PencilIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => setDeleteTarget(clinic)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" title="حذف">
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="حذف کلینیک"
|
||||
message={`آیا از حذف کلینیک "${deleteTarget?.name}" اطمینان دارید؟`}
|
||||
confirmLabel="حذف"
|
||||
danger
|
||||
loading={deleteMutation.isPending}
|
||||
onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget)}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { CheckIcon, TrashIcon } from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||||
import type { Comment } from '../types';
|
||||
import { formatDate } from '../lib/utils';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import DataTable, { Column } from '../components/ui/DataTable';
|
||||
import { ActiveBadge } from '../components/ui/StatusBadge';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
|
||||
const FILTERS = [
|
||||
{ value: '', label: 'همه' },
|
||||
{ value: 'false', label: 'در انتظار تأیید' },
|
||||
{ value: 'true', label: 'تأییدشده' },
|
||||
];
|
||||
|
||||
export default function CommentsPage() {
|
||||
const qc = useQueryClient();
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [approvedFilter, setApprovedFilter] = useState('false');
|
||||
const [deleteTarget, setDeleteTarget] = useState<Comment | null>(null);
|
||||
const limit = 15;
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['comments', page, search, approvedFilter],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||
if (search) params.set('search', search);
|
||||
if (approvedFilter !== '') params.set('is_approved', approvedFilter);
|
||||
return api.get<PaginatedResponse<Comment>>(`/api/v1/comments?${params}`);
|
||||
},
|
||||
});
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: (c: Comment) => api.patch<ApiResponse<null>>(`/api/v1/comment/${c.uuid}/approve`, {}),
|
||||
onSuccess: () => {
|
||||
toast.success('نظر تأیید شد');
|
||||
qc.invalidateQueries({ queryKey: ['comments'] });
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (c: Comment) => api.delete<ApiResponse<null>>(`/api/v1/comment/${c.uuid}`),
|
||||
onSuccess: () => {
|
||||
toast.success('نظر حذف شد');
|
||||
setDeleteTarget(null);
|
||||
qc.invalidateQueries({ queryKey: ['comments'] });
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const columns: Column<Comment>[] = [
|
||||
{
|
||||
key: 'patient_name',
|
||||
header: 'کاربر',
|
||||
render: (c) => <span className="font-medium text-gray-900">{c.patient_name}</span>,
|
||||
},
|
||||
{ key: 'doctor_name', header: 'پزشک', render: (c) => `دکتر ${c.doctor_name}` },
|
||||
{ key: 'title', header: 'عنوان' },
|
||||
{
|
||||
key: 'body',
|
||||
header: 'متن',
|
||||
render: (c) => (
|
||||
<span className="text-gray-500 text-xs line-clamp-2 max-w-xs">{c.body}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'is_approved',
|
||||
header: 'وضعیت',
|
||||
render: (c) => <ActiveBadge active={c.is_approved} />,
|
||||
},
|
||||
{ key: 'created_at', header: 'تاریخ', render: (c) => formatDate(c.created_at) },
|
||||
];
|
||||
|
||||
const items = data?.data?.items ?? [];
|
||||
const total = data?.data?.total ?? 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="نظرات"
|
||||
breadcrumbs={[{ label: 'داشبورد', to: '/admin/dashboard' }, { label: 'نظرات' }]}
|
||||
/>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
{FILTERS.map((f) => (
|
||||
<button key={f.value} onClick={() => { setApprovedFilter(f.value); setPage(1); }}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||
approvedFilter === f.value ? 'bg-primary-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DataTable<Comment>
|
||||
columns={columns}
|
||||
data={items}
|
||||
loading={isLoading}
|
||||
searchValue={search}
|
||||
onSearchChange={(v) => { setSearch(v); setPage(1); }}
|
||||
searchPlaceholder="جستجو بر اساس نام پزشک یا متن..."
|
||||
emptyMessage="هیچ نظری یافت نشد"
|
||||
actions={(comment) => (
|
||||
<>
|
||||
{!comment.is_approved && (
|
||||
<button onClick={() => approveMutation.mutate(comment)}
|
||||
className="p-1.5 text-gray-400 hover:text-green-600 hover:bg-green-50 rounded-lg transition-colors" title="تأیید">
|
||||
<CheckIcon className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => setDeleteTarget(comment)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" title="حذف">
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="حذف نظر"
|
||||
message="آیا از حذف این نظر اطمینان دارید؟"
|
||||
confirmLabel="حذف"
|
||||
danger
|
||||
loading={deleteMutation.isPending}
|
||||
onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget)}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import React from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ArrowRightIcon } from '@heroicons/react/24/outline';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import type { Doctor } from '../types';
|
||||
import { formatDate } from '../lib/utils';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import { ActiveBadge } from '../components/ui/StatusBadge';
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-3 border-b border-gray-100 last:border-0">
|
||||
<span className="text-sm text-gray-500">{label}</span>
|
||||
<span className="text-sm font-medium text-gray-900">{value ?? '—'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DoctorDetailPage() {
|
||||
const { uuid } = useParams<{ uuid: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['doctor', uuid],
|
||||
queryFn: () => api.get<ApiResponse<Doctor>>(`/api/v1/doctor/${uuid}`),
|
||||
enabled: !!uuid,
|
||||
});
|
||||
|
||||
const doctor = data?.data;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="جزئیات پزشک"
|
||||
breadcrumbs={[
|
||||
{ label: 'داشبورد', to: '/admin/dashboard' },
|
||||
{ label: 'پزشکان', to: '/admin/doctors' },
|
||||
{ label: 'جزئیات' },
|
||||
]}
|
||||
action={
|
||||
<button
|
||||
onClick={() => navigate('/admin/doctors')}
|
||||
className="flex items-center gap-2 text-sm text-gray-500 hover:text-gray-800 transition-colors"
|
||||
>
|
||||
<ArrowRightIcon className="w-4 h-4" />
|
||||
بازگشت
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 p-6 space-y-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="h-8 bg-gray-100 rounded-lg animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : doctor ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
{doctor.profile_image ? (
|
||||
<img src={doctor.profile_image} alt="" className="w-16 h-16 rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded-full bg-primary-100 flex items-center justify-center text-primary-700 text-2xl font-bold">
|
||||
{doctor.first_name?.[0] ?? '?'}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h2 className="font-bold text-gray-900">دکتر {doctor.first_name} {doctor.last_name}</h2>
|
||||
<p className="text-sm text-gray-500">{doctor.degree}</p>
|
||||
</div>
|
||||
</div>
|
||||
<InfoRow label="کد نظام پزشکی" value={<span dir="ltr">{doctor.medical_code}</span>} />
|
||||
<InfoRow label="جنسیت" value={doctor.gender === 'male' ? 'آقا' : 'خانم'} />
|
||||
<InfoRow label="وضعیت" value={<ActiveBadge active={doctor.is_active} />} />
|
||||
<InfoRow label="تاریخ ثبت" value={formatDate(doctor.created_at)} />
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||
<h3 className="font-semibold text-gray-800 mb-4">تخصصها</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{doctor.specialties?.length ? (
|
||||
doctor.specialties.map((s) => (
|
||||
<span key={s.uuid} className="px-3 py-1 bg-primary-50 text-primary-700 text-sm rounded-full">
|
||||
{s.name}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-gray-400 text-sm">تخصصی ثبت نشده</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{doctor.bio && (
|
||||
<div className="mt-6">
|
||||
<h3 className="font-semibold text-gray-800 mb-2">بیوگرافی</h3>
|
||||
<p className="text-sm text-gray-600 leading-relaxed">{doctor.bio}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 p-16 text-center text-gray-400">
|
||||
پزشکی یافت نشد
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { EyeIcon, TrashIcon, PencilIcon } from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||||
import type { Doctor } from '../types';
|
||||
import { formatDate } from '../lib/utils';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import DataTable, { Column } from '../components/ui/DataTable';
|
||||
import { ActiveBadge } from '../components/ui/StatusBadge';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
|
||||
export default function DoctorsPage() {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [deleteTarget, setDeleteTarget] = useState<Doctor | null>(null);
|
||||
const limit = 15;
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['doctors', page, search],
|
||||
queryFn: () =>
|
||||
api.get<PaginatedResponse<Doctor>>(
|
||||
`/api/v1/doctors?page=${page}&limit=${limit}${search ? `&search=${encodeURIComponent(search)}` : ''}`,
|
||||
),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (d: Doctor) => api.delete<ApiResponse<null>>(`/api/v1/doctor/${d.uuid}`),
|
||||
onSuccess: () => {
|
||||
toast.success('پزشک حذف شد');
|
||||
setDeleteTarget(null);
|
||||
qc.invalidateQueries({ queryKey: ['doctors'] });
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const columns: Column<Doctor>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'نام',
|
||||
render: (d) => (
|
||||
<div className="flex items-center gap-3">
|
||||
{d.profile_image ? (
|
||||
<img src={d.profile_image} alt="" className="w-8 h-8 rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-primary-100 flex items-center justify-center text-primary-700 text-xs font-bold">
|
||||
{d.first_name?.[0] ?? '?'}
|
||||
</div>
|
||||
)}
|
||||
<span className="font-medium text-gray-900">{d.first_name} {d.last_name}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'medical_code', header: 'کد نظام پزشکی' },
|
||||
{
|
||||
key: 'degree',
|
||||
header: 'درجه',
|
||||
render: (d) => <span className="text-xs bg-blue-50 text-blue-700 px-2 py-0.5 rounded-full">{d.degree}</span>,
|
||||
},
|
||||
{
|
||||
key: 'gender',
|
||||
header: 'جنسیت',
|
||||
render: (d) => d.gender === 'male' ? 'آقا' : 'خانم',
|
||||
},
|
||||
{
|
||||
key: 'specialties',
|
||||
header: 'تخصصها',
|
||||
render: (d) => (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{d.specialties?.slice(0, 2).map((s) => (
|
||||
<span key={s.uuid} className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">{s.name}</span>
|
||||
))}
|
||||
{(d.specialties?.length ?? 0) > 2 && (
|
||||
<span className="text-xs text-gray-400">+{d.specialties.length - 2}</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'is_active',
|
||||
header: 'وضعیت',
|
||||
render: (d) => <ActiveBadge active={d.is_active} />,
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
header: 'تاریخ ثبت',
|
||||
render: (d) => formatDate(d.created_at),
|
||||
},
|
||||
];
|
||||
|
||||
const items = data?.data?.items ?? [];
|
||||
const total = data?.data?.total ?? 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="پزشکان"
|
||||
breadcrumbs={[{ label: 'داشبورد', to: '/admin/dashboard' }, { label: 'پزشکان' }]}
|
||||
/>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
|
||||
<DataTable<Doctor>
|
||||
columns={columns}
|
||||
data={items}
|
||||
loading={isLoading}
|
||||
searchValue={search}
|
||||
onSearchChange={(v) => { setSearch(v); setPage(1); }}
|
||||
searchPlaceholder="جستجو بر اساس نام یا کد نظام پزشکی..."
|
||||
emptyMessage="هیچ پزشکی یافت نشد"
|
||||
actions={(doctor) => (
|
||||
<>
|
||||
<button
|
||||
onClick={() => navigate(`/admin/doctors/${doctor.uuid}`)}
|
||||
className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
||||
title="مشاهده"
|
||||
>
|
||||
<EyeIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate(`/admin/doctors/${doctor.uuid}?edit=1`)}
|
||||
className="p-1.5 text-gray-400 hover:text-primary-600 hover:bg-primary-50 rounded-lg transition-colors"
|
||||
title="ویرایش"
|
||||
>
|
||||
<PencilIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteTarget(doctor)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="حذف"
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="حذف پزشک"
|
||||
message={`آیا از حذف دکتر "${deleteTarget?.first_name} ${deleteTarget?.last_name}" اطمینان دارید؟`}
|
||||
confirmLabel="حذف"
|
||||
danger
|
||||
loading={deleteMutation.isPending}
|
||||
onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget)}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import React from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ArrowRightIcon } from '@heroicons/react/24/outline';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import type { Payment } from '../types';
|
||||
import { formatDate, formatDateTime, formatRial } from '../lib/utils';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import StatusBadge from '../components/ui/StatusBadge';
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-3 border-b border-gray-100 last:border-0">
|
||||
<span className="text-sm text-gray-500">{label}</span>
|
||||
<span className="text-sm font-medium text-gray-900">{value ?? '—'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PaymentDetailPage() {
|
||||
const { uuid } = useParams<{ uuid: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['payment', uuid],
|
||||
queryFn: () => api.get<ApiResponse<Payment>>(`/api/v1/payment/${uuid}`),
|
||||
enabled: !!uuid,
|
||||
});
|
||||
|
||||
const payment = data?.data;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="جزئیات پرداخت"
|
||||
breadcrumbs={[
|
||||
{ label: 'داشبورد', to: '/admin/dashboard' },
|
||||
{ label: 'پرداختها', to: '/admin/payments' },
|
||||
{ label: 'جزئیات' },
|
||||
]}
|
||||
action={
|
||||
<button onClick={() => navigate('/admin/payments')}
|
||||
className="flex items-center gap-2 text-sm text-gray-500 hover:text-gray-800 transition-colors">
|
||||
<ArrowRightIcon className="w-4 h-4" />
|
||||
بازگشت
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 p-6 space-y-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="h-8 bg-gray-100 rounded-lg animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : payment ? (
|
||||
<div className="max-w-lg">
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||
<InfoRow label="شناسه" value={<span dir="ltr" className="font-mono text-xs">{payment.uuid}</span>} />
|
||||
<InfoRow label="موبایل" value={<span dir="ltr">{payment.patient_mobile}</span>} />
|
||||
<InfoRow label="مبلغ" value={formatRial(payment.amount)} />
|
||||
<InfoRow label="وضعیت" value={<StatusBadge type="payment" value={payment.status} />} />
|
||||
<InfoRow label="درگاه" value={<span className="uppercase">{payment.gateway}</span>} />
|
||||
<InfoRow label="شماره مرجع" value={payment.ref_id ? <span dir="ltr" className="font-mono text-xs">{payment.ref_id}</span> : null} />
|
||||
<InfoRow label="تاریخ پرداخت" value={formatDateTime(payment.paid_at)} />
|
||||
<InfoRow label="تاریخ ثبت" value={formatDateTime(payment.created_at)} />
|
||||
{payment.appointment_uuid && (
|
||||
<InfoRow
|
||||
label="نوبت مرتبط"
|
||||
value={
|
||||
<a href={`/admin/appointments/${payment.appointment_uuid}`}
|
||||
className="text-primary-600 hover:underline text-xs font-mono">
|
||||
مشاهده نوبت
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 p-16 text-center text-gray-400">
|
||||
پرداختی یافت نشد
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { EyeIcon } from '@heroicons/react/24/outline';
|
||||
import { api } from '../lib/api';
|
||||
import type { PaginatedResponse } from '../lib/api';
|
||||
import type { Payment } from '../types';
|
||||
import { formatDate, formatRial, maskMobile } from '../lib/utils';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import DataTable, { Column } from '../components/ui/DataTable';
|
||||
import StatusBadge from '../components/ui/StatusBadge';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
|
||||
const STATUS_FILTERS = [
|
||||
{ value: '', label: 'همه' },
|
||||
{ value: 'pending', label: 'در انتظار' },
|
||||
{ value: 'received', label: 'موفق' },
|
||||
{ value: 'canceled', label: 'لغو شده' },
|
||||
{ value: 'refund', label: 'استرداد' },
|
||||
];
|
||||
|
||||
export default function PaymentsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const limit = 15;
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['payments', page, search, statusFilter],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||
if (search) params.set('search', search);
|
||||
if (statusFilter) params.set('status', statusFilter);
|
||||
return api.get<PaginatedResponse<Payment>>(`/api/v1/payments?${params}`);
|
||||
},
|
||||
});
|
||||
|
||||
const columns: Column<Payment>[] = [
|
||||
{
|
||||
key: 'patient_mobile',
|
||||
header: 'موبایل',
|
||||
render: (p) => <span dir="ltr">{maskMobile(p.patient_mobile)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'amount',
|
||||
header: 'مبلغ',
|
||||
render: (p) => <span className="font-medium">{formatRial(p.amount)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'وضعیت',
|
||||
render: (p) => <StatusBadge type="payment" value={p.status} />,
|
||||
},
|
||||
{
|
||||
key: 'gateway',
|
||||
header: 'درگاه',
|
||||
render: (p) => (
|
||||
<span className="text-xs bg-gray-100 text-gray-700 px-2 py-0.5 rounded-full uppercase">
|
||||
{p.gateway}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'ref_id',
|
||||
header: 'شماره مرجع',
|
||||
render: (p) => p.ref_id ? <span dir="ltr" className="font-mono text-xs">{p.ref_id}</span> : '—',
|
||||
},
|
||||
{
|
||||
key: 'paid_at',
|
||||
header: 'تاریخ پرداخت',
|
||||
render: (p) => formatDate(p.paid_at),
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
header: 'تاریخ ثبت',
|
||||
render: (p) => formatDate(p.created_at),
|
||||
},
|
||||
];
|
||||
|
||||
const items = data?.data?.items ?? [];
|
||||
const total = data?.data?.total ?? 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="پرداختها"
|
||||
breadcrumbs={[{ label: 'داشبورد', to: '/admin/dashboard' }, { label: 'پرداختها' }]}
|
||||
/>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
|
||||
<div className="flex items-center gap-3 mb-4 flex-wrap">
|
||||
{STATUS_FILTERS.map((f) => (
|
||||
<button key={f.value} onClick={() => { setStatusFilter(f.value); setPage(1); }}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||
statusFilter === f.value
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DataTable<Payment>
|
||||
columns={columns}
|
||||
data={items}
|
||||
loading={isLoading}
|
||||
searchValue={search}
|
||||
onSearchChange={(v) => { setSearch(v); setPage(1); }}
|
||||
searchPlaceholder="جستجو بر اساس موبایل یا شماره مرجع..."
|
||||
emptyMessage="هیچ پرداختی یافت نشد"
|
||||
actions={(payment) => (
|
||||
<button onClick={() => navigate(`/admin/payments/${payment.uuid}`)}
|
||||
className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors" title="مشاهده">
|
||||
<EyeIcon className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { TrashIcon, StarIcon } from '@heroicons/react/24/outline';
|
||||
import { StarIcon as StarSolid } from '@heroicons/react/24/solid';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||||
import type { Rating } from '../types';
|
||||
import { formatDate } from '../lib/utils';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import DataTable, { Column } from '../components/ui/DataTable';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
|
||||
function Stars({ value }: { value: number }) {
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
i <= value
|
||||
? <StarSolid key={i} className="w-3.5 h-3.5 text-yellow-400" />
|
||||
: <StarIcon key={i} className="w-3.5 h-3.5 text-gray-300" />
|
||||
))}
|
||||
<span className="text-xs text-gray-500 mr-1">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RatingsPage() {
|
||||
const qc = useQueryClient();
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [deleteTarget, setDeleteTarget] = useState<Rating | null>(null);
|
||||
const limit = 15;
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['ratings', page, search],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||
if (search) params.set('search', search);
|
||||
return api.get<PaginatedResponse<Rating>>(`/api/v1/rates?${params}`);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (r: Rating) => api.delete<ApiResponse<null>>(`/api/v1/rate/${r.uuid}`),
|
||||
onSuccess: () => {
|
||||
toast.success('امتیاز حذف شد');
|
||||
setDeleteTarget(null);
|
||||
qc.invalidateQueries({ queryKey: ['ratings'] });
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const columns: Column<Rating>[] = [
|
||||
{ key: 'patient_name', header: 'بیمار', render: (r) => <span className="font-medium">{r.patient_name}</span> },
|
||||
{ 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: 'skill', header: 'مهارت', render: (r) => <Stars value={r.skill} /> },
|
||||
{ key: 'behavior', header: 'رفتار', render: (r) => <Stars value={r.behavior} /> },
|
||||
{ key: 'created_at', header: 'تاریخ', render: (r) => formatDate(r.created_at) },
|
||||
];
|
||||
|
||||
const items = data?.data?.items ?? [];
|
||||
const total = data?.data?.total ?? 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="امتیازها"
|
||||
breadcrumbs={[{ label: 'داشبورد', to: '/admin/dashboard' }, { label: 'امتیازها' }]}
|
||||
/>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
|
||||
<DataTable<Rating>
|
||||
columns={columns}
|
||||
data={items}
|
||||
loading={isLoading}
|
||||
searchValue={search}
|
||||
onSearchChange={(v) => { setSearch(v); setPage(1); }}
|
||||
searchPlaceholder="جستجو بر اساس نام پزشک..."
|
||||
emptyMessage="هیچ امتیازی یافت نشد"
|
||||
actions={(rating) => (
|
||||
<button onClick={() => setDeleteTarget(rating)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" title="حذف">
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="حذف امتیاز"
|
||||
message="آیا از حذف این امتیاز اطمینان دارید؟"
|
||||
confirmLabel="حذف"
|
||||
danger
|
||||
loading={deleteMutation.isPending}
|
||||
onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget)}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { EyeIcon, TrashIcon, PlusIcon } from '@heroicons/react/24/outline';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||||
import type { Representation } from '../types';
|
||||
import { formatDate, formatRial, formatNumber } from '../lib/utils';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import DataTable, { Column } from '../components/ui/DataTable';
|
||||
import { ActiveBadge } from '../components/ui/StatusBadge';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
import Modal from '../components/ui/Modal';
|
||||
|
||||
const schema = z.object({
|
||||
domain: z.string().min(3, 'دامنه معتبر نیست'),
|
||||
city: z.string().min(2, 'شهر را وارد کنید'),
|
||||
commission_percent: z.coerce.number().min(0).max(100),
|
||||
});
|
||||
type FormData = z.infer<typeof schema>;
|
||||
|
||||
export default function RepresentationsPage() {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Representation | null>(null);
|
||||
const limit = 15;
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['representations', page, search],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||
if (search) params.set('search', search);
|
||||
return api.get<PaginatedResponse<Representation>>(`/api/v1/representations?${params}`);
|
||||
},
|
||||
});
|
||||
|
||||
const { register, handleSubmit, reset, formState: { errors, isSubmitting } } = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { commission_percent: 10 },
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (d: FormData) => api.post<ApiResponse<Representation>>('/api/v1/representation', d),
|
||||
onSuccess: () => {
|
||||
toast.success('نماینده اضافه شد');
|
||||
setAddOpen(false);
|
||||
reset();
|
||||
qc.invalidateQueries({ queryKey: ['representations'] });
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (r: Representation) => api.delete<ApiResponse<null>>(`/api/v1/representation/${r.uuid}`),
|
||||
onSuccess: () => {
|
||||
toast.success('نماینده حذف شد');
|
||||
setDeleteTarget(null);
|
||||
qc.invalidateQueries({ queryKey: ['representations'] });
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const columns: Column<Representation>[] = [
|
||||
{ key: 'domain', header: 'دامنه', render: (r) => <span dir="ltr" className="font-medium text-primary-700">{r.domain}</span> },
|
||||
{ key: 'city', header: 'شهر' },
|
||||
{
|
||||
key: 'commission_percent',
|
||||
header: 'کمیسیون',
|
||||
render: (r) => `${formatNumber(r.commission_percent)}٪`,
|
||||
},
|
||||
{
|
||||
key: 'wallet_balance',
|
||||
header: 'موجودی کیفپول',
|
||||
render: (r) => formatRial(r.wallet_balance),
|
||||
},
|
||||
{ key: 'is_active', header: 'وضعیت', render: (r) => <ActiveBadge active={r.is_active} /> },
|
||||
{ key: 'created_at', header: 'تاریخ ثبت', render: (r) => formatDate(r.created_at) },
|
||||
];
|
||||
|
||||
const items = data?.data?.items ?? [];
|
||||
const total = data?.data?.total ?? 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="نمایندگان"
|
||||
breadcrumbs={[{ label: 'داشبورد', to: '/admin/dashboard' }, { label: 'نمایندگان' }]}
|
||||
action={
|
||||
<button onClick={() => setAddOpen(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white text-sm rounded-[10px] hover:bg-primary-700 transition-colors">
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
افزودن نماینده
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
|
||||
<DataTable<Representation>
|
||||
columns={columns}
|
||||
data={items}
|
||||
loading={isLoading}
|
||||
searchValue={search}
|
||||
onSearchChange={(v) => { setSearch(v); setPage(1); }}
|
||||
searchPlaceholder="جستجو بر اساس دامنه یا شهر..."
|
||||
emptyMessage="هیچ نمایندهای یافت نشد"
|
||||
actions={(rep) => (
|
||||
<>
|
||||
<button onClick={() => navigate(`/admin/representations/${rep.uuid}`)}
|
||||
className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors" title="مشاهده">
|
||||
<EyeIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => setDeleteTarget(rep)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" title="حذف">
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
|
||||
</div>
|
||||
|
||||
<Modal open={addOpen} title="افزودن نماینده" onClose={() => setAddOpen(false)}
|
||||
footer={
|
||||
<>
|
||||
<button onClick={() => setAddOpen(false)}
|
||||
className="px-4 py-2 border border-gray-300 text-sm text-gray-700 rounded-[10px] hover:bg-gray-50 transition-colors">
|
||||
لغو
|
||||
</button>
|
||||
<button form="add-rep-form" type="submit" disabled={isSubmitting}
|
||||
className="px-4 py-2 bg-primary-600 text-white text-sm rounded-[10px] hover:bg-primary-700 disabled:opacity-50 transition-colors">
|
||||
{isSubmitting ? 'در حال ذخیره...' : 'ذخیره'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="add-rep-form" onSubmit={handleSubmit((d) => createMutation.mutate(d))} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">نام دامنه</label>
|
||||
<input {...register('domain')} dir="ltr" placeholder="example.com"
|
||||
className="w-full h-11 border border-gray-300 rounded-[10px] px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" />
|
||||
{errors.domain && <p className="text-red-500 text-xs mt-1">{errors.domain.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">شهر</label>
|
||||
<input {...register('city')} placeholder="تهران"
|
||||
className="w-full h-11 border border-gray-300 rounded-[10px] px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" />
|
||||
{errors.city && <p className="text-red-500 text-xs mt-1">{errors.city.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">درصد کمیسیون</label>
|
||||
<input {...register('commission_percent')} type="number" min="0" max="100" placeholder="10"
|
||||
className="w-full h-11 border border-gray-300 rounded-[10px] px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" />
|
||||
{errors.commission_percent && <p className="text-red-500 text-xs mt-1">{errors.commission_percent.message}</p>}
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="حذف نماینده"
|
||||
message={`آیا از حذف نماینده "${deleteTarget?.domain}" اطمینان دارید؟`}
|
||||
confirmLabel="حذف"
|
||||
danger
|
||||
loading={deleteMutation.isPending}
|
||||
onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget)}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { TrashIcon, PencilIcon } from '@heroicons/react/24/outline';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||||
import type { Secretary, SecretaryPermissions } from '../types';
|
||||
import { formatDate, maskMobile } from '../lib/utils';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import DataTable, { Column } from '../components/ui/DataTable';
|
||||
import { ActiveBadge } from '../components/ui/StatusBadge';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
import Modal from '../components/ui/Modal';
|
||||
|
||||
const DEFAULT_PERMISSIONS: SecretaryPermissions = {
|
||||
appointments: { view: true, create: false, cancel: false, update_status: false },
|
||||
addresses: { view: true, create: false, update: false, delete: false },
|
||||
clinic_info: { view: true, update: false },
|
||||
insurances: { view: true, create: false, update: false, delete: false },
|
||||
};
|
||||
|
||||
type PermSection = keyof SecretaryPermissions;
|
||||
type PermAction = string;
|
||||
|
||||
const PERMISSION_LABELS: Record<PermSection, { label: string; actions: { key: string; label: string }[] }> = {
|
||||
appointments: {
|
||||
label: 'نوبتها',
|
||||
actions: [
|
||||
{ key: 'view', label: 'مشاهده' },
|
||||
{ key: 'create', label: 'ایجاد' },
|
||||
{ key: 'cancel', label: 'لغو' },
|
||||
{ key: 'update_status', label: 'تغییر وضعیت' },
|
||||
],
|
||||
},
|
||||
addresses: {
|
||||
label: 'آدرسها',
|
||||
actions: [
|
||||
{ key: 'view', label: 'مشاهده' },
|
||||
{ key: 'create', label: 'ایجاد' },
|
||||
{ key: 'update', label: 'ویرایش' },
|
||||
{ key: 'delete', label: 'حذف' },
|
||||
],
|
||||
},
|
||||
clinic_info: {
|
||||
label: 'اطلاعات کلینیک',
|
||||
actions: [
|
||||
{ key: 'view', label: 'مشاهده' },
|
||||
{ key: 'update', label: 'ویرایش' },
|
||||
],
|
||||
},
|
||||
insurances: {
|
||||
label: 'بیمهها',
|
||||
actions: [
|
||||
{ key: 'view', label: 'مشاهده' },
|
||||
{ key: 'create', label: 'ایجاد' },
|
||||
{ key: 'update', label: 'ویرایش' },
|
||||
{ key: 'delete', label: 'حذف' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
function PermissionsMatrix({
|
||||
permissions,
|
||||
onChange,
|
||||
}: {
|
||||
permissions: SecretaryPermissions;
|
||||
onChange: (p: SecretaryPermissions) => void;
|
||||
}) {
|
||||
const toggle = (section: PermSection, action: string) => {
|
||||
const current = (permissions[section] as Record<string, boolean>)[action];
|
||||
onChange({
|
||||
...permissions,
|
||||
[section]: { ...(permissions[section] as Record<string, boolean>), [action]: !current },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-right">
|
||||
<th className="pb-2 text-gray-500 font-medium">بخش</th>
|
||||
{['مشاهده', 'ایجاد', 'ویرایش', 'حذف', 'لغو', 'تغییر وضعیت'].map((h) => (
|
||||
<th key={h} className="pb-2 text-gray-500 font-medium text-center text-xs">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(Object.keys(PERMISSION_LABELS) as PermSection[]).map((section) => {
|
||||
const config = PERMISSION_LABELS[section];
|
||||
const sectionPerms = permissions[section] as Record<string, boolean>;
|
||||
const allActions = ['view', 'create', 'update', 'delete', 'cancel', 'update_status'];
|
||||
return (
|
||||
<tr key={section} className="border-t border-gray-100">
|
||||
<td className="py-3 pr-0 text-gray-700 font-medium">{config.label}</td>
|
||||
{allActions.map((action) => {
|
||||
const actionConfig = config.actions.find((a) => a.key === action);
|
||||
if (!actionConfig) {
|
||||
return <td key={action} className="text-center text-gray-200">—</td>;
|
||||
}
|
||||
return (
|
||||
<td key={action} className="text-center py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sectionPerms[action] ?? false}
|
||||
onChange={() => toggle(section, action)}
|
||||
className="w-4 h-4 accent-primary-600 cursor-pointer"
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SecretariesPage() {
|
||||
const qc = useQueryClient();
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [editTarget, setEditTarget] = useState<Secretary | null>(null);
|
||||
const [editPerms, setEditPerms] = useState<SecretaryPermissions>(DEFAULT_PERMISSIONS);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Secretary | null>(null);
|
||||
const limit = 15;
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['secretaries', page, search],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||
if (search) params.set('search', search);
|
||||
return api.get<PaginatedResponse<Secretary>>(`/api/v1/secretaries?${params}`);
|
||||
},
|
||||
});
|
||||
|
||||
const updatePermsMutation = useMutation({
|
||||
mutationFn: ({ uuid, permissions }: { uuid: string; permissions: SecretaryPermissions }) =>
|
||||
api.patch<ApiResponse<null>>(`/api/v1/secretary/${uuid}/permissions`, { permissions }),
|
||||
onSuccess: () => {
|
||||
toast.success('دسترسیها بروزرسانی شد');
|
||||
setEditTarget(null);
|
||||
qc.invalidateQueries({ queryKey: ['secretaries'] });
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (s: Secretary) => api.delete<ApiResponse<null>>(`/api/v1/secretary/${s.uuid}`),
|
||||
onSuccess: () => {
|
||||
toast.success('منشی حذف شد');
|
||||
setDeleteTarget(null);
|
||||
qc.invalidateQueries({ queryKey: ['secretaries'] });
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const openEdit = (s: Secretary) => {
|
||||
setEditTarget(s);
|
||||
setEditPerms(s.permissions ?? DEFAULT_PERMISSIONS);
|
||||
};
|
||||
|
||||
const columns: Column<Secretary>[] = [
|
||||
{
|
||||
key: 'user_name',
|
||||
header: 'نام',
|
||||
render: (s) => <span className="font-medium text-gray-900">{s.user_name}</span>,
|
||||
},
|
||||
{
|
||||
key: 'mobile_number',
|
||||
header: 'موبایل',
|
||||
render: (s) => <span dir="ltr">{maskMobile(s.mobile_number)}</span>,
|
||||
},
|
||||
{ key: 'doctor_name', header: 'پزشک', render: (s) => `دکتر ${s.doctor_name}` },
|
||||
{ key: 'is_active', header: 'وضعیت', render: (s) => <ActiveBadge active={s.is_active} /> },
|
||||
{ key: 'created_at', header: 'تاریخ ثبت', render: (s) => formatDate(s.created_at) },
|
||||
];
|
||||
|
||||
const items = data?.data?.items ?? [];
|
||||
const total = data?.data?.total ?? 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="منشیها"
|
||||
breadcrumbs={[{ label: 'داشبورد', to: '/admin/dashboard' }, { label: 'منشیها' }]}
|
||||
/>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
|
||||
<DataTable<Secretary>
|
||||
columns={columns}
|
||||
data={items}
|
||||
loading={isLoading}
|
||||
searchValue={search}
|
||||
onSearchChange={(v) => { setSearch(v); setPage(1); }}
|
||||
searchPlaceholder="جستجو بر اساس نام یا پزشک..."
|
||||
emptyMessage="هیچ منشیای یافت نشد"
|
||||
actions={(sec) => (
|
||||
<>
|
||||
<button onClick={() => openEdit(sec)}
|
||||
className="p-1.5 text-gray-400 hover:text-primary-600 hover:bg-primary-50 rounded-lg transition-colors" title="ویرایش دسترسیها">
|
||||
<PencilIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => setDeleteTarget(sec)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" title="حذف">
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={!!editTarget}
|
||||
title={`دسترسیهای ${editTarget?.user_name}`}
|
||||
size="lg"
|
||||
onClose={() => setEditTarget(null)}
|
||||
footer={
|
||||
<>
|
||||
<button onClick={() => setEditTarget(null)}
|
||||
className="px-4 py-2 border border-gray-300 text-sm text-gray-700 rounded-[10px] hover:bg-gray-50 transition-colors">
|
||||
لغو
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editTarget && updatePermsMutation.mutate({ uuid: editTarget.uuid, permissions: editPerms })}
|
||||
disabled={updatePermsMutation.isPending}
|
||||
className="px-4 py-2 bg-primary-600 text-white text-sm rounded-[10px] hover:bg-primary-700 disabled:opacity-50 transition-colors">
|
||||
{updatePermsMutation.isPending ? 'در حال ذخیره...' : 'ذخیره دسترسیها'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<PermissionsMatrix permissions={editPerms} onChange={setEditPerms} />
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="حذف منشی"
|
||||
message={`آیا از حذف منشی "${deleteTarget?.user_name}" اطمینان دارید؟`}
|
||||
confirmLabel="حذف"
|
||||
danger
|
||||
loading={deleteMutation.isPending}
|
||||
onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget)}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { EyeIcon, CheckIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||||
import type { Settlement } from '../types';
|
||||
import { formatDate, formatRial } from '../lib/utils';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
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: 'rejected', label: 'رد شده' },
|
||||
];
|
||||
|
||||
export default function SettlementsPage() {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const [page, setPage] = useState(1);
|
||||
const [statusFilter, setStatusFilter] = useState('pending');
|
||||
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, statusFilter],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||
if (statusFilter) params.set('status', statusFilter);
|
||||
return api.get<PaginatedResponse<Settlement>>(`/api/v1/settlements?${params}`);
|
||||
},
|
||||
});
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: (s: Settlement) =>
|
||||
api.patch<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.patch<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) => <span className="font-medium">{s.representation_name}</span> },
|
||||
{ key: 'amount', header: 'مبلغ', render: (s) => formatRial(s.amount) },
|
||||
{ key: 'bank_card', header: 'شماره کارت', render: (s) => s.bank_card ? <span dir="ltr" className="font-mono text-xs">{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?.items ?? [];
|
||||
const total = data?.data?.total ?? 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="تسویهحساب"
|
||||
breadcrumbs={[{ label: 'داشبورد', to: '/admin/dashboard' }, { label: 'تسویهحساب' }]}
|
||||
/>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
{STATUS_FILTERS.map((f) => (
|
||||
<button key={f.value} onClick={() => { setStatusFilter(f.value); setPage(1); }}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||
statusFilter === f.value ? 'bg-primary-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DataTable<Settlement>
|
||||
columns={columns}
|
||||
data={items}
|
||||
loading={isLoading}
|
||||
emptyMessage="هیچ درخواست تسویهای یافت نشد"
|
||||
actions={(s) => (
|
||||
<>
|
||||
<button onClick={() => navigate(`/admin/settlements/${s.uuid}`)}
|
||||
className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors" title="مشاهده">
|
||||
<EyeIcon className="w-4 h-4" />
|
||||
</button>
|
||||
{s.status === 'pending' && (
|
||||
<>
|
||||
<button onClick={() => setApproveTarget(s)}
|
||||
className="p-1.5 text-gray-400 hover:text-green-600 hover:bg-green-50 rounded-lg transition-colors" title="تأیید">
|
||||
<CheckIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => setRejectTarget(s)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" title="رد">
|
||||
<XMarkIcon className="w-4 h-4" />
|
||||
</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="px-4 py-2 border border-gray-300 text-sm text-gray-700 rounded-[10px] hover:bg-gray-50 transition-colors">
|
||||
لغو
|
||||
</button>
|
||||
<button
|
||||
onClick={() => rejectTarget && rejectMutation.mutate({ s: rejectTarget, reason: rejectReason })}
|
||||
disabled={!rejectReason || rejectMutation.isPending}
|
||||
className="px-4 py-2 bg-red-600 text-white text-sm rounded-[10px] hover:bg-red-700 disabled:opacity-50 transition-colors">
|
||||
{rejectMutation.isPending ? 'در حال ارسال...' : 'رد کردن'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">دلیل رد:</label>
|
||||
<textarea
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
rows={4}
|
||||
placeholder="دلیل رد درخواست را بنویسید..."
|
||||
className="w-full border border-gray-300 rounded-[10px] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 resize-none"
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { CheckIcon, XMarkIcon, TrashIcon, PlusIcon, PencilIcon } from '@heroicons/react/24/outline';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||||
import type { SmsTemplate, SmsLog } from '../types';
|
||||
import { formatDate, formatDateTime } from '../lib/utils';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
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 templateSchema = z.object({
|
||||
name: z.string().min(2, 'نام قالب الزامی است'),
|
||||
category: z.string().min(1, 'دستهبندی الزامی است'),
|
||||
content: z.string().min(5, 'متن قالب الزامی است'),
|
||||
});
|
||||
type TemplateFormData = z.infer<typeof templateSchema>;
|
||||
|
||||
type Tab = 'samples' | 'pending' | 'logs';
|
||||
|
||||
export default function SmsPage() {
|
||||
const qc = useQueryClient();
|
||||
const [activeTab, setActiveTab] = useState<Tab>('samples');
|
||||
const [page, setPage] = useState(1);
|
||||
const [rejectTarget, setRejectTarget] = useState<SmsTemplate | null>(null);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<SmsTemplate | null>(null);
|
||||
const limit = 15;
|
||||
|
||||
const sampleTemplatesQuery = useQuery({
|
||||
queryKey: ['sms-samples', page],
|
||||
queryFn: () =>
|
||||
api.get<PaginatedResponse<SmsTemplate>>(`/api/v1/sms/sample-templates?page=${page}&limit=${limit}`),
|
||||
enabled: activeTab === 'samples',
|
||||
});
|
||||
|
||||
const pendingTemplatesQuery = useQuery({
|
||||
queryKey: ['sms-pending', page],
|
||||
queryFn: () =>
|
||||
api.get<PaginatedResponse<SmsTemplate>>(
|
||||
`/api/v1/sms/templates?status=pending_approval&page=${page}&limit=${limit}`,
|
||||
),
|
||||
enabled: activeTab === 'pending',
|
||||
});
|
||||
|
||||
const logsQuery = useQuery({
|
||||
queryKey: ['sms-logs', page],
|
||||
queryFn: () =>
|
||||
api.get<PaginatedResponse<SmsLog>>(`/api/v1/sms/logs?page=${page}&limit=${limit}`),
|
||||
enabled: activeTab === 'logs',
|
||||
});
|
||||
|
||||
const { register, handleSubmit, reset, formState: { errors, isSubmitting } } = useForm<TemplateFormData>({
|
||||
resolver: zodResolver(templateSchema),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (d: TemplateFormData) =>
|
||||
api.post<ApiResponse<SmsTemplate>>('/api/v1/sms/sample-template', d),
|
||||
onSuccess: () => {
|
||||
toast.success('قالب اضافه شد');
|
||||
setAddOpen(false);
|
||||
reset();
|
||||
qc.invalidateQueries({ queryKey: ['sms-samples'] });
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: (t: SmsTemplate) =>
|
||||
api.patch<ApiResponse<null>>(`/api/v1/sms/templates/${t.uuid}/approve`, {}),
|
||||
onSuccess: () => {
|
||||
toast.success('قالب تأیید شد');
|
||||
qc.invalidateQueries({ queryKey: ['sms-pending'] });
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: ({ t, reason }: { t: SmsTemplate; reason: string }) =>
|
||||
api.patch<ApiResponse<null>>(`/api/v1/sms/templates/${t.uuid}/reject`, { reason }),
|
||||
onSuccess: () => {
|
||||
toast.success('قالب رد شد');
|
||||
setRejectTarget(null);
|
||||
setRejectReason('');
|
||||
qc.invalidateQueries({ queryKey: ['sms-pending'] });
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (t: SmsTemplate) =>
|
||||
api.delete<ApiResponse<null>>(`/api/v1/sms/template/${t.uuid}`),
|
||||
onSuccess: () => {
|
||||
toast.success('قالب حذف شد');
|
||||
setDeleteTarget(null);
|
||||
qc.invalidateQueries({ queryKey: ['sms-samples'] });
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const templateColumns: Column<SmsTemplate>[] = [
|
||||
{ key: 'name', header: 'نام', render: (t) => <span className="font-medium">{t.name}</span> },
|
||||
{
|
||||
key: 'category',
|
||||
header: 'دستهبندی',
|
||||
render: (t) => (
|
||||
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">{t.category}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'content',
|
||||
header: 'محتوا',
|
||||
render: (t) => <span className="text-xs text-gray-500 line-clamp-1 max-w-xs">{t.content}</span>,
|
||||
},
|
||||
{ key: 'status', header: 'وضعیت', render: (t) => <StatusBadge type="sms" value={t.status} /> },
|
||||
{ key: 'created_at', header: 'تاریخ', render: (t) => formatDate(t.created_at) },
|
||||
];
|
||||
|
||||
const pendingColumns: Column<SmsTemplate>[] = [
|
||||
...templateColumns.filter((c) => c.key !== 'status'),
|
||||
{ key: 'owner_name', header: 'ارسالکننده' },
|
||||
];
|
||||
|
||||
const logColumns: Column<SmsLog>[] = [
|
||||
{ key: 'recipient', header: 'گیرنده', render: (l) => <span dir="ltr">{l.recipient}</span> },
|
||||
{ key: 'message', header: 'پیام', render: (l) => <span className="text-xs text-gray-500 line-clamp-1 max-w-xs">{l.message}</span> },
|
||||
{
|
||||
key: 'status',
|
||||
header: 'وضعیت',
|
||||
render: (l) => (
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||
l.status === 'sent' ? 'bg-green-100 text-green-700'
|
||||
: l.status === 'failed' ? 'bg-red-100 text-red-700'
|
||||
: 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{l.status === 'sent' ? 'ارسال شده' : l.status === 'failed' ? 'ناموفق' : 'در صف'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ key: 'provider', header: 'سرویسدهنده' },
|
||||
{ key: 'sent_at', header: 'زمان ارسال', render: (l) => formatDateTime(l.sent_at) },
|
||||
];
|
||||
|
||||
const tabs: { key: Tab; label: string }[] = [
|
||||
{ key: 'samples', label: 'قالبهای نمونه' },
|
||||
{ key: 'pending', label: 'در انتظار تأیید' },
|
||||
{ key: 'logs', label: 'لاگهای ارسال' },
|
||||
];
|
||||
|
||||
const handleTabChange = (tab: Tab) => {
|
||||
setActiveTab(tab);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="پیامک"
|
||||
breadcrumbs={[{ label: 'داشبورد', to: '/admin/dashboard' }, { label: 'پیامک' }]}
|
||||
action={
|
||||
activeTab === 'samples' ? (
|
||||
<button onClick={() => setAddOpen(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white text-sm rounded-[10px] hover:bg-primary-700 transition-colors">
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
افزودن قالب
|
||||
</button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100">
|
||||
<div className="flex border-b border-gray-200 px-6 pt-4">
|
||||
{tabs.map((t) => (
|
||||
<button key={t.key} onClick={() => handleTabChange(t.key)}
|
||||
className={`pb-3 px-4 text-sm font-medium border-b-2 transition-colors -mb-px ${
|
||||
activeTab === t.key
|
||||
? 'border-primary-600 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{activeTab === 'samples' && (
|
||||
<>
|
||||
<DataTable<SmsTemplate>
|
||||
columns={templateColumns}
|
||||
data={sampleTemplatesQuery.data?.data?.items ?? []}
|
||||
loading={sampleTemplatesQuery.isLoading}
|
||||
emptyMessage="هیچ قالبی یافت نشد"
|
||||
actions={(t) => (
|
||||
<>
|
||||
<button onClick={() => setDeleteTarget(t)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" title="حذف">
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<Pagination
|
||||
page={page}
|
||||
total={sampleTemplatesQuery.data?.data?.total ?? 0}
|
||||
limit={limit}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'pending' && (
|
||||
<>
|
||||
<DataTable<SmsTemplate>
|
||||
columns={pendingColumns}
|
||||
data={pendingTemplatesQuery.data?.data?.items ?? []}
|
||||
loading={pendingTemplatesQuery.isLoading}
|
||||
emptyMessage="هیچ قالبی در انتظار تأیید نیست"
|
||||
actions={(t) => (
|
||||
<>
|
||||
<button onClick={() => approveMutation.mutate(t)}
|
||||
className="p-1.5 text-gray-400 hover:text-green-600 hover:bg-green-50 rounded-lg transition-colors" title="تأیید">
|
||||
<CheckIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => setRejectTarget(t)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" title="رد">
|
||||
<XMarkIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<Pagination
|
||||
page={page}
|
||||
total={pendingTemplatesQuery.data?.data?.total ?? 0}
|
||||
limit={limit}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'logs' && (
|
||||
<>
|
||||
<DataTable<SmsLog>
|
||||
columns={logColumns}
|
||||
data={logsQuery.data?.data?.items ?? []}
|
||||
loading={logsQuery.isLoading}
|
||||
emptyMessage="لاگی یافت نشد"
|
||||
/>
|
||||
<Pagination
|
||||
page={page}
|
||||
total={logsQuery.data?.data?.total ?? 0}
|
||||
limit={limit}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal open={addOpen} title="افزودن قالب نمونه" onClose={() => setAddOpen(false)}
|
||||
footer={
|
||||
<>
|
||||
<button onClick={() => setAddOpen(false)}
|
||||
className="px-4 py-2 border border-gray-300 text-sm text-gray-700 rounded-[10px] hover:bg-gray-50 transition-colors">
|
||||
لغو
|
||||
</button>
|
||||
<button form="add-sms-form" type="submit" disabled={isSubmitting}
|
||||
className="px-4 py-2 bg-primary-600 text-white text-sm rounded-[10px] hover:bg-primary-700 disabled:opacity-50 transition-colors">
|
||||
{isSubmitting ? 'در حال ذخیره...' : 'ذخیره'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="add-sms-form" onSubmit={handleSubmit((d) => createMutation.mutate(d))} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">نام قالب</label>
|
||||
<input {...register('name')} placeholder="مثال: تأیید نوبت"
|
||||
className="w-full h-11 border border-gray-300 rounded-[10px] px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" />
|
||||
{errors.name && <p className="text-red-500 text-xs mt-1">{errors.name.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">دستهبندی</label>
|
||||
<input {...register('category')} placeholder="مثال: appointment"
|
||||
className="w-full h-11 border border-gray-300 rounded-[10px] px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" />
|
||||
{errors.category && <p className="text-red-500 text-xs mt-1">{errors.category.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">متن قالب</label>
|
||||
<textarea {...register('content')} rows={4} placeholder="متن پیامک..."
|
||||
className="w-full border border-gray-300 rounded-[10px] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 resize-none" />
|
||||
{errors.content && <p className="text-red-500 text-xs mt-1">{errors.content.message}</p>}
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal open={!!rejectTarget} title="رد قالب پیامک"
|
||||
onClose={() => { setRejectTarget(null); setRejectReason(''); }}
|
||||
footer={
|
||||
<>
|
||||
<button onClick={() => { setRejectTarget(null); setRejectReason(''); }}
|
||||
className="px-4 py-2 border border-gray-300 text-sm text-gray-700 rounded-[10px] hover:bg-gray-50 transition-colors">
|
||||
لغو
|
||||
</button>
|
||||
<button
|
||||
onClick={() => rejectTarget && rejectMutation.mutate({ t: rejectTarget, reason: rejectReason })}
|
||||
disabled={!rejectReason || rejectMutation.isPending}
|
||||
className="px-4 py-2 bg-red-600 text-white text-sm rounded-[10px] hover:bg-red-700 disabled:opacity-50 transition-colors">
|
||||
رد کردن
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">دلیل رد:</label>
|
||||
<textarea value={rejectReason} onChange={(e) => setRejectReason(e.target.value)}
|
||||
rows={4} placeholder="دلیل رد را بنویسید..."
|
||||
className="w-full border border-gray-300 rounded-[10px] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 resize-none" />
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="حذف قالب"
|
||||
message={`آیا از حذف قالب "${deleteTarget?.name}" اطمینان دارید؟`}
|
||||
confirmLabel="حذف"
|
||||
danger
|
||||
loading={deleteMutation.isPending}
|
||||
onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget)}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import React from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ArrowRightIcon } from '@heroicons/react/24/outline';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import type { User } from '../types';
|
||||
import { formatDate, formatDateTime } from '../lib/utils';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import { ActiveBadge } from '../components/ui/StatusBadge';
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-3 border-b border-gray-100 last:border-0">
|
||||
<span className="text-sm text-gray-500">{label}</span>
|
||||
<span className="text-sm font-medium text-gray-900">{value ?? '—'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UserDetailPage() {
|
||||
const { uuid } = useParams<{ uuid: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['user', uuid],
|
||||
queryFn: () => api.get<ApiResponse<User>>(`/api/v1/user-profile/${uuid}`),
|
||||
enabled: !!uuid,
|
||||
});
|
||||
|
||||
const user = data?.data;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="جزئیات کاربر"
|
||||
breadcrumbs={[
|
||||
{ label: 'داشبورد', to: '/admin/dashboard' },
|
||||
{ label: 'کاربران', to: '/admin/users' },
|
||||
{ label: 'جزئیات' },
|
||||
]}
|
||||
action={
|
||||
<button
|
||||
onClick={() => navigate('/admin/users')}
|
||||
className="flex items-center gap-2 text-sm text-gray-500 hover:text-gray-800 transition-colors"
|
||||
>
|
||||
<ArrowRightIcon className="w-4 h-4" />
|
||||
بازگشت
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 p-6 space-y-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="h-8 bg-gray-100 rounded-lg animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : user ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||
<h3 className="font-semibold text-gray-800 mb-4">اطلاعات پایه</h3>
|
||||
<InfoRow label="شناسه" value={<span dir="ltr" className="font-mono text-xs">{user.uuid}</span>} />
|
||||
<InfoRow
|
||||
label="نام و نام خانوادگی"
|
||||
value={`${user.first_name ?? ''} ${user.last_name ?? ''}`.trim() || '—'}
|
||||
/>
|
||||
<InfoRow label="موبایل" value={<span dir="ltr">{user.mobile_number}</span>} />
|
||||
<InfoRow label="ایمیل" value={user.email} />
|
||||
<InfoRow
|
||||
label="نقش"
|
||||
value={
|
||||
user.roles.includes('ROLE_ADMIN')
|
||||
? 'ادمین'
|
||||
: user.roles.includes('ROLE_DOCTOR')
|
||||
? 'پزشک'
|
||||
: 'کاربر'
|
||||
}
|
||||
/>
|
||||
<InfoRow label="وضعیت" value={<ActiveBadge active={user.is_active} />} />
|
||||
<InfoRow label="تاریخ ثبتنام" value={formatDate(user.created_at)} />
|
||||
<InfoRow label="آخرین بروزرسانی" value={formatDateTime(user.updated_at)} />
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||
<h3 className="font-semibold text-gray-800 mb-4">نقشها و دسترسیها</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{user.roles.map((role) => (
|
||||
<span key={role} className="px-3 py-1 bg-primary-50 text-primary-700 text-xs rounded-full font-medium">
|
||||
{role}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 p-16 text-center text-gray-400">
|
||||
کاربری یافت نشد
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { EyeIcon, TrashIcon, PencilIcon } from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||||
import type { User } from '../types';
|
||||
import { formatDate, maskMobile } from '../lib/utils';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import DataTable, { Column } from '../components/ui/DataTable';
|
||||
import { ActiveBadge } from '../components/ui/StatusBadge';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
|
||||
export default function UsersPage() {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [deleteTarget, setDeleteTarget] = useState<User | null>(null);
|
||||
const limit = 15;
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['users', page, search],
|
||||
queryFn: () =>
|
||||
api.get<PaginatedResponse<User>>(
|
||||
`/api/v1/users?page=${page}&limit=${limit}${search ? `&search=${encodeURIComponent(search)}` : ''}`,
|
||||
),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (user: User) => api.delete<ApiResponse<null>>(`/api/v1/user/${user.id}`),
|
||||
onSuccess: () => {
|
||||
toast.success('کاربر حذف شد');
|
||||
setDeleteTarget(null);
|
||||
qc.invalidateQueries({ queryKey: ['users'] });
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const columns: Column<User>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'نام',
|
||||
render: (u) => (
|
||||
<span className="font-medium text-gray-900">
|
||||
{u.first_name || u.last_name
|
||||
? `${u.first_name ?? ''} ${u.last_name ?? ''}`.trim()
|
||||
: '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'mobile_number',
|
||||
header: 'موبایل',
|
||||
render: (u) => <span dir="ltr">{maskMobile(u.mobile_number)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'roles',
|
||||
header: 'نقش',
|
||||
render: (u) => (
|
||||
<span className="text-xs bg-gray-100 text-gray-700 px-2 py-0.5 rounded-full">
|
||||
{u.roles.includes('ROLE_ADMIN')
|
||||
? 'ادمین'
|
||||
: u.roles.includes('ROLE_DOCTOR')
|
||||
? 'پزشک'
|
||||
: 'کاربر'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'is_active',
|
||||
header: 'وضعیت',
|
||||
render: (u) => <ActiveBadge active={u.is_active} />,
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
header: 'تاریخ ثبت',
|
||||
render: (u) => formatDate(u.created_at),
|
||||
},
|
||||
];
|
||||
|
||||
const items = data?.data?.items ?? [];
|
||||
const total = data?.data?.total ?? 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="کاربران"
|
||||
breadcrumbs={[{ label: 'داشبورد', to: '/admin/dashboard' }, { label: 'کاربران' }]}
|
||||
/>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
|
||||
<DataTable<User>
|
||||
columns={columns}
|
||||
data={items}
|
||||
loading={isLoading}
|
||||
searchValue={search}
|
||||
onSearchChange={(v) => { setSearch(v); setPage(1); }}
|
||||
searchPlaceholder="جستجو بر اساس نام یا موبایل..."
|
||||
emptyMessage="هیچ کاربری یافت نشد"
|
||||
actions={(user) => (
|
||||
<>
|
||||
<button
|
||||
onClick={() => navigate(`/admin/users/${user.uuid}`)}
|
||||
className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
||||
title="مشاهده"
|
||||
>
|
||||
<EyeIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate(`/admin/users/${user.uuid}?edit=1`)}
|
||||
className="p-1.5 text-gray-400 hover:text-primary-600 hover:bg-primary-50 rounded-lg transition-colors"
|
||||
title="ویرایش"
|
||||
>
|
||||
<PencilIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteTarget(user)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="حذف"
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="حذف کاربر"
|
||||
message={`آیا از حذف کاربر "${deleteTarget?.first_name ?? deleteTarget?.mobile_number}" اطمینان دارید؟ این عمل قابل بازگشت نیست.`}
|
||||
confirmLabel="حذف"
|
||||
danger
|
||||
loading={deleteMutation.isPending}
|
||||
onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget)}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -28,3 +28,11 @@ body {
|
||||
background-color: var(--color-bg-body);
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
@keyframes scale-in {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.line-clamp-1 { overflow: hidden; display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; }
|
||||
.line-clamp-2 { overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
export interface User {
|
||||
uuid: string;
|
||||
id: number;
|
||||
mobile_number: string;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
email: string | null;
|
||||
roles: string[];
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
uuid: string;
|
||||
blood_group: string | null;
|
||||
weight: number | null;
|
||||
height: number | null;
|
||||
diseases: string | null;
|
||||
medications: string | null;
|
||||
allergies: string | null;
|
||||
insurance_type: string | null;
|
||||
}
|
||||
|
||||
export interface Doctor {
|
||||
uuid: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
medical_code: string;
|
||||
gender: 'male' | 'female';
|
||||
degree: string;
|
||||
bio: string | null;
|
||||
profile_image: string | null;
|
||||
is_active: boolean;
|
||||
specialties: Specialty[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Clinic {
|
||||
uuid: string;
|
||||
name: string;
|
||||
phone: string | null;
|
||||
description: string | null;
|
||||
logo: string | null;
|
||||
is_active: boolean;
|
||||
doctors_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type AppointmentStatus =
|
||||
| 'waiting_for_payment'
|
||||
| 'reserved'
|
||||
| 'checked_in'
|
||||
| 'waiting'
|
||||
| 'in_progress'
|
||||
| 'visited'
|
||||
| 'completed'
|
||||
| 'cancelled_by_user'
|
||||
| 'cancelled_by_doctor'
|
||||
| 'cancelled_by_admin'
|
||||
| 'auto_cancel_unpaid'
|
||||
| 'no_show';
|
||||
|
||||
export interface Appointment {
|
||||
uuid: string;
|
||||
patient_name: string;
|
||||
patient_mobile: string;
|
||||
doctor_name: string;
|
||||
clinic_name: string | null;
|
||||
appointment_date: string;
|
||||
appointment_time: string;
|
||||
status: AppointmentStatus;
|
||||
amount: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type PaymentStatus = 'pending' | 'received' | 'canceled' | 'refund';
|
||||
export type PaymentGateway = 'mellat' | 'sep';
|
||||
|
||||
export interface Payment {
|
||||
uuid: string;
|
||||
amount: number;
|
||||
status: PaymentStatus;
|
||||
gateway: PaymentGateway;
|
||||
ref_id: string | null;
|
||||
patient_mobile: string;
|
||||
appointment_uuid: string | null;
|
||||
paid_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type SettlementStatus = 'pending' | 'approved' | 'rejected';
|
||||
|
||||
export interface Settlement {
|
||||
uuid: string;
|
||||
representation_name: string;
|
||||
amount: number;
|
||||
status: SettlementStatus;
|
||||
bank_card: string | null;
|
||||
bank_name: string | null;
|
||||
reject_reason: string | null;
|
||||
requested_at: string;
|
||||
processed_at: string | null;
|
||||
}
|
||||
|
||||
export interface Representation {
|
||||
uuid: string;
|
||||
domain: string;
|
||||
city: string;
|
||||
commission_percent: number;
|
||||
wallet_balance: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
uuid: string;
|
||||
patient_name: string;
|
||||
doctor_name: string;
|
||||
title: string;
|
||||
body: string;
|
||||
is_approved: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Rating {
|
||||
uuid: string;
|
||||
patient_name: string;
|
||||
doctor_name: string;
|
||||
diagnosis_accuracy: number;
|
||||
skill: number;
|
||||
behavior: number;
|
||||
cleanliness: number;
|
||||
waiting_time: number;
|
||||
overall: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type SmsTemplateStatus = 'draft' | 'pending_approval' | 'approved' | 'rejected';
|
||||
|
||||
export interface SmsTemplate {
|
||||
uuid: string;
|
||||
name: string;
|
||||
category: string;
|
||||
content: string;
|
||||
status: SmsTemplateStatus;
|
||||
owner_name: string | null;
|
||||
reject_reason: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SmsLog {
|
||||
uuid: string;
|
||||
recipient: string;
|
||||
message: string;
|
||||
status: 'queued' | 'sent' | 'failed';
|
||||
provider: string;
|
||||
sent_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type CategoryBundle =
|
||||
| 'state'
|
||||
| 'city'
|
||||
| 'specialty'
|
||||
| 'doctor_service'
|
||||
| 'insurance_type'
|
||||
| 'supplementary_insurance'
|
||||
| 'tag';
|
||||
|
||||
export interface Category {
|
||||
uuid: string;
|
||||
name: string;
|
||||
code: string | null;
|
||||
bundle: CategoryBundle;
|
||||
parent_uuid: string | null;
|
||||
parent_name: string | null;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface Blog {
|
||||
uuid: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
summary: string | null;
|
||||
content: string;
|
||||
cover_image: string | null;
|
||||
status: 'draft' | 'published';
|
||||
author_name: string;
|
||||
views_count: number;
|
||||
tags: string[];
|
||||
published_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Secretary {
|
||||
uuid: string;
|
||||
user_name: string;
|
||||
mobile_number: string;
|
||||
doctor_name: string;
|
||||
doctor_uuid: string;
|
||||
is_active: boolean;
|
||||
permissions: SecretaryPermissions;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SecretaryPermissions {
|
||||
appointments: {
|
||||
view: boolean;
|
||||
create: boolean;
|
||||
cancel: boolean;
|
||||
update_status: boolean;
|
||||
};
|
||||
addresses: {
|
||||
view: boolean;
|
||||
create: boolean;
|
||||
update: boolean;
|
||||
delete: boolean;
|
||||
};
|
||||
clinic_info: {
|
||||
view: boolean;
|
||||
update: boolean;
|
||||
};
|
||||
insurances: {
|
||||
view: boolean;
|
||||
create: boolean;
|
||||
update: boolean;
|
||||
delete: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Specialty {
|
||||
uuid: string;
|
||||
name: string;
|
||||
}
|
||||
Reference in New Issue
Block a user