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;
|
open: boolean;
|
||||||
mode: ModalMode;
|
mode: ModalMode;
|
||||||
data: Secretary | null;
|
data: SecretaryGroup | null;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
isClinic: boolean;
|
isClinic: boolean;
|
||||||
clinicDoctors: ClinicDoctor[];
|
clinicDoctors: ClinicDoctor[];
|
||||||
@@ -313,21 +313,23 @@ function SecretaryModal({
|
|||||||
});
|
});
|
||||||
const [doctorUuids, setDoctorUuids] = useState<string[]>([]);
|
const [doctorUuids, setDoctorUuids] = useState<string[]>([]);
|
||||||
|
|
||||||
// نمایش انتخاب چند پزشک فقط هنگام افزودنِ منشیِ کلینیک
|
// انتخاب چند پزشک برای منشیِ کلینیک، هم در افزودن و هم در ویرایش
|
||||||
const showDoctorPicker = isClinic && mode === "add";
|
const showDoctorPicker = isClinic && mode !== "view";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
setDoctorUuids([]);
|
setDoctorUuids([]);
|
||||||
if ((mode === "edit" || mode === "view") && data) {
|
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({
|
setForm({
|
||||||
name: parts[0] ?? "",
|
name: parts[0] ?? "",
|
||||||
family: parts.slice(1).join(" "),
|
family: parts.slice(1).join(" "),
|
||||||
telephone: data.mobile_number ?? "",
|
telephone: secretary.mobile_number ?? "",
|
||||||
national_code: data.national_code ?? "",
|
national_code: secretary.national_code ?? "",
|
||||||
address: data.address ?? "",
|
address: secretary.address ?? "",
|
||||||
permission: { ...EMPTY_PERMISSIONS, ...(data.permissions ?? {}) },
|
permission: { ...EMPTY_PERMISSIONS, ...(secretary.permissions ?? {}) },
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setForm({
|
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 ────────────────────────────────────────────────────────────
|
// ── Desktop table ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function SecretaryTable({
|
function SecretaryTable({
|
||||||
@@ -485,10 +526,10 @@ function SecretaryTable({
|
|||||||
onDeactivate,
|
onDeactivate,
|
||||||
showDoctor,
|
showDoctor,
|
||||||
}: {
|
}: {
|
||||||
data: Secretary[];
|
data: SecretaryGroup[];
|
||||||
onView: (s: Secretary) => void;
|
onView: (g: SecretaryGroup) => void;
|
||||||
onEdit: (s: Secretary) => void;
|
onEdit: (g: SecretaryGroup) => void;
|
||||||
onDeactivate: (s: Secretary) => void;
|
onDeactivate: (g: SecretaryGroup) => void;
|
||||||
showDoctor: boolean;
|
showDoctor: boolean;
|
||||||
}) {
|
}) {
|
||||||
const headCls =
|
const headCls =
|
||||||
@@ -512,8 +553,10 @@ function SecretaryTable({
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{data.map((row, idx) => (
|
{data.map((group, idx) => {
|
||||||
<tr key={row.uuid} className="border-b border-[#DBDBDB] dark:border-[#343645]">
|
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}>{idx + 1}</td>
|
||||||
<td className={cellCls}>
|
<td className={cellCls}>
|
||||||
<div className="flex items-center justify-start gap-[8px]">
|
<div className="flex items-center justify-start gap-[8px]">
|
||||||
@@ -521,26 +564,28 @@ function SecretaryTable({
|
|||||||
<span>{row.user_name}</span>
|
<span>{row.user_name}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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}>{row.national_code || "-"}</td>
|
||||||
<td className={cellCls + " text-center"}>{formatDate(Number(row.created_at))}</td>
|
<td className={cellCls + " text-center"}>{formatDate(Number(row.created_at))}</td>
|
||||||
<td className={cellCls}>
|
<td className={cellCls}>
|
||||||
<span dir="ltr">{row.mobile_number}</span>
|
<span dir="ltr">{row.mobile_number}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className={cellCls}>
|
<td className={cellCls}>
|
||||||
<RowButtons onView={() => onView(row)} onEdit={() => onEdit(row)} />
|
<RowButtons onView={() => onView(group)} onEdit={() => onEdit(group)} />
|
||||||
</td>
|
</td>
|
||||||
<td className={cellCls + " text-center"}>
|
<td className={cellCls + " text-center"}>
|
||||||
{row.is_active ? (
|
{row.is_active ? (
|
||||||
<button
|
<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"
|
className="border border-[#E53935] text-[#E53935] hover:bg-[#FFEBEE] text-[12px] font-medium py-[6px] px-[16px] rounded-[4px] cursor-pointer"
|
||||||
>
|
>
|
||||||
لغو همکاری
|
لغو همکاری
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<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"
|
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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -563,17 +609,21 @@ function SecretaryCards({
|
|||||||
onView,
|
onView,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDeactivate,
|
onDeactivate,
|
||||||
|
showDoctor,
|
||||||
}: {
|
}: {
|
||||||
data: Secretary[];
|
data: SecretaryGroup[];
|
||||||
onView: (s: Secretary) => void;
|
onView: (g: SecretaryGroup) => void;
|
||||||
onEdit: (s: Secretary) => void;
|
onEdit: (g: SecretaryGroup) => void;
|
||||||
onDeactivate: (s: Secretary) => void;
|
onDeactivate: (g: SecretaryGroup) => void;
|
||||||
|
showDoctor: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ul className="grid lg:hidden grid-cols-1 sm:grid-cols-2 gap-[24px] mt-[24px]">
|
<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
|
<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]"
|
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]">
|
<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>
|
<p className="text-[#616161] dark:text-[#D7D8ED] text-[14px] font-medium">{item.user_name}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full mt-[16px]">
|
<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">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-[#7E7E7E] dark:text-[#A1A1A1] text-[14px] font-normal">کدملی:</p>
|
<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>
|
<p className="text-[#616161] dark:text-[#A1A1A1] text-[14px] font-normal">{item.national_code || "-"}</p>
|
||||||
@@ -598,20 +659,20 @@ function SecretaryCards({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between mt-[16px]">
|
<div className="flex items-center justify-between mt-[16px]">
|
||||||
<button
|
<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"
|
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 />
|
<EyeIcon />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => onEdit(item)}
|
onClick={() => onEdit(group)}
|
||||||
className="border border-[#EFEFEF] dark:border-[#343645] rounded-[4px] p-[7px] cursor-pointer"
|
className="border border-[#EFEFEF] dark:border-[#343645] rounded-[4px] p-[7px] cursor-pointer"
|
||||||
>
|
>
|
||||||
<EditIcon />
|
<EditIcon />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => onDeactivate(item)}
|
onClick={() => onDeactivate(group)}
|
||||||
className={
|
className={
|
||||||
item.is_active
|
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"
|
? "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 ? "لغو همکاری" : "فعالسازی"}
|
{item.is_active ? "لغو همکاری" : "فعالسازی"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -735,8 +797,8 @@ function MySecretariesPageContent() {
|
|||||||
|
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [modalMode, setModalMode] = useState<ModalMode>("add");
|
const [modalMode, setModalMode] = useState<ModalMode>("add");
|
||||||
const [selected, setSelected] = useState<Secretary | null>(null);
|
const [selected, setSelected] = useState<SecretaryGroup | null>(null);
|
||||||
const [deactivateTarget, setDeactivateTarget] = useState<Secretary | null>(null);
|
const [deactivateTarget, setDeactivateTarget] = useState<SecretaryGroup | null>(null);
|
||||||
|
|
||||||
// clinic: list of doctors
|
// clinic: list of doctors
|
||||||
const { data: clinicDoctorsData } = useQuery<
|
const { data: clinicDoctorsData } = useQuery<
|
||||||
@@ -765,9 +827,14 @@ function MySecretariesPageContent() {
|
|||||||
const allSecretaries = isClinic ? (clinicSecrData?.data ?? []) : (doctorSecrData?.data ?? []);
|
const allSecretaries = isClinic ? (clinicSecrData?.data ?? []) : (doctorSecrData?.data ?? []);
|
||||||
const isLoading = isClinic ? clinicSecrLoading : doctorSecrLoading;
|
const isLoading = isClinic ? clinicSecrLoading : doctorSecrLoading;
|
||||||
|
|
||||||
// client-side filter by tab (active/previous)
|
// client-side filter by tab (active/previous), then one row per person
|
||||||
const secretaries = allSecretaries.filter((s) => (tab === 0 ? s.is_active : !s.is_active));
|
const secretaries = groupBySecretary(
|
||||||
const activeSecretaryCount = allSecretaries.filter((s) => s.is_active).length;
|
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 atLimit = activeSecretaryCount >= maxSecretaries;
|
||||||
|
|
||||||
const invalidate = () => {
|
const invalidate = () => {
|
||||||
@@ -805,13 +872,27 @@ function MySecretariesPageContent() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: ({ uuid, form }: { uuid: string; form: FormState }) =>
|
mutationFn: async ({
|
||||||
api.patch(`/api/v1/secretary/${uuid}`, {
|
group,
|
||||||
|
form,
|
||||||
|
doctorUuids,
|
||||||
|
}: { group: SecretaryGroup; form: FormState; doctorUuids: string[] }) => {
|
||||||
|
const body = {
|
||||||
name: `${form.name} ${form.family}`.trim(),
|
name: `${form.name} ${form.family}`.trim(),
|
||||||
national_code: digitsOnly(form.national_code, 10) || null,
|
national_code: digitsOnly(form.national_code, 10) || null,
|
||||||
address: form.address || null,
|
address: form.address || null,
|
||||||
permissions: { version: 1, resources: form.permission },
|
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: () => {
|
onSuccess: () => {
|
||||||
toast.success("منشی با موفقیت ویرایش شد");
|
toast.success("منشی با موفقیت ویرایش شد");
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
@@ -821,8 +902,9 @@ function MySecretariesPageContent() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const toggleActiveMutation = useMutation({
|
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 }) => {
|
onSuccess: (_, { active }) => {
|
||||||
toast.success(active ? "همکاری با منشی برقرار شد" : "همکاری با منشی لغو شد");
|
toast.success(active ? "همکاری با منشی برقرار شد" : "همکاری با منشی لغو شد");
|
||||||
setDeactivateTarget(null);
|
setDeactivateTarget(null);
|
||||||
@@ -840,15 +922,16 @@ function MySecretariesPageContent() {
|
|||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openModal = (mode: ModalMode, s: Secretary) => {
|
const openModal = (mode: ModalMode, g: SecretaryGroup) => {
|
||||||
setModalMode(mode);
|
setModalMode(mode);
|
||||||
setSelected(s);
|
setSelected(g);
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleModalSubmit = (form: FormState, doctorUuids: string[]) => {
|
const handleModalSubmit = (form: FormState, doctorUuids: string[]) => {
|
||||||
if (modalMode === "add") createMutation.mutate({ form, doctorUuids });
|
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 (
|
return (
|
||||||
@@ -905,6 +988,7 @@ function MySecretariesPageContent() {
|
|||||||
/>
|
/>
|
||||||
<SecretaryCards
|
<SecretaryCards
|
||||||
data={secretaries}
|
data={secretaries}
|
||||||
|
showDoctor={isClinic}
|
||||||
onView={(s) => openModal("view", s)}
|
onView={(s) => openModal("view", s)}
|
||||||
onEdit={(s) => openModal("edit", s)}
|
onEdit={(s) => openModal("edit", s)}
|
||||||
onDeactivate={(s) => setDeactivateTarget(s)}
|
onDeactivate={(s) => setDeactivateTarget(s)}
|
||||||
@@ -933,20 +1017,24 @@ function MySecretariesPageContent() {
|
|||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!deactivateTarget}
|
open={!!deactivateTarget}
|
||||||
title={deactivateTarget?.is_active ? "لغو همکاری با منشی" : "فعالسازی منشی"}
|
title={deactivateTarget?.primary.is_active ? "لغو همکاری با منشی" : "فعالسازی منشی"}
|
||||||
message={
|
message={
|
||||||
deactivateTarget?.is_active
|
deactivateTarget?.primary.is_active
|
||||||
? `آیا مطمئن هستید که میخواهید همکاری با «${deactivateTarget?.user_name}» را لغو کنید؟`
|
? `آیا مطمئن هستید که میخواهید همکاری با «${deactivateTarget?.primary.user_name}»` +
|
||||||
: `آیا مطمئن هستید که میخواهید «${deactivateTarget?.user_name}» را دوباره فعال کنید؟`
|
(deactivateTarget && deactivateTarget.doctorNames.length > 1
|
||||||
|
? ` برای همهی ${deactivateTarget.doctorNames.length} پزشک`
|
||||||
|
: "") +
|
||||||
|
" را لغو کنید؟"
|
||||||
|
: `آیا مطمئن هستید که میخواهید «${deactivateTarget?.primary.user_name}» را دوباره فعال کنید؟`
|
||||||
}
|
}
|
||||||
confirmLabel={deactivateTarget?.is_active ? "لغو همکاری" : "فعالسازی"}
|
confirmLabel={deactivateTarget?.primary.is_active ? "لغو همکاری" : "فعالسازی"}
|
||||||
danger={deactivateTarget?.is_active}
|
danger={deactivateTarget?.primary.is_active}
|
||||||
loading={toggleActiveMutation.isPending}
|
loading={toggleActiveMutation.isPending}
|
||||||
onConfirm={() =>
|
onConfirm={() =>
|
||||||
deactivateTarget &&
|
deactivateTarget &&
|
||||||
toggleActiveMutation.mutate({
|
toggleActiveMutation.mutate({
|
||||||
uuid: deactivateTarget.uuid,
|
group: deactivateTarget,
|
||||||
active: !deactivateTarget.is_active,
|
active: !deactivateTarget.primary.is_active,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
onCancel={() => setDeactivateTarget(null)}
|
onCancel={() => setDeactivateTarget(null)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
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";
|
import { renderWithProviders } from "../test/utils";
|
||||||
|
|
||||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn() } }));
|
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";
|
import MySecretariesPage from "./MySecretariesPage";
|
||||||
|
|
||||||
const get = api.get as ReturnType<typeof vi.fn>;
|
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(() => {
|
beforeEach(() => {
|
||||||
get.mockReset();
|
get.mockReset();
|
||||||
|
patch.mockReset();
|
||||||
|
put.mockReset();
|
||||||
|
patch.mockResolvedValue({ success: true, data: {} });
|
||||||
|
put.mockResolvedValue({ success: true, data: {} });
|
||||||
get.mockImplementation((url: string) => {
|
get.mockImplementation((url: string) => {
|
||||||
if (url.includes("/clinic/doctor-list/"))
|
if (url.includes("/clinic/doctor-list/"))
|
||||||
return Promise.resolve({ success: true, data: { data: [
|
return Promise.resolve({ success: true, data: { data: [
|
||||||
@@ -56,3 +92,61 @@ describe("MySecretariesPage — clinic multi-doctor", () => {
|
|||||||
expect(screen.getByText("لغو همه")).toBeInTheDocument();
|
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'): پزشکانِ خواستهشده افزوده/فعال و بقیه غیرفعال میشوند. برای «افزودن/حذف پزشک از یک منشی موجود» بدون تغییر ساختاری.
|
همگامسازی مجموعهی پزشکانِ یک منشیِ کلینیک (owner_type='clinic'): پزشکانِ خواستهشده افزوده/فعال و بقیه غیرفعال میشوند. برای «افزودن/حذف پزشک از یک منشی موجود» بدون تغییر ساختاری.
|
||||||
|
|
||||||
|
> ردیفهای تازهساختهشده `national_code`، `address` و `permissions` را از ردیفهای موجودِ همان منشی کپی میکنند تا پروفایل یک شخص روی همهی پزشکانش یکسان بماند. اگر همراه با ویرایش پروفایل صدا زده میشود، اول `PATCH /api/v1/secretary/{uuid}` روی ردیفهای موجود و بعد این اندپوینت را فراخوانی کنید.
|
||||||
|
|
||||||
**Permission:** `ROLE_CLINIC` (must own clinic) | `ROLE_ADMIN`
|
**Permission:** `ROLE_CLINIC` (must own clinic) | `ROLE_ADMIN`
|
||||||
|
|
||||||
### Path Parameters
|
### Path Parameters
|
||||||
|
|||||||
@@ -142,6 +142,9 @@ class SecretaryService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$added = $removed = $skippedLimit = $skippedNotInClinic = [];
|
$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) {
|
foreach ($wanted as $doctorUuid) {
|
||||||
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||||
@@ -162,6 +165,11 @@ class SecretaryService
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$row = new DoctorSecretary($doctor, $secretary, DoctorSecretary::OWNER_CLINIC, $clinic);
|
$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);
|
$this->secretaryRepo->save($row, false);
|
||||||
$added[] = $row;
|
$added[] = $row;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,6 +107,36 @@ class ClinicSharedSecretaryTest extends ApiTestCase
|
|||||||
$this->assertSame(1, $body['data']['removed']); // doctor0
|
$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
|
public function testForeignOwnerCannotSync(): void
|
||||||
{
|
{
|
||||||
[, $clinic, ] = $this->makeClinicWithDoctors(1);
|
[, $clinic, ] = $this->makeClinicWithDoctors(1);
|
||||||
|
|||||||
Reference in New Issue
Block a user