Files
clinicpro/assets/admin/pages/MySecretariesPage.tsx
T
hamedandClaude Opus 4.8 00cb9aaa1a feat(admin): normalize Persian/Arabic digits in every numeric field
Users typing on a Persian keyboard produced two distinct failures. Fields with
type="number" silently returned an empty string — the browser rejects Persian
digits, so the value was lost and saved as empty or zero. Text fields passed the
Persian characters straight through to the database, where a mobile stored as
۰۹۱۲… never matches 09… again. The secretary form hit the second case with no
validation at all.

Frontend:
- Adds digitsOnly() and the national-code schemas to lib/utils, plus lib/forms
  with numericField()/latinDigitsField() wrappers for React Hook Form fields.
- Converts every type="number" input to type="text" inputMode="numeric" with
  digit normalization; none remain. Fields that legitimately carry non-digits
  (sheba, landline) only get the digits translated, keeping IR and separators.
- Points the patient national-code and mobile schemas at the shared normalizing
  schemas, which accept Persian input instead of rejecting it.
- Drops two duplicate local digit converters in favour of the shared helper.

Backend:
- Adds NumericFieldNormalizerSubscriber, translating digits in whitelisted
  numeric keys of JSON request bodies under /api/v1/ before controllers run, so
  nobat724_front and clinic-pro-tauri are covered too. Translation only — no
  characters are stripped, non-string values and other keys are untouched.

Three component tests asserted on role="spinbutton" and numeric input values;
both are properties of type="number", so they were updated to match the new
text inputs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 10:38:56 +03:30

965 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link } from "react-router-dom";
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";
// ── 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" stroke="#EFEFEF" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M10 15V5" stroke="#EFEFEF" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</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" stroke="#616161" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<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" stroke="#616161" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</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" stroke="#616161" strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
<path d="M9.9082 4.2085C10.2665 6.5085 12.1332 8.26683 14.4499 8.50016" stroke="#616161" strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
<path d="M2.5 18.3335H17.5" stroke="#616161" strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
</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-[#616161] dark:text-[#A1A1A1] 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-[#5559CE] to-[#7B7FE8] text-white font-bold flex-shrink-0"
style={{ width: size, height: size, fontSize: size / 2 }}
>
{firstLetter}
</div>
);
}
// ── Permission sections (based on existing clinicpro pages) ─────────────────
const EMPTY_PERMISSIONS: SecretaryPermissions = {
appointments: { view: false, create: false, cancel: false, update_status: false },
patients: { view: false, create: false, update: false, delete: false },
payments: { view: false, create: false, update: false, delete: false },
insurances: { view: false, create: false, update: false, delete: false },
addresses: { view: false, create: false, update: false, delete: false },
clinic_info: { view: false, update: false },
};
type PermSection = keyof SecretaryPermissions;
const PERMISSION_SECTIONS: {
key: PermSection;
title: string;
items: { key: string; label: string }[];
}[] = [
{
key: "appointments",
title: "مدیریت نوبت‌ها",
items: [
{ key: "view", label: "مشاهده نوبت‌ها" },
{ key: "create", label: "ایجاد نوبت" },
{ key: "cancel", label: "لغو نوبت" },
{ key: "update_status", label: "تغییر وضعیت نوبت" },
],
},
{
key: "patients",
title: "پرونده بیماران",
items: [
{ key: "view", label: "مشاهده بیماران" },
{ key: "create", label: "ایجاد بیمار" },
{ key: "update", label: "ویرایش بیمار" },
{ key: "delete", label: "حذف بیمار" },
],
},
{
key: "payments",
title: "مدیریت پرداخت‌ها",
items: [
{ key: "view", label: "مشاهده پرداخت‌ها" },
{ key: "create", label: "ثبت پرداخت" },
{ key: "update", label: "ویرایش پرداخت" },
{ key: "delete", label: "حذف پرداخت" },
],
},
{
key: "insurances",
title: "مدیریت بیمه‌ها",
items: [
{ key: "view", label: "مشاهده بیمه‌ها" },
{ key: "create", label: "ایجاد بیمه" },
{ key: "update", label: "ویرایش بیمه" },
{ key: "delete", label: "حذف بیمه" },
],
},
{
key: "addresses",
title: "آدرس‌ها",
items: [
{ key: "view", label: "مشاهده آدرس‌ها" },
{ key: "create", label: "ایجاد آدرس" },
{ key: "update", label: "ویرایش آدرس" },
{ key: "delete", label: "حذف آدرس" },
],
},
{
key: "clinic_info",
title: "اطلاعات کلینیک",
items: [
{ key: "view", label: "مشاهده اطلاعات" },
{ key: "update", label: "ویرایش اطلاعات" },
],
},
];
function PermissionAccordions({
permissions,
onChange,
disabled,
}: {
permissions: SecretaryPermissions;
onChange: (section: PermSection, item: string, value: boolean) => void;
disabled?: boolean;
}) {
const [openKeys, setOpenKeys] = useState<Set<string>>(
new Set(["appointments", "patients"]),
);
const toggleOpen = (key: string) => {
setOpenKeys((prev) => {
const next = new Set(prev);
next.has(key) ? next.delete(key) : next.add(key);
return next;
});
};
return (
<div className="w-full">
<p className="text-[#525252] dark:text-[#D7D8ED] text-[16px] font-bold mb-[16px]">
مجوزهای دسترسی
</p>
<div className="flex flex-col gap-[8px] w-full items-stretch">
{PERMISSION_SECTIONS.map((section) => {
const open = openKeys.has(section.key);
const sectionPerm = permissions[section.key] as Record<string, boolean>;
return (
<div
key={section.key}
className="w-full border border-[#DBDBDB] dark:border-[#343645] rounded-[8px] overflow-hidden"
>
<button
type="button"
onClick={() => toggleOpen(section.key)}
className="w-full flex items-center justify-between px-[16px] min-h-[64px] cursor-pointer"
>
<p className="text-[#525252] dark:text-[#D7D8ED] text-[14px] font-medium">
{section.title}
</p>
<ChevronDown open={open} />
</button>
{open && (
<div className="px-[16px] pt-[8px] pb-[16px]">
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-[16px] gap-y-0">
{section.items.map((item) => (
<label
key={item.key}
className="flex items-center gap-[11px] cursor-pointer py-[9px] min-h-[42px]"
>
<input
type="checkbox"
checked={sectionPerm?.[item.key] ?? false}
disabled={disabled}
onChange={(e) =>
onChange(section.key, item.key, e.target.checked)
}
className="w-[20px] h-[20px] shrink-0"
style={{ accentColor: "#5559CE", cursor: "pointer" }}
/>
<span className="text-[#616161] dark:text-[#A1A1A1] text-[14px]">
{item.label}
</span>
</label>
))}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
}
// ── 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-[#FAFAFA] dark:bg-[#222433] rounded-[8px] border border-[#D7D7D7] dark:border-[#343645] " +
"text-[#7E7E7E] dark:text-[#D7D8ED] text-[16px] font-normal px-[12px] py-[12.5px] outline-none " +
"focus:border-[#5559CE] 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;
permission: SecretaryPermissions;
}
function SecretaryModal({
open,
mode,
data,
saving,
isClinic,
clinicDoctors,
onClose,
onSubmit,
}: {
open: boolean;
mode: ModalMode;
data: Secretary | 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: "",
permission: EMPTY_PERMISSIONS,
});
const [doctorUuids, setDoctorUuids] = useState<string[]>([]);
// نمایش انتخاب چند پزشک فقط هنگام افزودنِ منشیِ کلینیک
const showDoctorPicker = isClinic && mode === "add";
useEffect(() => {
if (!open) return;
setDoctorUuids([]);
if ((mode === "edit" || mode === "view") && data) {
const parts = (data.user_name ?? "").split(" ");
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 ?? {}) },
});
} else {
setForm({
name: "",
family: "",
telephone: "",
national_code: "",
address: "",
permission: EMPTY_PERMISSIONS,
});
}
}, [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 setPermission = (section: PermSection, item: string, value: boolean) =>
setForm((prev) => ({
...prev,
permission: {
...prev.permission,
[section]: { ...(prev.permission[section] as Record<string, boolean>), [item]: 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-[#5559CE] dark:border-transparent dark:bg-[#C7CEF4] text-[#5559CE] dark:text-[#222433]
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-[#5559CE] text-[#EFEFEF] 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-[#525252] dark:text-[#D7D8ED] text-[16px] font-bold">
پزشکانِ این منشی
</p>
<span className="text-[13px] text-[#7E7E7E]">{doctorUuids.length} انتخاب‌شده</span>
</div>
<p className="text-[13px] text-[#7E7E7E] mb-[12px]">
منشی فقط به نوبت‌ها و اطلاعاتِ پزشکانِ انتخاب‌شده دسترسی خواهد داشت.
</p>
<DoctorMultiSelect doctors={clinicDoctors} selected={doctorUuids} onChange={setDoctorUuids} />
</div>
)}
{/* اطلاعات پایه */}
<div className="w-full">
<p className="text-[#525252] dark:text-[#D7D8ED] 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-[#525252] dark:text-[#D7D8ED] 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-[#525252] dark:text-[#D7D8ED] 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-[#525252] dark:text-[#D7D8ED] 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-[#525252] dark:text-[#D7D8ED] 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-[#525252] dark:text-[#D7D8ED] text-[14px] font-medium">آدرس</p>
<DefaultTextField placeholder="آدرس" value={form.address} onChange={(v) => setField("address", v)} disabled={disabled} multiline rows={2} />
</div>
<PermissionAccordions permissions={form.permission} onChange={setPermission} disabled={disabled} />
</div>
</Modal>
);
}
// ── Row buttons (view + edit) ───────────────────────────────────────────────
function RowButtons({ onView, onEdit }: { onView: () => void; onEdit: () => 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>
</div>
);
}
// ── Desktop table ────────────────────────────────────────────────────────────
function SecretaryTable({
data,
onView,
onEdit,
onDeactivate,
showDoctor,
}: {
data: Secretary[];
onView: (s: Secretary) => void;
onEdit: (s: Secretary) => void;
onDeactivate: (s: Secretary) => void;
showDoctor: boolean;
}) {
const headCls =
"text-[#616161] dark:text-[#D7D8ED] text-[14px] font-normal py-[10px] px-[18px] text-start";
const cellCls =
"text-[#616161] dark:text-[#A1A1A1] 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-[#E7E7E7] dark:border-[#35343D]">
<table className="w-full border-collapse">
<thead>
<tr className="bg-[#EFEFEF] dark:bg-[#35343D]">
<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((row, idx) => (
<tr key={row.uuid} 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]">
<Avatar name={row.user_name} size={32} />
<span>{row.user_name}</span>
</div>
</td>
{showDoctor && <td className={cellCls}>{row.doctor_name}</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)} />
</td>
<td className={cellCls + " text-center"}>
{row.is_active ? (
<button
onClick={() => onDeactivate(row)}
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)}
className="border border-[#2E7D32] text-[#2E7D32] hover:bg-[#E8F5E9] 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,
}: {
data: Secretary[];
onView: (s: Secretary) => void;
onEdit: (s: Secretary) => void;
onDeactivate: (s: Secretary) => void;
}) {
return (
<ul className="grid lg:hidden grid-cols-1 sm:grid-cols-2 gap-[24px] mt-[24px]">
{data.map((item) => (
<div
key={item.uuid}
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]">
<Avatar name={item.user_name} size={32} />
<p className="text-[#616161] dark:text-[#D7D8ED] text-[14px] font-medium">{item.user_name}</p>
</div>
<div className="w-full mt-[16px]">
<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>
</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">{formatDate(Number(item.created_at))}</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" dir="ltr">{item.mobile_number}</p>
</div>
</div>
<div className="flex items-center justify-between mt-[16px]">
<button
onClick={() => onView(item)}
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)}
className="border border-[#EFEFEF] dark:border-[#343645] rounded-[4px] p-[7px] cursor-pointer"
>
<EditIcon />
</button>
</div>
<button
onClick={() => onDeactivate(item)}
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"
: "w-full border border-[#2E7D32] text-[#2E7D32] hover:bg-[#E8F5E9] 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-[#EFEFEF] dark:border-[#343645] rounded-[8px] overflow-hidden">
{/* هدر: جستجو + انتخاب همه */}
<div className="flex items-center gap-[8px] p-[10px] border-b border-[#EFEFEF] dark:border-[#343645] bg-[#FAFAFC] dark:bg-[#222433]">
<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" stroke="#9A9AB0" 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-[#525252] dark:text-[#D7D8ED]"
/>
<button
type="button"
onClick={toggleAll}
className="text-[12px] text-[#5559CE] font-medium whitespace-nowrap cursor-pointer"
>
{allSelected ? "لغو همه" : "انتخاب همه"}
</button>
</div>
{/* چیپ‌های انتخاب‌شده */}
{selected.length > 0 && (
<div className="flex flex-wrap gap-[6px] p-[10px] border-b border-[#EFEFEF] dark:border-[#343645]">
{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-[#EEF0FF] dark:bg-[#33365A] text-[#5559CE] dark:text-[#C7CEF4] 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-[#7E7E7E] 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-[#F2F2F6] dark:border-[#2A2C3A] last:border-b-0 " +
(checked ? "bg-[#F5F6FF] dark:bg-[#2A2D45]" : "hover:bg-[#FAFAFC] dark:hover:bg-[#2A2C3A]")
}
>
<input
type="checkbox"
className="accent-[#5559CE] w-[16px] h-[16px]"
checked={checked}
onChange={() => toggle(d.uuid)}
/>
<Avatar name={d.name} size={26} />
<span className="text-[13px] text-[#525252] dark:text-[#D7D8ED]">{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 [selected, setSelected] = useState<Secretary | null>(null);
const [deactivateTarget, setDeactivateTarget] = useState<Secretary | 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)
const secretaries = allSecretaries.filter((s) => (tab === 0 ? s.is_active : !s.is_active));
const activeSecretaryCount = allSecretaries.filter((s) => s.is_active).length;
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,
permissions: { version: 1, resources: form.permission },
};
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();
},
onError: (e: any) => toast.error(e.message),
});
const updateMutation = useMutation({
mutationFn: ({ uuid, form }: { uuid: string; form: FormState }) =>
api.patch(`/api/v1/secretary/${uuid}`, {
name: `${form.name} ${form.family}`.trim(),
national_code: digitsOnly(form.national_code, 10) || null,
address: form.address || null,
permissions: { version: 1, resources: form.permission },
}),
onSuccess: () => {
toast.success("منشی با موفقیت ویرایش شد");
setModalOpen(false);
invalidate();
},
onError: (e: any) => toast.error(e.message),
});
const toggleActiveMutation = useMutation({
mutationFn: ({ uuid, active }: { uuid: string; active: boolean }) =>
api.patch(`/api/v1/secretary/${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 openModal = (mode: ModalMode, s: Secretary) => {
setModalMode(mode);
setSelected(s);
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 });
};
return (
<div dir="rtl">
<div className="flex items-center justify-between">
<p className="text-[#525252] dark:text-[#D7D8ED] 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-[#EFEFEF] dark:border-[#343645]">
{["منشی های فعلی", "منشی های قبلی"].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-[#5559CE] border-[#5559CE]"
: "text-[#495057] dark:text-[#A1A1A1] border-transparent")
}
>
{label}
</button>
))}
</div>
<button
onClick={handleAddClick}
className="shadow-none gap-[8px] bg-[#5559CE] text-[#EFEFEF] 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-[#7E7E7E] py-[40px]">پروفایل پزشک یافت نشد</div>
) : isLoading ? (
<div className="mt-[24px] text-center text-[#7E7E7E] py-[40px]">در حال بارگذاری...</div>
) : secretaries.length === 0 ? (
<div className="mt-[24px] text-center text-[#7E7E7E] py-[60px]">
{tab === 0 ? "هنوز منشی فعالی اضافه نشده است" : "منشی قبلی‌ای وجود ندارد"}
</div>
) : (
<>
<SecretaryTable
data={secretaries}
showDoctor={isClinic}
onView={(s) => openModal("view", s)}
onEdit={(s) => openModal("edit", s)}
onDeactivate={(s) => setDeactivateTarget(s)}
/>
<SecretaryCards
data={secretaries}
onView={(s) => openModal("view", s)}
onEdit={(s) => openModal("edit", s)}
onDeactivate={(s) => setDeactivateTarget(s)}
/>
</>
)}
{atLimit && (
<div className="mt-[16px]">
<Link to="/admin/subscription" className="text-[#5559CE] text-[13px] underline">
برای افزودن منشی بیشتر، پنل خود را ارتقا دهید
</Link>
</div>
)}
<SecretaryModal
open={modalOpen}
mode={modalMode}
data={selected}
saving={saving}
isClinic={isClinic}
clinicDoctors={clinicDoctors}
onClose={() => setModalOpen(false)}
onSubmit={handleModalSubmit}
/>
<ConfirmDialog
open={!!deactivateTarget}
title={deactivateTarget?.is_active ? "لغو همکاری با منشی" : "فعال‌سازی منشی"}
message={
deactivateTarget?.is_active
? `آیا مطمئن هستید که می‌خواهید همکاری با «${deactivateTarget?.user_name}» را لغو کنید؟`
: `آیا مطمئن هستید که می‌خواهید «${deactivateTarget?.user_name}» را دوباره فعال کنید؟`
}
confirmLabel={deactivateTarget?.is_active ? "لغو همکاری" : "فعال‌سازی"}
danger={deactivateTarget?.is_active}
loading={toggleActiveMutation.isPending}
onConfirm={() =>
deactivateTarget &&
toggleActiveMutation.mutate({
uuid: deactivateTarget.uuid,
active: !deactivateTarget.is_active,
})
}
onCancel={() => setDeactivateTarget(null)}
/>
</div>
);
}
export default function MySecretariesPage() {
return (
<SettingsLayout active="secretary">
<MySecretariesPageContent />
</SettingsLayout>
);
}