feat(secretary): implement grouping of secretaries by doctor and sync profile data across links
This commit is contained in:
@@ -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<string[]>([]);
|
||||
|
||||
// نمایش انتخاب چند پزشک فقط هنگام افزودنِ منشیِ کلینیک
|
||||
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<string, SecretaryGroup>();
|
||||
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({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row, idx) => (
|
||||
<tr key={row.uuid} className="border-b border-[#DBDBDB] dark:border-[#343645]">
|
||||
{data.map((group, idx) => {
|
||||
const row = group.primary;
|
||||
return (
|
||||
<tr key={group.key} className="border-b border-[#DBDBDB] dark:border-[#343645]">
|
||||
<td className={cellCls}>{idx + 1}</td>
|
||||
<td className={cellCls}>
|
||||
<div className="flex items-center justify-start gap-[8px]">
|
||||
@@ -521,26 +564,28 @@ function SecretaryTable({
|
||||
<span>{row.user_name}</span>
|
||||
</div>
|
||||
</td>
|
||||
{showDoctor && <td className={cellCls}>{row.doctor_name}</td>}
|
||||
{showDoctor && (
|
||||
<td className={cellCls + " whitespace-normal"}>{group.doctorNames.join("، ")}</td>
|
||||
)}
|
||||
<td className={cellCls}>{row.national_code || "-"}</td>
|
||||
<td className={cellCls + " text-center"}>{formatDate(Number(row.created_at))}</td>
|
||||
<td className={cellCls}>
|
||||
<span dir="ltr">{row.mobile_number}</span>
|
||||
</td>
|
||||
<td className={cellCls}>
|
||||
<RowButtons onView={() => onView(row)} onEdit={() => onEdit(row)} />
|
||||
<RowButtons onView={() => onView(group)} onEdit={() => onEdit(group)} />
|
||||
</td>
|
||||
<td className={cellCls + " text-center"}>
|
||||
{row.is_active ? (
|
||||
<button
|
||||
onClick={() => onDeactivate(row)}
|
||||
onClick={() => onDeactivate(group)}
|
||||
className="border border-[#E53935] text-[#E53935] hover:bg-[#FFEBEE] text-[12px] font-medium py-[6px] px-[16px] rounded-[4px] cursor-pointer"
|
||||
>
|
||||
لغو همکاری
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onDeactivate(row)}
|
||||
onClick={() => onDeactivate(group)}
|
||||
className="border border-[#2E7D32] text-[#2E7D32] hover:bg-[#E8F5E9] text-[12px] font-medium py-[6px] px-[16px] rounded-[4px] cursor-pointer"
|
||||
>
|
||||
فعالسازی
|
||||
@@ -548,7 +593,8 @@ function SecretaryTable({
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -563,17 +609,21 @@ function SecretaryCards({
|
||||
onView,
|
||||
onEdit,
|
||||
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;
|
||||
}) {
|
||||
return (
|
||||
<ul className="grid lg:hidden grid-cols-1 sm:grid-cols-2 gap-[24px] mt-[24px]">
|
||||
{data.map((item) => (
|
||||
{data.map((group) => {
|
||||
const item = group.primary;
|
||||
return (
|
||||
<div
|
||||
key={item.uuid}
|
||||
key={group.key}
|
||||
className="bg-[#FFF] dark:bg-[#222433] dark:shadow-transparent rounded-[8px] shadow-[0px_1px_24.8px_0px_rgba(204,204,204,0.18)] p-[12px]"
|
||||
>
|
||||
<div className="flex items-center justify-start gap-[8px]">
|
||||
@@ -581,6 +631,17 @@ function SecretaryCards({
|
||||
<p className="text-[#616161] dark:text-[#D7D8ED] text-[14px] font-medium">{item.user_name}</p>
|
||||
</div>
|
||||
<div className="w-full mt-[16px]">
|
||||
{showDoctor && (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-[8px]">
|
||||
<p className="text-[#7E7E7E] dark:text-[#A1A1A1] text-[14px] font-normal shrink-0">پزشک:</p>
|
||||
<p className="text-[#616161] dark:text-[#A1A1A1] text-[14px] font-normal text-left">
|
||||
{group.doctorNames.join("، ")}
|
||||
</p>
|
||||
</div>
|
||||
<span className="block w-full h-px bg-[#EFEFEF] dark:bg-[#343645] my-[8px]" />
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[#7E7E7E] dark:text-[#A1A1A1] text-[14px] font-normal">کدملی:</p>
|
||||
<p className="text-[#616161] dark:text-[#A1A1A1] text-[14px] font-normal">{item.national_code || "-"}</p>
|
||||
@@ -598,20 +659,20 @@ function SecretaryCards({
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-[16px]">
|
||||
<button
|
||||
onClick={() => onView(item)}
|
||||
onClick={() => onView(group)}
|
||||
className="text-[#5559CE] dark:bg-[#C7CEF4] gap-[5px] text-[14px] font-medium border border-[#5559CE] py-[8px] px-[12px] rounded-[4px] cursor-pointer flex items-center"
|
||||
>
|
||||
<EyeIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onEdit(item)}
|
||||
onClick={() => onEdit(group)}
|
||||
className="border border-[#EFEFEF] dark:border-[#343645] rounded-[4px] p-[7px] cursor-pointer"
|
||||
>
|
||||
<EditIcon />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onDeactivate(item)}
|
||||
onClick={() => onDeactivate(group)}
|
||||
className={
|
||||
item.is_active
|
||||
? "w-full border border-[#E53935] text-[#E53935] hover:bg-[#FFEBEE] text-[14px] font-medium py-[8px] mt-[12px] rounded-[4px] cursor-pointer"
|
||||
@@ -621,7 +682,8 @@ function SecretaryCards({
|
||||
{item.is_active ? "لغو همکاری" : "فعالسازی"}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -735,8 +797,8 @@ function MySecretariesPageContent() {
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modalMode, setModalMode] = useState<ModalMode>("add");
|
||||
const [selected, setSelected] = useState<Secretary | null>(null);
|
||||
const [deactivateTarget, setDeactivateTarget] = useState<Secretary | null>(null);
|
||||
const [selected, setSelected] = useState<SecretaryGroup | null>(null);
|
||||
const [deactivateTarget, setDeactivateTarget] = useState<SecretaryGroup | null>(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() {
|
||||
/>
|
||||
<SecretaryCards
|
||||
data={secretaries}
|
||||
showDoctor={isClinic}
|
||||
onView={(s) => openModal("view", s)}
|
||||
onEdit={(s) => openModal("edit", s)}
|
||||
onDeactivate={(s) => setDeactivateTarget(s)}
|
||||
@@ -933,20 +1017,24 @@ function MySecretariesPageContent() {
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deactivateTarget}
|
||||
title={deactivateTarget?.is_active ? "لغو همکاری با منشی" : "فعالسازی منشی"}
|
||||
title={deactivateTarget?.primary.is_active ? "لغو همکاری با منشی" : "فعالسازی منشی"}
|
||||
message={
|
||||
deactivateTarget?.is_active
|
||||
? `آیا مطمئن هستید که میخواهید همکاری با «${deactivateTarget?.user_name}» را لغو کنید؟`
|
||||
: `آیا مطمئن هستید که میخواهید «${deactivateTarget?.user_name}» را دوباره فعال کنید؟`
|
||||
deactivateTarget?.primary.is_active
|
||||
? `آیا مطمئن هستید که میخواهید همکاری با «${deactivateTarget?.primary.user_name}»` +
|
||||
(deactivateTarget && deactivateTarget.doctorNames.length > 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)}
|
||||
|
||||
@@ -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<typeof vi.fn>;
|
||||
const patch = api.patch as ReturnType<typeof vi.fn>;
|
||||
const put = api.put as ReturnType<typeof vi.fn>;
|
||||
|
||||
/** یک منشیِ مشترک بین دو پزشک = دو ردیف لینک با 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(<MySecretariesPage />, { 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(<MySecretariesPage />, { 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(<MySecretariesPage />, { 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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user