feat(secretary): implement multi-doctor assignment for clinic secretaries

- Added functionality to assign a single secretary to multiple doctors within a clinic, allowing for scoped access to appointments.
- Introduced `SecretaryService` to handle the logic for assigning and syncing doctors for a secretary.
- Updated `SecretaryController` to support multi-doctor assignment via new endpoints and modified existing ones.
- Enhanced `DoctorSecretary` entity to include secretary UUID in its serialized output.
- Implemented repository methods to facilitate the retrieval and management of doctor-secretary relationships.
- Adjusted appointment filtering in `MyAppointmentsController` to ensure secretaries only see appointments for assigned doctors.
- Created tests to validate the new multi-doctor assignment functionality and appointment access restrictions.
- Updated frontend components to support multi-select for doctors in the secretary management UI.
This commit is contained in:
hamed
2026-07-18 08:49:04 +03:30
parent 6ab7ed38b8
commit 1779e0d6de
13 changed files with 947 additions and 51 deletions
+143 -37
View File
@@ -5,7 +5,6 @@ import { toast } from "sonner";
import SettingsLayout from "../components/layout/SettingsLayout";
import ConfirmDialog from "../components/ui/ConfirmDialog";
import Modal from "../components/ui/Modal";
import SearchableSelect from "../components/ui/SearchableSelect";
import type { ApiResponse } from "../lib/api";
import { api } from "../lib/api";
import { formatDate } from "../lib/utils";
@@ -284,6 +283,8 @@ function SecretaryModal({
mode,
data,
saving,
isClinic,
clinicDoctors,
onClose,
onSubmit,
}: {
@@ -291,8 +292,10 @@ function SecretaryModal({
mode: ModalMode;
data: Secretary | null;
saving: boolean;
isClinic: boolean;
clinicDoctors: ClinicDoctor[];
onClose: () => void;
onSubmit: (form: FormState) => void;
onSubmit: (form: FormState, doctorUuids: string[]) => void;
}) {
const [form, setForm] = useState<FormState>({
name: "",
@@ -302,9 +305,14 @@ function SecretaryModal({
address: "",
permission: EMPTY_PERMISSIONS,
});
const [doctorUuids, setDoctorUuids] = useState<string[]>([]);
// نمایش انتخاب چند پزشک فقط هنگام افزودنِ منشیِ کلینیک
const showDoctorPicker = isClinic && mode === "add";
useEffect(() => {
if (!open) return;
setDoctorUuids([]);
if ((mode === "edit" || mode === "view") && data) {
const parts = (data.user_name ?? "").split(" ");
setForm({
@@ -357,7 +365,9 @@ function SecretaryModal({
if (!form.telephone.trim()) return toast.error("لطفاً شماره تلفن را وارد کنید");
if (!/^09\d{9}$/.test(form.telephone))
return toast.error("شماره تلفن باید 11 رقم و با 09 شروع شود");
onSubmit(form);
if (showDoctorPicker && doctorUuids.length === 0)
return toast.error("حداقل یک پزشک را انتخاب کنید");
onSubmit(form, doctorUuids);
};
return (
@@ -388,6 +398,22 @@ function SecretaryModal({
}
>
<div dir="rtl" className="flex flex-col justify-start items-start gap-[24px] w-full">
{/* انتخاب پزشکان (فقط افزودن منشیِ کلینیک) */}
{showDoctorPicker && (
<div className="w-full">
<div className="flex items-center justify-between mb-[16px]">
<p className="text-[#525252] dark:text-[#D7D8ED] text-[16px] font-bold">
پزشکانِ این منشی
</p>
<span className="text-[13px] text-[#7E7E7E]">{doctorUuids.length} انتخابشده</span>
</div>
<p className="text-[13px] text-[#7E7E7E] mb-[12px]">
منشی فقط به نوبتها و اطلاعاتِ پزشکانِ انتخابشده دسترسی خواهد داشت.
</p>
<DoctorMultiSelect doctors={clinicDoctors} selected={doctorUuids} onChange={setDoctorUuids} />
</div>
)}
{/* اطلاعات پایه */}
<div className="w-full">
<p className="text-[#525252] dark:text-[#D7D8ED] text-[16px] font-bold mb-[16px]">
@@ -599,6 +625,97 @@ interface ClinicDoctor {
name: string;
}
// چک‌لیستِ چند-انتخابی پزشکان کلینیک برای تخصیص یک منشیِ مشترک
function DoctorMultiSelect({
doctors,
selected,
onChange,
}: {
doctors: ClinicDoctor[];
selected: string[];
onChange: (v: string[]) => void;
}) {
const [q, setQ] = useState("");
const filtered = q ? doctors.filter((d) => d.name.includes(q)) : doctors;
const toggle = (uuid: string) =>
onChange(selected.includes(uuid) ? selected.filter((x) => x !== uuid) : [...selected, uuid]);
const allSelected = doctors.length > 0 && selected.length === doctors.length;
const toggleAll = () => onChange(allSelected ? [] : doctors.map((d) => d.uuid));
return (
<div className="w-full border border-[#EFEFEF] dark:border-[#343645] rounded-[8px] overflow-hidden">
{/* هدر: جستجو + انتخاب همه */}
<div className="flex items-center gap-[8px] p-[10px] border-b border-[#EFEFEF] dark:border-[#343645] bg-[#FAFAFC] dark:bg-[#222433]">
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" className="flex-shrink-0">
<path d="M9 16A7 7 0 109 2a7 7 0 000 14zM18 18l-3.5-3.5" stroke="#9A9AB0" strokeWidth="1.5" strokeLinecap="round" />
</svg>
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="جستجوی پزشک..."
className="flex-1 text-[13px] bg-transparent outline-none text-[#525252] dark:text-[#D7D8ED]"
/>
<button
type="button"
onClick={toggleAll}
className="text-[12px] text-[#5559CE] font-medium whitespace-nowrap cursor-pointer"
>
{allSelected ? "لغو همه" : "انتخاب همه"}
</button>
</div>
{/* چیپ‌های انتخاب‌شده */}
{selected.length > 0 && (
<div className="flex flex-wrap gap-[6px] p-[10px] border-b border-[#EFEFEF] dark:border-[#343645]">
{selected.map((uuid) => {
const d = doctors.find((x) => x.uuid === uuid);
if (!d) return null;
return (
<span
key={uuid}
onClick={() => toggle(uuid)}
className="inline-flex items-center gap-[4px] bg-[#EEF0FF] dark:bg-[#33365A] text-[#5559CE] dark:text-[#C7CEF4] text-[12px] px-[8px] py-[3px] rounded-full cursor-pointer"
>
{d.name}
<span className="text-[14px] leading-none">×</span>
</span>
);
})}
</div>
)}
{/* لیست پزشکان */}
<div className="max-h-[220px] overflow-y-auto">
{filtered.length === 0 ? (
<p className="text-center text-[12px] text-[#7E7E7E] py-[14px]">نتیجهای یافت نشد</p>
) : (
filtered.map((d) => {
const checked = selected.includes(d.uuid);
return (
<label
key={d.uuid}
className={
"flex items-center gap-[10px] px-[12px] py-[9px] cursor-pointer border-b border-[#F2F2F6] dark:border-[#2A2C3A] last:border-b-0 " +
(checked ? "bg-[#F5F6FF] dark:bg-[#2A2D45]" : "hover:bg-[#FAFAFC] dark:hover:bg-[#2A2C3A]")
}
>
<input
type="checkbox"
className="accent-[#5559CE] w-[16px] h-[16px]"
checked={checked}
onChange={() => toggle(d.uuid)}
/>
<Avatar name={d.name} size={26} />
<span className="text-[13px] text-[#525252] dark:text-[#D7D8ED]">{d.name}</span>
</label>
);
})
)}
</div>
</div>
);
}
function MySecretariesPageContent() {
const qc = useQueryClient();
const { doctorUuid, dbUuid, primaryRole } = useAuthStore();
@@ -606,8 +723,7 @@ function MySecretariesPageContent() {
const { maxSecretaries } = useSubscription();
const [tab, setTab] = useState(0); // 0: منشی های فعلی، 1: منشی های قبلی
const [selectedDoctorUuid, setSelectedDoctorUuid] = useState<string>("");
const activeDoctorUuid = isClinic ? selectedDoctorUuid : (doctorUuid ?? "");
const activeDoctorUuid = doctorUuid ?? "";
const [modalOpen, setModalOpen] = useState(false);
const [modalMode, setModalMode] = useState<ModalMode>("add");
@@ -615,7 +731,7 @@ function MySecretariesPageContent() {
const [deactivateTarget, setDeactivateTarget] = useState<Secretary | null>(null);
// clinic: list of doctors
const { data: clinicDoctorsData, isLoading: clinicDoctorsLoading } = useQuery<
const { data: clinicDoctorsData } = useQuery<
ApiResponse<{ data: ClinicDoctor[] }>
>({
queryKey: ["clinic-doctors", dbUuid],
@@ -652,17 +768,28 @@ function MySecretariesPageContent() {
};
const createMutation = useMutation({
mutationFn: (form: FormState) =>
api.post("/api/v1/secretary", {
doctor_uuid: activeDoctorUuid,
mutationFn: ({ form, doctorUuids }: { form: FormState; doctorUuids: string[] }) => {
const base = {
mobile_number: form.telephone,
name: `${form.name} ${form.family}`.trim(),
national_code: form.national_code || null,
address: form.address || null,
permissions: { version: 1, resources: form.permission },
}),
onSuccess: () => {
toast.success("منشی با موفقیت اضافه شد");
};
return api.post<ApiResponse<any>>(
"/api/v1/secretary",
isClinic
? { ...base, doctor_uuids: doctorUuids }
: { ...base, doctor_uuid: activeDoctorUuid },
);
},
onSuccess: (res: ApiResponse<any>) => {
const skippedLimit = res?.data?.skipped_limit?.length ?? 0;
if (isClinic && skippedLimit > 0) {
toast.warning(`${skippedLimit} پزشک به‌دلیل محدودیت پلن اضافه نشد`);
} else {
toast.success("منشی با موفقیت اضافه شد");
}
setModalOpen(false);
invalidate();
},
@@ -699,7 +826,6 @@ function MySecretariesPageContent() {
const saving = createMutation.isPending || updateMutation.isPending;
const handleAddClick = () => {
if (isClinic && !selectedDoctorUuid) return toast.error("ابتدا یک پزشک را انتخاب کنید");
if (atLimit) return toast.error(`حداکثر ${maxSecretaries} منشی مجاز است؛ برای افزودن، پنل را ارتقا دهید`);
setModalMode("add");
setSelected(null);
@@ -712,8 +838,8 @@ function MySecretariesPageContent() {
setModalOpen(true);
};
const handleModalSubmit = (form: FormState) => {
if (modalMode === "add") createMutation.mutate(form);
const handleModalSubmit = (form: FormState, doctorUuids: string[]) => {
if (modalMode === "add") createMutation.mutate({ form, doctorUuids });
else if (modalMode === "edit" && selected) updateMutation.mutate({ uuid: selected.uuid, form });
};
@@ -723,27 +849,6 @@ function MySecretariesPageContent() {
<p className="text-[#525252] dark:text-[#D7D8ED] text-[20px] font-bold">لیست منشی ها</p>
</div>
{/* کلینیک: انتخاب پزشک */}
{isClinic && (
<div className="mt-[16px] max-w-[360px]">
<label className="text-[#525252] dark:text-[#D7D8ED] text-[14px] font-medium block mb-[8px]">
پزشک مورد نظر برای افزودن منشی جدید
</label>
{clinicDoctorsLoading ? (
<p className="text-[13px] text-[#7E7E7E]">در حال بارگذاری...</p>
) : clinicDoctors.length === 0 ? (
<p className="text-[13px] text-[#7E7E7E]">هیچ پزشکی در این کلینیک تعریف نشده است.</p>
) : (
<SearchableSelect
options={clinicDoctors.map((d) => ({ value: d.uuid, label: d.name }))}
value={selectedDoctorUuid}
onChange={(v) => setSelectedDoctorUuid(v ? String(v) : "")}
placeholder="یک پزشک را انتخاب کنید"
/>
)}
</div>
)}
{/* تب‌ها + دکمه افزودن */}
<div className="w-full flex items-end justify-between mt-[20px]">
<div className="flex items-center gap-[8px] border-b border-[#EFEFEF] dark:border-[#343645]">
@@ -764,7 +869,6 @@ function MySecretariesPageContent() {
</div>
<button
onClick={handleAddClick}
disabled={isClinic && !selectedDoctorUuid}
className="shadow-none gap-[8px] bg-[#5559CE] text-[#EFEFEF] text-[14px] md:text-[15px] lg:text-[16px]
font-medium py-[10px] px-[16px] h-[43px] md:h-[45px] lg:h-[48px] rounded-[4px] cursor-pointer
flex items-center disabled:opacity-60"
@@ -813,6 +917,8 @@ function MySecretariesPageContent() {
mode={modalMode}
data={selected}
saving={saving}
isClinic={isClinic}
clinicDoctors={clinicDoctors}
onClose={() => setModalOpen(false)}
onSubmit={handleModalSubmit}
/>
@@ -0,0 +1,58 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { screen, fireEvent } from "@testing-library/react";
import { renderWithProviders } from "../test/utils";
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn() } }));
vi.mock("../lib/api", () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
vi.mock("../stores/authStore", () => ({
useAuthStore: () => ({ doctorUuid: null, dbUuid: "clinic-1", primaryRole: "clinic" }),
}));
vi.mock("../hooks/useSubscription", () => ({
useSubscription: () => ({ maxSecretaries: 5 }),
}));
import { api } from "../lib/api";
import MySecretariesPage from "./MySecretariesPage";
const get = api.get as ReturnType<typeof vi.fn>;
beforeEach(() => {
get.mockReset();
get.mockImplementation((url: string) => {
if (url.includes("/clinic/doctor-list/"))
return Promise.resolve({ success: true, data: { data: [
{ uuid: "doc-a", name: "دکتر الف" },
{ uuid: "doc-b", name: "دکتر ب" },
] } });
if (url.includes("/secretaries/clinic/")) return Promise.resolve({ success: true, data: [] });
return Promise.resolve({ success: true, data: [] });
});
});
describe("MySecretariesPage — clinic multi-doctor", () => {
it("shows the doctor multi-select inside the add modal", async () => {
renderWithProviders(<MySecretariesPage />, { route: "/admin/my-secretaries" });
// پیکر روی صفحه نیست تا وقتی مودال باز شود
expect(screen.queryByText("پزشکانِ این منشی")).not.toBeInTheDocument();
fireEvent.click(await screen.findByText("اضافه کردن منشی"));
expect(await screen.findByText("پزشکانِ این منشی")).toBeInTheDocument();
expect(screen.getByText("دکتر الف")).toBeInTheDocument();
expect(screen.getByText("دکتر ب")).toBeInTheDocument();
expect(screen.getByText("انتخاب همه")).toBeInTheDocument();
});
it("selecting all picks every clinic doctor", async () => {
renderWithProviders(<MySecretariesPage />, { route: "/admin/my-secretaries" });
fireEvent.click(await screen.findByText("اضافه کردن منشی"));
await screen.findByText("پزشکانِ این منشی");
fireEvent.click(screen.getByText("انتخاب همه"));
expect(screen.getByText(/[۲2] انتخاب‌شده/)).toBeInTheDocument();
expect(screen.getByText("لغو همه")).toBeInTheDocument();
});
});
+1
View File
@@ -383,6 +383,7 @@ export interface Blog {
export interface Secretary {
uuid: string;
secretary_uuid?: string;
user_name: string;
mobile_number: string;
doctor_name: string;