- Added seed_realistic_data.php to clean existing data and populate the database with realistic entries for doctors, clinics, and secretaries. - Created a structured approach to generate 100 doctors per city with diverse specialties and services. - Implemented database cleanup routines to ensure a fresh start for data seeding. - Enhanced the DoctorSecretaryRepository with improved comments for clarity.
17 KiB
اعمال محدودیتهای اشتراک در پنل ادمین (Frontend Gate)
زمینه
سیستم اشتراک در backend کاملاً پیاده است:
SubscriptionService::hasFeature(entityType, entityId, feature)— بررسی دسترسی به قابلیتSubscriptionService::getSecretaryLimit(...)— حداکثر منشی مجازSubscriptionPlan::features— یک آرایه JSON مثل{ "patient_records": true, "services": true, "sms_panel": true }- backend روی API های
PatientControllerوClinicServiceControllerگیت دارد (ERR_SUBSCRIPTION_REQUIRED)
مشکل: Frontend هیچ اطلاعی از اشتراک فعال کاربر ندارد. Sidebar همه منوها را به همه نقشها نشان میدهد، صفحات بدون هشدار باز میشوند، و کاربر با پیام خطای backend مواجه میشود بجای راهنمای ارتقاء پنل.
هدف: وقتی کاربر (پزشک / کلینیک / منشی) وارد پنل میشود:
- اطلاعات اشتراک فعال لود شود و در یک context/hook مشترک در دسترس باشد
- آیتمهای Sidebar که نیاز به feature دارند — اگر feature فعال نیست — با آیکون قفل نمایش داده شوند یا پنهان شوند
- صفحاتی که feature ندارند، یک بنر «برای استفاده از این قابلیت پنل خود را ارتقاء دهید» نمایش دهند
مشکل / هدف
قابلیتهای محدودشده توسط اشتراک
| Feature Key | صفحه/منو | نقش مرتبط |
|---|---|---|
patient_records |
/admin/my-patients |
doctor, clinic, secretary |
services |
/admin/clinic-services |
doctor, clinic |
sms_panel |
/admin/sms-wallet |
doctor, clinic |
max_secretaries |
/admin/my-secretaries (تعداد) |
doctor, clinic |
نکته منشی: منشی اشتراک مستقل ندارد — اشتراک entity ای که به آن متصل است (دکتر یا کلینیک) اعمال میشود. endpoint /api/v1/subscription/my برای منشی null برمیگرداند (چون resolveEntity در SubscriptionController فقط ROLE_DOCTOR و ROLE_CLINIC را handle میکند).
فایلهای مرتبط
| فایل | نقش |
|---|---|
src/Subscription/Controller/SubscriptionController.php |
endpoint GET /api/v1/subscription/my — برای doctor و clinic |
src/Subscription/Service/SubscriptionService.php |
hasFeature(), getSecretaryLimit() |
assets/admin/stores/authStore.ts |
state مرکزی auth — باید subscription هم اینجا باشد |
assets/admin/components/layout/Sidebar.tsx |
buildSections() — منوها بر اساس primaryRole |
assets/admin/App.tsx |
RoleRoute — گیت نقشها، باید feature gate هم اضافه شود |
assets/admin/pages/MyPatientsPage.tsx |
نیاز به patient_records |
assets/admin/pages/ClinicServicesPage.tsx |
نیاز به services |
assets/admin/pages/SmsWalletPage.tsx |
نیاز به sms_panel |
assets/admin/pages/MySecretariesPage.tsx |
نیاز به max_secretaries بیش از ۱ |
assets/admin/pages/SubscriptionPage.tsx |
صفحه ارتقاء پنل — مقصد CTA ها |
وضعیت فعلی
Backend — endpoint اشتراک
// SubscriptionController.php — GET /api/v1/subscription/my
// فقط doctor و clinic را handle میکند
private function resolveEntity(User $user): array
{
if ($user->hasRole('ROLE_DOCTOR')) {
$doctor = $this->doctorRepo->findByUser($user);
return $doctor !== null ? ['doctor', $doctor->getId()] : ['doctor', null];
}
if ($user->hasRole('ROLE_CLINIC')) {
$clinic = $this->clinicRepo->findByUser($user);
return $clinic !== null ? ['clinic', $clinic->getId()] : ['clinic', null];
}
return ['unknown', null]; // منشی → 403
}
پاسخ:
{
"success": true,
"data": {
"subscription": {
"uuid": "...",
"plan": {
"name": "basic",
"level": 1,
"features": {
"patient_records": true,
"services": true,
"sms_panel": false
},
"max_secretaries": 3
},
"is_trial": false,
"expires_at": 1750000000,
"days_remaining": 45
},
"used_trial": false
}
}
اگر اشتراک فعال نداشته باشد: "subscription": null
Frontend — authStore
// authStore.ts — فعلاً subscription اصلاً در store نیست
interface AuthState {
primaryRole: "admin" | "clinic" | "doctor" | "secretary" | "user" | null;
dbUuid: string | null;
// ... subscription وجود ندارد
}
Frontend — Sidebar
// Sidebar.tsx — همه آیتمها را بدون بررسی feature نمایش میدهد
if (primaryRole === "doctor") {
return [
{
label: "مدیریت",
items: [
{
to: "/admin/my-patients",
icon: FolderOpenIcon,
label: "پرونده بیماران",
},
{
to: "/admin/clinic-services",
icon: WrenchScrewdriverIcon,
label: "سرویسها",
},
{
to: "/admin/sms-wallet",
icon: DevicePhoneMobileIcon,
label: "کیف پول پیامک",
},
],
},
];
}
Frontend — App.tsx
// فقط نقشها چک میشوند، feature gate وجود ندارد
<Route path="my-patients" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']}><MyPatientsPage /></RoleRoute>} />
<Route path="clinic-services" element={<RoleRoute roles={['doctor', 'clinic']}><ClinicServicesPage /></RoleRoute>} />
<Route path="sms-wallet" element={<RoleRoute roles={['doctor', 'clinic']}><SmsWalletPage /></RoleRoute>} />
وظایف
۱. Backend — منشی میتواند اشتراک entity خود را ببیند
در SubscriptionController::resolveEntity() حالت ROLE_SECRETARY را اضافه کن:
if ($user->hasRole('ROLE_SECRETARY')) {
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
if ($dbUuid) {
$clinic = $this->clinicRepo->findByUuid($dbUuid);
if ($clinic) return ['clinic', $clinic->getId()];
$doctor = $this->doctorRepo->findByUuid($dbUuid);
if ($doctor) return ['doctor', $doctor->getId()];
}
}
نیاز به inject کردن UserActiveContextRepository و ClinicRepository و DoctorRepository به SubscriptionController دارد.
۲. Frontend — hook مشترک useSubscription
یک فایل assets/admin/hooks/useSubscription.ts بساز:
import { useQuery } from "@tanstack/react-query";
import { api } from "../lib/api";
import { useAuthStore } from "../stores/authStore";
import type { ApiResponse, MySubscriptionData } from "../types";
export function useSubscription() {
const primaryRole = useAuthStore((s) => s.primaryRole);
const enabled =
primaryRole === "doctor" ||
primaryRole === "clinic" ||
primaryRole === "secretary";
const { data } = useQuery<ApiResponse<MySubscriptionData>>({
queryKey: ["subscription-my"],
queryFn: () => api.get("/api/v1/subscription/my"),
enabled,
staleTime: 2 * 60 * 1000,
});
const sub = data?.data?.subscription ?? null;
const features: Record<string, boolean> = sub?.plan?.features ?? {};
const maxSecretaries: number = sub?.plan?.max_secretaries ?? 1;
const hasPlan = sub !== null;
return {
subscription: sub,
hasFeature: (key: string) => hasPlan && (features[key] ?? false),
maxSecretaries,
hasPlan,
isExpiringSoon:
(sub?.days_remaining ?? 0) > 0 && (sub?.days_remaining ?? 0) <= 7,
};
}
نکته: query key ['subscription-my'] همان key ی است که SubscriptionPage هم استفاده میکند — cache مشترک، یک بار fetch.
۳. Frontend — Sidebar با قفل feature
در Sidebar.tsx:
import { useSubscription } from "../../hooks/useSubscription";
// ...
export default function Sidebar() {
const primaryRole = useAuthStore((s) => s.primaryRole);
const dbUuid = useAuthStore((s) => s.dbUuid);
const { hasFeature } = useSubscription();
// ...
}
نوع SectionItem را گسترش بده:
type SectionItem = {
to: string;
icon: React.ElementType;
label: string;
feature?: string; // اگر تعریف شد، بدون آن feature → آیکون قفل
};
آیتمهای محدود را با feature mark کن:
{ to: '/admin/my-patients', icon: FolderOpenIcon, label: 'پرونده بیماران', feature: 'patient_records' },
{ to: '/admin/clinic-services',icon: WrenchScrewdriverIcon, label: 'سرویسها', feature: 'services' },
{ to: '/admin/sms-wallet', icon: DevicePhoneMobileIcon, label: 'کیف پول پیامک', feature: 'sms_panel' },
در render هر آیتم:
const isLocked = item.feature ? !hasFeature(item.feature) : false;
<NavLink
to={isLocked ? "/admin/subscription" : item.to}
style={isLocked ? { opacity: 0.5 } : {}}
title={isLocked ? "نیاز به ارتقاء پنل" : undefined}
>
<item.icon />
{item.label}
{isLocked && <LockClosedIcon style={{ width: 12, marginRight: "auto" }} />}
</NavLink>;
import کن: import { LockClosedIcon } from '@heroicons/react/24/outline';
۴. Frontend — FeatureGate component
یک component کوچک assets/admin/components/ui/FeatureGate.tsx بساز:
import React from "react";
import { Link } from "react-router-dom";
import { LockClosedIcon } from "@heroicons/react/24/outline";
import { useSubscription } from "../../hooks/useSubscription";
interface FeatureGateProps {
feature: string;
children: React.ReactNode;
}
export default function FeatureGate({ feature, children }: FeatureGateProps) {
const { hasFeature, hasPlan } = useSubscription();
if (hasFeature(feature)) return <>{children}</>;
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
minHeight: 320,
gap: 16,
padding: 40,
textAlign: "center",
}}
>
<div
style={{
width: 64,
height: 64,
borderRadius: "50%",
background: "oklch(0.97 0.01 256)",
display: "grid",
placeItems: "center",
}}
>
<LockClosedIcon style={{ width: 28, color: "var(--text-3)" }} />
</div>
<div>
<div style={{ fontWeight: 700, fontSize: 16, marginBottom: 6 }}>
این قابلیت در پنل فعلی شما فعال نیست
</div>
<div
style={{
color: "var(--text-3)",
fontSize: 13.5,
maxWidth: 360,
}}
>
{hasPlan
? "برای استفاده از این قابلیت پنل خود را ارتقاء دهید"
: "برای استفاده از این قابلیت یک پنل اشتراکی فعال کنید"}
</div>
</div>
<Link
to="/admin/subscription"
className="btn primary sm"
style={{ textDecoration: "none" }}
>
{hasPlan ? "ارتقاء پنل" : "مشاهده پنلهای اشتراکی"}
</Link>
</div>
);
}
۵. Frontend — استفاده از FeatureGate در صفحات
در MyPatientsPage.tsx محتوای اصلی را wrap کن:
import FeatureGate from "../components/ui/FeatureGate";
// ...
return (
<FeatureGate feature="patient_records">{/* کد فعلی صفحه */}</FeatureGate>
);
در ClinicServicesPage.tsx:
return <FeatureGate feature="services">{/* کد فعلی */}</FeatureGate>;
در SmsWalletPage.tsx:
return <FeatureGate feature="sms_panel">{/* کد فعلی */}</FeatureGate>;
۶. Frontend — محدودیت تعداد منشی در MySecretariesPage
در MySecretariesPage.tsx وقتی کاربر میخواهد منشی جدید اضافه کند:
import { useSubscription } from "../hooks/useSubscription";
// ...
const { maxSecretaries } = useSubscription();
// تعداد فعلی منشی ها از data موجود
const activeCount = secretaries.filter((s) => s.is_active).length;
const isAtLimit = activeCount >= maxSecretaries;
// روی دکمه «افزودن منشی»:
<button
className="btn primary sm"
onClick={() => setAddOpen(true)}
disabled={isAtLimit}
title={
isAtLimit
? `حداکثر ${maxSecretaries} منشی در پنل فعلی مجاز است`
: undefined
}
>
افزودن منشی
</button>;
{
isAtLimit && (
<div style={{ fontSize: 12, color: "var(--text-3)", marginTop: 4 }}>
برای افزودن منشی بیشتر{" "}
<Link to="/admin/subscription">پنل خود را ارتقاء دهید</Link>
</div>
);
}
نکات مهم
- admin نقش اشتراک ندارد:
useSubscriptionفقط برای doctor/clinic/secretary فعال است. در Sidebar ادمین هیچ feature gate نداشته باشد. - secretary اشتراک entity خود را میبیند: بعد از task 1،
/api/v1/subscription/myبرای secretary هم کار میکند. اگر db_uuid هنوز set نشده (قبل از انتخاب context)،subscription: nullبرمیگردد —useSubscriptionبهدرستیhasPlan: falseبرمیگرداند. - Cache مشترک: SubscriptionPage هم از
queryKey: ['subscription-my']استفاده میکند — تغییر ندهید تا cache reuse شود. - پلن free: در DB ممکن است اصلاً subscription نداشته باشند (
subscription: null). این حالت را باhasPlan: falsehandle کن. فرض نکن که "free" یک plan object است. - LockClosedIcon را import کن از
@heroicons/react/24/outline— باید در heroicons وجود داشته باشد. - migration لازم نیست — فقط backend method و frontend تغییر میکند.
- مستندات: بعد از تغییر
SubscriptionController، فایلdocs/api/subscription.mdباید بهروز شود تاGET /api/v1/subscription/myبرای secretary هم مستند شود.
ترتیب اجرا
- Backend:
SubscriptionController::resolveEntity()— حالت secretary - تست route:
ddev exec php bin/console cache:clear - Frontend:
assets/admin/hooks/useSubscription.ts— hook جدید - Frontend:
assets/admin/components/ui/FeatureGate.tsx— component جدید - Frontend:
Sidebar.tsx— اضافه کردنfeatureبه آیتمها و render قفل - Frontend:
MyPatientsPage.tsx،ClinicServicesPage.tsx،SmsWalletPage.tsx— wrap با FeatureGate - Frontend:
MySecretariesPage.tsx— محدودیت تعداد منشی - Build:
ddev exec yarn dev - TypeScript:
ddev exec npx tsc --noEmit - مستندات:
docs/api/subscription.md