From 89e23a2c0d6344f7cd4c2f78bc43f3ad350a88f9 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Wed, 15 Jul 2026 12:23:17 +0330 Subject: [PATCH] feat: port secretaries tab from tauri to admin my-secretaries page - Redesign MySecretariesPage pixel-perfect to clinic-pro-tauri (active/previous tabs, desktop table, mobile cards, add/edit/view modal with permission accordions, deactivate confirm) - Permission sections based on existing admin pages (appointments, patients, payments, insurances, addresses, clinic_info) - Extend DoctorSecretary with national_code + address columns (+migration); wire create/update in SecretaryController; add patients/payments to DEFAULT_PERMISSIONS - Extend Secretary/SecretaryPermissions types; update admin SecretariesPage - Backend + frontend tests; update docs/api/secretary.md Co-Authored-By: Claude Opus 4.8 --- assets/admin/pages/MySecretariesPage.test.tsx | 110 ++ assets/admin/pages/MySecretariesPage.tsx | 1445 +++++++++-------- assets/admin/pages/SecretariesPage.tsx | 22 +- assets/admin/types/index.ts | 26 +- docs/api/secretary.md | 76 +- migrations/Version20260715083856.php | 31 + .../Controller/SecretaryController.php | 20 + src/Secretary/Entity/DoctorSecretary.php | 16 +- tests/Secretary/SecretaryFieldsTest.php | 109 ++ 9 files changed, 1146 insertions(+), 709 deletions(-) create mode 100644 assets/admin/pages/MySecretariesPage.test.tsx create mode 100644 migrations/Version20260715083856.php create mode 100644 tests/Secretary/SecretaryFieldsTest.php diff --git a/assets/admin/pages/MySecretariesPage.test.tsx b/assets/admin/pages/MySecretariesPage.test.tsx new file mode 100644 index 00000000..64886fcb --- /dev/null +++ b/assets/admin/pages/MySecretariesPage.test.tsx @@ -0,0 +1,110 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { screen, fireEvent } from "@testing-library/react"; +import { renderWithProviders } from "../test/utils"; + +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +vi.mock("../lib/api", () => ({ + api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() }, + ApiError: class extends Error {}, +})); +vi.mock("../stores/authStore", () => ({ + useAuthStore: () => ({ doctorUuid: "doc-1", dbUuid: "doc-1", primaryRole: "doctor" }), +})); +vi.mock("../hooks/useSubscription", () => ({ + useSubscription: () => ({ maxSecretaries: 5 }), +})); + +import { api } from "../lib/api"; +import MySecretariesPage from "./MySecretariesPage"; + +const get = api.get as ReturnType; + +const fullPerms = { + appointments: { view: true, 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 }, +}; + +const activeSecretary = { + uuid: "sec-1", + user_name: "سارا احمدی", + mobile_number: "09121234567", + doctor_name: "دکتر تست", + doctor_uuid: "doc-1", + is_active: true, + national_code: "1234567890", + address: "یزد", + permissions: fullPerms, + created_at: 1700000000, +}; + +const previousSecretary = { + ...activeSecretary, + uuid: "sec-2", + user_name: "مینا رضایی", + mobile_number: "09129876543", + is_active: false, + national_code: "9999999999", +}; + +function mockData(rows = [activeSecretary, previousSecretary]) { + get.mockImplementation((url: string) => { + if (url.includes("/secretaries/")) return Promise.resolve({ success: true, data: rows }); + return Promise.resolve({ success: true, data: [] }); + }); +} + +beforeEach(() => { + get.mockReset(); + mockData(); +}); + +describe("MySecretariesPage", () => { + it("renders the title and both tabs", async () => { + renderWithProviders(, { route: "/admin/my-secretaries" }); + expect(await screen.findByText("لیست منشی ها")).toBeInTheDocument(); + expect(screen.getByText("منشی های فعلی")).toBeInTheDocument(); + expect(screen.getByText("منشی های قبلی")).toBeInTheDocument(); + }); + + it("shows active secretaries with national code on the default tab", async () => { + renderWithProviders(, { route: "/admin/my-secretaries" }); + expect(await screen.findAllByText("سارا احمدی")).not.toHaveLength(0); + expect(screen.getAllByText("1234567890").length).toBeGreaterThan(0); + // inactive secretary is hidden on the active tab + expect(screen.queryByText("مینا رضایی")).not.toBeInTheDocument(); + }); + + it("switches to the previous tab and lists inactive secretaries", async () => { + renderWithProviders(, { route: "/admin/my-secretaries" }); + await screen.findAllByText("سارا احمدی"); + + fireEvent.click(screen.getByText("منشی های قبلی")); + + expect(await screen.findAllByText("مینا رضایی")).not.toHaveLength(0); + expect(screen.queryByText("سارا احمدی")).not.toBeInTheDocument(); + }); + + it("shows an empty state when there are no active secretaries", async () => { + mockData([previousSecretary]); + renderWithProviders(, { route: "/admin/my-secretaries" }); + expect(await screen.findByText("هنوز منشی فعالی اضافه نشده است")).toBeInTheDocument(); + }); + + it("opens the add modal with permission sections based on existing pages", async () => { + renderWithProviders(, { route: "/admin/my-secretaries" }); + await screen.findAllByText("سارا احمدی"); + + fireEvent.click(screen.getByText("اضافه کردن منشی")); + + expect(await screen.findByText("اضافه کردن منشی جدید")).toBeInTheDocument(); + expect(screen.getByText("مجوزهای دسترسی")).toBeInTheDocument(); + expect(screen.getByText("مدیریت نوبت‌ها")).toBeInTheDocument(); + expect(screen.getByText("پرونده بیماران")).toBeInTheDocument(); + expect(screen.getByText("مدیریت پرداخت‌ها")).toBeInTheDocument(); + expect(screen.getByText("مدیریت بیمه‌ها")).toBeInTheDocument(); + }); +}); diff --git a/assets/admin/pages/MySecretariesPage.tsx b/assets/admin/pages/MySecretariesPage.tsx index ac67af52..e6ea185a 100644 --- a/assets/admin/pages/MySecretariesPage.tsx +++ b/assets/admin/pages/MySecretariesPage.tsx @@ -1,350 +1,711 @@ -import { - CheckCircleIcon, - IdentificationIcon, - NoSymbolIcon, - PencilIcon, - PhoneIcon, - PlusIcon, -} from "@heroicons/react/24/outline"; -import { zodResolver } from "@hookform/resolvers/zod"; +import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useState } from "react"; -import SettingsLayout from "../components/layout/SettingsLayout"; -import { useForm } from "react-hook-form"; import { Link } from "react-router-dom"; import { toast } from "sonner"; -import { z } from "zod"; +import SettingsLayout from "../components/layout/SettingsLayout"; import ConfirmDialog from "../components/ui/ConfirmDialog"; -import DataTable, { type Column } from "../components/ui/DataTable"; -import Modal from "../components/ui/Modal"; -import PageHeader from "../components/ui/PageHeader"; -import { ActiveBadge } from "../components/ui/StatusBadge"; -import { useSubscription } from "../hooks/useSubscription"; +import SearchableSelect from "../components/ui/SearchableSelect"; import type { ApiResponse } from "../lib/api"; import { api } from "../lib/api"; -import { formatDate, maskMobile, iranMobileSchema } from "../lib/utils"; -import MobileInput from "../components/ui/MobileInput"; -import SearchableSelect from "../components/ui/SearchableSelect"; +import { formatDate } from "../lib/utils"; +import { useSubscription } from "../hooks/useSubscription"; import { useAuthStore } from "../stores/authStore"; import type { Secretary, SecretaryPermissions } from "../types"; -// ── Default permissions & labels ────────────────────────────────────────── +// ── SVG icons (copied verbatim from clinic-pro-tauri) ─────────────────────── -const DEFAULT_PERMISSIONS: SecretaryPermissions = { - appointments: { - view: true, - create: false, - cancel: false, - update_status: false, - }, - addresses: { view: true, create: false, update: false, delete: false }, - clinic_info: { view: true, update: false }, - insurances: { view: true, create: false, update: false, delete: false }, -}; +const PlusIcon = () => ( + + + + +); -type PermSection = keyof SecretaryPermissions; +const CloseModalIcon = () => ( + + + +); -const PERMISSION_LABELS: Record< - PermSection, - { label: string; actions: { key: string; label: string }[] } -> = { - appointments: { - label: "نوبت‌ها", - actions: [ - { key: "view", label: "مشاهده" }, - { key: "create", label: "ایجاد" }, - { key: "cancel", label: "لغو" }, - { key: "update_status", label: "تغییر وضعیت" }, - ], - }, - addresses: { - label: "آدرس‌ها", - actions: [ - { key: "view", label: "مشاهده" }, - { key: "create", label: "ایجاد" }, - { key: "update", label: "ویرایش" }, - { key: "delete", label: "حذف" }, - ], - }, - clinic_info: { - label: "اطلاعات کلینیک", - actions: [ - { key: "view", label: "مشاهده" }, - { key: "update", label: "ویرایش" }, - ], - }, - insurances: { - label: "بیمه‌ها", - actions: [ - { key: "view", label: "مشاهده" }, - { key: "create", label: "ایجاد" }, - { key: "update", label: "ویرایش" }, - { key: "delete", label: "حذف" }, - ], - }, -}; +const EyeIcon = () => ( + + + + +); -const ALL_ACTIONS = [ - "view", - "create", - "update", - "delete", - "cancel", - "update_status", -]; -const ACTION_HEADERS = [ - "مشاهده", - "ایجاد", - "ویرایش", - "حذف", - "لغو", - "تغییر وضعیت", -]; +const EditIcon = () => ( + + + + + +); -function PermissionsMatrix({ - permissions, - onChange, -}: { - permissions: SecretaryPermissions; - onChange: (p: SecretaryPermissions) => void; -}) { - const toggle = (section: PermSection, action: string) => { - const cur = (permissions[section] as Record)[action]; - onChange({ - ...permissions, - [section]: { - ...(permissions[section] as Record), - [action]: !cur, - }, - }); - }; +const ChevronDown = ({ open }: { open: boolean }) => ( + + + +); +// ── Avatar (first-letter gradient fallback, like tauri) ───────────────────── + +function Avatar({ name, size = 32 }: { name?: string; size?: number }) { + const firstLetter = (name?.trim().charAt(0) || "?").toUpperCase(); return ( -
- - - - - {ACTION_HEADERS.map((h) => ( - - ))} - - - - {(Object.keys(PERMISSION_LABELS) as PermSection[]).map( - (section, idx) => { - const config = PERMISSION_LABELS[section]; - const sectionPrm = permissions[section] as Record< - string, - boolean - >; - return ( - - - {ALL_ACTIONS.map((action) => { - const ac = config.actions.find( - (a) => a.key === action, - ); - if (!ac) - return ( - - ); - return ( - - ); - })} - - ); - }, - )} - -
- بخش - - {h} -
- {config.label} - - — - - - toggle(section, action) - } - style={{ - width: 16, - height: 16, - accentColor: - "var(--primary)", - cursor: "pointer", - }} - /> -
+
+ {firstLetter}
); } -// ── Create form schema ───────────────────────────────────────────────────── +// ── Permission sections (based on existing clinicpro pages) ───────────────── -const createSchema = z.object({ - name: z.string().min(2, "نام الزامی است"), - mobile_number: iranMobileSchema, -}); -type CreateForm = z.infer; +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>( + 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 ( +
+

+ مجوزهای دسترسی +

+
+ {PERMISSION_SECTIONS.map((section) => { + const open = openKeys.has(section.key); + const sectionPerm = permissions[section.key] as Record; + return ( +
+ + {open && ( +
+
+ {section.items.map((item) => ( + + ))} +
+
+ )} +
+ ); + })} +
+
+ ); +} + +// ── Text field (tauri DefaultTextField look) ──────────────────────────────── + +function DefaultTextField({ + placeholder, + value, + onChange, + disabled, + multiline, + rows, +}: { + placeholder?: string; + value: string; + onChange: (v: string) => void; + disabled?: boolean; + multiline?: boolean; + rows?: 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 ( +