Backend already returned 403 for ungranted secretary actions, but the UI still showed the add/edit/delete buttons (e.g. clinic-services showed «بخش جدید» to a secretary without services.create). Sweep every secretary-reachable page so each create/edit/delete/manage control renders only when the matching usePermissions().can(resource, action) is true. Owner/doctor/clinic are unaffected — can() returns true when there is no permission context — so this restricts only secretaries and mirrors the server checks. Pages/components gated (resource): - services: ClinicServicesPage, ServiceDetailPage (+ its tabs) - inventory: InventoryPage, InventoryItemsTable, InventoryActionsMenu, PackagesView - tags: TagsSettingsPage · staff: StaffPage · discounts: DiscountTab - sms: SmsWalletPage · insurances: TenantInsuranceContracts - clinic_doctors: ClinicDoctorsPage + ClinicDoctorsManager (props, default true) - patients: PatientsListPage, MyPatientsPage, PatientDetailPage (records/notes/ sessions/attachments/calls/wallet — create/update/delete split) - appointments: AppointmentsPage (add + empty-slot booking gated by create), TurnsTable (status dropdown → read-only badge without update_status; actions menu hidden without manage/cancel) - appointment_settings: AppointmentSettingsPage + ClinicAppointmentSettingsPage pass readOnly to ScheduleSection + FreeVisitPrice (new readOnly prop) Not gated: view/read, search, filter, tabs, navigation, export, and modal submit buttons reachable only via an already-gated trigger. tsc clean; full frontend suite 501/501 passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
293 lines
11 KiB
TypeScript
293 lines
11 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().optional(),
|
|
job_title: z.string().optional(),
|
|
address: z.string().optional(),
|
|
national_code: z.string().optional(),
|
|
});
|
|
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 ?? '',
|
|
});
|
|
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: '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="مدیریت پرسنل"
|
|
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="افزودن پرسنل جدید">
|
|
<form onSubmit={createForm.handleSubmit((d) => createMutation.mutate(d))}>
|
|
<StaffFormFields form={createForm} />
|
|
<div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
|
|
<button type="submit" className="btn primary" disabled={createMutation.isPending}>
|
|
{createMutation.isPending ? 'در حال ذخیره...' : 'ذخیره'}
|
|
</button>
|
|
<button type="button" className="btn" onClick={() => setCreateOpen(false)}>انصراف</button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
|
|
{/* Modal ویرایش */}
|
|
<Modal open={!!editTarget} onClose={() => setEditTarget(null)} title="ویرایش پرسنل">
|
|
<form
|
|
onSubmit={editForm.handleSubmit((d) =>
|
|
editTarget && editMutation.mutate({ uuid: editTarget.uuid, body: d })
|
|
)}
|
|
>
|
|
<StaffFormFields form={editForm} />
|
|
<div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
|
|
<button type="submit" className="btn primary" disabled={editMutation.isPending}>
|
|
{editMutation.isPending ? 'در حال ذخیره...' : 'ذخیره'}
|
|
</button>
|
|
<button type="button" className="btn" onClick={() => setEditTarget(null)}>انصراف</button>
|
|
</div>
|
|
</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>
|
|
);
|
|
}
|
|
|
|
function StaffFormFields({ form }: { form: ReturnType<typeof useForm<StaffFormData>> }) {
|
|
const { register, formState: { errors } } = form;
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
|
<div className="field">
|
|
<label>نام و نام خانوادگی *</label>
|
|
<input {...register('full_name')} placeholder="علی رضایی" />
|
|
{errors.full_name && <span className="field-error">{errors.full_name.message}</span>}
|
|
</div>
|
|
<div className="field">
|
|
<label>سمت</label>
|
|
<input {...register('job_title')} placeholder="پرستار" />
|
|
</div>
|
|
</div>
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
|
<div className="field">
|
|
<label>تلفن</label>
|
|
<input {...numericField(register('phone'), 11)} placeholder="09121234567" />
|
|
</div>
|
|
<div className="field">
|
|
<label>کد ملی</label>
|
|
<input {...numericField(register('national_code'), 10)} placeholder="0012345678" />
|
|
</div>
|
|
</div>
|
|
<div className="field">
|
|
<label>آدرس</label>
|
|
<input {...register('address')} placeholder="آدرس محل سکونت" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|