feat(secretary): implement grouping of secretaries by doctor and sync profile data across links

This commit is contained in:
hamed
2026-07-18 10:54:37 +03:30
parent 00cb9aaa1a
commit b659b84a7f
5 changed files with 273 additions and 51 deletions
+138 -50
View File
@@ -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"],
});
});
});
+2
View File
@@ -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);