- Implemented BlogBodySanitizer to clean HTML content before saving articles, ensuring security against XSS attacks. - Added tests for BlogBodySanitizer to verify that unsafe tags and attributes are stripped from the content. - Introduced ApiLeastPrivilegeTest to ensure that unauthorized users cannot access sensitive API routes, maintaining strict access control.
942 lines
46 KiB
TypeScript
942 lines
46 KiB
TypeScript
import { useEffect, useState } from "react";
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { Link } from "react-router";
|
||
import { toast } from "sonner";
|
||
import SettingsLayout from "../components/layout/SettingsLayout";
|
||
import ConfirmDialog from "../components/ui/ConfirmDialog";
|
||
import Modal from "../components/ui/Modal";
|
||
import type { ApiResponse } from "../lib/api";
|
||
import { api } from "../lib/api";
|
||
import { formatDate, digitsOnly, IRAN_MOBILE_RE, IRAN_NATIONAL_CODE_RE } from "../lib/utils";
|
||
import { useSubscription } from "../hooks/useSubscription";
|
||
import { useAuthStore } from "../stores/authStore";
|
||
import type { Secretary, SecretaryPermissions } from "../types";
|
||
import SecretaryPermissionsModal from "../components/SecretaryPermissionsModal";
|
||
import Switch from '../components/ui/Switch';
|
||
|
||
// ── SVG icons (copied verbatim from clinic-pro-tauri) ───────────────────────
|
||
|
||
const PlusIcon = () => (
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||
<path d="M5 10H15" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ stroke: 'var(--on-primary)' }}/>
|
||
<path d="M10 15V5" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ stroke: 'var(--on-primary)' }}/>
|
||
</svg>
|
||
);
|
||
|
||
const EyeIcon = () => (
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||
<path d="M12.9833 9.99993C12.9833 11.6499 11.6499 12.9833 9.99993 12.9833C8.34993 12.9833 7.0166 11.6499 7.0166 9.99993C7.0166 8.34993 8.34993 7.0166 9.99993 7.0166C11.6499 7.0166 12.9833 8.34993 12.9833 9.99993Z" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ stroke: 'var(--text-2)' }}/>
|
||
<path d="M9.99987 16.8918C12.9415 16.8918 15.6832 15.1584 17.5915 12.1584C18.3415 10.9834 18.3415 9.00843 17.5915 7.83343C15.6832 4.83343 12.9415 3.1001 9.99987 3.1001C7.0582 3.1001 4.31654 4.83343 2.4082 7.83343C1.6582 9.00843 1.6582 10.9834 2.4082 12.1584C4.31654 15.1584 7.0582 16.8918 9.99987 16.8918Z" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ stroke: 'var(--text-2)' }}/>
|
||
</svg>
|
||
);
|
||
|
||
const EditIcon = () => (
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||
<path d="M11.0504 3.00002L4.20878 10.2417C3.95045 10.5167 3.70045 11.0584 3.65045 11.4334L3.34211 14.1334C3.23378 15.1084 3.93378 15.775 4.90045 15.6084L7.58378 15.15C7.95878 15.0834 8.48378 14.8084 8.74211 14.525L15.5838 7.28335C16.7671 6.03335 17.3004 4.60835 15.4588 2.86668C13.6254 1.14168 12.2338 1.75002 11.0504 3.00002Z" strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" style={{ stroke: 'var(--text-2)' }}/>
|
||
<path d="M9.9082 4.2085C10.2665 6.5085 12.1332 8.26683 14.4499 8.50016" strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" style={{ stroke: 'var(--text-2)' }}/>
|
||
<path d="M2.5 18.3335H17.5" strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" style={{ stroke: 'var(--text-2)' }}/>
|
||
</svg>
|
||
);
|
||
|
||
const ShieldIcon = () => (
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||
<path d="M8.7415 1.8584L4.5832 3.42506C3.62486 3.78340 2.8415 4.91673 2.8415 5.93340V12.1084C2.8415 13.0917 3.4915 14.3834 4.28317 14.9751L7.86650 17.6501C9.04150 18.5334 10.9748 18.5334 12.1498 17.6501L15.7332 14.9751C16.5248 14.3834 17.1748 13.0917 17.1748 12.1084V5.93340C17.1748 4.90840 16.3915 3.77506 15.4332 3.41673L11.2748 1.8584C10.5665 1.60006 9.4332 1.60006 8.7415 1.8584Z" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ stroke: 'var(--text-2)' }}/>
|
||
<path d="M7.5415 9.71673L8.99984 11.1751L12.4665 7.70840" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ stroke: 'var(--text-2)' }}/>
|
||
</svg>
|
||
);
|
||
|
||
const ChevronDown = ({ open }: { open: boolean }) => (
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
width="20"
|
||
height="20"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
className="text-[var(--text-2)] transition-transform"
|
||
style={{ transform: open ? "rotate(180deg)" : "rotate(0deg)" }}
|
||
>
|
||
<path d="M6 9L12 15L18 9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||
</svg>
|
||
);
|
||
|
||
// ── Avatar (first-letter gradient fallback, like tauri) ─────────────────────
|
||
|
||
function Avatar({ name, size = 32 }: { name?: string; size?: number }) {
|
||
const firstLetter = (name?.trim().charAt(0) || "?").toUpperCase();
|
||
return (
|
||
<div
|
||
className="rounded-full flex items-center justify-center bg-gradient-to-br from-[var(--primary)] to-[var(--primary)] text-[var(--on-primary)] font-bold flex-shrink-0"
|
||
style={{ width: size, height: size, fontSize: size / 2 }}
|
||
>
|
||
{firstLetter}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Permission sections (based on existing clinicpro pages) ─────────────────
|
||
|
||
// ── Text field (tauri DefaultTextField look) ────────────────────────────────
|
||
|
||
function DefaultTextField({
|
||
placeholder,
|
||
value,
|
||
onChange,
|
||
disabled,
|
||
multiline,
|
||
rows,
|
||
numeric,
|
||
maxDigits,
|
||
}: {
|
||
placeholder?: string;
|
||
value: string;
|
||
onChange: (v: string) => void;
|
||
disabled?: boolean;
|
||
multiline?: boolean;
|
||
rows?: number;
|
||
/** فیلد فقطعددی: ارقام فارسی/عربی به لاتین تبدیل و غیررقم حذف میشود. */
|
||
numeric?: boolean;
|
||
maxDigits?: number;
|
||
}) {
|
||
const cls =
|
||
"w-full bg-[var(--bg)] dark:bg-[var(--surface)] rounded-[8px] border border-[var(--border-2)] dark:border-[var(--border)] " +
|
||
"text-[var(--text-2)] dark:text-[var(--text)] text-[16px] font-normal px-[12px] py-[12.5px] outline-none " +
|
||
"focus:border-[var(--primary)] disabled:opacity-70";
|
||
if (multiline) {
|
||
return (
|
||
<textarea
|
||
className={cls}
|
||
placeholder={placeholder}
|
||
value={value}
|
||
rows={rows ?? 2}
|
||
disabled={disabled}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
/>
|
||
);
|
||
}
|
||
return (
|
||
<input
|
||
className={cls}
|
||
placeholder={placeholder}
|
||
value={value}
|
||
disabled={disabled}
|
||
{...(numeric ? { type: "tel", inputMode: "numeric" as const, dir: "ltr" as const } : {})}
|
||
onChange={(e) => onChange(numeric ? digitsOnly(e.target.value, maxDigits) : e.target.value)}
|
||
/>
|
||
);
|
||
}
|
||
|
||
// ── Add/Edit/View modal ─────────────────────────────────────────────────────
|
||
|
||
type ModalMode = "add" | "edit" | "view";
|
||
|
||
interface FormState {
|
||
name: string;
|
||
family: string;
|
||
telephone: string;
|
||
national_code: string;
|
||
address: string;
|
||
}
|
||
|
||
function SecretaryModal({
|
||
open,
|
||
mode,
|
||
data,
|
||
saving,
|
||
isClinic,
|
||
clinicDoctors,
|
||
onClose,
|
||
onSubmit,
|
||
}: {
|
||
open: boolean;
|
||
mode: ModalMode;
|
||
data: SecretaryGroup | null;
|
||
saving: boolean;
|
||
isClinic: boolean;
|
||
clinicDoctors: ClinicDoctor[];
|
||
onClose: () => void;
|
||
onSubmit: (form: FormState, doctorUuids: string[]) => void;
|
||
}) {
|
||
const [form, setForm] = useState<FormState>({
|
||
name: "",
|
||
family: "",
|
||
telephone: "",
|
||
national_code: "",
|
||
address: "",
|
||
});
|
||
const [doctorUuids, setDoctorUuids] = useState<string[]>([]);
|
||
|
||
// انتخاب چند پزشک برای منشیِ کلینیک، هم در افزودن و هم در ویرایش
|
||
const showDoctorPicker = isClinic && mode !== "view";
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
setDoctorUuids([]);
|
||
if ((mode === "edit" || mode === "view") && data) {
|
||
const secretary = data.primary;
|
||
const parts = (secretary.user_name ?? "").split(" ");
|
||
setDoctorUuids(data.doctorUuids);
|
||
setForm({
|
||
name: parts[0] ?? "",
|
||
family: parts.slice(1).join(" "),
|
||
telephone: secretary.mobile_number ?? "",
|
||
national_code: secretary.national_code ?? "",
|
||
address: secretary.address ?? "",
|
||
});
|
||
} else {
|
||
setForm({
|
||
name: "",
|
||
family: "",
|
||
telephone: "",
|
||
national_code: "",
|
||
address: "",
|
||
});
|
||
}
|
||
}, [open, mode, data]);
|
||
|
||
const disabled = mode === "view";
|
||
const title =
|
||
mode === "add"
|
||
? "اضافه کردن منشی جدید"
|
||
: mode === "edit"
|
||
? "ویرایش منشی"
|
||
: "مشاهده اطلاعات منشی";
|
||
|
||
const setField = (field: keyof FormState, value: string) =>
|
||
setForm((prev) => ({ ...prev, [field]: value }));
|
||
|
||
const handleSubmit = () => {
|
||
if (mode === "view") {
|
||
onClose();
|
||
return;
|
||
}
|
||
if (!form.name.trim()) return toast.error("لطفاً نام را وارد کنید");
|
||
if (!form.family.trim()) return toast.error("لطفاً نام خانوادگی را وارد کنید");
|
||
if (!form.telephone.trim()) return toast.error("لطفاً شماره تلفن را وارد کنید");
|
||
if (!IRAN_MOBILE_RE.test(digitsOnly(form.telephone, 11)))
|
||
return toast.error("شماره تلفن باید 11 رقم و با 09 شروع شود");
|
||
if (form.national_code.trim() && !IRAN_NATIONAL_CODE_RE.test(digitsOnly(form.national_code, 10)))
|
||
return toast.error("کد ملی باید ۱۰ رقم باشد");
|
||
if (showDoctorPicker && doctorUuids.length === 0)
|
||
return toast.error("حداقل یک پزشک را انتخاب کنید");
|
||
onSubmit(form, doctorUuids);
|
||
};
|
||
|
||
return (
|
||
<Modal
|
||
open={open}
|
||
onClose={onClose}
|
||
title={title}
|
||
size="lg"
|
||
footer={
|
||
<>
|
||
<button
|
||
onClick={onClose}
|
||
className="border border-[var(--primary)] dark:border-transparent dark:bg-[var(--primary-soft2)] text-[var(--primary)] dark:text-[var(--surface)]
|
||
h-[40px] md:h-[44px] lg:h-[48px] py-[11px] px-[48px] rounded-[4px] cursor-pointer"
|
||
>
|
||
{mode === "view" ? "بستن" : "انصراف"}
|
||
</button>
|
||
{mode !== "view" && (
|
||
<button
|
||
onClick={handleSubmit}
|
||
disabled={saving}
|
||
className="bg-[var(--primary)] text-[var(--on-primary)] rounded-[4px] h-[40px] md:h-[44px] lg:h-[48px] py-[11px] px-[48px] cursor-pointer disabled:opacity-60"
|
||
>
|
||
{saving ? "در حال ذخیره..." : "ذخیره"}
|
||
</button>
|
||
)}
|
||
</>
|
||
}
|
||
>
|
||
<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-[var(--text)] text-[16px] font-bold">
|
||
پزشکانِ این منشی
|
||
</p>
|
||
<span className="text-[13px] text-[var(--text-2)]">{doctorUuids.length} انتخابشده</span>
|
||
</div>
|
||
<p className="text-[13px] text-[var(--text-2)] mb-[12px]">
|
||
منشی فقط به نوبتها و اطلاعاتِ پزشکانِ انتخابشده دسترسی خواهد داشت.
|
||
</p>
|
||
<DoctorMultiSelect doctors={clinicDoctors} selected={doctorUuids} onChange={setDoctorUuids} />
|
||
</div>
|
||
)}
|
||
|
||
{/* اطلاعات پایه */}
|
||
<div className="w-full">
|
||
<p className="text-[var(--text)] text-[16px] font-bold mb-[16px]">
|
||
اطلاعات پایه
|
||
</p>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-[16px]">
|
||
<div className="flex w-full flex-col items-start justify-start gap-[12px]">
|
||
<p className="w-full text-right text-[var(--text)] text-[14px] font-medium">نام</p>
|
||
<DefaultTextField placeholder="نام" value={form.name} onChange={(v) => setField("name", v)} disabled={disabled} />
|
||
</div>
|
||
<div className="flex w-full flex-col items-start justify-start gap-[12px]">
|
||
<p className="w-full text-right text-[var(--text)] text-[14px] font-medium">نام خانوادگی</p>
|
||
<DefaultTextField placeholder="نام خانوادگی" value={form.family} onChange={(v) => setField("family", v)} disabled={disabled} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="w-full grid grid-cols-1 md:grid-cols-2 gap-[16px]">
|
||
<div className="flex w-full flex-col items-start justify-start gap-[12px]">
|
||
<p className="w-full text-right text-[var(--text)] text-[14px] font-medium">شماره موبایل</p>
|
||
<DefaultTextField numeric maxDigits={11} placeholder="09121234567" value={form.telephone} onChange={(v) => setField("telephone", v)} disabled={disabled || mode === "edit"} />
|
||
</div>
|
||
<div className="flex w-full flex-col items-start justify-start gap-[12px]">
|
||
<p className="w-full text-right text-[var(--text)] text-[14px] font-medium">کد ملی</p>
|
||
<DefaultTextField numeric maxDigits={10} placeholder="کد ملی" value={form.national_code} onChange={(v) => setField("national_code", v)} disabled={disabled} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex w-full flex-col items-start justify-start gap-[12px]">
|
||
<p className="w-full text-right text-[var(--text)] text-[14px] font-medium">آدرس</p>
|
||
<DefaultTextField placeholder="آدرس" value={form.address} onChange={(v) => setField("address", v)} disabled={disabled} multiline rows={2} />
|
||
</div>
|
||
|
||
{mode === "add" && (
|
||
<p className="w-full text-right text-[var(--text-3)] text-[12.5px] leading-[1.9]">
|
||
بعد از ذخیره، پنجرهٔ دسترسیها باز میشود تا مجوزهای این منشی را تنظیم کنید.
|
||
</p>
|
||
)}
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
// ── Row buttons (view + edit) ───────────────────────────────────────────────
|
||
|
||
function RowButtons({
|
||
onView,
|
||
onEdit,
|
||
onPermissions,
|
||
}: { onView: () => void; onEdit: () => void; onPermissions: () => void }) {
|
||
return (
|
||
<div className="flex items-center justify-start gap-[16px]">
|
||
<button onClick={onView} className="p-1 cursor-pointer" title="مشاهده">
|
||
<EyeIcon />
|
||
</button>
|
||
<button onClick={onEdit} className="p-1 cursor-pointer" title="ویرایش">
|
||
<EditIcon />
|
||
</button>
|
||
{/* آیکون و نه دکمهٔ متنی: ستون عملیات با یک دکمهٔ سوم متنی از عرض
|
||
جدول بیرون میزد و «نام منشی» را میبرید. در کارت موبایل جا هست. */}
|
||
<button onClick={onPermissions} className="p-1 cursor-pointer" title="دسترسیها" type="button">
|
||
<ShieldIcon />
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 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({
|
||
data,
|
||
onView,
|
||
onEdit,
|
||
onDeactivate,
|
||
onPermissions,
|
||
showDoctor,
|
||
}: {
|
||
data: SecretaryGroup[];
|
||
onView: (g: SecretaryGroup) => void;
|
||
onEdit: (g: SecretaryGroup) => void;
|
||
onDeactivate: (g: SecretaryGroup) => void;
|
||
onPermissions: (g: SecretaryGroup) => void;
|
||
showDoctor: boolean;
|
||
}) {
|
||
const headCls =
|
||
"text-[var(--text-2)] dark:text-[var(--text)] text-[14px] font-normal py-[10px] px-[18px] text-start";
|
||
const cellCls =
|
||
"text-[var(--text-2)] text-[16px] font-medium px-[18px] py-[10px] whitespace-nowrap";
|
||
return (
|
||
<div className="hidden lg:block mt-[24px]">
|
||
<div className="rounded-[8px] overflow-hidden border border-solid border-[var(--border)]">
|
||
<table className="w-full border-collapse">
|
||
<thead>
|
||
<tr className="bg-[var(--surface-2)]">
|
||
<th className={headCls}>ردیف</th>
|
||
<th className={headCls}>نام منشی</th>
|
||
{showDoctor && <th className={headCls}>پزشک</th>}
|
||
<th className={headCls}>کد ملی</th>
|
||
<th className={headCls + " text-center"}>تاریخ همکاری</th>
|
||
<th className={headCls}>شماره تماس</th>
|
||
<th className={headCls}>عملیات</th>
|
||
<th className={headCls + " text-center"}>لغو همکاری</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{data.map((group, idx) => {
|
||
const row = group.primary;
|
||
return (
|
||
<tr key={group.key} className="border-b border-[var(--border-2)]">
|
||
<td className={cellCls}>{idx + 1}</td>
|
||
<td className={cellCls}>
|
||
<div className="flex items-center justify-start gap-[8px]">
|
||
<Avatar name={row.user_name} size={32} />
|
||
<span>{row.user_name}</span>
|
||
</div>
|
||
</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(group)} onEdit={() => onEdit(group)} onPermissions={() => onPermissions(group)} />
|
||
</td>
|
||
<td className={cellCls + " text-center"}>
|
||
{row.is_active ? (
|
||
<button
|
||
onClick={() => onDeactivate(group)}
|
||
className="border border-[var(--danger)] text-[var(--danger)] hover:bg-[var(--danger-bg)] text-[12px] font-medium py-[6px] px-[16px] rounded-[4px] cursor-pointer"
|
||
>
|
||
لغو همکاری
|
||
</button>
|
||
) : (
|
||
<button
|
||
onClick={() => onDeactivate(group)}
|
||
className="border border-[var(--success)] text-[var(--success)] hover:bg-[var(--success-bg)] text-[12px] font-medium py-[6px] px-[16px] rounded-[4px] cursor-pointer"
|
||
>
|
||
فعالسازی
|
||
</button>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Mobile cards ─────────────────────────────────────────────────────────────
|
||
|
||
function SecretaryCards({
|
||
data,
|
||
onView,
|
||
onEdit,
|
||
onDeactivate,
|
||
onPermissions,
|
||
showDoctor,
|
||
}: {
|
||
data: SecretaryGroup[];
|
||
onView: (g: SecretaryGroup) => void;
|
||
onEdit: (g: SecretaryGroup) => void;
|
||
onDeactivate: (g: SecretaryGroup) => void;
|
||
onPermissions: (g: SecretaryGroup) => void;
|
||
showDoctor: boolean;
|
||
}) {
|
||
return (
|
||
<ul className="grid lg:hidden grid-cols-1 sm:grid-cols-2 gap-[24px] mt-[24px]">
|
||
{data.map((group) => {
|
||
const item = group.primary;
|
||
return (
|
||
<div
|
||
key={group.key}
|
||
className="bg-[var(--surface)] 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]">
|
||
<Avatar name={item.user_name} size={32} />
|
||
<p className="text-[var(--text-2)] 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-[var(--text-2)] text-[14px] font-normal shrink-0">پزشک:</p>
|
||
<p className="text-[var(--text-2)] text-[14px] font-normal text-left">
|
||
{group.doctorNames.join("، ")}
|
||
</p>
|
||
</div>
|
||
<span className="block w-full h-px bg-[var(--surface-2)] my-[8px]" />
|
||
</>
|
||
)}
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-[var(--text-2)] text-[14px] font-normal">کدملی:</p>
|
||
<p className="text-[var(--text-2)] text-[14px] font-normal">{item.national_code || "-"}</p>
|
||
</div>
|
||
<span className="block w-full h-px bg-[var(--surface-2)] my-[8px]" />
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-[var(--text-2)] text-[14px] font-normal">تاریخ همکاری:</p>
|
||
<p className="text-[var(--text-2)] text-[14px] font-normal">{formatDate(Number(item.created_at))}</p>
|
||
</div>
|
||
<span className="block w-full h-px bg-[var(--surface-2)] my-[8px]" />
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-[var(--text-2)] text-[14px] font-normal">شماره تماس:</p>
|
||
<p className="text-[var(--text-2)] text-[14px] font-normal" dir="ltr">{item.mobile_number}</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center justify-between mt-[16px]">
|
||
<button
|
||
onClick={() => onView(group)}
|
||
className="text-[var(--primary)] dark:bg-[var(--primary-soft2)] gap-[5px] text-[14px] font-medium border border-[var(--primary)] py-[8px] px-[12px] rounded-[4px] cursor-pointer flex items-center"
|
||
>
|
||
<EyeIcon />
|
||
</button>
|
||
<button
|
||
onClick={() => onEdit(group)}
|
||
className="border border-[var(--border)] rounded-[4px] p-[7px] cursor-pointer"
|
||
>
|
||
<EditIcon />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => onPermissions(group)}
|
||
className="btn ghost sm"
|
||
>
|
||
دسترسیها
|
||
</button>
|
||
</div>
|
||
<button
|
||
onClick={() => onDeactivate(group)}
|
||
className={
|
||
item.is_active
|
||
? "w-full border border-[var(--danger)] text-[var(--danger)] hover:bg-[var(--danger-bg)] text-[14px] font-medium py-[8px] mt-[12px] rounded-[4px] cursor-pointer"
|
||
: "w-full border border-[var(--success)] text-[var(--success)] hover:bg-[var(--success-bg)] text-[14px] font-medium py-[8px] mt-[12px] rounded-[4px] cursor-pointer"
|
||
}
|
||
>
|
||
{item.is_active ? "لغو همکاری" : "فعالسازی"}
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
</ul>
|
||
);
|
||
}
|
||
|
||
// ── Main content ─────────────────────────────────────────────────────────────
|
||
|
||
interface ClinicDoctor {
|
||
uuid: string;
|
||
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-[var(--border)] rounded-[8px] overflow-hidden">
|
||
{/* هدر: جستجو + انتخاب همه */}
|
||
<div className="flex items-center gap-[8px] p-[10px] border-b border-[var(--border)] bg-[var(--surface)]">
|
||
<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" style={{ stroke: 'var(--text-3)' }} 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-[var(--text)]"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={toggleAll}
|
||
className="text-[12px] text-[var(--primary)] font-medium whitespace-nowrap cursor-pointer"
|
||
>
|
||
{allSelected ? "لغو همه" : "انتخاب همه"}
|
||
</button>
|
||
</div>
|
||
|
||
{/* چیپهای انتخابشده */}
|
||
{selected.length > 0 && (
|
||
<div className="flex flex-wrap gap-[6px] p-[10px] border-b border-[var(--border)]">
|
||
{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-[var(--primary-soft)] text-[var(--primary)] 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-[var(--text-2)] 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-[var(--border)] last:border-b-0 " +
|
||
(checked ? "bg-[var(--primary-soft)] dark:bg-[var(--surface-3)]" : "hover:bg-[var(--surface)] dark:hover:bg-[var(--surface-2)]")
|
||
}
|
||
>
|
||
<Switch checked={checked} onChange={() => toggle(d.uuid)} ariaLabel={d.name} />
|
||
<Avatar name={d.name} size={26} />
|
||
<span className="text-[13px] text-[var(--text)]">{d.name}</span>
|
||
</label>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MySecretariesPageContent() {
|
||
const qc = useQueryClient();
|
||
const { doctorUuid, dbUuid, primaryRole } = useAuthStore();
|
||
const isClinic = primaryRole === "clinic";
|
||
const { maxSecretaries } = useSubscription();
|
||
|
||
const [tab, setTab] = useState(0); // 0: منشی های فعلی، 1: منشی های قبلی
|
||
const activeDoctorUuid = doctorUuid ?? "";
|
||
|
||
const [modalOpen, setModalOpen] = useState(false);
|
||
const [modalMode, setModalMode] = useState<ModalMode>("add");
|
||
const [permOpen, setPermOpen] = useState(false);
|
||
const [permTarget, setPermTarget] = useState<SecretaryGroup | null>(null);
|
||
const [selected, setSelected] = useState<SecretaryGroup | null>(null);
|
||
const [deactivateTarget, setDeactivateTarget] = useState<SecretaryGroup | null>(null);
|
||
|
||
// clinic: list of doctors
|
||
const { data: clinicDoctorsData } = useQuery<
|
||
ApiResponse<{ data: ClinicDoctor[] }>
|
||
>({
|
||
queryKey: ["clinic-doctors", dbUuid],
|
||
queryFn: () => api.get(`/api/v1/clinic/doctor-list/${dbUuid}`),
|
||
enabled: isClinic && !!dbUuid,
|
||
});
|
||
const clinicDoctors: ClinicDoctor[] = clinicDoctorsData?.data?.data ?? [];
|
||
|
||
// clinic: all secretaries across clinic
|
||
const { data: clinicSecrData, isLoading: clinicSecrLoading } = useQuery<ApiResponse<Secretary[]>>({
|
||
queryKey: ["my-secretaries-clinic", dbUuid],
|
||
queryFn: () => api.get(`/api/v1/secretaries/clinic/${dbUuid}`),
|
||
enabled: isClinic && !!dbUuid,
|
||
});
|
||
|
||
// doctor: secretaries for the doctor
|
||
const { data: doctorSecrData, isLoading: doctorSecrLoading } = useQuery<ApiResponse<Secretary[]>>({
|
||
queryKey: ["my-secretaries", activeDoctorUuid],
|
||
queryFn: () => api.get(`/api/v1/secretaries/${activeDoctorUuid}`),
|
||
enabled: !isClinic && !!activeDoctorUuid,
|
||
});
|
||
|
||
const allSecretaries = isClinic ? (clinicSecrData?.data ?? []) : (doctorSecrData?.data ?? []);
|
||
const isLoading = isClinic ? clinicSecrLoading : doctorSecrLoading;
|
||
|
||
// 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 = () => {
|
||
qc.invalidateQueries({ queryKey: ["my-secretaries"] });
|
||
qc.invalidateQueries({ queryKey: ["my-secretaries-clinic"] });
|
||
};
|
||
|
||
const createMutation = useMutation({
|
||
mutationFn: ({ form, doctorUuids }: { form: FormState; doctorUuids: string[] }) => {
|
||
const base = {
|
||
mobile_number: digitsOnly(form.telephone, 11),
|
||
name: `${form.name} ${form.family}`.trim(),
|
||
national_code: digitsOnly(form.national_code, 10) || null,
|
||
address: form.address || null,
|
||
// مجوز اینجا ارسال نمیشود: منشیِ تازه پیشفرضِ نقش را از بکاند
|
||
// میگیرد و تنظیمش در پنجرهٔ جداگانهٔ دسترسیها انجام میشود.
|
||
};
|
||
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();
|
||
|
||
// منشیِ تازه با پیشفرضِ نقش ساخته میشود؛ بلافاصله پنجرهٔ دسترسیها باز
|
||
// میشود تا مالک همانجا تنظیمش کند، بدون اینکه فرمِ افزودن طولانی شود.
|
||
// پاسخ دو شکل دارد: چندپزشکیِ کلینیک (`created`) و تکپزشکی (`data`).
|
||
const payload = res?.data?.data ?? res?.data;
|
||
const rows: Secretary[] = payload?.created ?? (payload?.uuid ? [payload] : []);
|
||
if (rows.length > 0) {
|
||
setPermTarget(groupBySecretary(rows)[0] ?? null);
|
||
setPermOpen(true);
|
||
}
|
||
},
|
||
onError: (e: any) => toast.error(e.message),
|
||
});
|
||
|
||
const updateMutation = useMutation({
|
||
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,
|
||
// مجوزها در SecretaryPermissionsModal ذخیره میشوند، نه اینجا.
|
||
};
|
||
// هر رابطهی پزشک-منشی جداگانه ذخیره میشود تا پروفایل در همه یکسان بماند
|
||
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);
|
||
invalidate();
|
||
},
|
||
onError: (e: any) => toast.error(e.message),
|
||
});
|
||
|
||
const toggleActiveMutation = useMutation({
|
||
// همکاری با یک منشی روی همهی پزشکانش لغو/برقرار میشود
|
||
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);
|
||
invalidate();
|
||
},
|
||
onError: (e: any) => toast.error(e.message),
|
||
});
|
||
|
||
const saving = createMutation.isPending || updateMutation.isPending;
|
||
|
||
const handleAddClick = () => {
|
||
if (atLimit) return toast.error(`حداکثر ${maxSecretaries} منشی مجاز است؛ برای افزودن، پنل را ارتقا دهید`);
|
||
setModalMode("add");
|
||
setSelected(null);
|
||
setModalOpen(true);
|
||
};
|
||
|
||
const openPermissions = (g: SecretaryGroup) => {
|
||
setPermTarget(g);
|
||
setPermOpen(true);
|
||
};
|
||
|
||
const openModal = (mode: ModalMode, g: SecretaryGroup) => {
|
||
setModalMode(mode);
|
||
setSelected(g);
|
||
setModalOpen(true);
|
||
};
|
||
|
||
const handleModalSubmit = (form: FormState, doctorUuids: string[]) => {
|
||
if (modalMode === "add") createMutation.mutate({ form, doctorUuids });
|
||
else if (modalMode === "edit" && selected)
|
||
updateMutation.mutate({ group: selected, form, doctorUuids });
|
||
};
|
||
|
||
return (
|
||
<div dir="rtl">
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-[var(--text)] text-[20px] font-bold">لیست منشی ها</p>
|
||
</div>
|
||
|
||
{/* تبها + دکمه افزودن */}
|
||
<div className="w-full flex items-end justify-between mt-[20px]">
|
||
<div className="flex items-center gap-[8px] border-b border-[var(--border)]">
|
||
{["منشی های فعلی", "منشی های قبلی"].map((label, idx) => (
|
||
<button
|
||
key={idx}
|
||
onClick={() => setTab(idx)}
|
||
className={
|
||
"px-[12px] py-[10px] text-[14px] md:text-[16px] font-bold cursor-pointer border-b-2 -mb-px " +
|
||
(tab === idx
|
||
? "text-[var(--primary)] border-[var(--primary)]"
|
||
: "text-[var(--text)] dark:text-[var(--text-2)] border-transparent")
|
||
}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<button
|
||
onClick={handleAddClick}
|
||
className="shadow-none gap-[8px] bg-[var(--primary)] text-[var(--on-primary)] 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"
|
||
>
|
||
<PlusIcon />
|
||
اضافه کردن منشی
|
||
</button>
|
||
</div>
|
||
|
||
{!isClinic && !doctorUuid ? (
|
||
<div className="mt-[24px] text-center text-[var(--text-2)] py-[40px]">پروفایل پزشک یافت نشد</div>
|
||
) : isLoading ? (
|
||
<div className="mt-[24px] text-center text-[var(--text-2)] py-[40px]">در حال بارگذاری...</div>
|
||
) : secretaries.length === 0 ? (
|
||
<div className="mt-[24px] text-center text-[var(--text-2)] py-[60px]">
|
||
{tab === 0 ? "هنوز منشی فعالی اضافه نشده است" : "منشی قبلیای وجود ندارد"}
|
||
</div>
|
||
) : (
|
||
<>
|
||
<SecretaryTable
|
||
data={secretaries}
|
||
showDoctor={isClinic}
|
||
onView={(s) => openModal("view", s)}
|
||
onEdit={(s) => openModal("edit", s)}
|
||
onDeactivate={(s) => setDeactivateTarget(s)}
|
||
onPermissions={openPermissions}
|
||
/>
|
||
<SecretaryCards
|
||
data={secretaries}
|
||
showDoctor={isClinic}
|
||
onView={(s) => openModal("view", s)}
|
||
onEdit={(s) => openModal("edit", s)}
|
||
onDeactivate={(s) => setDeactivateTarget(s)}
|
||
onPermissions={openPermissions}
|
||
/>
|
||
</>
|
||
)}
|
||
|
||
{atLimit && (
|
||
<div className="mt-[16px]">
|
||
<Link to="/admin/subscription" className="text-[var(--primary)] text-[13px] underline">
|
||
برای افزودن منشی بیشتر، پنل خود را ارتقا دهید
|
||
</Link>
|
||
</div>
|
||
)}
|
||
|
||
<SecretaryModal
|
||
open={modalOpen}
|
||
mode={modalMode}
|
||
data={selected}
|
||
saving={saving}
|
||
isClinic={isClinic}
|
||
clinicDoctors={clinicDoctors}
|
||
onClose={() => setModalOpen(false)}
|
||
onSubmit={handleModalSubmit}
|
||
/>
|
||
|
||
<SecretaryPermissionsModal
|
||
open={permOpen}
|
||
secretary={permTarget?.primary ?? null}
|
||
links={permTarget?.links.map((l) => l.uuid) ?? []}
|
||
isClinic={isClinic}
|
||
onClose={() => setPermOpen(false)}
|
||
onSaved={invalidate}
|
||
/>
|
||
|
||
<ConfirmDialog
|
||
open={!!deactivateTarget}
|
||
title={deactivateTarget?.primary.is_active ? "لغو همکاری با منشی" : "فعالسازی منشی"}
|
||
message={
|
||
deactivateTarget?.primary.is_active
|
||
? `آیا مطمئن هستید که میخواهید همکاری با «${deactivateTarget?.primary.user_name}»` +
|
||
(deactivateTarget && deactivateTarget.doctorNames.length > 1
|
||
? ` برای همهی ${deactivateTarget.doctorNames.length} پزشک`
|
||
: "") +
|
||
" را لغو کنید؟"
|
||
: `آیا مطمئن هستید که میخواهید «${deactivateTarget?.primary.user_name}» را دوباره فعال کنید؟`
|
||
}
|
||
confirmLabel={deactivateTarget?.primary.is_active ? "لغو همکاری" : "فعالسازی"}
|
||
danger={deactivateTarget?.primary.is_active}
|
||
loading={toggleActiveMutation.isPending}
|
||
onConfirm={() =>
|
||
deactivateTarget &&
|
||
toggleActiveMutation.mutate({
|
||
group: deactivateTarget,
|
||
active: !deactivateTarget.primary.is_active,
|
||
})
|
||
}
|
||
onCancel={() => setDeactivateTarget(null)}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function MySecretariesPage() {
|
||
return (
|
||
<SettingsLayout active="secretary">
|
||
<MySecretariesPageContent />
|
||
</SettingsLayout>
|
||
);
|
||
}
|