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:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user