# 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.site` — **Admin:** `/admin` — **Swagger:** `/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`) ```bash # 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-driven; each domain is its own namespace `App\\` holding its own layers: ``` src// 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 , QueryClientProvider, (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`: ```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/…"` بدهید. - بقیه: `` بالای هدر صفحه. - دکمهٔ دست‌ساز نسازید — ظاهر مرجع `cp-btn-secondary` با ارتفاع ۳۶ و آیکون `ChevronRightIcon` است (همان دکمهٔ صفحهٔ سرویس‌ها) و رفتارش در `hooks/useGoBack.ts` متمرکز است: یک قدم عقب در تاریخچهٔ پنل، و در ورود مستقیم/رفرش (`location.key === 'default'`) رفتن به `fallback`. ### وضعیت لیست‌ها در URL، نه در state جستجو، فیلترها، شمارهٔ صفحه و نمای هر صفحهٔ لیست باید در query string بنشیند — با `hooks/useUrlState.ts`: ```tsx const [urlState, setUrlState] = useUrlState({ page: '1', search: '', status: '' }); const page = pageOf(urlState.page); const setSearch = (v: string) => setUrlState({ search: v, page: '1' }); ``` دلیلش «بازگشت» است: `navigate(-1)` همان URL قبلی را برمی‌گرداند، پس هرچه در URL باشد سرِ جایش برمی‌گردد و هرچه در `useState` باشد با remount می‌پرد. سود جانبی: رفرش و اشتراک لینک همان نما را می‌دهد. فیلدِ جستجوی debounce‌شده local می‌ماند و فقط مقدار نهایی به URL می‌رود. --- ## Conventions **Naming:** pages `XxxPage.tsx`, components PascalCase, hooks `useXxx.ts`. Backend PSR-4 `App\\`; 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` (single resource): extract with `data?.data` - `PaginatedResponse` (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`. **Routing:** React Router v7, `` 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//Entity/Foo.php` — Doctrine entity (attributes; see model pattern). 2. `src//Repository/FooRepository.php` — queries. 3. `src//Service/FooService.php` — logic (optional but preferred). 4. `src//Controller/FooController.php` — `extends 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 ```php #[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/` → `docs/api/.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](docs/architecture/tenancy.md) ## قواعد غیرقابل‌مذاکره 1. SOLID در هر کد جدید. کامپوننت/کلاس چندمسئولیتی ننویس. 2. API جدید فقط وقتی هیچ اندپوینت موجودی — حتی با توسعه — کافی نباشد. همیشه اول بگرد، بعد توسعه بده، در آخر بساز. و دلیلش را بنویس. 3. مستندات کوتاه و دقیق روی هر چیز عمومی. کامنت بدیهی ننویس. 4. هیچ تسکی بدون تست (موفق + خطا + مرزی) و بدون اجرای موفق تست‌ها تمام‌شده نیست. 5. ورودی من فارسی است. اول منظورم را به spec انگلیسی تبدیل کن و برداشتت را به فارسی تأیید بگیر. کد و مستندات و کامیت انگلیسی؛ گفت‌وگو با من فارسی. رشته‌های UI فارسی و از فایل i18n.