12 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.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, notdocs/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/, testsApp\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.nodenative-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) · 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\<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 withdata?.dataPaginatedResponse<T>(admin lists): items atdata?.data, total atdata?.meta?.totalRecords
State management:
- Server state → TanStack Query (cache,
staleTime: 30s,retry: 1). - Client state → Zustand.
authStore(JWT + user, persisted tolocalStorage['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;ExceptionSubscribercatches it and formats via$this->error(). Codes + Persian messages insrc/Shared/Constant/ErrorCodes.php. - Pitfall — double nesting:
$this->success(['data' => $x])yields{ data: { data: x } }; the frontend must then readdata?.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)
src/<Domain>/Entity/Foo.php— Doctrine entity (attributes; see model pattern).src/<Domain>/Repository/FooRepository.php— queries.src/<Domain>/Service/FooService.php— logic (optional but preferred).src/<Domain>/Controller/FooController.php—extends BaseController, route/api/v1/..., guard with#[IsGranted], return a response helper.ddev exec php bin/console doctrine:migrations:diffthenmigrate.- 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).