Files
clinicpro/assets/admin/pages/UsersPage.tsx
T

439 lines
19 KiB
TypeScript

import React, { useState, useEffect, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router';
import {
MagnifyingGlassIcon, PlusIcon,
EyeIcon, TrashIcon, ArrowPathIcon,
CheckCircleIcon, XCircleIcon, ShieldCheckIcon,
} from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import { useUrlState, pageOf } from '../hooks/useUrlState';
import Portal from '../components/ui/Portal';
import type { ApiResponse, PaginatedResponse } from '../lib/api';
import { formatDate, formatNumber, maskMobile } from '../lib/utils';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import Pagination from '../components/ui/Pagination';
import SearchableSelect from '../components/ui/SearchableSelect';
import { XMarkIcon } from '@heroicons/react/24/outline';
import { useOverlayDismiss } from '../hooks/useOverlayDismiss';
// ── Types ─────────────────────────────────────────────────────────────────
interface AdminUser {
uuid: string;
id: number;
mobile_number: string;
name: string | null;
email: string | null;
roles: string[];
is_active: boolean;
status: number;
created_at: string;
}
interface UserStats {
total: number;
active: number;
inactive: number;
admins: number;
doctors: number;
patients: number;
}
// ── Helpers ───────────────────────────────────────────────────────────────
const HUES_LIST = [256, 205, 162, 295, 272];
const ROLE_META: Record<string, { label: string; badgeCls: string }> = {
admin: { label: 'ادمین', badgeCls: 'violet' },
doctor: { label: 'پزشک', badgeCls: 'blue' },
secretary: { label: 'منشی', badgeCls: 'amber' },
clinic: { label: 'کلینیک', badgeCls: 'green' },
representation: { label: 'نماینده', badgeCls: 'red' },
patient: { label: 'بیمار', badgeCls: 'gray' },
};
// نقش‌هایی که از این صفحه قابل تغییرِ مستقیم‌اند (backend `roleMap`). «نماینده» از این‌جا ست نمی‌شود
// چون ساخت نماینده رکورد Representation هم می‌خواهد؛ فقط برای نمایش badge/فیلتر تعریف شده است.
const ASSIGNABLE_ROLES = ['admin', 'doctor', 'secretary', 'clinic', 'patient'];
function getPrimaryRole(roles: string[]): string {
if (roles.includes('ROLE_ADMIN')) return 'admin';
if (roles.includes('ROLE_DOCTOR')) return 'doctor';
if (roles.includes('ROLE_SECRETARY')) return 'secretary';
if (roles.includes('ROLE_CLINIC')) return 'clinic';
if (roles.includes('ROLE_REPRESENTATION')) return 'representation';
return 'patient';
}
function UserAvatar({ name, id, size = 'sm' }: { name: string | null; id: number; size?: 'sm' | 'lg' }) {
const initials = name
? name.split(' ').filter(Boolean).map((w) => w[0]).join('').toUpperCase().slice(0, 2)
: '؟';
const hue = HUES_LIST[id % HUES_LIST.length];
return (
<div
className={`avatar ${size}`}
style={{ background: `linear-gradient(145deg, oklch(0.62 0.15 ${hue}), oklch(0.48 0.16 ${hue}))` }}
>
{initials}
</div>
);
}
function RoleBadge({ roles }: { roles: string[] }) {
const key = getPrimaryRole(roles);
const meta = ROLE_META[key];
return (
<span className={`badge ${meta.badgeCls}`}>
<span className="bdot" />
{meta.label}
</span>
);
}
function ActiveBadge({ active }: { active: boolean }) {
return (
<span className={`badge ${active ? 'green' : 'gray'}`}>
<span className="bdot" />
{active ? 'فعال' : 'غیرفعال'}
</span>
);
}
// ── Role Change Modal ─────────────────────────────────────────────────────
function ChangeRoleModal({ user, onClose, onSave, loading }: {
user: AdminUser; onClose: () => void;
onSave: (role: string) => void; loading: boolean;
}) {
const [selected, setSelected] = useState(getPrimaryRole(user.roles));
const dismiss = useOverlayDismiss(onClose);
return (
<Portal>
<div className="overlay" {...dismiss}>
<div className="modal" style={{ maxWidth: 400 }}>
<div className="modal-head">
<h2 style={{ fontSize: 16 }}>تغییر نقش</h2>
<button className="mini-btn" onClick={onClose}>
<XMarkIcon style={{ width: 18, height: 18 }} />
</button>
</div>
<div className="modal-body">
<p className="muted" style={{ fontSize: 13, marginBottom: 16 }}>{user.name || user.mobile_number}</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{Object.entries(ROLE_META).filter(([key]) => ASSIGNABLE_ROLES.includes(key)).map(([key, meta]) => (
<label
key={key}
style={{
display: 'flex', alignItems: 'center', gap: 12, padding: '11px 14px',
borderRadius: 'var(--r)', cursor: 'pointer',
border: `2px solid ${selected === key ? 'var(--primary)' : 'var(--border)'}`,
background: selected === key ? 'var(--primary-soft)' : 'var(--surface-2)',
transition: '.15s',
}}
>
<input type="radio" name="role" value={key} checked={selected === key}
onChange={() => setSelected(key)} style={{ accentColor: 'var(--primary)', width: 16, height: 16 }} />
<span className={`badge ${meta.badgeCls}`} style={{ pointerEvents: 'none' }}>
<span className="bdot" />{meta.label}
</span>
</label>
))}
</div>
</div>
<div className="modal-foot" style={{ justifyContent: 'flex-end' }}>
<button className="btn ghost sm" onClick={onClose} disabled={loading}>انصراف</button>
<button className="btn primary sm" onClick={() => onSave(selected)} disabled={loading}>
{loading ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</div>
</div>
</div>
</Portal>
);
}
// ── Constants ─────────────────────────────────────────────────────────────
const ROLE_TABS = [
{ key: '', label: 'همه نقش‌ها' },
{ key: 'admin', label: 'ادمین' },
{ key: 'doctor', label: 'پزشک' },
{ key: 'secretary', label: 'منشی' },
{ key: 'clinic', label: 'کلینیک' },
{ key: 'representation', label: 'نماینده' },
{ key: 'patient', label: 'بیمار' },
];
// ── Main Page ─────────────────────────────────────────────────────────────
export default function UsersPage() {
const navigate = useNavigate();
const qc = useQueryClient();
// وضعیت لیست در URL می‌ماند تا «بازگشت» از پروفایل کاربر، همین فیلترها و صفحه را برگرداند.
const [urlState, setUrlState] = useUrlState({ page: '1', search: '', role: '', status: '' });
const page = pageOf(urlState.page);
const search = urlState.search;
const role = urlState.role;
const status = urlState.status;
const setPage = (p: number) => setUrlState({ page: String(p) });
const setRole = (v: string) => setUrlState({ role: v, page: '1' });
const setStatus = (v: string) => setUrlState({ status: v, page: '1' });
const [limit] = useState(25);
// فیلد جستجو local می‌ماند (تایپ روان)؛ مقدارِ debounce‌شده به URL می‌رود.
const [searchInput, setSearchInput] = useState(urlState.search);
const [deleteTarget, setDeleteTarget] = useState<AdminUser | null>(null);
const [roleTarget, setRoleTarget] = useState<AdminUser | null>(null);
useEffect(() => {
const t = setTimeout(() => setUrlState({ search: searchInput, page: '1' }), 350);
return () => clearTimeout(t);
}, [searchInput]);
// ── Queries ──
const statsQ = useQuery({
queryKey: ['users-stats'],
queryFn: () => api.get<ApiResponse<UserStats>>('/api/v1/admin/users/stats'),
staleTime: 30_000,
});
const usersQ = useQuery({
queryKey: ['users', page, limit, search, role, status],
queryFn: () => {
const p = new URLSearchParams({ page: String(page), limit: String(limit) });
if (search) p.set('search', search);
if (role) p.set('role', role);
if (status) p.set('status', status);
return api.get<PaginatedResponse<AdminUser>>(`/api/v1/admin/users?${p}`);
},
});
const stats = useMemo<UserStats | undefined>(
() => (statsQ.data?.data as any)?.data ?? statsQ.data?.data, [statsQ.data]
);
const items = usersQ.data?.data ?? [];
const total = usersQ.data?.meta?.totalRecords ?? 0;
// ── Mutations ──
const deleteMut = useMutation({
mutationFn: (uuid: string) => api.delete<ApiResponse<null>>(`/api/v1/admin/users/${uuid}`),
onSuccess: () => {
toast.success('کاربر حذف شد');
setDeleteTarget(null);
qc.invalidateQueries({ queryKey: ['users'] });
qc.invalidateQueries({ queryKey: ['users-stats'] });
},
onError: (e: Error) => toast.error(e.message),
});
const toggleStatusMut = useMutation({
mutationFn: (uuid: string) => api.post<ApiResponse<{ is_active: boolean }>>(`/api/v1/admin/users/${uuid}/status`, {}),
onSuccess: () => {
toast.success('وضعیت کاربر تغییر کرد');
qc.invalidateQueries({ queryKey: ['users'] });
qc.invalidateQueries({ queryKey: ['users-stats'] });
},
onError: (e: Error) => toast.error(e.message),
});
const updateRoleMut = useMutation({
mutationFn: ({ uuid, role: r }: { uuid: string; role: string }) =>
api.put<ApiResponse<null>>(`/api/v1/admin/users/${uuid}/role`, { role: r }),
onSuccess: () => {
toast.success('نقش کاربر بروزرسانی شد');
setRoleTarget(null);
qc.invalidateQueries({ queryKey: ['users'] });
qc.invalidateQueries({ queryKey: ['users-stats'] });
},
onError: (e: Error) => toast.error(e.message),
});
// ── KPI data ──
const kpiCards = [
{ label: 'کل کاربران', value: stats?.total, bg: 'var(--surface-3)', color: 'var(--text-2)' },
{ label: 'فعال', value: stats?.active, bg: 'var(--success-bg)', color: 'var(--success)' },
{ label: 'غیرفعال', value: stats?.inactive, bg: 'var(--surface-3)', color: 'var(--text-3)' },
{ label: 'ادمین‌ها', value: stats?.admins, bg: 'var(--violet-bg)', color: 'var(--violet)' },
{ label: 'پزشکان', value: stats?.doctors, bg: 'var(--info-bg)', color: 'var(--info)' },
{ label: 'بیماران', value: stats?.patients, bg: 'var(--warning-bg)', color: 'var(--warning)' },
];
return (
<div className="fade-in">
{/* Header */}
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
<div>
<h1 className="section-title">کاربران</h1>
<div className="muted" style={{ fontSize: 13, marginTop: 3 }}>مدیریت کاربران، نقش‌ها و سطح دسترسی</div>
</div>
<button className="btn primary sm">
<PlusIcon style={{ width: 15, height: 15 }} />
افزودن کاربر
</button>
</div>
{/* KPI cards */}
<div className="stat-grid">
{kpiCards.map((c) => (
<div key={c.label} className="stat">
<div className="ico" style={{ background: c.bg, color: c.color }}>
<svg style={{ width: 20, height: 20 }} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div className="lbl">{c.label}</div>
<div className="val">
{c.value === undefined
? <span className="skeleton" style={{ display: 'inline-block', width: 48, height: 26, borderRadius: 4 }} />
: formatNumber(c.value)}
</div>
</div>
))}
</div>
{/* Main card */}
<div className="card">
{/* Toolbar */}
<div className="card-pad" style={{ paddingBottom: 0 }}>
<div className="toolbar">
<div className="field" style={{ minWidth: 240 }}>
<MagnifyingGlassIcon style={{ width: 17, height: 17 }} />
<input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="شماره کاربری، نام، موبایل یا ایمیل..."
/>
</div>
<div style={{ minWidth: 160 }}>
<SearchableSelect
options={ROLE_TABS.map(t => ({ value: t.key, label: t.label }))}
value={role || ''}
onChange={(v) => setRole(v ? String(v) : '')}
height={36}
/>
</div>
<div className="seg">
<button className={!status ? 'on' : ''} onClick={() => setStatus('')}>همه</button>
<button className={status === '1' ? 'on' : ''} onClick={() => setStatus('1')}>فعال</button>
<button className={status === '0' ? 'on' : ''} onClick={() => setStatus('0')}>غیرفعال</button>
</div>
<div className="spacer" />
<button className="btn ghost sm" onClick={() => usersQ.refetch()} disabled={usersQ.isFetching} title="به‌روزرسانی">
<ArrowPathIcon style={{ width: 15, height: 15, animation: usersQ.isFetching ? 'spin 1s linear infinite' : undefined }} />
</button>
</div>
</div>
{/* Table */}
<div className="table-wrap">
<table className="t">
<thead>
<tr>
<th>کاربر</th>
<th>شماره موبایل</th>
<th>نقش</th>
<th>تاریخ عضویت</th>
<th>وضعیت</th>
<th></th>
</tr>
</thead>
<tbody>
{usersQ.isLoading && Array.from({ length: 7 }).map((_, i) => (
<tr key={i}>
{Array.from({ length: 6 }).map((_, j) => (
<td key={j}>
<div className="skeleton" style={{ height: 14, borderRadius: 6, width: j === 1 ? '70%' : '55%' }} />
</td>
))}
</tr>
))}
{!usersQ.isLoading && items.length === 0 && (
<tr>
<td colSpan={6}>
<div className="empty">هیچ کاربری یافت نشد</div>
</td>
</tr>
)}
{!usersQ.isLoading && items.map((user) => (
<tr key={user.uuid}>
<td>
<div className="cell-user">
<UserAvatar name={user.name} id={user.id} />
<div>
<b style={{ cursor: 'pointer' }} onClick={() => navigate(`/admin/users/${user.uuid}`)}>
{user.name || '—'}
</b>
<br /><small>#{user.id}</small>
</div>
</div>
</td>
<td style={{ fontFamily: 'monospace', direction: 'ltr', textAlign: 'right' }}>
{user.mobile_number}
</td>
<td><RoleBadge roles={user.roles} /></td>
<td className="muted">{formatDate(user.created_at)}</td>
<td><ActiveBadge active={user.is_active} /></td>
<td>
<div className="row-actions">
<button className="mini-btn" title="مشاهده"
onClick={() => navigate(`/admin/users/${user.uuid}`)}>
<EyeIcon style={{ width: 16, height: 16 }} />
</button>
<button className="mini-btn" title="تغییر نقش"
onClick={() => setRoleTarget(user)}>
<ShieldCheckIcon style={{ width: 16, height: 16 }} />
</button>
<button className="mini-btn" title={user.is_active ? 'غیرفعال کردن' : 'فعال‌سازی'}
onClick={() => toggleStatusMut.mutate(user.uuid)} disabled={toggleStatusMut.isPending}>
{user.is_active
? <XCircleIcon style={{ width: 16, height: 16 }} />
: <CheckCircleIcon style={{ width: 16, height: 16 }} />}
</button>
<button className="mini-btn danger" title="حذف" onClick={() => setDeleteTarget(user)}>
<TrashIcon style={{ width: 16, height: 16 }} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
</div>
{/* Dialogs */}
<ConfirmDialog
open={!!deleteTarget}
title="حذف کاربر"
message={`آیا از حذف کاربر "${deleteTarget?.name ?? deleteTarget?.mobile_number}" اطمینان دارید؟ این عمل قابل بازگشت نیست.`}
confirmLabel="حذف"
danger
loading={deleteMut.isPending}
onConfirm={() => deleteTarget && deleteMut.mutate(deleteTarget.uuid)}
onCancel={() => setDeleteTarget(null)}
/>
{roleTarget && (
<ChangeRoleModal
user={roleTarget}
loading={updateRoleMut.isPending}
onClose={() => setRoleTarget(null)}
onSave={(r) => updateRoleMut.mutate({ uuid: roleTarget.uuid, role: r })}
/>
)}
</div>
);
}