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:
@@ -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
@@ -14,9 +14,11 @@ import Modal from '../components/ui/Modal';
|
|||||||
|
|
||||||
const DEFAULT_PERMISSIONS: SecretaryPermissions = {
|
const DEFAULT_PERMISSIONS: SecretaryPermissions = {
|
||||||
appointments: { view: true, create: false, cancel: false, update_status: false },
|
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 },
|
addresses: { view: true, create: false, update: false, delete: false },
|
||||||
clinic_info: { view: true, update: false },
|
clinic_info: { view: true, update: false },
|
||||||
insurances: { view: true, create: false, update: false, delete: false },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type PermSection = keyof SecretaryPermissions;
|
type PermSection = keyof SecretaryPermissions;
|
||||||
@@ -31,6 +33,24 @@ const PERMISSION_LABELS: Record<PermSection, { label: string; actions: { key: st
|
|||||||
{ key: 'update_status', label: 'تغییر وضعیت' },
|
{ 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: {
|
addresses: {
|
||||||
label: 'آدرسها',
|
label: 'آدرسها',
|
||||||
actions: [
|
actions: [
|
||||||
|
|||||||
@@ -330,6 +330,8 @@ export interface Secretary {
|
|||||||
doctor_name: string;
|
doctor_name: string;
|
||||||
doctor_uuid: string;
|
doctor_uuid: string;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
|
national_code?: string | null;
|
||||||
|
address?: string | null;
|
||||||
permissions: SecretaryPermissions;
|
permissions: SecretaryPermissions;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
@@ -341,6 +343,24 @@ export interface SecretaryPermissions {
|
|||||||
cancel: boolean;
|
cancel: boolean;
|
||||||
update_status: 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: {
|
addresses: {
|
||||||
view: boolean;
|
view: boolean;
|
||||||
create: boolean;
|
create: boolean;
|
||||||
@@ -351,12 +371,6 @@ export interface SecretaryPermissions {
|
|||||||
view: boolean;
|
view: boolean;
|
||||||
update: boolean;
|
update: boolean;
|
||||||
};
|
};
|
||||||
insurances: {
|
|
||||||
view: boolean;
|
|
||||||
create: boolean;
|
|
||||||
update: boolean;
|
|
||||||
delete: boolean;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Specialty {
|
export interface Specialty {
|
||||||
|
|||||||
+58
-18
@@ -31,6 +31,9 @@ Create a secretary for a doctor.
|
|||||||
{
|
{
|
||||||
"doctor_uuid": "550e8400-...",
|
"doctor_uuid": "550e8400-...",
|
||||||
"mobile_number": "09123456789",
|
"mobile_number": "09123456789",
|
||||||
|
"name": "سارا احمدی",
|
||||||
|
"national_code": "1234567890",
|
||||||
|
"address": "یزد، خیابان تست",
|
||||||
"password": "secretaryPass123",
|
"password": "secretaryPass123",
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
@@ -41,19 +44,31 @@ Create a secretary for a doctor.
|
|||||||
"cancel": false,
|
"cancel": false,
|
||||||
"update_status": true
|
"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": {
|
"addresses": {
|
||||||
"view": true,
|
"view": true,
|
||||||
"create": false,
|
"create": false,
|
||||||
"update": false,
|
"update": false,
|
||||||
"delete": false
|
"delete": false
|
||||||
},
|
},
|
||||||
"clinic_info": { "view": true, "update": false },
|
"clinic_info": { "view": true, "update": false }
|
||||||
"insurances": {
|
|
||||||
"view": true,
|
|
||||||
"create": false,
|
|
||||||
"update": false,
|
|
||||||
"delete": false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,11 +78,16 @@ Create a secretary for a doctor.
|
|||||||
| --------------- | ------------- | -------- | -------------------------------------------- |
|
| --------------- | ------------- | -------- | -------------------------------------------- |
|
||||||
| `doctor_uuid` | string (UUID) | ✅ | Doctor to assign secretary to |
|
| `doctor_uuid` | string (UUID) | ✅ | Doctor to assign secretary to |
|
||||||
| `mobile_number` | string | ✅ | Secretary's login mobile |
|
| `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) |
|
| `password` | string | ❌ | Initial password (auto-generated if omitted) |
|
||||||
| `permissions` | object | ❌ | Permission set (see structure below) |
|
| `permissions` | object | ❌ | Permission set (see structure below) |
|
||||||
|
|
||||||
**Permissions Structure:**
|
**Permissions Structure:**
|
||||||
|
|
||||||
|
مجموعهٔ منابع (resources) بر اساس صفحات موجود پنل ادمین است. `mergePermissions` هر منبع/اکشن ارسالشده را deep-merge میکند؛ فقط `appointments` در بکاند enforce میشود (`MyAppointmentsController`, `DashboardController`)، بقیه UI/ذخیرهای هستند.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
@@ -78,6 +98,24 @@ Create a secretary for a doctor.
|
|||||||
"cancel": false, // Can cancel appointments
|
"cancel": false, // Can cancel appointments
|
||||||
"update_status": true // Can mark as completed/no_show
|
"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": {
|
"addresses": {
|
||||||
"view": true,
|
"view": true,
|
||||||
"create": false,
|
"create": false,
|
||||||
@@ -87,12 +125,6 @@ Create a secretary for a doctor.
|
|||||||
"clinic_info": {
|
"clinic_info": {
|
||||||
"view": true,
|
"view": true,
|
||||||
"update": false
|
"update": false
|
||||||
},
|
|
||||||
"insurances": {
|
|
||||||
"view": true,
|
|
||||||
"create": false,
|
|
||||||
"update": false,
|
|
||||||
"delete": false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,6 +144,8 @@ Create a secretary for a doctor.
|
|||||||
"owner_type": "doctor",
|
"owner_type": "doctor",
|
||||||
"clinic_uuid": null,
|
"clinic_uuid": null,
|
||||||
"is_active": true,
|
"is_active": true,
|
||||||
|
"national_code": "1234567890",
|
||||||
|
"address": "یزد، خیابان تست",
|
||||||
"permissions": { ... },
|
"permissions": { ... },
|
||||||
"created_at": 1717000000
|
"created_at": 1717000000
|
||||||
}
|
}
|
||||||
@@ -178,7 +212,7 @@ Get secretary detail.
|
|||||||
|
|
||||||
## PATCH `/api/v1/secretary/{uuid}`
|
## 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
|
**Permission:** `ROLE_DOCTOR` — must be the linked doctor
|
||||||
|
|
||||||
@@ -187,6 +221,9 @@ Update secretary active status or permissions.
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"active": false,
|
"active": false,
|
||||||
|
"name": "نام جدید",
|
||||||
|
"national_code": "9999999999",
|
||||||
|
"address": "آدرس جدید",
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"resources": {
|
"resources": {
|
||||||
@@ -201,10 +238,13 @@ Update secretary active status or permissions.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
| ------------- | ------- | -------- | ------------------------ |
|
| --------------- | ------- | -------- | -------------------------------------------- |
|
||||||
| `active` | boolean | ❌ | Enable/disable secretary |
|
| `active` | boolean | ❌ | Enable/disable secretary |
|
||||||
| `permissions` | object | ❌ | New permissions object |
|
| `name` | string | ❌ | بهروزرسانی نام کامل منشی (`user_name`) |
|
||||||
|
| `national_code` | string | ❌ | بهروزرسانی کد ملی (nullable) |
|
||||||
|
| `address` | string | ❌ | بهروزرسانی آدرس (nullable) |
|
||||||
|
| `permissions` | object | ❌ | New permissions object (deep-merged) |
|
||||||
|
|
||||||
### Response `200`
|
### Response `200`
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
$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'])) {
|
if (!empty($data['permissions'])) {
|
||||||
$secretary->mergePermissions($data['permissions']);
|
$secretary->mergePermissions($data['permissions']);
|
||||||
}
|
}
|
||||||
@@ -168,6 +175,19 @@ class SecretaryController extends BaseController
|
|||||||
$secretary->setActive((bool) $data['active']);
|
$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'])) {
|
if (!empty($data['permissions'])) {
|
||||||
$secretary->mergePermissions($data['permissions']);
|
$secretary->mergePermissions($data['permissions']);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,9 +21,11 @@ class DoctorSecretary
|
|||||||
'version' => 1,
|
'version' => 1,
|
||||||
'resources' => [
|
'resources' => [
|
||||||
'appointments' => ['view' => true, 'create' => true, 'cancel' => false, 'update_status' => true],
|
'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],
|
'addresses' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false],
|
||||||
'clinic_info' => ['view' => true, 'update' => 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)]
|
#[ORM\Column(name: 'permission', type: 'json', nullable: true)]
|
||||||
private ?array $permissions = null;
|
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')]
|
#[ORM\Column(type: 'boolean')]
|
||||||
private bool $active = true;
|
private bool $active = true;
|
||||||
|
|
||||||
@@ -81,12 +89,16 @@ class DoctorSecretary
|
|||||||
public function getOwnerType(): string { return $this->ownerType; }
|
public function getOwnerType(): string { return $this->ownerType; }
|
||||||
public function getClinic(): ?Clinic { return $this->clinic; }
|
public function getClinic(): ?Clinic { return $this->clinic; }
|
||||||
public function getPermissions(): array { return $this->permissions ?? self::DEFAULT_PERMISSIONS; }
|
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 isActive(): bool { return $this->active; }
|
||||||
public function getCreatedAt(): int { return $this->createdAt; }
|
public function getCreatedAt(): int { return $this->createdAt; }
|
||||||
public function getUpdatedAt(): int { return $this->updatedAt; }
|
public function getUpdatedAt(): int { return $this->updatedAt; }
|
||||||
|
|
||||||
public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; }
|
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 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 */
|
/** Deep merge: only provided resources/actions are updated */
|
||||||
public function mergePermissions(array $patch): void
|
public function mergePermissions(array $patch): void
|
||||||
@@ -122,6 +134,8 @@ class DoctorSecretary
|
|||||||
'owner_type' => $this->ownerType,
|
'owner_type' => $this->ownerType,
|
||||||
'clinic_uuid' => $this->clinic?->getUuid(),
|
'clinic_uuid' => $this->clinic?->getUuid(),
|
||||||
'is_active' => $this->active,
|
'is_active' => $this->active,
|
||||||
|
'national_code' => $this->nationalCode,
|
||||||
|
'address' => $this->address,
|
||||||
'permissions' => $this->getPermissions()['resources'] ?? $this->getPermissions(),
|
'permissions' => $this->getPermissions()['resources'] ?? $this->getPermissions(),
|
||||||
'created_at' => $this->createdAt,
|
'created_at' => $this->createdAt,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user