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 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-15 12:23:17 +03:30
co-authored by Claude Opus 4.8
parent 57c5383baf
commit 89e23a2c0d
9 changed files with 1146 additions and 709 deletions
@@ -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<typeof vi.fn>;
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(<MySecretariesPage />, { 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(<MySecretariesPage />, { 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(<MySecretariesPage />, { 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(<MySecretariesPage />, { route: "/admin/my-secretaries" });
expect(await screen.findByText("هنوز منشی فعالی اضافه نشده است")).toBeInTheDocument();
});
it("opens the add modal with permission sections based on existing pages", async () => {
renderWithProviders(<MySecretariesPage />, { 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();
});
});
File diff suppressed because it is too large Load Diff
+21 -1
View File
@@ -14,9 +14,11 @@ import Modal from '../components/ui/Modal';
const DEFAULT_PERMISSIONS: SecretaryPermissions = {
appointments: { view: true, create: false, cancel: false, update_status: false },
patients: { view: true, create: false, update: false, delete: false },
payments: { view: true, create: false, update: false, delete: false },
insurances: { view: true, create: false, update: false, delete: 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 },
};
type PermSection = keyof SecretaryPermissions;
@@ -31,6 +33,24 @@ const PERMISSION_LABELS: Record<PermSection, { label: string; actions: { key: st
{ key: 'update_status', label: 'تغییر وضعیت' },
],
},
patients: {
label: 'پرونده بیماران',
actions: [
{ key: 'view', label: 'مشاهده' },
{ key: 'create', label: 'ایجاد' },
{ key: 'update', label: 'ویرایش' },
{ key: 'delete', label: 'حذف' },
],
},
payments: {
label: 'پرداخت‌ها',
actions: [
{ key: 'view', label: 'مشاهده' },
{ key: 'create', label: 'ایجاد' },
{ key: 'update', label: 'ویرایش' },
{ key: 'delete', label: 'حذف' },
],
},
addresses: {
label: 'آدرس‌ها',
actions: [
+20 -6
View File
@@ -330,6 +330,8 @@ export interface Secretary {
doctor_name: string;
doctor_uuid: string;
is_active: boolean;
national_code?: string | null;
address?: string | null;
permissions: SecretaryPermissions;
created_at: string;
}
@@ -341,6 +343,24 @@ export interface SecretaryPermissions {
cancel: boolean;
update_status: boolean;
};
patients: {
view: boolean;
create: boolean;
update: boolean;
delete: boolean;
};
payments: {
view: boolean;
create: boolean;
update: boolean;
delete: boolean;
};
insurances: {
view: boolean;
create: boolean;
update: boolean;
delete: boolean;
};
addresses: {
view: boolean;
create: boolean;
@@ -351,12 +371,6 @@ export interface SecretaryPermissions {
view: boolean;
update: boolean;
};
insurances: {
view: boolean;
create: boolean;
update: boolean;
delete: boolean;
};
}
export interface Specialty {
+58 -18
View File
@@ -31,6 +31,9 @@ Create a secretary for a doctor.
{
"doctor_uuid": "550e8400-...",
"mobile_number": "09123456789",
"name": "سارا احمدی",
"national_code": "1234567890",
"address": "یزد، خیابان تست",
"password": "secretaryPass123",
"permissions": {
"version": 1,
@@ -41,19 +44,31 @@ Create a secretary for a doctor.
"cancel": false,
"update_status": true
},
"patients": {
"view": true,
"create": false,
"update": false,
"delete": false
},
"payments": {
"view": true,
"create": false,
"update": false,
"delete": false
},
"insurances": {
"view": true,
"create": false,
"update": false,
"delete": 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
}
"clinic_info": { "view": true, "update": false }
}
}
}
@@ -63,11 +78,16 @@ Create a secretary for a doctor.
| --------------- | ------------- | -------- | -------------------------------------------- |
| `doctor_uuid` | string (UUID) | ✅ | Doctor to assign secretary to |
| `mobile_number` | string | ✅ | Secretary's login mobile |
| `name` | string | ❌ | Full name (نام + نام خانوادگی) → `user_name` |
| `national_code` | string | ❌ | کد ملی منشی (nullable) |
| `address` | string | ❌ | آدرس منشی (nullable) |
| `password` | string | ❌ | Initial password (auto-generated if omitted) |
| `permissions` | object | ❌ | Permission set (see structure below) |
**Permissions Structure:**
مجموعهٔ منابع (resources) بر اساس صفحات موجود پنل ادمین است. `mergePermissions` هر منبع/اکشن ارسال‌شده را deep-merge می‌کند؛ فقط `appointments` در بک‌اند enforce می‌شود (`MyAppointmentsController`, `DashboardController`)، بقیه UI/ذخیره‌ای هستند.
```json
{
"version": 1,
@@ -78,6 +98,24 @@ Create a secretary for a doctor.
"cancel": false, // Can cancel appointments
"update_status": true // Can mark as completed/no_show
},
"patients": {
"view": true,
"create": false,
"update": false,
"delete": false
},
"payments": {
"view": true,
"create": false,
"update": false,
"delete": false
},
"insurances": {
"view": true,
"create": false,
"update": false,
"delete": false
},
"addresses": {
"view": true,
"create": false,
@@ -87,12 +125,6 @@ Create a secretary for a doctor.
"clinic_info": {
"view": true,
"update": false
},
"insurances": {
"view": true,
"create": false,
"update": false,
"delete": false
}
}
}
@@ -112,6 +144,8 @@ Create a secretary for a doctor.
"owner_type": "doctor",
"clinic_uuid": null,
"is_active": true,
"national_code": "1234567890",
"address": "یزد، خیابان تست",
"permissions": { ... },
"created_at": 1717000000
}
@@ -178,7 +212,7 @@ Get secretary detail.
## PATCH `/api/v1/secretary/{uuid}`
Update secretary active status or permissions.
Update secretary active status, profile fields (name/national_code/address), or permissions. تمام فیلدها اختیاری‌اند و فقط موارد ارسال‌شده اعمال می‌شوند.
**Permission:** `ROLE_DOCTOR` — must be the linked doctor
@@ -187,6 +221,9 @@ Update secretary active status or permissions.
```json
{
"active": false,
"name": "نام جدید",
"national_code": "9999999999",
"address": "آدرس جدید",
"permissions": {
"version": 1,
"resources": {
@@ -201,10 +238,13 @@ Update secretary active status or permissions.
}
```
| Field | Type | Required | Description |
| ------------- | ------- | -------- | ------------------------ |
| `active` | boolean | ❌ | Enable/disable secretary |
| `permissions` | object | ❌ | New permissions object |
| Field | Type | Required | Description |
| --------------- | ------- | -------- | -------------------------------------------- |
| `active` | boolean | ❌ | Enable/disable secretary |
| `name` | string | ❌ | به‌روزرسانی نام کامل منشی (`user_name`) |
| `national_code` | string | ❌ | به‌روزرسانی کد ملی (nullable) |
| `address` | string | ❌ | به‌روزرسانی آدرس (nullable) |
| `permissions` | object | ❌ | New permissions object (deep-merged) |
### Response `200`
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260715083856 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add national_code and address columns to doctor_secretaries';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE doctor_secretaries ADD national_code VARCHAR(20) DEFAULT NULL, ADD address LONGTEXT DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE doctor_secretaries DROP national_code, DROP address');
}
}
@@ -108,6 +108,13 @@ class SecretaryController extends BaseController
$secretary = new DoctorSecretary($doctor, $secretaryUser, $ownerType, $ownerClinic);
if (array_key_exists('national_code', $data)) {
$secretary->setNationalCode($data['national_code'] !== null ? trim((string) $data['national_code']) : null);
}
if (array_key_exists('address', $data)) {
$secretary->setAddress($data['address'] !== null ? trim((string) $data['address']) : null);
}
if (!empty($data['permissions'])) {
$secretary->mergePermissions($data['permissions']);
}
@@ -168,6 +175,19 @@ class SecretaryController extends BaseController
$secretary->setActive((bool) $data['active']);
}
if (!empty($data['name'])) {
$secretaryUser = $secretary->getSecretary();
$secretaryUser->setRealName(trim((string) $data['name']));
$this->userRepo->save($secretaryUser);
}
if (array_key_exists('national_code', $data)) {
$secretary->setNationalCode($data['national_code'] !== null ? trim((string) $data['national_code']) : null);
}
if (array_key_exists('address', $data)) {
$secretary->setAddress($data['address'] !== null ? trim((string) $data['address']) : null);
}
if (!empty($data['permissions'])) {
$secretary->mergePermissions($data['permissions']);
}
+15 -1
View File
@@ -21,9 +21,11 @@ class DoctorSecretary
'version' => 1,
'resources' => [
'appointments' => ['view' => true, 'create' => true, 'cancel' => false, 'update_status' => true],
'patients' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false],
'payments' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false],
'insurances' => ['view' => true, 'create' => false, 'update' => false, 'delete' => 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],
],
];
@@ -53,6 +55,12 @@ class DoctorSecretary
#[ORM\Column(name: 'permission', type: 'json', nullable: true)]
private ?array $permissions = null;
#[ORM\Column(name: 'national_code', type: 'string', length: 20, nullable: true)]
private ?string $nationalCode = null;
#[ORM\Column(name: 'address', type: 'text', nullable: true)]
private ?string $address = null;
#[ORM\Column(type: 'boolean')]
private bool $active = true;
@@ -81,12 +89,16 @@ class DoctorSecretary
public function getOwnerType(): string { return $this->ownerType; }
public function getClinic(): ?Clinic { return $this->clinic; }
public function getPermissions(): array { return $this->permissions ?? self::DEFAULT_PERMISSIONS; }
public function getNationalCode(): ?string { return $this->nationalCode; }
public function getAddress(): ?string { return $this->address; }
public function isActive(): bool { return $this->active; }
public function getCreatedAt(): int { return $this->createdAt; }
public function getUpdatedAt(): int { return $this->updatedAt; }
public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; }
public function setPermissions(array $v): self { $this->permissions = $v; $this->touch(); return $this; }
public function setNationalCode(?string $v): self { $this->nationalCode = $v; $this->touch(); return $this; }
public function setAddress(?string $v): self { $this->address = $v; $this->touch(); return $this; }
/** Deep merge: only provided resources/actions are updated */
public function mergePermissions(array $patch): void
@@ -122,6 +134,8 @@ class DoctorSecretary
'owner_type' => $this->ownerType,
'clinic_uuid' => $this->clinic?->getUuid(),
'is_active' => $this->active,
'national_code' => $this->nationalCode,
'address' => $this->address,
'permissions' => $this->getPermissions()['resources'] ?? $this->getPermissions(),
'created_at' => $this->createdAt,
];
+109
View File
@@ -0,0 +1,109 @@
<?php
namespace App\Tests\Secretary;
use App\Doctor\Entity\Doctor;
use App\Tests\ApiTestCase;
/**
* Covers the national_code / address fields and the extended permission
* taxonomy (patients, payments) on the secretary create/update endpoints.
*/
class SecretaryFieldsTest extends ApiTestCase
{
/** @return array{0: \App\Auth\Entity\User, 1: Doctor} */
private function makeDoctor(): array
{
$owner = $this->createUser(['ROLE_DOCTOR']);
$doctor = new Doctor($owner, 'دکتر تست');
$this->em->persist($doctor);
$this->em->flush();
return [$owner, $doctor];
}
public function testCreatePersistsNationalCodeAddressAndExtendedPermissions(): void
{
[$owner, $doctor] = $this->makeDoctor();
$mobile = '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
$res = $this->authJson('POST', '/api/v1/secretary', $owner, [
'doctor_uuid' => $doctor->getUuid(),
'mobile_number' => $mobile,
'name' => 'سارا احمدی',
'national_code' => '1234567890',
'address' => 'یزد، خیابان تست',
'permissions' => [
'version' => 1,
'resources' => [
'patients' => ['view' => true, 'create' => true],
'payments' => ['view' => true],
],
],
]);
$this->assertSame(201, $this->responseCode());
$data = $res['data']['data'] ?? $res['data'];
$this->assertSame('1234567890', $data['national_code']);
$this->assertSame('یزد، خیابان تست', $data['address']);
$this->assertTrue($data['permissions']['patients']['view']);
$this->assertTrue($data['permissions']['patients']['create']);
$this->assertTrue($data['permissions']['payments']['view']);
}
public function testCreateWithoutOptionalFieldsPersistsNulls(): void
{
[$owner, $doctor] = $this->makeDoctor();
$mobile = '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
$res = $this->authJson('POST', '/api/v1/secretary', $owner, [
'doctor_uuid' => $doctor->getUuid(),
'mobile_number' => $mobile,
'name' => 'بدون کدملی',
]);
$this->assertSame(201, $this->responseCode());
$data = $res['data']['data'] ?? $res['data'];
$this->assertNull($data['national_code']);
$this->assertNull($data['address']);
}
public function testUpdateChangesNameNationalCodeAddressAndPermissions(): void
{
[$owner, $doctor] = $this->makeDoctor();
$mobile = '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
$created = $this->authJson('POST', '/api/v1/secretary', $owner, [
'doctor_uuid' => $doctor->getUuid(),
'mobile_number' => $mobile,
'name' => 'نام اولیه',
]);
$uuid = ($created['data']['data'] ?? $created['data'])['uuid'];
$res = $this->authJson('PATCH', '/api/v1/secretary/' . $uuid, $owner, [
'name' => 'نام جدید',
'national_code' => '9999999999',
'address' => 'آدرس جدید',
'permissions' => [
'version' => 1,
'resources' => ['appointments' => ['create' => false]],
],
]);
$this->assertSame(200, $this->responseCode());
$data = $res['data']['data'] ?? $res['data'];
$this->assertSame('نام جدید', $data['user_name']);
$this->assertSame('9999999999', $data['national_code']);
$this->assertSame('آدرس جدید', $data['address']);
$this->assertFalse($data['permissions']['appointments']['create']);
}
public function testCreateRequiresDoctorUuidAndMobile(): void
{
[$owner] = $this->makeDoctor();
$this->authJson('POST', '/api/v1/secretary', $owner, ['name' => 'ناقص']);
$this->assertSame(422, $this->responseCode());
}
}