From b659b84a7f44ae67e7c7612d46a5c564843a4302 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sat, 18 Jul 2026 10:54:37 +0330 Subject: [PATCH] feat(secretary): implement grouping of secretaries by doctor and sync profile data across links --- assets/admin/pages/MySecretariesPage.tsx | 188 +++++++++++++----- .../pages/MySecretariesPageClinic.test.tsx | 96 ++++++++- docs/api/secretary.md | 2 + src/Secretary/Service/SecretaryService.php | 8 + tests/Secretary/ClinicSharedSecretaryTest.php | 30 +++ 5 files changed, 273 insertions(+), 51 deletions(-) diff --git a/assets/admin/pages/MySecretariesPage.tsx b/assets/admin/pages/MySecretariesPage.tsx index a39aa00d..7ed2204e 100644 --- a/assets/admin/pages/MySecretariesPage.tsx +++ b/assets/admin/pages/MySecretariesPage.tsx @@ -296,7 +296,7 @@ function SecretaryModal({ }: { open: boolean; mode: ModalMode; - data: Secretary | null; + data: SecretaryGroup | null; saving: boolean; isClinic: boolean; clinicDoctors: ClinicDoctor[]; @@ -313,21 +313,23 @@ function SecretaryModal({ }); const [doctorUuids, setDoctorUuids] = useState([]); - // نمایش انتخاب چند پزشک فقط هنگام افزودنِ منشیِ کلینیک - const showDoctorPicker = isClinic && mode === "add"; + // انتخاب چند پزشک برای منشیِ کلینیک، هم در افزودن و هم در ویرایش + const showDoctorPicker = isClinic && mode !== "view"; useEffect(() => { if (!open) return; setDoctorUuids([]); if ((mode === "edit" || mode === "view") && data) { - const parts = (data.user_name ?? "").split(" "); + const secretary = data.primary; + const parts = (secretary.user_name ?? "").split(" "); + setDoctorUuids(data.doctorUuids); setForm({ name: parts[0] ?? "", family: parts.slice(1).join(" "), - telephone: data.mobile_number ?? "", - national_code: data.national_code ?? "", - address: data.address ?? "", - permission: { ...EMPTY_PERMISSIONS, ...(data.permissions ?? {}) }, + telephone: secretary.mobile_number ?? "", + national_code: secretary.national_code ?? "", + address: secretary.address ?? "", + permission: { ...EMPTY_PERMISSIONS, ...(secretary.permissions ?? {}) }, }); } else { setForm({ @@ -476,6 +478,45 @@ function RowButtons({ onView, onEdit }: { onView: () => void; onEdit: () => void ); } +// ── Grouping ───────────────────────────────────────────────────────────────── + +/** + * `doctor_secretaries` holds one link row per (doctor, secretary) pair, so a + * secretary shared between N doctors arrives as N rows. The UI shows one row + * per person; every action fans back out over `links`. + */ +interface SecretaryGroup { + key: string; + primary: Secretary; + links: Secretary[]; + doctorNames: string[]; + doctorUuids: string[]; +} + +function groupBySecretary(rows: Secretary[]): SecretaryGroup[] { + const groups = new Map(); + for (const row of rows) { + const key = row.secretary_uuid ?? row.uuid; // fallback: payload without secretary_uuid + const group = groups.get(key); + if (!group) { + groups.set(key, { + key, + primary: row, + links: [row], + doctorNames: [row.doctor_name], + doctorUuids: [row.doctor_uuid], + }); + continue; + } + group.links.push(row); + if (!group.doctorUuids.includes(row.doctor_uuid)) { + group.doctorUuids.push(row.doctor_uuid); + group.doctorNames.push(row.doctor_name); + } + } + return [...groups.values()]; +} + // ── Desktop table ──────────────────────────────────────────────────────────── function SecretaryTable({ @@ -485,10 +526,10 @@ function SecretaryTable({ onDeactivate, showDoctor, }: { - data: Secretary[]; - onView: (s: Secretary) => void; - onEdit: (s: Secretary) => void; - onDeactivate: (s: Secretary) => void; + data: SecretaryGroup[]; + onView: (g: SecretaryGroup) => void; + onEdit: (g: SecretaryGroup) => void; + onDeactivate: (g: SecretaryGroup) => void; showDoctor: boolean; }) { const headCls = @@ -512,8 +553,10 @@ function SecretaryTable({ - {data.map((row, idx) => ( - + {data.map((group, idx) => { + const row = group.primary; + return ( + {idx + 1}
@@ -521,26 +564,28 @@ function SecretaryTable({ {row.user_name}
- {showDoctor && {row.doctor_name}} + {showDoctor && ( + {group.doctorNames.join("، ")} + )} {row.national_code || "-"} {formatDate(Number(row.created_at))} {row.mobile_number} - onView(row)} onEdit={() => onEdit(row)} /> + onView(group)} onEdit={() => onEdit(group)} /> {row.is_active ? ( ) : ( - ))} + ); + })} ); } @@ -735,8 +797,8 @@ function MySecretariesPageContent() { const [modalOpen, setModalOpen] = useState(false); const [modalMode, setModalMode] = useState("add"); - const [selected, setSelected] = useState(null); - const [deactivateTarget, setDeactivateTarget] = useState(null); + const [selected, setSelected] = useState(null); + const [deactivateTarget, setDeactivateTarget] = useState(null); // clinic: list of doctors const { data: clinicDoctorsData } = useQuery< @@ -765,9 +827,14 @@ function MySecretariesPageContent() { const allSecretaries = isClinic ? (clinicSecrData?.data ?? []) : (doctorSecrData?.data ?? []); const isLoading = isClinic ? clinicSecrLoading : doctorSecrLoading; - // client-side filter by tab (active/previous) - const secretaries = allSecretaries.filter((s) => (tab === 0 ? s.is_active : !s.is_active)); - const activeSecretaryCount = allSecretaries.filter((s) => s.is_active).length; + // client-side filter by tab (active/previous), then one row per person + const secretaries = groupBySecretary( + allSecretaries.filter((s) => (tab === 0 ? s.is_active : !s.is_active)), + ); + // سهمیه پلن بر اساس تعداد افراد است، نه تعداد رابطه‌های پزشک-منشی + const activeSecretaryCount = new Set( + allSecretaries.filter((s) => s.is_active).map((s) => s.secretary_uuid ?? s.uuid), + ).size; const atLimit = activeSecretaryCount >= maxSecretaries; const invalidate = () => { @@ -805,13 +872,27 @@ function MySecretariesPageContent() { }); const updateMutation = useMutation({ - mutationFn: ({ uuid, form }: { uuid: string; form: FormState }) => - api.patch(`/api/v1/secretary/${uuid}`, { + mutationFn: async ({ + group, + form, + doctorUuids, + }: { group: SecretaryGroup; form: FormState; doctorUuids: string[] }) => { + const body = { name: `${form.name} ${form.family}`.trim(), national_code: digitsOnly(form.national_code, 10) || null, address: form.address || null, permissions: { version: 1, resources: form.permission }, - }), + }; + // هر رابطه‌ی پزشک-منشی جداگانه ذخیره می‌شود تا پروفایل در همه یکسان بماند + await Promise.all(group.links.map((link) => api.patch(`/api/v1/secretary/${link.uuid}`, body))); + // sync بعد از patch اجرا می‌شود تا رابطه‌های تازه، اطلاعاتِ به‌روز را ارث ببرند + if (isClinic && dbUuid) { + await api.put(`/api/v1/secretaries/clinic/${dbUuid}/doctors`, { + secretary_uuid: group.key, + doctor_uuids: doctorUuids, + }); + } + }, onSuccess: () => { toast.success("منشی با موفقیت ویرایش شد"); setModalOpen(false); @@ -821,8 +902,9 @@ function MySecretariesPageContent() { }); const toggleActiveMutation = useMutation({ - mutationFn: ({ uuid, active }: { uuid: string; active: boolean }) => - api.patch(`/api/v1/secretary/${uuid}`, { active }), + // همکاری با یک منشی روی همه‌ی پزشکانش لغو/برقرار می‌شود + mutationFn: ({ group, active }: { group: SecretaryGroup; active: boolean }) => + Promise.all(group.links.map((link) => api.patch(`/api/v1/secretary/${link.uuid}`, { active }))), onSuccess: (_, { active }) => { toast.success(active ? "همکاری با منشی برقرار شد" : "همکاری با منشی لغو شد"); setDeactivateTarget(null); @@ -840,15 +922,16 @@ function MySecretariesPageContent() { setModalOpen(true); }; - const openModal = (mode: ModalMode, s: Secretary) => { + const openModal = (mode: ModalMode, g: SecretaryGroup) => { setModalMode(mode); - setSelected(s); + setSelected(g); setModalOpen(true); }; const handleModalSubmit = (form: FormState, doctorUuids: string[]) => { if (modalMode === "add") createMutation.mutate({ form, doctorUuids }); - else if (modalMode === "edit" && selected) updateMutation.mutate({ uuid: selected.uuid, form }); + else if (modalMode === "edit" && selected) + updateMutation.mutate({ group: selected, form, doctorUuids }); }; return ( @@ -905,6 +988,7 @@ function MySecretariesPageContent() { /> openModal("view", s)} onEdit={(s) => openModal("edit", s)} onDeactivate={(s) => setDeactivateTarget(s)} @@ -933,20 +1017,24 @@ function MySecretariesPageContent() { 1 + ? ` برای همه‌ی ${deactivateTarget.doctorNames.length} پزشک` + : "") + + " را لغو کنید؟" + : `آیا مطمئن هستید که می‌خواهید «${deactivateTarget?.primary.user_name}» را دوباره فعال کنید؟` } - confirmLabel={deactivateTarget?.is_active ? "لغو همکاری" : "فعال‌سازی"} - danger={deactivateTarget?.is_active} + confirmLabel={deactivateTarget?.primary.is_active ? "لغو همکاری" : "فعال‌سازی"} + danger={deactivateTarget?.primary.is_active} loading={toggleActiveMutation.isPending} onConfirm={() => deactivateTarget && toggleActiveMutation.mutate({ - uuid: deactivateTarget.uuid, - active: !deactivateTarget.is_active, + group: deactivateTarget, + active: !deactivateTarget.primary.is_active, }) } onCancel={() => setDeactivateTarget(null)} diff --git a/assets/admin/pages/MySecretariesPageClinic.test.tsx b/assets/admin/pages/MySecretariesPageClinic.test.tsx index f29e96d2..4ca6a7b0 100644 --- a/assets/admin/pages/MySecretariesPageClinic.test.tsx +++ b/assets/admin/pages/MySecretariesPageClinic.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; -import { screen, fireEvent } from "@testing-library/react"; +import { screen, fireEvent, waitFor, within } from "@testing-library/react"; import { renderWithProviders } from "../test/utils"; vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn() } })); @@ -18,9 +18,45 @@ import { api } from "../lib/api"; import MySecretariesPage from "./MySecretariesPage"; const get = api.get as ReturnType; +const patch = api.patch as ReturnType; +const put = api.put as ReturnType; + +/** یک منشیِ مشترک بین دو پزشک = دو ردیف لینک با secretary_uuid یکسان */ +const sharedSecretaryRows = [ + { + uuid: "link-a", + secretary_uuid: "sec-1", + user_name: "ساسان عطایی", + mobile_number: "09120671732", + doctor_name: "دکتر الف", + doctor_uuid: "doc-a", + is_active: true, + national_code: "1212121212", + address: null, + permissions: {}, + created_at: "1750000000", + }, + { + uuid: "link-b", + secretary_uuid: "sec-1", + user_name: "ساسان عطایی", + mobile_number: "09120671732", + doctor_name: "دکتر ب", + doctor_uuid: "doc-b", + is_active: true, + national_code: "1212121212", + address: null, + permissions: {}, + created_at: "1750000000", + }, +]; beforeEach(() => { get.mockReset(); + patch.mockReset(); + put.mockReset(); + patch.mockResolvedValue({ success: true, data: {} }); + put.mockResolvedValue({ success: true, data: {} }); get.mockImplementation((url: string) => { if (url.includes("/clinic/doctor-list/")) return Promise.resolve({ success: true, data: { data: [ @@ -56,3 +92,61 @@ describe("MySecretariesPage — clinic multi-doctor", () => { expect(screen.getByText("لغو همه")).toBeInTheDocument(); }); }); + +describe("MySecretariesPage — one row per secretary", () => { + beforeEach(() => { + 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: sharedSecretaryRows }); + return Promise.resolve({ success: true, data: [] }); + }); + }); + + it("renders a secretary shared by two doctors as a single row", async () => { + renderWithProviders(, { route: "/admin/my-secretaries" }); + + const table = within(await screen.findByRole("table")); + expect(table.getAllByText("ساسان عطایی")).toHaveLength(1); + expect(table.getByText("دکتر الف، دکتر ب")).toBeInTheDocument(); + }); + + it("cancelling collaboration deactivates every link row", async () => { + renderWithProviders(, { route: "/admin/my-secretaries" }); + const table = within(await screen.findByRole("table")); + // اولین «لغو همکاری» عنوان ستون است؛ دکمه‌ی ردیف مورد نظر است + fireEvent.click(table.getByRole("button", { name: "لغو همکاری" })); + + // متن تأیید باید تعداد پزشکان را نشان دهد + expect(await screen.findByText(/برای همه‌ی .* پزشک/)).toBeInTheDocument(); + fireEvent.click(screen.getAllByText("لغو همکاری").pop()!); + + await waitFor(() => expect(patch).toHaveBeenCalledTimes(2)); + expect(patch).toHaveBeenCalledWith("/api/v1/secretary/link-a", { active: false }); + expect(patch).toHaveBeenCalledWith("/api/v1/secretary/link-b", { active: false }); + }); + + it("editing patches every link row and syncs the doctor set", async () => { + renderWithProviders(, { route: "/admin/my-secretaries" }); + fireEvent.click((await screen.findAllByTitle("ویرایش"))[0]); + + // مجموعه‌ی پزشکانِ فعلی باید از قبل انتخاب شده باشد + expect(await screen.findByText("پزشکانِ این منشی")).toBeInTheDocument(); + expect(screen.getByText(/[۲2] انتخاب‌شده/)).toBeInTheDocument(); + + // حذف یکی از پزشکان (آخرین مورد = ردیف لیست، نه چیپ انتخاب‌شده) + fireEvent.click(screen.getAllByText("دکتر ب").pop()!); + fireEvent.click(screen.getByText("ذخیره")); + + await waitFor(() => expect(put).toHaveBeenCalledTimes(1)); + expect(patch).toHaveBeenCalledTimes(2); + expect(put).toHaveBeenCalledWith("/api/v1/secretaries/clinic/clinic-1/doctors", { + secretary_uuid: "sec-1", + doctor_uuids: ["doc-a"], + }); + }); +}); diff --git a/docs/api/secretary.md b/docs/api/secretary.md index 69b3bf26..b6f0aa43 100644 --- a/docs/api/secretary.md +++ b/docs/api/secretary.md @@ -398,6 +398,8 @@ Get all secretaries across **all doctors** of a clinic. هم‌گام‌سازی مجموعه‌ی پزشکانِ یک منشیِ کلینیک (owner_type='clinic'): پزشکانِ خواسته‌شده افزوده/فعال و بقیه غیرفعال می‌شوند. برای «افزودن/حذف پزشک از یک منشی موجود» بدون تغییر ساختاری. +> ردیف‌های تازه‌ساخته‌شده `national_code`، `address` و `permissions` را از ردیف‌های موجودِ همان منشی کپی می‌کنند تا پروفایل یک شخص روی همه‌ی پزشکانش یکسان بماند. اگر همراه با ویرایش پروفایل صدا زده می‌شود، اول `PATCH /api/v1/secretary/{uuid}` روی ردیف‌های موجود و بعد این اندپوینت را فراخوانی کنید. + **Permission:** `ROLE_CLINIC` (must own clinic) | `ROLE_ADMIN` ### Path Parameters diff --git a/src/Secretary/Service/SecretaryService.php b/src/Secretary/Service/SecretaryService.php index 208dfd52..14919c6f 100644 --- a/src/Secretary/Service/SecretaryService.php +++ b/src/Secretary/Service/SecretaryService.php @@ -142,6 +142,9 @@ class SecretaryService } $added = $removed = $skippedLimit = $skippedNotInClinic = []; + // New links inherit the person's existing profile/permissions so that a + // multi-doctor secretary stays a single consistent record. + $template = $existing[0] ?? null; foreach ($wanted as $doctorUuid) { $doctor = $this->doctorRepo->findByUuid($doctorUuid); @@ -162,6 +165,11 @@ class SecretaryService continue; } $row = new DoctorSecretary($doctor, $secretary, DoctorSecretary::OWNER_CLINIC, $clinic); + if ($template !== null) { + $row->setNationalCode($template->getNationalCode()) + ->setAddress($template->getAddress()) + ->setPermissions($template->getPermissions()); + } $this->secretaryRepo->save($row, false); $added[] = $row; } diff --git a/tests/Secretary/ClinicSharedSecretaryTest.php b/tests/Secretary/ClinicSharedSecretaryTest.php index f30b6781..ed37f464 100644 --- a/tests/Secretary/ClinicSharedSecretaryTest.php +++ b/tests/Secretary/ClinicSharedSecretaryTest.php @@ -107,6 +107,36 @@ class ClinicSharedSecretaryTest extends ApiTestCase $this->assertSame(1, $body['data']['removed']); // doctor0 } + public function testSyncCopiesProfileAndPermissionsToNewLinks(): void + { + [$owner, $clinic, $doctors] = $this->makeClinicWithDoctors(2); + $mobile = $this->mobile(); + + $assigned = $this->authJson('POST', '/api/v1/secretary', $owner, [ + 'mobile_number' => $mobile, + 'doctor_uuids' => [$doctors[0]->getUuid()], + 'national_code' => '1212121212', + 'address' => 'یزد، خیابان تست', + 'permissions' => ['version' => 1, 'resources' => ['patients' => ['view' => true]]], + ]); + $secretaryUuid = $assigned['data']['secretary_uuid']; + + // adding a second doctor must clone the person's profile onto the new link + $this->authJson('PUT', '/api/v1/secretaries/clinic/' . $clinic->getUuid() . '/doctors', $owner, [ + 'secretary_uuid' => $secretaryUuid, + 'doctor_uuids' => [$doctors[0]->getUuid(), $doctors[1]->getUuid()], + ]); + $this->assertSame(200, $this->responseCode()); + + $rows = $this->authJson('GET', '/api/v1/secretaries/clinic/' . $clinic->getUuid(), $owner); + $this->assertCount(2, $rows['data']); + foreach ($rows['data'] as $row) { + $this->assertSame('1212121212', $row['national_code']); + $this->assertSame('یزد، خیابان تست', $row['address']); + $this->assertTrue($row['permissions']['patients']['view']); + } + } + public function testForeignOwnerCannotSync(): void { [, $clinic, ] = $this->makeClinicWithDoctors(1);