diff --git a/CLAUDE.md b/CLAUDE.md index 16da7e72..698743ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,178 +1,226 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +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. -## Project Overview +- **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. -**ClinicPro** — a clinic management and appointment booking platform migrated from Drupal to Symfony 7. It consists of a Symfony REST API backend and a React 19 admin SPA bundled inside Symfony via Webpack Encore. - -- **Local URL:** `https://clinic-pro.ddev.site` -- **Admin panel:** `https://clinic-pro.ddev.site/admin` -- **Swagger UI:** `https://clinic-pro.ddev.site/api/doc` +> **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. --- -## Commands +## Stack & versions -All commands run inside ddev: prefix with `ddev exec` unless noted. +### 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/` -### First-time setup -```bash -ddev exec composer install -ddev exec php bin/console lexik:jwt:generate-keypair # generate JWT keys -ddev exec php bin/console doctrine:migrations:migrate --no-interaction -ddev exec yarn install && ddev exec yarn dev -ddev exec php bin/console app:create-admin # create first admin user -``` +### 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) -### Backend (PHP/Symfony) -```bash -ddev exec php bin/console cache:clear -ddev exec php bin/console doctrine:migrations:migrate --no-interaction -ddev exec php bin/console doctrine:migrations:diff --no-interaction # generate migration after entity change -ddev exec php bin/console debug:router | grep api -ddev exec php bin/console messenger:consume async # start queue worker (SMS, async jobs) -ddev exec php bin/console messenger:consume scheduler_default # run scheduled tasks (expires unpaid bookings every 1 min) - -# Tests -ddev exec php bin/phpunit -ddev exec php bin/phpunit tests/SomeTest.php # single test file - -# Static analysis (level 5, with Symfony + Doctrine extensions) -ddev exec php vendor/bin/phpstan analyse -``` - -### Frontend (React/TypeScript) -```bash -ddev exec yarn dev # one-off dev build (use this to check for errors) -ddev exec yarn watch # watch mode -ddev exec yarn build # production build - -# Type check only (faster) -ddev exec npx tsc --noEmit --project tsconfig.json -``` - -> **Note:** The CSS build has a known `lightningcss.linux-arm64-gnu.node` native module error inside ddev — this is pre-existing and does not block JS/TS compilation. TypeScript errors only appear in TSC output. +### 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 | --- -## Architecture +## Commands (prefix with `ddev exec`) -### Backend — `src/` - -Domain-driven structure; each domain is its own namespace under `App\\`: +```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 ``` -src/ - Admin/Controller/AdminApiController.php # all admin-only list/stats endpoints - Appointment/ Doctor/ Clinic/ - Auth/ Payment/ Rating/ - Blog/ Representation/ Secretary/ - Category/ Settlement/ Sms/ - Shared/Controller/BaseController.php # all controllers extend this - Shared/Constant/ErrorCodes.php +> 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`. -**Every controller extends `BaseController`** which provides four response helpers: - -| Method | Shape | When to use | -|--------|-------|-------------| -| `$this->success($data)` | `{ success, data: $data }` | Single resource / action | -| `$this->paginated($items, $total, $page, $limit)` | `{ success, data: $items[], meta: { totalRecords, totalPages, currentPage } }` | Admin list endpoints | -| `$this->error($code, $message, $status)` | `{ success:false, errors:[{code,message}] }` | All error responses | -| `$this->validationError($violations)` | `{ success:false, errors:[{code,field,message}] }` HTTP 422 | Input validation failures | - -**Domain exceptions:** throw `AppException(ErrorCodes::ERR_XXX, null, $httpStatus)` anywhere in the domain — `ExceptionSubscriber` catches it and calls `$this->error()` automatically. All error codes and their Persian messages live in `src/Shared/Constant/ErrorCodes.php`. - -**Critical pitfall — double-nested responses:** -`$this->success(['data' => $rep->toArray()])` produces `{ data: { data: {...} } }`, so the frontend must extract with `data?.data?.data`. The `paginated()` helper does NOT nest — it returns `data` as a flat array. +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/` - -Single-page app mounted at `/admin/*`: - ``` -assets/admin/ - App.tsx # React Router routes - pages/ # one file per page - components/ - ui/ # DataTable, Modal, ConfirmDialog, PageHeader, StatusBadge, Pagination, - # SearchableSelect, PersianDateInput, PersianCalendar, AppointmentStatusDropdown - layout/ # AdminLayout, Sidebar, Topbar - hooks/ # custom React hooks - lib/api.ts # fetch wrapper (reads JWT from localStorage key: clinicpro-auth) - lib/utils.ts # formatRial, formatNumber, formatDate, formatDateTime - types/index.ts # all TypeScript interfaces - stores/ - authStore.ts # Zustand auth store (persisted to localStorage) - uiStore.ts # sidebar open/close state +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 ``` -**Data fetching pattern:** TanStack Query v5 (`useQuery` / `useMutation`). Query keys use `['resource-name', page, filters]`. - -**API response types in `lib/api.ts`:** -- `ApiResponse` — for single-resource responses: extract with `data?.data` -- `PaginatedResponse` — for admin lists: items at `data?.data`, total at `data?.meta?.totalRecords` - -**Forms:** React Hook Form + Zod resolver. Schema defined with `z.object()`, type inferred with `z.infer`. - -### Auth - -- JWT stored in Zustand store → `localStorage['clinicpro-auth']` → `state.token` -- `api.ts` reads it automatically for every request -- Admin routes require `ROLE_ADMIN`. `#[IsGranted('ROLE_ADMIN')]` on controller class or method. -- Public endpoints listed in `config/packages/security.yaml` under `public_endpoints` firewall pattern - -### Category / Bundle system - -Categories are polymorphic via a `bundle` string field. Used for: `state`, `city`, `specially_doctor`, `doctor_services`, `insurance_type`, `supplementary_insurance`, `tag`. - -City IDs (integer FK to `categories.id` where `bundle='city'`) are stored on entities like `Representation.cityId`. To get the city name, LEFT JOIN the categories table in DQL. - -### Database - -- MariaDB 11.8 via ddev -- Doctrine ORM with integer Unix timestamps (`createdAt`, `updatedAt`) — **not** DateTime objects -- All admin list queries in `AdminApiController` use DQL array hydration (`.getArrayResult()`) to avoid triggering non-existent getter errors on entities -- Migrations in `migrations/` — always run `doctrine:migrations:diff` after entity changes - --- -## Key Patterns +## Styling & design tokens -**Adding a new admin list endpoint (backend):** -1. Add method to `src/Admin/Controller/AdminApiController.php` -2. Use `$this->em->createQueryBuilder()` with `->getArrayResult()` (never use entity getters in admin list queries) -3. Return `$this->paginated($items, $total, $page, $limit)` +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: -**Adding a new admin page (frontend):** -1. Create `assets/admin/pages/XxxPage.tsx` -2. Use `PaginatedResponse` with `useQuery` -3. Extract: `data?.data` for items, `data?.meta?.totalRecords` for total -4. Add route in `App.tsx` -5. Add UI components: ``, ``, ``, `` - -**Category API endpoint pattern:** `GET /api/v1/categorys/{bundle}` (note: typo `categorys` is intentional — existing route). Response is double-nested: extract array with `data?.data?.data ?? []`. - ---- - -## Standing Rule — API Documentation - -**Whenever any API endpoint is created or modified** (controller file, route, request/response structure, error code, permission), the corresponding file in `docs/api/` **must be updated in the same session**. - -| Changed file | Doc to update | +| Group | Tokens | |---|---| -| `src/Auth/*` | `docs/api/auth.md` | -| `src/Doctor/*` | `docs/api/doctor.md` | -| `src/Clinic/*` | `docs/api/clinic.md` + `docs/api/clinic-invitation.md` | -| `src/Appointment/Controller/AppointmentController.php` | `docs/api/appointment.md` | -| `src/Appointment/Controller/AppointmentSettings*` | `docs/api/appointment-settings.md` | -| `src/Payment/*` | `docs/api/payment.md` | -| `src/Settlement/*` | `docs/api/settlement.md` | -| `src/Rating/*` | `docs/api/rating.md` | -| `src/Secretary/*` | `docs/api/secretary.md` | -| `src/Representation/*` | `docs/api/representation.md` | -| `src/Sms/*` | `docs/api/sms.md` | -| `src/Blog/*` | `docs/api/blog.md` | -| `src/Admin/*` | `docs/api/admin.md` | -| Category/Province/City controllers | `docs/api/location.md`, `docs/api/specialty.md`, `docs/api/insurance.md`, `docs/api/doctor-service.md`, `docs/api/tag.md` | +| 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) · `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`. + +--- + +## 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`).