Files
clinicpro/CLAUDE.md
T
hamed e0e8fbd1e4 feat: implement BackButton component for consistent navigation
- Added BackButton component to standardize back navigation across pages.
- Integrated BackButton into various pages, replacing custom back buttons for consistency.
- Updated PageHeader to accept backTo prop for displaying BackButton when navigating from subpages.
- Created useGoBack hook to handle navigation logic, determining whether to go back in history or redirect to a fallback page.
- Added tests for BackButton and its integration with PageHeader to ensure expected behavior.
2026-07-29 20:26:51 +03:30

15 KiB

CLAUDE.md

Guidance for Claude Code when working in ClinicPro — a clinic management & appointment platform. A Symfony 7.4 REST API backend plus a React 19 admin SPA bundled inside Symfony via Webpack Encore.

  • Local: https://clinic-pro.ddev.siteAdmin: /adminSwagger: /api/doc
  • Runs inside ddev: prefix commands with ddev exec.
  • Entire product is Persian/Farsi, RTL, Jalali (Shamsi) dates. Keep new strings Persian, dates Jalali.

Source of truth for design tokens is assets/admin/styles.css, not docs/admin-ui/ui-design-spec.md. That spec doc is an older aspirational draft (purple palette, tiptap, date-io) that does not match the shipped code (indigo palette, CKEditor, jalaali-js). Trust the code.


Stack & versions

Backend (composer.json)

  • PHP ≥ 8.2, Symfony 7.4, Doctrine ORM 3.6 + migrations, MariaDB 11.8 (via ddev)
  • Auth: JWT (lexik/jwt-authentication-bundle)
  • Async/scheduled: symfony/messenger + symfony/scheduler + symfony/redis-messenger
  • API docs: nelmio/api-doc-bundle + zircote/swagger-php; CORS: nelmio/cors-bundle
  • Also: symfony/uid, symfony/rate-limiter, altcha-org/altcha, Twig, symfony/ux-react
  • PSR-4: App\src/, tests App\Tests\tests/

Frontend admin SPA (package.json)

  • React 19 + TypeScript 5, bundled by Webpack Encore 6 (not Vite)
  • Tailwind CSS v4 (@tailwindcss/postcss, CSS-first config)
  • Server state: TanStack Query v5 · Tables: TanStack Table v8
  • Client state: Zustand 5 · Forms: React Hook Form 7 + Zod 3 (@hookform/resolvers)
  • Routing: React Router v7 · Icons: Heroicons v2 · Charts: Recharts 3
  • Select: react-select · Rich text: CKEditor 5 · Dates: jalaali-js · Maps: leaflet/react-leaflet
  • Toasts: sonner (+ react-hot-toast) · Font: Vazirmatn via @fontsource/vazirmatn
  • Tests: Vitest + Testing Library (jsdom)

Build entry points (webpack.config.js)

Three Encore entries → public/build/:

Entry Source Purpose
admin assets/admin/index.tsx React admin SPA, mounted at /admin/*
app assets/app.js Stimulus/UX React controllers
home assets/home/index.js Public-facing pages

Commands (prefix with ddev exec)

# Backend
php bin/console cache:clear
php bin/console doctrine:migrations:diff --no-interaction   # after any entity change
php bin/console doctrine:migrations:migrate --no-interaction
php bin/console debug:router | grep api
php bin/console messenger:consume async                     # SMS / async jobs
php bin/console messenger:consume scheduler_default         # scheduled tasks
php bin/phpunit                                             # tests
php vendor/bin/phpstan analyse                              # static analysis (level 5)

# Frontend
yarn dev            # one-off build (use to check for errors)
yarn watch          # watch mode
yarn build          # production
npx tsc --noEmit --project tsconfig.json   # type check only (faster)
yarn test           # vitest

Known: CSS build has a pre-existing lightningcss.linux-arm64-gnu.node native-module error inside ddev; it does not block JS/TS compilation. TypeScript errors still surface in TSC output.


Folder structure & where things go

Backend — src/<Domain>/

Domain-driven; each domain is its own namespace App\<Domain>\ holding its own layers:

src/<Domain>/
  Controller/    # HTTP endpoints — extend BaseController
  Entity/        # Doctrine entities
  Repository/    # Doctrine repositories (DQL / query builders)
  Service/       # domain logic
  Command/       # console commands (optional)

Domains include: Doctor, Patient, Appointment, Payment, Clinic, ClinicInvitation, ClinicService, DoctorService, Secretary, Staff, Settlement, Billing, Subscription, Rating, Blog, Sms, Category, Location, Specialty, Insurance, Tag, Representation, Auth, Admin, Dashboard, UserProfile, Config.

Shared infra:

src/Shared/Controller/BaseController.php    # every controller extends this
src/Shared/Constant/ErrorCodes.php          # all error codes + Persian messages

Cross-cutting: migrations/, config/, templates/, docs/api/ (endpoint docs).

Frontend — assets/admin/

index.tsx              # entry: mounts <App/>, QueryClientProvider, <Toaster/> (sonner)
App.tsx                # React Router v7 routes
pages/                 # one file per page — XxxPage.tsx (~50 pages)
components/
  ui/                  # shared design-system components (see below)
  *.tsx                # feature-specific composites (ServiceTariffModal, ImageCropModal, …)
hooks/                 # useSubscription, usePaymentConfig, usePwaInstall, …
lib/api.ts             # fetch wrapper; reads JWT from localStorage['clinicpro-auth']
lib/utils.ts           # formatRial, formatNumber, formatDate, formatDateTime
types/index.ts         # all shared TypeScript interfaces
stores/                # authStore.ts (persisted), uiStore.ts (sidebar/ui)
styles.css             # Tailwind entry + all design tokens

Styling & design tokens

Tailwind v4, imported in assets/admin/styles.css:

@import "tailwindcss";
@import "@fontsource/vazirmatn/{300..800}.css";
@custom-variant dark (&:where(.dark, .dark *));
@theme { --font-sans: "Vazirmatn", ui-sans-serif, system-ui, sans-serif; }

All design tokens are CSS custom properties in the :root block of styles.css — reference them via Tailwind arbitrary values (bg-[var(--surface)]) or plain CSS, do not hardcode hex:

Group Tokens
Brand --primary:#5559CE (indigo), --primary-600/700, --primary-soft/soft2, --on-primary
Accent --accent:#f0682a (orange, matches clinic-pro-tauri), --accent-600, --accent-bg
Surfaces --bg, --bg-2, --surface, --surface-2/3, --border, --border-2
Text --text, --text-2, --text-3
Status --success/-bg, --warning/-bg, --danger/-bg, --info/-bg, --violet/-bg
Stat cards --stat-{amber,violet,green,pink}-{bg,fg}
Radius --r-xs:7 · --r-sm:8 · --r:14 · --r-lg:18 · --r-xl:24 · --r-pill:999 (px)
Shadow --shadow-sm, --shadow, --shadow-lg
Layout --sidebar-w:243, --collapsed-w:90, --topbar-h:64, --gap:20, --card-pad:22, --row-h:56
Motion --ease: cubic-bezier(.22,.61,.36,1)

Theming: dark mode overrides via [data-theme="dark"]; compact density via [data-density="compact"]. oklch color versions applied under @supports with sRGB fallbacks for old WebKit.


Design system components — assets/admin/components/ui/

Reuse these before building new ones:

DataTable (sortable, search, skeleton loading, empty state, bulk) · Modal · ConfirmDialog · PageHeader (title + breadcrumb + action + backTo) · BackButton · StatCard · StatusBadge · Pagination · SearchableSelect · AppointmentStatusDropdown · PersianDateInput / PersianDatePicker / PersianCalendar · MobileInput · PriceInput · Portal · FeatureGate · Altcha · InviteDoctorModal · PwaInstallBanner / PwaLoginCard / NotificationMobileCard.

Feature composites (not generic) live one level up in components/*.tsx.

دکمهٔ بازگشت — الزامی در صفحات زیرمجموعه

هر صفحه‌ای که از دل صفحهٔ دیگری باز می‌شود (جزئیات، فرم ساخت/ویرایش، زیرصفحه‌های تنظیمات) باید دکمهٔ «بازگشت» داشته باشد، با یک ظاهر و یک رفتار:

  • صفحاتی که PageHeader دارند: فقط backTo="/admin/…" بدهید.
  • بقیه: <BackButton fallback="/admin/…" /> بالای هدر صفحه.
  • دکمهٔ دست‌ساز نسازید — ظاهر مرجع cp-btn-secondary با ارتفاع ۳۶ و آیکون ChevronRightIcon است (همان دکمهٔ صفحهٔ سرویس‌ها) و رفتارش در hooks/useGoBack.ts متمرکز است: یک قدم عقب در تاریخچهٔ پنل، و در ورود مستقیم/رفرش (location.key === 'default') رفتن به fallback.

Conventions

Naming: pages XxxPage.tsx, components PascalCase, hooks useXxx.ts. Backend PSR-4 App\<Domain>\<Layer>; entities singular (Doctor), tables snake_case plural (patient_records).

Data fetching: TanStack Query only — useQuery / useMutation. Query keys ['resource-name', page, filters]. All HTTP goes through lib/api.ts, which injects the JWT automatically.

  • ApiResponse<T> (single resource): extract with data?.data
  • PaginatedResponse<T> (admin lists): items at data?.data, total at data?.meta?.totalRecords

State management:

  • Server state → TanStack Query (cache, staleTime: 30s, retry: 1).
  • Client state → Zustand. authStore (JWT + user, persisted to localStorage['clinicpro-auth']), uiStore (sidebar/ui flags).

Forms: React Hook Form + Zod resolver — z.object({...}), type via z.infer<typeof schema>.

Routing: React Router v7, <BrowserRouter> in index.tsx, route table in App.tsx, SPA served at /admin/*.

Auth: JWT issued by Symfony. Admin routes guard with #[IsGranted('ROLE_ADMIN')] on the controller class/method. Public endpoints are whitelisted in config/packages/security.yaml.


Backend endpoint & model patterns

Response envelope — BaseController helpers

Every controller extends BaseController:

Method Shape
$this->success($data) { success, data }
$this->paginated($items, $total, $page, $limit) { success, data:[], meta:{ totalRecords, totalPages, currentPage } } (flat — no nesting)
$this->error($code, $message, $status) { success:false, errors:[{code,message}] }
$this->validationError($violations) { success:false, errors:[{code,field,message}] } HTTP 422
  • Errors: throw AppException(ErrorCodes::ERR_XXX, null, $httpStatus) anywhere in a domain; ExceptionSubscriber catches it and formats via $this->error(). Codes + Persian messages in src/Shared/Constant/ErrorCodes.php.
  • Pitfall — double nesting: $this->success(['data' => $x]) yields { data: { data: x } }; the frontend must then read data?.data?.data. Prefer passing the payload directly.
  • Admin list queries use ->getArrayResult() (array hydration), never entity getters, to avoid missing-getter errors.

Adding an endpoint (one domain)

  1. src/<Domain>/Entity/Foo.php — Doctrine entity (attributes; see model pattern).
  2. src/<Domain>/Repository/FooRepository.php — queries.
  3. src/<Domain>/Service/FooService.php — logic (optional but preferred).
  4. src/<Domain>/Controller/FooController.phpextends BaseController, route /api/v1/..., guard with #[IsGranted], return a response helper.
  5. ddev exec php bin/console doctrine:migrations:diff then migrate.
  6. Update the matching docs/api/*.md (see Standing Rule).

Model (entity) pattern

#[ORM\Entity(repositoryClass: FooRepository::class)]
#[ORM\Table(name: 'foos')]
class Foo
{
    #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: 'integer')]
    private ?int $id = null;

    #[ORM\Column(type: 'string', length: 36, unique: true)]
    private string $uuid;                 // Uuid::v4()->toRfc4122() in constructor

    #[ORM\Column(name: 'created_at', type: 'integer')]
    private int $createdAt;               // Unix timestamp (time()), NOT DateTime

    #[ORM\ManyToOne(targetEntity: User::class)]
    #[ORM\JoinColumn(nullable: false, onDelete: 'RESTRICT')]
    private User $user;
}

Conventions: integer surrogate id; string uuid (v4) for external references; timestamps are int Unix, not DateTime; column names snake_case via name:; relations ManyToOne / OneToMany.

Category / bundle system

Categories are polymorphic via a bundle string: state, city, specially_doctor, doctor_services, insurance_type, supplementary_insurance, tag. City is an integer FK to categories.id where bundle='city' (e.g. Representation.cityId) — LEFT JOIN categories in DQL to get the name. Endpoint: GET /api/v1/categorys/{bundle} (typo categorys is the real route); its response is double-nested → extract with data?.data?.data ?? [].


Standing Rule — API docs

Whenever any endpoint is created or modified (route, request/response shape, error code, permission), update the matching file in docs/api/ in the same session. Mapping mirrors src/<Domain>docs/api/<domain>.md (e.g. src/Doctor/*docs/api/doctor.md, src/Admin/*docs/api/admin.md, Category/Location controllers → docs/api/location.md + specialty.md + insurance.md + tag.md).

Project skills

.claude/skills/: add-admin-endpoint, add-admin-page, sync-db, prompt-writer, run-prompt. Prefer them over hand-rolling. Test users: TEST_USERS.md (rebuild: ddev exec php create_test_users.php).


جداسازی محیط (tenant)

هر دادهٔ عملیاتی به یک محیط تعلق دارد: مطب شخصی یک پزشک، یا یک کلینیک — جفت (entity_type, entity_id).

  • entity جدید یا App\Shared\Tenant\TenantOwnedTrait می‌گیرد، یا با دلیل در App\Shared\Tenant\GlobalTables ثبت می‌شود. TenantSchemaCoverageTest هر دو حالت را اجبار می‌کند و entity طبقه‌بندی‌نشده را قرمز می‌کند.
  • محیط جاری همیشه از EntityContextResolver گرفته می‌شود، نه از نقش کاربر.
  • TenantFilter تور ایمنی است، نه جایگزین authorization — روی SQL خام، getReference() و فرزندان aggregate اعمال نمی‌شود.
  • ایندکس‌های لیست باید entity_type, entity_id را ستون اول داشته باشند.

جزئیات و جدول کاملِ «چه تضمین می‌دهد و چه نمی‌دهد»: docs/architecture/tenancy.md

قواعد غیرقابل‌مذاکره

  1. SOLID در هر کد جدید. کامپوننت/کلاس چندمسئولیتی ننویس.
  2. API جدید فقط وقتی هیچ اندپوینت موجودی — حتی با توسعه — کافی نباشد. همیشه اول بگرد، بعد توسعه بده، در آخر بساز. و دلیلش را بنویس.
  3. مستندات کوتاه و دقیق روی هر چیز عمومی. کامنت بدیهی ننویس.
  4. هیچ تسکی بدون تست (موفق + خطا + مرزی) و بدون اجرای موفق تست‌ها تمام‌شده نیست.
  5. ورودی من فارسی است. اول منظورم را به spec انگلیسی تبدیل کن و برداشتت را به فارسی تأیید بگیر. کد و مستندات و کامیت انگلیسی؛ گفت‌وگو با من فارسی. رشته‌های UI فارسی و از فایل i18n.