Files
clinicpro/assets/admin/pages/StaffPage.tsx
T
hamed ecdefa3c24 feat(tour): add onboarding tours for various admin pages
- Integrated TourButton component into SettingsMenuPage, SkillsPage, SmsWalletPage, StaffPage, StaffSessionDetailPage, StaffTreatmentSessionsPage, SubscriptionPage, TagsSettingsPage, TreatmentCasesPage to enhance user onboarding experience.
- Created new tour definitions for appointments, clinics, staff management, financial management, and patient management, ensuring comprehensive guidance for users navigating the admin panel.
- Updated documentation to reflect the addition of tours and their implementation details.
2026-08-10 10:10:20 +03:30

388 lines
15 KiB
TypeScript

import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { PencilIcon, EyeIcon, EyeSlashIcon, PlusIcon, UserGroupIcon } 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 { ClinicStaff } from '../types';
import { formatDate } from '../lib/utils';
import DataTable, { type Column } from '../components/ui/DataTable';
import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import PageHeader from '../components/ui/PageHeader';
import SettingsLayout from '../components/layout/SettingsLayout';
import { ActiveBadge } from '../components/ui/StatusBadge';
import { numericField } from '../lib/forms';
import { usePermissions } from '../hooks/usePermissions';
// هر پرسنل حساب کاربری ورود دارد، پس موبایل همان نام‌کاربری است و اجباری.
const schema = z.object({
full_name: z.string().min(2, 'نام حداقل ۲ کاراکتر باید باشد'),
phone: z.string().regex(/^09\d{9}$/, 'شماره موبایل معتبر (۱۱ رقمی) وارد کنید'),
job_title: z.string().optional(),
address: z.string().optional(),
national_code: z.string().optional(),
password: z.string().optional(),
}).superRefine((data, ctx) => {
if (data.password && data.password.length < 8) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['password'],
message: 'رمز عبور حداقل ۸ کاراکتر باشد',
});
}
});
type StaffFormData = z.infer<typeof schema>;
const EMPTY: ClinicStaff[] = [];
export default function StaffPage() {
const qc = useQueryClient();
// مجوزهای منشی؛ برای owner/پزشک همیشه true (usePermissions بدون context آزاد است).
const { can } = usePermissions();
const canCreate = can('staff', 'create');
const canUpdate = can('staff', 'update');
const [createOpen, setCreateOpen] = useState(false);
const [editTarget, setEditTarget] = useState<ClinicStaff | null>(null);
const [toggleTarget, setToggleTarget] = useState<ClinicStaff | null>(null);
const { data, isLoading } = useQuery<ApiResponse<ClinicStaff[]>>({
queryKey: ['staff'],
queryFn: () => api.get('/api/v1/staff'),
});
const staff = data?.data ?? EMPTY;
const total = staff.length;
const active = staff.filter((s) => s.active).length;
const inactive = total - active;
const createForm = useForm<StaffFormData>({ resolver: zodResolver(schema) });
const editForm = useForm<StaffFormData>({ resolver: zodResolver(schema) });
const createMutation = useMutation({
mutationFn: (body: StaffFormData) => api.post('/api/v1/staff', body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['staff'] });
setCreateOpen(false);
createForm.reset();
toast.success('پرسنل ایجاد شد');
},
onError: (err: any) => toast.error(err.message),
});
const editMutation = useMutation({
mutationFn: ({ uuid, body }: { uuid: string; body: StaffFormData }) =>
api.patch(`/api/v1/staff/${uuid}`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['staff'] });
setEditTarget(null);
toast.success('اطلاعات پرسنل ویرایش شد');
},
onError: (err: any) => toast.error(err.message),
});
const toggleMutation = useMutation({
mutationFn: (uuid: string) => api.patch(`/api/v1/staff/${uuid}/toggle`, {}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['staff'] });
setToggleTarget(null);
toast.success('وضعیت پرسنل تغییر کرد');
},
onError: (err: any) => toast.error(err.message),
});
const openEdit = (s: ClinicStaff) => {
editForm.reset({
full_name: s.full_name,
phone: s.phone ?? '',
job_title: s.job_title ?? '',
address: s.address ?? '',
national_code: s.national_code ?? '',
password: '',
});
setEditTarget(s);
};
const columns: Column<ClinicStaff>[] = [
{
key: 'full_name',
header: 'پرسنل',
render: (s) => (
<div>
<div style={{ fontWeight: 600, fontSize: 14 }}>{s.full_name}</div>
{s.job_title && (
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 2 }}>{s.job_title}</div>
)}
</div>
),
},
{
key: 'phone',
header: 'تلفن',
render: (s) => (
<span style={{ fontSize: 13, direction: 'ltr', display: 'inline-block' }}>
{s.phone ?? '—'}
</span>
),
},
{
key: 'national_code',
header: 'کد ملی',
render: (s) => (
<span style={{ fontSize: 13, direction: 'ltr', display: 'inline-block' }}>
{s.national_code ?? '—'}
</span>
),
},
{
key: 'active',
header: 'وضعیت',
render: (s) => <ActiveBadge active={s.active} />,
},
{
key: 'has_account',
header: 'حساب کاربری',
render: (s) => (
<span className={`badge ${s.has_account ? 'green' : ''}`} style={{ fontSize: 12 }}>
{s.has_account ? 'دارد' : 'ندارد'}
</span>
),
},
{
key: 'created_at',
header: 'تاریخ ثبت',
render: (s) => (
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>{formatDate(s.created_at)}</span>
),
},
{
key: 'uuid',
header: 'عملیات',
render: (s) => (
<div style={{ display: 'flex', gap: 6 }}>
{canUpdate && (
<button className="btn sm" onClick={() => openEdit(s)} title="ویرایش">
<PencilIcon style={{ width: 15 }} />
</button>
)}
{canUpdate && (
<button
className="btn sm"
onClick={() => setToggleTarget(s)}
title={s.active ? 'غیرفعال‌سازی' : 'فعال‌سازی'}
>
{s.active
? <EyeSlashIcon style={{ width: 15 }} />
: <EyeIcon style={{ width: 15 }} />
}
</button>
)}
</div>
),
},
];
return (
<SettingsLayout active="staff">
<PageHeader
title="مدیریت پرسنل"
tourId="staff"
description="لیست پرسنل کلینیک / مطب"
action={
canCreate ? (
<button className="btn primary sm" onClick={() => setCreateOpen(true)}>
<PlusIcon style={{ width: 16 }} /> افزودن پرسنل
</button>
) : undefined
}
/>
{/* KPI bar */}
{!isLoading && (
<div className="stat-grid" style={{ marginBottom: 16 }}>
<div className="card card-pad" style={{ textAlign: 'center' }}>
<div style={{ fontSize: 28, fontWeight: 800, color: 'var(--text-1)' }}>{total}</div>
<div style={{ fontSize: 13, color: 'var(--text-3)', marginTop: 2 }}>کل پرسنل</div>
</div>
<div className="card card-pad" style={{ textAlign: 'center' }}>
<div style={{ fontSize: 28, fontWeight: 800, color: 'oklch(0.55 0.16 162)' }}>{active}</div>
<div style={{ fontSize: 13, color: 'var(--text-3)', marginTop: 2 }}>فعال</div>
</div>
<div className="card card-pad" style={{ textAlign: 'center' }}>
<div style={{ fontSize: 28, fontWeight: 800, color: 'oklch(0.55 0.18 25)' }}>{inactive}</div>
<div style={{ fontSize: 13, color: 'var(--text-3)', marginTop: 2 }}>غیرفعال</div>
</div>
</div>
)}
<div className="card">
{staff.length === 0 && !isLoading ? (
<div style={{ textAlign: 'center', padding: '60px 24px', color: 'var(--text-3)' }}>
<UserGroupIcon style={{ width: 48, margin: '0 auto 16px', display: 'block', opacity: 0.4 }} />
<div style={{ fontWeight: 600, fontSize: 15, marginBottom: 8, color: 'var(--text-2)' }}>هنوز پرسنلی ثبت نشده</div>
<div style={{ fontSize: 13, marginBottom: 20 }}>اولین عضو تیم خود را اضافه کنید</div>
{canCreate && (
<button className="btn primary sm" onClick={() => setCreateOpen(true)}>
<PlusIcon style={{ width: 16 }} /> افزودن پرسنل
</button>
)}
</div>
) : (
<DataTable columns={columns} data={staff} loading={isLoading} />
)}
</div>
{/* Modal ایجاد */}
<Modal
open={createOpen}
onClose={() => setCreateOpen(false)}
title="افزودن پرسنل جدید"
size="lg"
footer={
<>
<button type="button" className="btn ghost sm" onClick={() => setCreateOpen(false)}>انصراف</button>
<button form="staff-create-form" type="submit" className="btn primary sm" disabled={createMutation.isPending}>
{createMutation.isPending ? 'در حال ذخیره...' : 'ذخیره پرسنل'}
</button>
</>
}
>
<form id="staff-create-form" onSubmit={createForm.handleSubmit((d) => createMutation.mutate(d))}>
<StaffFormFields form={createForm} mode="create" />
</form>
</Modal>
{/* Modal ویرایش */}
<Modal
open={!!editTarget}
onClose={() => setEditTarget(null)}
title={editTarget ? `ویرایش — ${editTarget.full_name}` : 'ویرایش پرسنل'}
size="lg"
footer={
<>
<button type="button" className="btn ghost sm" onClick={() => setEditTarget(null)}>انصراف</button>
<button form="staff-edit-form" type="submit" className="btn primary sm" disabled={editMutation.isPending}>
{editMutation.isPending ? 'در حال ذخیره...' : 'ذخیره تغییرات'}
</button>
</>
}
>
<form
id="staff-edit-form"
onSubmit={editForm.handleSubmit((d) =>
editTarget && editMutation.mutate({ uuid: editTarget.uuid, body: d })
)}
>
<StaffFormFields form={editForm} mode="edit" />
</form>
</Modal>
{/* Confirm toggle */}
<ConfirmDialog
open={!!toggleTarget}
title={toggleTarget?.active ? 'غیرفعال‌سازی پرسنل' : 'فعال‌سازی پرسنل'}
message={`آیا مطمئن هستید که می‌خواهید «${toggleTarget?.full_name}» را ${toggleTarget?.active ? 'غیرفعال' : 'فعال'} کنید؟`}
confirmLabel={toggleTarget?.active ? 'غیرفعال کن' : 'فعال کن'}
onConfirm={() => toggleTarget && toggleMutation.mutate(toggleTarget.uuid)}
onCancel={() => setToggleTarget(null)}
loading={toggleMutation.isPending}
/>
</SettingsLayout>
);
}
const Required = () => <span style={{ color: 'var(--danger)' }}>*</span>;
/** تیتر یک بخش از فرم — همان الگوی سایر فرم‌های چندبخشی پنل. */
function FormSection({ title, hint, children }: { title: string; hint?: string; children: React.ReactNode }) {
return (
<section style={{ marginBottom: 22 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, marginBottom: 12 }}>
<h3 style={{ fontSize: 14, fontWeight: 700, color: 'var(--text)' }}>{title}</h3>
{hint && <span style={{ fontSize: 12, color: 'var(--text-3)' }}>{hint}</span>}
</div>
{children}
</section>
);
}
function StaffFormFields({ form, mode }: { form: ReturnType<typeof useForm<StaffFormData>>; mode: 'create' | 'edit' }) {
const { register, formState: { errors } } = form;
return (
<>
<FormSection title="مشخصات پرسنل">
<div className="grid-2" style={{ gap: 14, marginBottom: 0 }}>
<div className="form-row" style={{ marginBottom: 0 }}>
<label>نام و نام خانوادگی <Required /></label>
<input {...register('full_name')} className={`input ${errors.full_name ? 'err' : ''}`} placeholder="علی رضایی" />
{errors.full_name && <p className="err-text">{errors.full_name.message}</p>}
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label>سمت</label>
<input {...register('job_title')} className="input" placeholder="پرستار" />
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label>کد ملی</label>
<input {...numericField(register('national_code'), 10)} className="input" dir="ltr" inputMode="numeric" placeholder="0012345678" />
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label>آدرس</label>
<input {...register('address')} className="input" placeholder="آدرس محل سکونت" />
</div>
</div>
</FormSection>
<FormSection title="حساب کاربری ورود به پنل" hint="برای هر پرسنل ساخته می‌شود">
<div
style={{
background: 'var(--primary-soft)',
border: '1px solid var(--border)',
borderRadius: 'var(--r)',
padding: 16,
}}
>
<p style={{ fontSize: 12.5, color: 'var(--text-2)', lineHeight: 1.9, marginBottom: 14 }}>
پرسنل با همین شمارهٔ موبایل وارد پنل می‌شود و فقط داشبورد و سرویس‌های خودش را می‌بیند.
</p>
<div className="grid-2" style={{ gap: 14, marginBottom: 0 }}>
<div className="form-row" style={{ marginBottom: 0 }}>
<label>موبایل نام کاربری <Required /></label>
<input
{...numericField(register('phone'), 11)}
className={`input ${errors.phone ? 'err' : ''}`}
dir="ltr"
inputMode="numeric"
autoComplete="off"
placeholder="09121234567"
/>
{errors.phone && <p className="err-text">{errors.phone.message}</p>}
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label>رمز عبور</label>
<input
type="password"
autoComplete="new-password"
{...register('password')}
className={`input ${errors.password ? 'err' : ''}`}
dir="ltr"
placeholder={mode === 'edit' ? 'خالی = بدون تغییر' : 'حداقل ۸ کاراکتر'}
/>
{errors.password
? <p className="err-text">{errors.password.message}</p>
: <p style={{ fontSize: 11.5, color: 'var(--text-3)' }}>
{mode === 'edit'
? 'برای تغییر رمز، مقدار جدید را وارد کنید.'
: 'خالی بگذارید تا پرسنل خودش با «فراموشی رمز» تعیین کند.'}
</p>}
</div>
</div>
</div>
</FormSection>
</>
);
}