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:
hamed
2026-06-09 22:53:26 +03:30
parent e522c741b8
commit f619449167
30 changed files with 3870 additions and 0 deletions
+63
View File
@@ -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>
);
}
+122
View File
@@ -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>
);
}
+50
View File
@@ -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>
);
}
+40
View File
@@ -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>
);
}
+72
View File
@@ -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>
);
}
+101
View File
@@ -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'} />
);
}
+76
View File
@@ -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: [];
}
+44
View File
@@ -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>
);
}
+129
View File
@@ -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>
);
}
+168
View File
@@ -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>
);
}
+166
View File
@@ -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>
);
}
+242
View File
@@ -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>
);
}
+89
View File
@@ -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>
);
}
+124
View File
@@ -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>
);
}
+141
View File
@@ -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>
);
}
+110
View File
@@ -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>
);
}
+156
View File
@@ -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>
);
}
+88
View File
@@ -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>
);
}
+124
View File
@@ -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>
);
}
+105
View File
@@ -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>
);
}
+178
View File
@@ -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>
);
}
+253
View File
@@ -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>
);
}
+166
View File
@@ -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>
);
}
+339
View File
@@ -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>
);
}
+103
View File
@@ -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>
);
}
+144
View File
@@ -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>
);
}
+8
View File
@@ -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; }
+235
View File
@@ -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;
}