Files
clinicpro/.claude/prompt/subscription-gate-ui.md
hamed df7a784701 feat: implement realistic data seeding for doctors, clinics, and secretaries
- 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.
2026-06-15 14:18:25 +03:30

17 KiB
Raw Permalink Blame History

اعمال محدودیت‌های اشتراک در پنل ادمین (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 مواجه می‌شود بجای راهنمای ارتقاء پنل.

هدف: وقتی کاربر (پزشک / کلینیک / منشی) وارد پنل می‌شود:

  1. اطلاعات اشتراک فعال لود شود و در یک context/hook مشترک در دسترس باشد
  2. آیتم‌های Sidebar که نیاز به feature دارند — اگر feature فعال نیست — با آیکون قفل نمایش داده شوند یا پنهان شوند
  3. صفحاتی که 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: false handle کن. فرض نکن که "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 هم مستند شود.

ترتیب اجرا

  1. Backend: SubscriptionController::resolveEntity() — حالت secretary
  2. تست route: ddev exec php bin/console cache:clear
  3. Frontend: assets/admin/hooks/useSubscription.ts — hook جدید
  4. Frontend: assets/admin/components/ui/FeatureGate.tsx — component جدید
  5. Frontend: Sidebar.tsx — اضافه کردن feature به آیتم‌ها و render قفل
  6. Frontend: MyPatientsPage.tsx، ClinicServicesPage.tsx، SmsWalletPage.tsx — wrap با FeatureGate
  7. Frontend: MySecretariesPage.tsx — محدودیت تعداد منشی
  8. Build: ddev exec yarn dev
  9. TypeScript: ddev exec npx tsc --noEmit
  10. مستندات: docs/api/subscription.md