Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 62d5d94121 | |||
| 880809fdbe | |||
| e52701dba3 | |||
| b1bcfc69e4 | |||
| 0d2c279f4b | |||
| fb59aed2f8 | |||
| 4add3ab859 | |||
| 19b5671b0d | |||
| ea6976351a |
29
.cursor/rules/adminjs.mdc
Normal file
29
.cursor/rules/adminjs.mdc
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
description: AdminJS panel must stay in sync with Prisma schema changes
|
||||||
|
globs: backend/src/admin/**,backend/prisma/schema.prisma,backend/prisma/migrations/**
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# AdminJS ↔ Prisma sync (required)
|
||||||
|
|
||||||
|
Ops panel at `/admin` (`backend/src/admin/`). Resources are a **manual allowlist** — Prisma does **not** auto-update AdminJS.
|
||||||
|
|
||||||
|
## When you change `schema.prisma`
|
||||||
|
|
||||||
|
**Before finishing the task**, update AdminJS:
|
||||||
|
|
||||||
|
1. Open [`backend/src/admin/resources.ts`](backend/src/admin/resources.ts) (`buildAdminResources`).
|
||||||
|
2. **New model** ops may need to inspect/fix → add `resource(...)` + navigation group + hide secrets.
|
||||||
|
3. **Renamed / removed model** → update or remove the matching resource (broken `getModelByName` breaks `/admin` boot).
|
||||||
|
4. **New secret fields** (hashes, tokens, share tokens) → hide via `isVisible: false` (list/filter/show/edit).
|
||||||
|
5. **Catalog-like reference data** → list/show/edit only; disable `new` / `delete` / `bulkDelete`.
|
||||||
|
6. **Composite `@@id` only** (no single `@id`) → **do not register** — AdminJS list returns 500 (`Resource does not have an id property`). Examples: `LabCaseDetail`, `MembershipPermission`, read-cursor tables.
|
||||||
|
7. Skip pure join/cursor tables unless ops need them and they have a single id.
|
||||||
|
|
||||||
|
Auth: `ADMINJS_EMAIL` / `ADMINJS_PASSWORD` — production login disabled if password missing or still `admin123`.
|
||||||
|
|
||||||
|
Production Docker: `ADMIN_JS_TMP_DIR=/app/adminjs-tmp` (not `.adminjs`) so `components.bundle.js` can be written/served.
|
||||||
|
|
||||||
|
## Secrets to hide
|
||||||
|
|
||||||
|
`passwordHash`, session `token`/`refreshToken`, invite/OTP `tokenHash`/`codeHash`, `LabCase.accessToken`.
|
||||||
@@ -7,6 +7,7 @@ alwaysApply: false
|
|||||||
# Appointments
|
# Appointments
|
||||||
|
|
||||||
- List includes `hasTreatment` when a `Treatment` row is linked (`appointmentId`).
|
- List includes `hasTreatment` when a `Treatment` row is linked (`appointmentId`).
|
||||||
|
- **Patient:** create/update must use a **named** patient of this org (`createdByOrganizationId`, not walk-in). Helper `ensurePatientInOrg`. Do not book another clinic’s patient UUID.
|
||||||
- **Patient change** blocked while linked → `APPOINTMENT_PATIENT_LOCKED` (UI: `patientLockedHint`).
|
- **Patient change** blocked while linked → `APPOINTMENT_PATIENT_LOCKED` (UI: `patientLockedHint`).
|
||||||
- **Delete** blocked while linked → `APPOINTMENT_HAS_TREATMENT` (hide delete + `deleteBlockedHint`). Empty appointments (no treatment yet) remain deletable.
|
- **Delete** blocked while linked → `APPOINTMENT_HAS_TREATMENT` (hide delete + `deleteBlockedHint`). Empty appointments (no treatment yet) remain deletable.
|
||||||
- **Past days:** new bookings stay blocked. Existing appointments **without** treatment can be edited/deleted; with treatment → toast `infoEditBlockedHasTreatment` (no modal). Banner clicks are not gated by `canBook` (slots still are).
|
- **Past days:** new bookings stay blocked. Existing appointments **without** treatment can be edited/deleted; with treatment → toast `infoEditBlockedHasTreatment` (no modal). Banner clicks are not gated by `canBook` (slots still are).
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ throw new AppException(ErrorCode.PERMISSION_DENIED, HttpStatus.FORBIDDEN);
|
|||||||
- Schema: `backend/prisma/schema.prisma`
|
- Schema: `backend/prisma/schema.prisma`
|
||||||
- Always add a migration for schema changes (`npm run prisma:migrate` in backend).
|
- Always add a migration for schema changes (`npm run prisma:migrate` in backend).
|
||||||
- Seed permissions stay in sync with `ALL_TAB_PERMISSIONS` in `common/permissions.ts`.
|
- Seed permissions stay in sync with `ALL_TAB_PERMISSIONS` in `common/permissions.ts`.
|
||||||
|
- **Schema change ⇒ AdminJS:** update `backend/src/admin/resources.ts` in the same change (add/rename/remove resources, hide new secrets). See `.cursor/rules/adminjs.mdc`.
|
||||||
|
|
||||||
## API responses
|
## API responses
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ Monorepo: `backend/` (NestJS + Prisma), `frontend/` (Next.js + next-intl), `infr
|
|||||||
|
|
||||||
- **CLINIC** orgs: patients, appointments, treatment, staff.
|
- **CLINIC** orgs: patients, appointments, treatment, staff.
|
||||||
- **LAB** orgs: cases, tasks, lab workflows.
|
- **LAB** orgs: cases, tasks, lab workflows.
|
||||||
|
- Named **patients** are scoped to `createdByOrganizationId`. `mobile` stays globally unique — other-org / walk-in hit `PATIENT_MOBILE_UNAVAILABLE` (no shared row).
|
||||||
- Tab access: `TAB_*_READ` / `TAB_*_EDIT` in `backend/src/common/permissions.ts`. EDIT implies READ.
|
- Tab access: `TAB_*_READ` / `TAB_*_EDIT` in `backend/src/common/permissions.ts`. EDIT implies READ.
|
||||||
|
|
||||||
## Agent behavior
|
## Agent behavior
|
||||||
@@ -19,6 +20,7 @@ Monorepo: `backend/` (NestJS + Prisma), `frontend/` (Next.js + next-intl), `infr
|
|||||||
- **Never commit or push** unless the user explicitly asks.
|
- **Never commit or push** unless the user explicitly asks.
|
||||||
- Prefer minimal diffs; reuse existing components and API patterns.
|
- Prefer minimal diffs; reuse existing components and API patterns.
|
||||||
- After cross-cutting changes: `backend` → `npm run build`; `frontend` → `npx tsc --noEmit`.
|
- After cross-cutting changes: `backend` → `npm run build`; `frontend` → `npx tsc --noEmit`.
|
||||||
|
- Prisma `schema.prisma` changes ⇒ update AdminJS allowlist (`backend/src/admin/resources.ts`) — `.cursor/rules/adminjs.mdc`.
|
||||||
|
|
||||||
## i18n
|
## i18n
|
||||||
|
|
||||||
@@ -44,4 +46,4 @@ Logical failures: `AppException(ErrorCode.X)` → `errors.X` in en/fa/nl. UI: `g
|
|||||||
|
|
||||||
## Notifications (inbox + live tabs)
|
## Notifications (inbox + live tabs)
|
||||||
|
|
||||||
Header bell: `UserNotification` + Socket.IO. Same `notification.created` also drives sidebar tab badges and soft list refresh on **currently open** Cases/Tasks/Treatment/Orgs pages. Skills: `.cursor/skills/notifications-inbox/SKILL.md`, `.cursor/skills/tab-badges/SKILL.md`.
|
Header bell: `UserNotification` + Socket.IO. Same `notification.created` also drives sidebar tab badges and soft list refresh on **currently open** Cases/Tasks/Treatment/Orgs pages. Inbox + Tasks/Treatment badges use **`CASE_COMPLETED`** (every task in the case done); per-step `TASK_COMPLETED` is timeline-only. Skills: `.cursor/skills/notifications-inbox/SKILL.md`, `.cursor/skills/tab-badges/SKILL.md`.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
description: Brand and FDI SVG sources live under frontend/src/assets, not public/
|
description: Brand and FDI SVG sources live under frontend/src/assets, not public/
|
||||||
globs: frontend/src/assets/**,frontend/src/components/ui/shared/Brand*.tsx,frontend/public/**,frontend/scripts/**
|
globs: frontend/src/assets/**,frontend/src/components/ui/shared/Brand*.tsx,frontend/public/**,frontend/scripts/**,frontend/Dockerfile
|
||||||
alwaysApply: false
|
alwaysApply: false
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -11,3 +11,4 @@ alwaysApply: false
|
|||||||
- **FDI tooth sources:** `frontend/src/assets/fdi/`. The chart uses inlined paths in `realisticToothAssets.ts`; regenerate with `frontend/scripts/extract-tooth-svgs.mjs`.
|
- **FDI tooth sources:** `frontend/src/assets/fdi/`. The chart uses inlined paths in `realisticToothAssets.ts`; regenerate with `frontend/scripts/extract-tooth-svgs.mjs`.
|
||||||
- **Prosthesis catalog illustrations:** source `frontend/src/assets/prosthesis-catalog/*.svg` (painted navy, not `currentColor`). Serve the same filenames from `frontend/public/prosthesis-catalog/` as `<img>` — Next cannot import these as URLs under Turbopack 16.1, and denture/veneer are too large to inline. Map codes in `prosthesisCatalogIcons.ts`. Copy into `public/` when adding a file.
|
- **Prosthesis catalog illustrations:** source `frontend/src/assets/prosthesis-catalog/*.svg` (painted navy, not `currentColor`). Serve the same filenames from `frontend/public/prosthesis-catalog/` as `<img>` — Next cannot import these as URLs under Turbopack 16.1, and denture/veneer are too large to inline. Map codes in `prosthesisCatalogIcons.ts`. Copy into `public/` when adding a file.
|
||||||
- **`public/`** is only for files that must be fetched by URL (e.g. og images, prosthesis catalog icons). Do not put themeable brand/FDI SVGs there — `<img src>` cannot inherit `currentColor`.
|
- **`public/`** is only for files that must be fetched by URL (e.g. og images, prosthesis catalog icons). Do not put themeable brand/FDI SVGs there — `<img src>` cannot inherit `currentColor`.
|
||||||
|
- **Docker:** Next `output: 'standalone'` does **not** include `public/`. `frontend/Dockerfile` must `COPY` builder `/app/public` to `./public` next to `server.js` (after the standalone copy). Missing this 404s `/prosthesis-catalog/*.svg` in staging/prod.
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ alwaysApply: false
|
|||||||
- **Route:** `/lab-case/[token]` → `CaseTasksFocusView` (dashboard layout, auth required).
|
- **Route:** `/lab-case/[token]` → `CaseTasksFocusView` (dashboard layout, auth required).
|
||||||
- **Access:** lab (`TAB_TASKS_*`) or clinic treatment **provider** (`TAB_TREATMENT_EDIT` + `isActorTreatmentProvider`); else `LAB_CASE_ACCESS_DENIED`.
|
- **Access:** lab (`TAB_TASKS_*`) or clinic treatment **provider** (`TAB_TREATMENT_EDIT` + `isActorTreatmentProvider`); else `LAB_CASE_ACCESS_DENIED`.
|
||||||
- **Task status on link page:** same assignee rule as Tasks — `canEditLabTaskStatus`; backend `PATCH /tasks/:id` enforces assignee.
|
- **Task status on link page:** same assignee rule as Tasks — `canEditLabTaskStatus`; backend `PATCH /tasks/:id` enforces assignee.
|
||||||
- **Auth redirect:** `postAuthRedirect.ts`; dashboard stores path on logout redirect; login stores `?from=` **then** `useEnterAppWhenAuthenticated` consumes **once** after org ready — ❌ do not consume in `useAuth.login()` / `registerTrial`. Invites: `login()` then `navigateIntoAppIfOrgSelected` (no enter-app hook on invite pages).
|
- **Auth redirect:** `postAuthRedirect.ts`; dashboard stores path on logout redirect; login stores `?from=` **then** `useEnterAppWhenAuthenticated` consumes **once** after org ready — ❌ do not consume in `useAuth.login()` / `registerTrial`. Invites (join + `password_setup`): `login()` then `navigateIntoAppIfOrgSelected` (no enter-app hook on invite pages).
|
||||||
- **Login page:** wrap `useSearchParams` in `<Suspense>` for `next build`.
|
- **Login page:** wrap `useSearchParams` in `<Suspense>` for `next build`.
|
||||||
|
|
||||||
Skill: `.cursor/skills/lab-case-share-link/SKILL.md`
|
Skill: `.cursor/skills/lab-case-share-link/SKILL.md`
|
||||||
|
|||||||
@@ -17,5 +17,6 @@ alwaysApply: false
|
|||||||
- **Mobile UX:** `LAB_TASK_STATUS_SELECT_CLASS` (44px tap target on small screens); `TaskCaseGroupHeader` sticky while scrolling grouped tasks; filter selects use same touch sizing on Tasks.
|
- **Mobile UX:** `LAB_TASK_STATUS_SELECT_CLASS` (44px tap target on small screens); `TaskCaseGroupHeader` sticky while scrolling grouped tasks; filter selects use same touch sizing on Tasks.
|
||||||
- **Show in case:** `GET /tasks/locate-page` finds page in full list; highlight + scroll.
|
- **Show in case:** `GET /tasks/locate-page` finds page in full list; highlight + scroll.
|
||||||
- **Today deep links:** `parseTasksSearchParams` + `prosthesisTypeCode` / `unassignedOnly` / `overdueOnly` query params on Tasks.
|
- **Today deep links:** `parseTasksSearchParams` + `prosthesisTypeCode` / `unassignedOnly` / `overdueOnly` query params on Tasks.
|
||||||
|
- **Inbox / tab badges:** completing a step writes timeline `TASK_COMPLETED` only; inbox + Tasks/Treatment badges fire on `CASE_COMPLETED` when no in-progress tasks remain.
|
||||||
|
|
||||||
Full map: `.cursor/skills/lab-tasks/SKILL.md`
|
Full map: `.cursor/skills/lab-tasks/SKILL.md`
|
||||||
|
|||||||
12
.cursor/rules/patients.mdc
Normal file
12
.cursor/rules/patients.mdc
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
description: Clinic patients — org-scoped named records, globally unique mobile
|
||||||
|
globs: backend/src/modules/patients/**,backend/src/modules/appointments/appointments.service.ts,backend/src/modules/treatments/treatments.service.ts,frontend/src/components/ui/patient/**,frontend/src/lib/api/patients.ts
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Patients
|
||||||
|
|
||||||
|
- List / get / update / create: **this org** + `isWalkIn: false` (`createdByOrganizationId`).
|
||||||
|
- `Patient.mobile` stays **globally unique**. Same-org named create returns `{ existing: true }`. Other org, walk-in, or null creator → `PATIENT_MOBILE_UNAVAILABLE` (409). Do **not** return or mention the other clinic’s row.
|
||||||
|
- Appointment create/update and `POST /treatments` named `patientId`: `ensurePatientInOrg` (named + this org). Walk-in sentinel is per clinic (`walk-in-patient.ts`), hidden from Patients/search/booking.
|
||||||
|
- History / lab-case lists still query treatments for **this** `organizationId` even if the patient UUID is guessed.
|
||||||
@@ -10,7 +10,7 @@ alwaysApply: false
|
|||||||
|
|
||||||
- **Login + register:** `useEnterAppWhenAuthenticated` after org ready → `appPathAfterAuth()` (`consumeAuthRedirect()` once, else `/today`).
|
- **Login + register:** `useEnterAppWhenAuthenticated` after org ready → `appPathAfterAuth()` (`consumeAuthRedirect()` once, else `/today`).
|
||||||
- **Login `?from=`:** `storeAuthRedirectFromPath` **before** that hook (effect order).
|
- **Login `?from=`:** `storeAuthRedirectFromPath` **before** that hook (effect order).
|
||||||
- **Staff / org invite:** accept → `login(email, password)` → `navigateIntoAppIfOrgSelected`. ❌ Do not put the hook on invite pages (logged-in visitors must finish accept).
|
- **Staff / org invite:** accept → `login(email, password)` → `navigateIntoAppIfOrgSelected`. Same for `/accept-invite` `mode: password_setup` (password fields only). ❌ Do not put the hook on invite pages (logged-in visitors must finish accept). Login does not special-case `passwordHash: null` — those users cannot sign in until they set a password via the setup link.
|
||||||
- **Forgot password:** navigates itself to `/settings/account?reset=1`. ❌ Do not add the enter-app hook there.
|
- **Forgot password:** navigates itself to `/settings/account?reset=1`. ❌ Do not add the enter-app hook there.
|
||||||
- **Multi-org:** redirect stays in sessionStorage until `selectOrganization()` → `appPathAfterAuth()`.
|
- **Multi-org:** redirect stays in sessionStorage until `selectOrganization()` → `appPathAfterAuth()`.
|
||||||
- ❌ Never `consumeAuthRedirect()` inside `useAuth.login()` or `registerTrial`.
|
- ❌ Never `consumeAuthRedirect()` inside `useAuth.login()` or `registerTrial`.
|
||||||
|
|||||||
13
.cursor/rules/staff.mdc
Normal file
13
.cursor/rules/staff.mdc
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
description: Staff passwords — setup link only; never set another user’s password
|
||||||
|
globs: backend/src/modules/staff/**,frontend/src/components/ui/staff/**,frontend/src/app/**/accept-invite/**,frontend/src/lib/api/staff.ts
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Staff passwords
|
||||||
|
|
||||||
|
- Owner / `TAB_STAFF_EDIT` may **clear** a password, never set one for someone else.
|
||||||
|
- `POST /staff/members/:membershipId/clear-password`: `passwordHash: null`, delete sessions, revoke unused invites on that membership, mint a 7-day `/accept-invite` URL. Refuse owner, self, pending, disabled (`STAFF_CANNOT_CLEAR_OWN_PASSWORD`, `STAFF_PASSWORD_CLEAR_ACTIVE_ONLY`).
|
||||||
|
- List DTO: `hasPassword` boolean only (never the hash). `previewInvite` `mode`: `join` | `password_setup` from `membership.isActive`.
|
||||||
|
- Login page unchanged — null hash is invalid credentials until they set a password on the setup link.
|
||||||
|
- `/accept-invite` `password_setup`: password fields only; then `login()` + `navigateIntoAppIfOrgSelected` (no enter-app hook).
|
||||||
@@ -6,7 +6,7 @@ alwaysApply: false
|
|||||||
|
|
||||||
# Tab badges (Cases / Tasks / Treatment)
|
# Tab badges (Cases / Tasks / Treatment)
|
||||||
|
|
||||||
- **Split counts (Option B):** Lab Cases = sent + clinic comments + important; Lab Tasks = completions + lab comments + assignments (assignee-only); Clinic Treatment = visible lab comments + completions.
|
- **Split counts (Option B):** Lab Cases = sent + clinic comments + important; Lab Tasks = case fully completed + lab comments + assignments (assignee-only); Clinic Treatment = visible lab comments + case fully completed. Per-step `TASK_COMPLETED` is timeline-only (not badges or inbox).
|
||||||
- **API:** `GET /notifications/tab-counts`; **Cases + Treatment** use per-case read + `hasUnread` on list cards; Tasks marks read on tab visit. Treatment rail uses `TreatmentLabCasesPanel` + `LabCaseTrackerCard` + activity feed.
|
- **API:** `GET /notifications/tab-counts`; **Cases + Treatment** use per-case read + `hasUnread` on list cards; Tasks marks read on tab visit. Treatment rail uses `TreatmentLabCasesPanel` + `LabCaseTrackerCard` + activity feed.
|
||||||
- **Pattern:** `useTabBadgeCounts` + `notifyTabBadgesChanged()` — same shape as `usePendingConnectionsCount`.
|
- **Pattern:** `useTabBadgeCounts` + `notifyTabBadgesChanged()` — same shape as `usePendingConnectionsCount`.
|
||||||
- **Live:** inbox Socket.IO `notification.created` → `notifyTabBadgesChanged()` (and org pending event when relevant). **Mounted** Cases/Tasks/Treatment/Orgs pages soft-refetch lists; unmounted tabs do not. Sidebar badge counts always refetch (hook is always mounted).
|
- **Live:** inbox Socket.IO `notification.created` → `notifyTabBadgesChanged()` (and org pending event when relevant). **Mounted** Cases/Tasks/Treatment/Orgs pages soft-refetch lists; unmounted tabs do not. Sidebar badge counts always refetch (hook is always mounted).
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Follow this checklist. Adapt steps if the feature is read-only or org-type-speci
|
|||||||
|
|
||||||
```
|
```
|
||||||
- [ ] 1. Permissions & org type
|
- [ ] 1. Permissions & org type
|
||||||
- [ ] 2. Backend module
|
- [ ] 2. Backend module (+ Prisma / AdminJS if new models)
|
||||||
- [ ] 3. Frontend UI + thin page
|
- [ ] 3. Frontend UI + thin page
|
||||||
- [ ] 4. i18n (en, fa, nl)
|
- [ ] 4. i18n (en, fa, nl)
|
||||||
- [ ] 5. Verify build / tsc
|
- [ ] 5. Verify build / tsc
|
||||||
@@ -40,6 +40,7 @@ backend/src/modules/{feature}/
|
|||||||
- Service-level permission checks with `hasEffectivePermission`.
|
- Service-level permission checks with `hasEffectivePermission`.
|
||||||
- DTOs use `ErrorCode` validation messages.
|
- DTOs use `ErrorCode` validation messages.
|
||||||
- Register in `app.module.ts`.
|
- Register in `app.module.ts`.
|
||||||
|
- If you add/change Prisma models: update AdminJS allowlist in `backend/src/admin/resources.ts` (same PR). See `.cursor/rules/adminjs.mdc`.
|
||||||
|
|
||||||
## 3. Frontend
|
## 3. Frontend
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ Helpers: `lib/auth/postAuthRedirect.ts` (`sessionStorage` key `authRedirect`).
|
|||||||
|
|
||||||
1. Logged-out user hits `/lab-case/{token}` → dashboard layout stores path + `router.replace('/login?from=…')`.
|
1. Logged-out user hits `/lab-case/{token}` → dashboard layout stores path + `router.replace('/login?from=…')`.
|
||||||
2. Login page `useSearchParams` (inside **Suspense**) calls `storeAuthRedirectFromPath(from)` **before** `useEnterAppWhenAuthenticated`.
|
2. Login page `useSearchParams` (inside **Suspense**) calls `storeAuthRedirectFromPath(from)` **before** `useEnterAppWhenAuthenticated`.
|
||||||
3. After login/register + org ready: **one** consume via `appPathAfterAuth()` in that hook. Staff/org invite: `login()` then `navigateIntoAppIfOrgSelected` (❌ no hook on invite pages).
|
3. After login/register + org ready: **one** consume via `appPathAfterAuth()` in that hook. Staff/org invite (join + `password_setup`): `login()` then `navigateIntoAppIfOrgSelected` (❌ no hook on invite pages).
|
||||||
4. **Do not** `consumeAuthRedirect()` inside `useAuth.login()` or `registerTrial` — double consume sends user to `/today`.
|
4. **Do not** `consumeAuthRedirect()` inside `useAuth.login()` or `registerTrial` — double consume sends user to `/today`.
|
||||||
5. Multi-org: redirect stays in storage until `selectOrganization()` → `appPathAfterAuth()`.
|
5. Multi-org: redirect stays in storage until `selectOrganization()` → `appPathAfterAuth()`.
|
||||||
6. Forgot-password navigates to account reset itself — do not add the enter-app hook there.
|
6. Forgot-password navigates to account reset itself — do not add the enter-app hook there.
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ Keep changes minimal — match existing `sm:` breakpoint patterns elsewhere in t
|
|||||||
|
|
||||||
## Tab badges + live soft refresh
|
## Tab badges + live soft refresh
|
||||||
|
|
||||||
See `.cursor/skills/tab-badges/SKILL.md` — split lab Cases/Tasks counts, clinic Treatment; `useTabBadgeCounts` + `notifyTabBadgesChanged`.
|
See `.cursor/skills/tab-badges/SKILL.md` — split lab Cases/Tasks counts, clinic Treatment; `useTabBadgeCounts` + `notifyTabBadgesChanged`. Inbox and Tasks/Treatment badges fire on `CASE_COMPLETED` (all tasks done), not each workflow step. The case timeline still records `TASK_COMPLETED` per step.
|
||||||
|
|
||||||
Inbox Socket.IO `notification.created` → `notifyTabBadgesChanged()` → silent `loadTasks({ silent: true })` on an open Tasks page (filters preserved; no remount). Details: `.cursor/skills/notifications-inbox/SKILL.md`.
|
Inbox Socket.IO `notification.created` → `notifyTabBadgesChanged()` → silent `loadTasks({ silent: true })` on an open Tasks page (filters preserved; no remount). Details: `.cursor/skills/notifications-inbox/SKILL.md`.
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ Full tab-badge map: `.cursor/skills/tab-badges/SKILL.md`.
|
|||||||
|
|
||||||
## Emit sites (parallel to LabCaseActivity)
|
## Emit sites (parallel to LabCaseActivity)
|
||||||
|
|
||||||
CASE_SENT, CLINIC_COMMENT, LAB_COMMENT (+ LAB_COMMENT_CLINIC), CASE_IMPORTANT, TASK_COMPLETED, TASK_ASSIGNED (assignee only), CONNECTION_REQUEST, STAFF_INVITE — see service call sites.
|
CASE_SENT, CLINIC_COMMENT, LAB_COMMENT (+ LAB_COMMENT_CLINIC), CASE_IMPORTANT, CASE_COMPLETED (all tasks in the case done — not each step), TASK_ASSIGNED (assignee only), CONNECTION_REQUEST, STAFF_INVITE — see service call sites.
|
||||||
|
|
||||||
**Inbox card context** is denormalized inside `UserNotificationService.notify()` (`enrichInboxPayload`) from ids already on the payload (`labCaseId`, `taskId`, `fromOrganizationId`). Emit sites stay thin (`{ labCaseId }`, etc.). Inbox list/read does **not** join related tables. Older rows may lack these fields until new events are emitted.
|
**Inbox card context** is denormalized inside `UserNotificationService.notify()` (`enrichInboxPayload`) from ids already on the payload (`labCaseId`, `taskId`, `fromOrganizationId`). Emit sites stay thin (`{ labCaseId }`, etc.). Inbox list/read does **not** join related tables. Older rows may lack these fields until new events are emitted.
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Frontend hook: [`frontend/src/lib/hooks/useTabBadgeCounts.ts`](frontend/src/lib/
|
|||||||
|
|
||||||
## Models
|
## Models
|
||||||
|
|
||||||
- **`LabCaseActivity`** — append-only events: `CASE_SENT`, `CLINIC_COMMENT`, `LAB_COMMENT`, `CASE_IMPORTANT`, `CASE_AMENDED` (stub for Step 7), `TASK_COMPLETED`, `TASK_ASSIGNED`
|
- **`LabCaseActivity`** — append-only events: `CASE_SENT`, `CLINIC_COMMENT`, `LAB_COMMENT`, `CASE_IMPORTANT`, `CASE_AMENDED` (stub for Step 7), `TASK_COMPLETED` (timeline only), `TASK_ASSIGNED`, `CASE_COMPLETED`
|
||||||
- **`LabCaseUserTabReadState`** — per user/org/tab cursor (`TASKS`) for sidebar badge clearing on tab visit.
|
- **`LabCaseUserTabReadState`** — per user/org/tab cursor (`TASKS`) for sidebar badge clearing on tab visit.
|
||||||
- **`LabCaseUserReadState`** — per user/org/labCase cursor; drives Cases tab count and `hasUnread` on case list cards
|
- **`LabCaseUserReadState`** — per user/org/labCase cursor; drives Cases tab count and `hasUnread` on case list cards
|
||||||
|
|
||||||
@@ -22,8 +22,8 @@ Frontend hook: [`frontend/src/lib/hooks/useTabBadgeCounts.ts`](frontend/src/lib/
|
|||||||
| Org | Tab | Activity types |
|
| Org | Tab | Activity types |
|
||||||
|-----|-----|----------------|
|
|-----|-----|----------------|
|
||||||
| LAB | Cases | `CASE_SENT`, `CLINIC_COMMENT`, `CASE_IMPORTANT` |
|
| LAB | Cases | `CASE_SENT`, `CLINIC_COMMENT`, `CASE_IMPORTANT` |
|
||||||
| LAB | Tasks | `TASK_COMPLETED`, `LAB_COMMENT`, `TASK_ASSIGNED` (assignee only) |
|
| LAB | Tasks | `CASE_COMPLETED`, `LAB_COMMENT`, `TASK_ASSIGNED` (assignee only) |
|
||||||
| CLINIC | Treatment | `LAB_COMMENT` (only `visibleToClinic`), `TASK_COMPLETED` — **only lab cases for treatments the user provided** |
|
| CLINIC | Treatment | `LAB_COMMENT` (only `visibleToClinic`), `CASE_COMPLETED` — **only lab cases for treatments the user provided** |
|
||||||
|
|
||||||
Counts exclude events where `actorUserId === current user`. Clinic `LAB_COMMENT` counts only when `payload.visibleToClinic === true`.
|
Counts exclude events where `actorUserId === current user`. Clinic `LAB_COMMENT` counts only when `payload.visibleToClinic === true`.
|
||||||
|
|
||||||
@@ -43,7 +43,8 @@ Counts exclude events where `actorUserId === current user`. Clinic `LAB_COMMENT`
|
|||||||
| First send | `treatments.service` `sendLabCase` → `CASE_SENT` |
|
| First send | `treatments.service` `sendLabCase` → `CASE_SENT` |
|
||||||
| Comment | `lab-case-comments.service` → `CLINIC_COMMENT` / `LAB_COMMENT` |
|
| Comment | `lab-case-comments.service` → `CLINIC_COMMENT` / `LAB_COMMENT` |
|
||||||
| Mark important | `cases.service` `updateImportant` (only when set true) → `CASE_IMPORTANT` |
|
| Mark important | `cases.service` `updateImportant` (only when set true) → `CASE_IMPORTANT` |
|
||||||
| Task completed | `tasks.service` `updateStatus` → `TASK_COMPLETED` |
|
| Task completed (step) | `tasks.service` `updateStatus` → `TASK_COMPLETED` **activity only** (case timeline; not inbox or tab badges) |
|
||||||
|
| Case fully completed | `tasks.service` `updateStatus` when no in-progress tasks remain → `CASE_COMPLETED` activity + inbox |
|
||||||
| Task assigned | `cases.service` `assignTask` → `TASK_ASSIGNED` (inbox + Tasks badge for **assignee**, including self-assign) |
|
| Task assigned | `cases.service` `assignTask` → `TASK_ASSIGNED` (inbox + Tasks badge for **assignee**, including self-assign) |
|
||||||
|
|
||||||
After mutations, frontend calls `notifyTabBadgesChanged()` (window event).
|
After mutations, frontend calls `notifyTabBadgesChanged()` (window event).
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ Right-column entry is **not** a three-step wizard. Type dropdown + `TreatmentDet
|
|||||||
| **Treatment** | Type dropdown + `TreatmentDetailAttachmentsStrip`, `FdiToothChart` / `ProsthesisAssignChart`, full-width Notes | Default |
|
| **Treatment** | Type dropdown + `TreatmentDetailAttachmentsStrip`, `FdiToothChart` / `ProsthesisAssignChart`, full-width Notes | Default |
|
||||||
| **Lab** | `LabCasesDispatchPanel` in the chart slot | Lab-dependent type + user clicks **Lab dispatch** (or rail / Go to dispatch). Auto-ensures a shipment draft (teeth/arch not required to create the draft). **No default lab or prosthesis type** on a new detail (including siblings in the same plan). Last **3 sent** labs appear as chips under search — pick is explicit. Comments stay on the dispatch panel. After send, the same case QR as lab Cases is shown (`shareUrl`): dest/jobs share a row with the thumb; tracker + comments are full width below. |
|
| **Lab** | `LabCasesDispatchPanel` in the chart slot | Lab-dependent type + user clicks **Lab dispatch** (or rail / Go to dispatch). Auto-ensures a shipment draft (teeth/arch not required to create the draft). **No default lab or prosthesis type** on a new detail (including siblings in the same plan). Last **3 sent** labs appear as chips under search — pick is explicit. Comments stay on the dispatch panel. After send, the same case QR as lab Cases is shown (`shareUrl`): dest/jobs share a row with the thumb; tracker + comments are full width below. |
|
||||||
|
|
||||||
- Prosthesis types are assigned on the chart (`ProsthesisAssignChart` + `ProsthesisJobPopover` in `prosthesisTree.ts`). The picker is two columns (`4fr` wrapping category grid / `1fr` add-ons) with a vertical `border-e` divider. Category and subcategory tiles (and matching leaves) show SVGs from `src/assets/prosthesis-catalog` (served from `public/prosthesis-catalog`) via `prosthesisCatalogIcons.ts`. Category tiles keep the wrapping `minmax(8rem, 1fr)` grid and stretch to fill the overlay; expanded children use `minmax(10.2rem, 1fr)` with a parent-colored **L** rail sized to the first child card (not a per-card tree). Parent-bar back arrow is black. Child labels stay one line and ellipsize (`…`) when they overflow. **Add detail**, Lab dispatch, and Chart share one control width (`WorkspaceActionLabel` in `TreatmentDetailsEditor`). Indirect children are **Veneer → Inlay → Onlay → Overlay** (same order as the category title). Crown leaves without a dedicated SVG use the monolithic zirconia drawing, not the Crown parent icon. Empty crown suggestion: plus + dashed chip (`addonCrownCanBeAdded`) inside a full-height slot; after a crown is picked the slot stays as the filled type chip. Arch Upper/Lower/Both is a compact `h-8` segmented control at half the tree column width — it **is** the assignment (`retargetArchJobs`): Both→Upper/Lower drops the other jaw; Upper/Lower→Both copies the type onto the empty jaw; Upper↔Lower moves the job. Chart Upper/Lower arch labels are dashed outline buttons with plus (open the picker; control shows current jobs, or the clicked jaw if none). Category parents use the heaviest family pastel; children only lighten. One **restoration** per tooth (crown / veneer-inlay-onlay-overlay). **Screw-retained** is implant (`stackGroup: implant`, paints the crown) and is itself the restoration — no crown suggestion slot, and Crown / Indirect are disabled. Implant or post & core (without a non-crown restoration) shows a **crown** suggestion slot. A veneer/inlay/onlay/overlay hides the suggestion slot. **Post & core** category is visible but disabled when a restoration or implant is on the tooth. **Implant** category is disabled when post & core is on the tooth. Complete denture / overdenture / appliances / digital use **Upper arch / Lower arch** (`UA`/`LA`). **Partial denture** is tooth-level (select FDI teeth, Removable in the tooth picker); after send it is **one lab job** for all those teeth. Picker leaves are filtered by `chartRegion` so Removable appears in both tooth and arch pickers. Prosthesis FDI teeth **must** have jobs — never persist selected teeth without `toothProsthesis` (`pruneDetailTeethToJobs`). Catalog has no `addonKind` — stacking uses `stackGroup` plus the crown suggestion slot.
|
- Prosthesis types are assigned on the chart (`ProsthesisAssignChart` + `ProsthesisJobPopover` in `prosthesisTree.ts`). The picker is two columns (`4fr` wrapping category grid / `1fr` add-ons) with a vertical `border-e` divider. Category and subcategory tiles (and matching leaves) show SVGs from `src/assets/prosthesis-catalog` (served from `public/prosthesis-catalog`) via `prosthesisCatalogIcons.ts`. Production Docker must copy `public/` into the standalone image (see `.cursor/rules/frontend-assets.mdc`). Category tiles keep the wrapping `minmax(8rem, 1fr)` grid and stretch to fill the overlay; expanded children use `minmax(10.2rem, 1fr)` with a parent-colored **L** rail sized to the first child card (not a per-card tree). Parent-bar back arrow is black. Child labels stay one line and ellipsize (`…`) when they overflow. **Add detail**, Lab dispatch, and Chart share one control width (`WorkspaceActionLabel` in `TreatmentDetailsEditor`). Indirect children are **Veneer → Inlay → Onlay → Overlay** (same order as the category title). Crown leaves without a dedicated SVG use the monolithic zirconia drawing, not the Crown parent icon. Empty crown suggestion: plus + dashed chip (`addonCrownCanBeAdded`) inside a full-height slot; after a crown is picked the slot stays as the filled type chip. Arch Upper/Lower/Both is a compact `h-8` segmented control at half the tree column width — it **is** the assignment (`retargetArchJobs`): Both→Upper/Lower drops the other jaw; Upper/Lower→Both copies the type onto the empty jaw; Upper↔Lower moves the job. Chart Upper/Lower arch labels are dashed outline buttons with plus (open the picker; control shows current jobs, or the clicked jaw if none). Category parents use the heaviest family pastel; children only lighten. One **restoration** per tooth (crown / veneer-inlay-onlay-overlay). **Screw-retained** is implant (`stackGroup: implant`, paints the crown) and is itself the restoration — no crown suggestion slot, and Crown / Indirect are disabled. Implant or post & core (without a non-crown restoration) shows a **crown** suggestion slot. A veneer/inlay/onlay/overlay hides the suggestion slot. **Post & core** category is visible but disabled when a restoration or implant is on the tooth. **Implant** category is disabled when post & core is on the tooth. Complete denture / overdenture / appliances / digital use **Upper arch / Lower arch** (`UA`/`LA`). **Partial denture** is tooth-level (select FDI teeth, Removable in the tooth picker); after send it is **one lab job** for all those teeth. Picker leaves are filtered by `chartRegion` so Removable appears in both tooth and arch pickers. Prosthesis FDI teeth **must** have jobs — never persist selected teeth without `toothProsthesis` (`pruneDetailTeethToJobs`). Catalog has no `addonKind` — stacking uses `stackGroup` plus the crown suggestion slot.
|
||||||
- Detail chips show **type + teeth**, not “Detail N”. Lab-dependent chips use colored sent/unsent text (same size as the label); sent date stays on Lab dispatch.
|
- Detail chips show **type + teeth**, not “Detail N”. Lab-dependent chips use colored sent/unsent text (same size as the label); sent date stays on Lab dispatch.
|
||||||
- Detail type may differ from appointment purpose. Purpose seeds the first line of an empty **appointment** draft (first open, and **Add detail** when the plan is `[]`). Later **Add detail** starts with an empty type. Unscheduled / New treatment still seeds a blank first line.
|
- Detail type may differ from appointment purpose. Purpose seeds the first line of an empty **appointment** draft (first open, and **Add detail** when the plan is `[]`). Later **Add detail** starts with an empty type. Unscheduled / New treatment still seeds a blank first line.
|
||||||
- Lab shipments rail / “Go to dispatch” / load-with-focus **opens the dispatch view** in the chart slot (`pendingScrollToLabRef` + `labPanelRef`).
|
- Lab shipments rail / “Go to dispatch” / load-with-focus **opens the dispatch view** in the chart slot (`pendingScrollToLabRef` + `labPanelRef`).
|
||||||
@@ -188,7 +188,7 @@ Use shared `Checkbox` (not native `<input type="checkbox">`) to avoid focus-driv
|
|||||||
|
|
||||||
| `GET /treatments/day?from&to` | Standalone (unscheduled) strip cards |
|
| `GET /treatments/day?from&to` | Standalone (unscheduled) strip cards |
|
||||||
|
|
||||||
| `POST /treatments` | Create standalone `{ patientId?, walkIn?, treatmentAt }` |
|
| `POST /treatments` | Create standalone `{ patientId?, walkIn?, treatmentAt }`. Named `patientId` must be this org (`ensurePatientInOrg`). |
|
||||||
|
|
||||||
| `DELETE /treatments/:id` | Empty standalone only (`appointmentId` null, no detail rows). UI may `PUT` `{ details: [] }` first when the strip looks blank but autosave has not finished. |
|
| `DELETE /treatments/:id` | Empty standalone only (`appointmentId` null, no detail rows). UI may `PUT` `{ details: [] }` first when the strip looks blank but autosave has not finished. |
|
||||||
|
|
||||||
@@ -202,7 +202,7 @@ Use shared `Checkbox` (not native `<input type="checkbox">`) to avoid focus-driv
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
Walk-in uses one sentinel `Patient` per clinic (`isWalkIn`, hidden from Patients/search/booking). Display via i18n, never the stored name. Patient search: same workspace patient with a live visit → no-op; else open today’s strip visit if any; else load latest history into the editor; **no history and no strip visit → do not auto-create**. Detach the previous visit, keep the searched patient, and show an inline editor empty state (`noTreatmentFoundTitle` / `noTreatmentFoundBody`) that points to **New treatment** in the rail (Walk-in, current named patient card, or search).
|
Walk-in uses one sentinel `Patient` per clinic (`isWalkIn`, hidden from Patients/search/booking). Display via i18n, never the stored name. Named patients are this-org only (`createdByOrganizationId`); another clinic’s mobile is `PATIENT_MOBILE_UNAVAILABLE`, not a shared row. Patient search: same workspace patient with a live visit → no-op; else open today’s strip visit if any; else load latest history into the editor; **no history and no strip visit → do not auto-create**. Detach the previous visit, keep the searched patient, and show an inline editor empty state (`noTreatmentFoundTitle` / `noTreatmentFoundBody`) that points to **New treatment** in the rail (Walk-in, current named patient card, or search).
|
||||||
|
|
||||||
Draft writes for appointments require provider match (`ensureAppointmentProvider`). Standalone requires `treatment.providerUserId === actor`.
|
Draft writes for appointments require provider match (`ensureAppointmentProvider`). Standalone requires `treatment.providerUserId === actor`.
|
||||||
|
|
||||||
|
|||||||
15
AGENTS.md
15
AGENTS.md
@@ -58,13 +58,17 @@ frontend/src/
|
|||||||
- Detail treatment type need **not** match appointment purpose — purpose only seeds the **first** line of an empty **appointment** draft (first open, and **Add detail** when the plan is `[]`). Further **Add detail** starts with an empty type. Unscheduled / New treatment still seeds a blank first line.
|
- Detail treatment type need **not** match appointment purpose — purpose only seeds the **first** line of an empty **appointment** draft (first open, and **Add detail** when the plan is `[]`). Further **Add detail** starts with an empty type. Unscheduled / New treatment still seeds a blank first line.
|
||||||
- **History filters** are client-side only (`treatmentHistoryFilters.ts`): “Not shipped to lab” + single date on already-fetched patient history; includes live current draft when filtering.
|
- **History filters** are client-side only (`treatmentHistoryFilters.ts`): “Not shipped to lab” + single date on already-fetched patient history; includes live current draft when filtering.
|
||||||
- **Lab shipments rail**: unified list with scope toggle **This patient** vs **All updates** (unread across org for **this clinician's cases only**, includes patient name). Opening a case from the rail **opens the lab dispatch view** (same slot as the chart).
|
- **Lab shipments rail**: unified list with scope toggle **This patient** vs **All updates** (unread across org for **this clinician's cases only**, includes patient name). Opening a case from the rail **opens the lab dispatch view** (same slot as the chart).
|
||||||
- **Unread semantics**: Treatment tab badge = count of unread cases **for the user's own treatment plans** (per-case read cursor) and clears when a case is opened/marked read (not on tab visit).
|
- **Unread semantics**: Treatment tab badge = count of unread cases **for the user's own treatment plans** (per-case read cursor) and clears when a case is opened/marked read (not on tab visit). Completions count only when **every task in the case is done** (`CASE_COMPLETED`) — not each workflow step.
|
||||||
- **Live lab rail**: `notification.created` → `notifyTabBadgesChanged()` silently refreshes patient lab cases + unread rail (does **not** clear draft/form state).
|
- **Live lab rail**: `notification.created` → `notifyTabBadgesChanged()` silently refreshes patient lab cases + unread rail (does **not** clear draft/form state).
|
||||||
- **Lab shipment progress + comments**: shown in **Lab dispatch panel** for the active shipment; expanding activity / opening comments marks that case read. Shared UI: `LabCaseCommentsPanel` — newest first; sent = start / received = end (`text-start`/`justify-start`, RTL-safe); pass `viewerSide`.
|
- **Lab shipment progress + comments**: shown in **Lab dispatch panel** for the active shipment; expanding activity / opening comments marks that case read. Shared UI: `LabCaseCommentsPanel` — newest first; sent = start / received = end (`text-start`/`justify-start`, RTL-safe); pass `viewerSide`.
|
||||||
|
|
||||||
**Appointments (quick ref):** Do not delete (or change patient) when `hasTreatment`; codes `APPOINTMENT_HAS_TREATMENT` / `APPOINTMENT_PATIENT_LOCKED`. Past days: no new bookings; edit/delete OK without treatment; with treatment → toast. Appointment delete does not cascade-delete treatments. Working hours: client IANA `timeZone` on create/update — never `Date#getHours()`/`getDay()` on the UTC server. Logical API errors: `AppException` + `errors.*` (never Nest English throws). See `.cursor/rules/appointments.mdc`, `.cursor/skills/api-errors/SKILL.md`.
|
**Appointments (quick ref):** Do not delete (or change patient) when `hasTreatment`; codes `APPOINTMENT_HAS_TREATMENT` / `APPOINTMENT_PATIENT_LOCKED`. Past days: no new bookings; edit/delete OK without treatment; with treatment → toast. Appointment delete does not cascade-delete treatments. Working hours: client IANA `timeZone` on create/update — never `Date#getHours()`/`getDay()` on the UTC server. Logical API errors: `AppException` + `errors.*` (never Nest English throws). Patient must belong to this org (`ensurePatientInOrg`). See `.cursor/rules/appointments.mdc`, `.cursor/skills/api-errors/SKILL.md`.
|
||||||
|
|
||||||
**Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; filter by case source (`origin`: received vs generated); prosthesis colors from catalog; job titles show the picker path to the leaf (`prosthesisJobPath.ts`); task assignment in **Cases** (compact row: status + assignee + last update); on **Tasks**, all staff see every task but only assignee (or unassigned pool) can change status — others see “Assigned to {name}” instead of the status dropdown; **case due dates** set/edited in clinic Treatment lab dispatch, shown on lab Cases/Tasks with overdue filter + sort; completing **`intraoral_scan`** completes every scan task in that case (case-scoped; catalog first step for all prosthesis types); **mobile:** larger task status controls, sticky case header when grouped; **tab badges:** `LabCaseActivity` + `GET /notifications/tab-counts` (lab Cases/Tasks split, clinic Treatment) — live via inbox Socket.IO → `notifyTabBadgesChanged()` + soft list refresh — see `.cursor/skills/lab-tasks/SKILL.md`, `.cursor/skills/tab-badges/SKILL.md`, `.cursor/skills/notifications-inbox/SKILL.md`.
|
**Patients (quick ref):** List/get/update/create are this-org named patients (`createdByOrganizationId`, `isWalkIn: false`). Mobile stays globally unique. Same-org mobile create returns `existing: true`; other org / walk-in / null creator → `PATIENT_MOBILE_UNAVAILABLE` (409, no leak). See `.cursor/rules/patients.mdc`.
|
||||||
|
|
||||||
|
**Staff (quick ref):** Owner / `TAB_STAFF_EDIT` can **remove** a password (`POST /staff/members/:id/clear-password`) and copy a setup link — never set one for someone else. Login page unchanged (`passwordHash: null` cannot sign in). `/accept-invite` `password_setup` is password-only. See `.cursor/rules/staff.mdc`.
|
||||||
|
|
||||||
|
**Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; filter by case source (`origin`: received vs generated); prosthesis colors from catalog; job titles show the picker path to the leaf (`prosthesisJobPath.ts`); task assignment in **Cases** (compact row: status + assignee + last update); on **Tasks**, all staff see every task but only assignee (or unassigned pool) can change status — others see “Assigned to {name}” instead of the status dropdown; **case due dates** set/edited in clinic Treatment lab dispatch, shown on lab Cases/Tasks with overdue filter + sort; completing **`intraoral_scan`** completes every scan task in that case (case-scoped; catalog first step for all prosthesis types); **mobile:** larger task status controls, sticky case header when grouped; **tab badges / inbox:** Tasks + Treatment badges and the header bell increment on **`CASE_COMPLETED`** (all tasks in the case done), not each step — the case timeline still lists `TASK_COMPLETED` per step; live via inbox Socket.IO → `notifyTabBadgesChanged()` + soft list refresh — see `.cursor/skills/lab-tasks/SKILL.md`, `.cursor/skills/tab-badges/SKILL.md`, `.cursor/skills/notifications-inbox/SKILL.md`.
|
||||||
|
|
||||||
**Lab Cases tab:** Filter by **prosthesis type** (not treatment type); auto-select newest case on open; list **10 per page**; left rail list fills column height (`flex-1 overflow-y-auto`); list cards use `LabCaseProsthesisGroupsList` (colored type + teeth, shared with Treatment rail) plus **Received** / **Generated** origin badges. Job titles show the picker path to the **leaf** (`prosthesisJobPath.ts` — e.g. Crowns · PFM Crown, not “Crowns”; indirect is Inlay · Layered ceramic, not the long Veneer/Inlay/Onlay/Overlay parent). Deep link: `?caseId=`, `?clinicOrganizationId=`. **Share link:** QR + URL on sent cases (attachment left, QR right); opens `/lab-case/[token]` focus page. **Case Sheet PDF:** client A4 (`jspdf`/`html2canvas`); hex-only print layout; optional `externalCode` replaces order number. Lab-origin Start has no clinic send; comments and mark-read still work (no clinic-visibility toggle). Unstarted generated drafts can be deleted (`DELETE /cases/:id`). **Live:** inbox Socket.IO → `notifyTabBadgesChanged()` soft-refreshes list + selected detail (no remount). See `.cursor/skills/lab-cases/SKILL.md` and `.cursor/skills/lab-case-share-link/SKILL.md`.
|
**Lab Cases tab:** Filter by **prosthesis type** (not treatment type); auto-select newest case on open; list **10 per page**; left rail list fills column height (`flex-1 overflow-y-auto`); list cards use `LabCaseProsthesisGroupsList` (colored type + teeth, shared with Treatment rail) plus **Received** / **Generated** origin badges. Job titles show the picker path to the **leaf** (`prosthesisJobPath.ts` — e.g. Crowns · PFM Crown, not “Crowns”; indirect is Inlay · Layered ceramic, not the long Veneer/Inlay/Onlay/Overlay parent). Deep link: `?caseId=`, `?clinicOrganizationId=`. **Share link:** QR + URL on sent cases (attachment left, QR right); opens `/lab-case/[token]` focus page. **Case Sheet PDF:** client A4 (`jspdf`/`html2canvas`); hex-only print layout; optional `externalCode` replaces order number. Lab-origin Start has no clinic send; comments and mark-read still work (no clinic-visibility toggle). Unstarted generated drafts can be deleted (`DELETE /cases/:id`). **Live:** inbox Socket.IO → `notifyTabBadgesChanged()` soft-refreshes list + selected detail (no remount). See `.cursor/skills/lab-cases/SKILL.md` and `.cursor/skills/lab-case-share-link/SKILL.md`.
|
||||||
|
|
||||||
@@ -72,7 +76,7 @@ frontend/src/
|
|||||||
- Token on first ship → `/{locale}/lab-case/{token}` after login.
|
- Token on first ship → `/{locale}/lab-case/{token}` after login.
|
||||||
- **Lab:** view/edit tasks (assignee rules), comments + visibility toggle.
|
- **Lab:** view/edit tasks (assignee rules), comments + visibility toggle.
|
||||||
- **Clinic:** treatment **provider** with `TAB_TREATMENT_EDIT` — read-only tasks, can comment. Same QR as lab Cases appears on Treatment **Lab dispatch** after send (dest/jobs beside the thumb; tracker + comments full width below).
|
- **Clinic:** treatment **provider** with `TAB_TREATMENT_EDIT` — read-only tasks, can comment. Same QR as lab Cases appears on Treatment **Lab dispatch** after send (dest/jobs beside the thumb; tracker + comments full width below).
|
||||||
- Logged out → login with `?from=` → `storeAuthRedirectFromPath` then `useEnterAppWhenAuthenticated` (`consumeAuthRedirect` once after org ready — not inside `useAuth.login()` / `registerTrial`). Trial register uses the same hook; staff/org invite accept then `login()` + `navigateIntoAppIfOrgSelected`. See `.cursor/rules/post-auth-navigation.mdc`.
|
- Logged out → login with `?from=` → `storeAuthRedirectFromPath` then `useEnterAppWhenAuthenticated` (`consumeAuthRedirect` once after org ready — not inside `useAuth.login()` / `registerTrial`). Trial register uses the same hook; staff/org invite (including `password_setup`) then `login()` + `navigateIntoAppIfOrgSelected`. See `.cursor/rules/post-auth-navigation.mdc`.
|
||||||
|
|
||||||
**Today dashboard:** KPIs + charts per org type/permissions; deep links via `today-deep-links.ts` (Tasks KPIs/charts, Staff highlight, case partners). See `.cursor/skills/today-dashboard/SKILL.md`.
|
**Today dashboard:** KPIs + charts per org type/permissions; deep links via `today-deep-links.ts` (Tasks KPIs/charts, Staff highlight, case partners). See `.cursor/skills/today-dashboard/SKILL.md`.
|
||||||
|
|
||||||
@@ -82,11 +86,14 @@ frontend/src/
|
|||||||
backend/src/
|
backend/src/
|
||||||
modules/{feature}/ → controller, service, dto, module
|
modules/{feature}/ → controller, service, dto, module
|
||||||
common/ → guards, permissions, errors, utils
|
common/ → guards, permissions, errors, utils
|
||||||
|
admin/ → AdminJS `/admin` panel (curated Prisma resources)
|
||||||
prisma/ → schema, migrations, seed
|
prisma/ → schema, migrations, seed
|
||||||
```
|
```
|
||||||
|
|
||||||
Errors: `AppException` + `ErrorCode` → frontend `getUserFacingError()`. Unexpected 500s: GlitchTip (`SENTRY_DSN`). Never throw raw strings for user-facing failures.
|
Errors: `AppException` + `ErrorCode` → frontend `getUserFacingError()`. Unexpected 500s: GlitchTip (`SENTRY_DSN`). Never throw raw strings for user-facing failures.
|
||||||
|
|
||||||
|
**AdminJS:** Manual resource allowlist in `backend/src/admin/resources.ts`. **Whenever `schema.prisma` changes**, update AdminJS resources in the same task (new/renamed/removed models, hide secrets). Models with only composite `@@id` must not be registered (list 500). Rule: `.cursor/rules/adminjs.mdc`.
|
||||||
|
|
||||||
## Git & commits
|
## Git & commits
|
||||||
|
|
||||||
- **Do not commit or push** unless the user explicitly asks.
|
- **Do not commit or push** unless the user explicitly asks.
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ Clinics may only dispatch to labs they are linked to: `OrganizationLink` (A↔B,
|
|||||||
### Layout conventions worth knowing before you create a file
|
### Layout conventions worth knowing before you create a file
|
||||||
|
|
||||||
- **Prisma lives outside `src/`**: `backend/prisma/` holds `schema.prisma`, migrations, seeds *and* `prisma.module.ts` / `prisma.service.ts` — hence imports like `../../../prisma/prisma.service`. Register new Nest modules in `app.module.ts`.
|
- **Prisma lives outside `src/`**: `backend/prisma/` holds `schema.prisma`, migrations, seeds *and* `prisma.module.ts` / `prisma.service.ts` — hence imports like `../../../prisma/prisma.service`. Register new Nest modules in `app.module.ts`.
|
||||||
|
- **AdminJS (`/admin`)** resources are a manual allowlist in `backend/src/admin/resources.ts` — update them whenever `schema.prisma` changes (see `.cursor/rules/adminjs.mdc`).
|
||||||
- **Frontend layering** (`.cursor/rules/frontend-components.mdc`): `app/**/page.tsx` is a thin wrapper only → route logic in `components/ui/{feature}/{Feature}Page.tsx` → JSX in `components/ui/**` → pure helpers in `components/{feature}/` or `components/shared/`. No JSX outside `ui/`, no pure helpers inside it.
|
- **Frontend layering** (`.cursor/rules/frontend-components.mdc`): `app/**/page.tsx` is a thin wrapper only → route logic in `components/ui/{feature}/{Feature}Page.tsx` → JSX in `components/ui/**` → pure helpers in `components/{feature}/` or `components/shared/`. No JSX outside `ui/`, no pure helpers inside it.
|
||||||
- **i18n is mandatory, not a follow-up**: every user-visible string goes into `en.json`, `fa.json`, **and** `nl.json`. `fa` is RTL, so use logical `text-start`/`text-end`, never `text-left`/`text-right`. Dates/times/numbers go through `lib/i18n/format.ts`; form dates use `AppDateInput`, never a native date input.
|
- **i18n is mandatory, not a follow-up**: every user-visible string goes into `en.json`, `fa.json`, **and** `nl.json`. `fa` is RTL, so use logical `text-start`/`text-end`, never `text-left`/`text-right`. Dates/times/numbers go through `lib/i18n/format.ts`; form dates use `AppDateInput`, never a native date input.
|
||||||
- Treatment attachments are written to disk at `backend/uploads/treatments` relative to `process.cwd()`.
|
- Treatment attachments are written to disk at `backend/uploads/treatments` relative to `process.cwd()`.
|
||||||
@@ -106,4 +107,4 @@ Jest covers pure logic only — permission normalization, phone/timezone helpers
|
|||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
Images are built on a dev machine and pulled by the server; Compose files and scripts are in `infrastructure/` (`docker-compose.{prod,staging,registry}.yml`). Full guide: `infrastructure/DEPLOY.md`. Root `README.md` covers the Docker Hub + Let's Encrypt path and the Gitea registry path. Frontend `NEXT_PUBLIC_*` are **build args** — changing the public domain requires rebuilding the frontend image.
|
Images are built on a dev machine and pulled by the server; Compose files and scripts are in `infrastructure/` (`docker-compose.{prod,staging,registry}.yml`). Full guide: `infrastructure/DEPLOY.md`. Root `README.md` covers the Docker Hub + Let's Encrypt path and the Gitea registry path. Frontend `NEXT_PUBLIC_*` are **build args** — changing the public domain requires rebuilding the frontend image. Next `output: 'standalone'` does **not** include `public/`; `frontend/Dockerfile` copies `/app/public` next to `server.js` (catalog icons at `/prosthesis-catalog/*.svg`). Production tags are immutable — CI clones `--branch $tag`; cut a new `v*` instead of moving an existing tag.
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ Workflow: [`.gitea/workflows/registry-build-deploy.yml`](.gitea/workflows/regist
|
|||||||
| Path | Role |
|
| Path | Role |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `backend/Dockerfile` | API image |
|
| `backend/Dockerfile` | API image |
|
||||||
| `frontend/Dockerfile` | Web image |
|
| `frontend/Dockerfile` | Web image (`standalone` + copy `public/` for catalog icons) |
|
||||||
| `infrastructure/STAGING-DEPLOY.md` | Staging setup, CI variables, testing |
|
| `infrastructure/STAGING-DEPLOY.md` | Staging setup, CI variables, testing |
|
||||||
| `infrastructure/docker-compose.registry.yml` | Pull-only staging stack (registry images + nginx + postgres) |
|
| `infrastructure/docker-compose.registry.yml` | Pull-only staging stack (registry images + nginx + postgres) |
|
||||||
| `infrastructure/deploy.registry.env.example` | Template for `deploy.registry.env` |
|
| `infrastructure/deploy.registry.env.example` | Template for `deploy.registry.env` |
|
||||||
|
|||||||
@@ -53,7 +53,11 @@ COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
|||||||
# Windows git/build context may use CRLF; strip before chmod (fixes dumb-init "No such file or directory").
|
# Windows git/build context may use CRLF; strip before chmod (fixes dumb-init "No such file or directory").
|
||||||
RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh && chmod +x /usr/local/bin/docker-entrypoint.sh
|
RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh && chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
RUN mkdir -p /app/logs && \
|
# AdminJS writes/serves the custom-components bundle here. Avoid the default
|
||||||
|
# ".adminjs" path — Express sendFile + nosniff can 500 on dot-directories.
|
||||||
|
ENV ADMIN_JS_TMP_DIR=/app/adminjs-tmp
|
||||||
|
|
||||||
|
RUN mkdir -p /app/logs /app/adminjs-tmp && \
|
||||||
chown -R dyolink:nodejs /app
|
chown -R dyolink:nodejs /app
|
||||||
|
|
||||||
USER dyolink
|
USER dyolink
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "LabCaseActivityType" ADD VALUE 'CASE_COMPLETED';
|
||||||
|
|
||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "UserNotificationType" ADD VALUE 'CASE_COMPLETED';
|
||||||
|
|
||||||
|
-- Step-level TASK_COMPLETED inbox rows were spam; case-complete uses CASE_COMPLETED going forward.
|
||||||
|
DELETE FROM "UserNotification" WHERE type = 'TASK_COMPLETED';
|
||||||
@@ -492,6 +492,7 @@ enum LabCaseActivityType {
|
|||||||
CASE_AMENDED
|
CASE_AMENDED
|
||||||
TASK_COMPLETED
|
TASK_COMPLETED
|
||||||
TASK_ASSIGNED
|
TASK_ASSIGNED
|
||||||
|
CASE_COMPLETED
|
||||||
}
|
}
|
||||||
|
|
||||||
enum LabCaseTabReadTarget {
|
enum LabCaseTabReadTarget {
|
||||||
@@ -508,6 +509,7 @@ enum UserNotificationType {
|
|||||||
CASE_IMPORTANT
|
CASE_IMPORTANT
|
||||||
TASK_COMPLETED
|
TASK_COMPLETED
|
||||||
TASK_ASSIGNED
|
TASK_ASSIGNED
|
||||||
|
CASE_COMPLETED
|
||||||
CONNECTION_REQUEST
|
CONNECTION_REQUEST
|
||||||
STAFF_INVITE
|
STAFF_INVITE
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
// backend/src/admin/admin.module.ts
|
|
||||||
import { DynamicModule, Module } from '@nestjs/common';
|
import { DynamicModule, Module } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { componentLoader, Components } from './components';
|
import { componentLoader, Components } from './components';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { Database, Resource, getModelByName } from '@adminjs/prisma'; // 👈 Add getModelByName
|
import { Database, Resource } from '@adminjs/prisma';
|
||||||
import AdminJS from 'adminjs';
|
import AdminJS from 'adminjs';
|
||||||
|
import { buildAdminResources } from './resources';
|
||||||
|
|
||||||
// Register the adapter
|
|
||||||
AdminJS.registerAdapter({ Database, Resource });
|
AdminJS.registerAdapter({ Database, Resource });
|
||||||
|
|
||||||
|
const LOCAL_DEFAULT_ADMIN_PASSWORD = 'admin123';
|
||||||
|
const LOCAL_DEFAULT_ADMIN_EMAIL = 'admin@dyolink.com';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
})
|
})
|
||||||
@@ -17,10 +19,24 @@ export class AdminModule {
|
|||||||
const { AdminModule: AdminJSModule } = await import('@adminjs/nestjs');
|
const { AdminModule: AdminJSModule } = await import('@adminjs/nestjs');
|
||||||
|
|
||||||
const authenticate = async (email: string, password: string) => {
|
const authenticate = async (email: string, password: string) => {
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
const adminEmail =
|
const adminEmail =
|
||||||
process.env.ADMINJS_EMAIL?.trim() || 'admin@dyolink.com';
|
process.env.ADMINJS_EMAIL?.trim() || LOCAL_DEFAULT_ADMIN_EMAIL;
|
||||||
const adminPassword = process.env.ADMINJS_PASSWORD || 'admin123';
|
const adminPassword = process.env.ADMINJS_PASSWORD;
|
||||||
if (email === adminEmail && password === adminPassword) {
|
|
||||||
|
if (isProduction) {
|
||||||
|
if (
|
||||||
|
!adminPassword ||
|
||||||
|
adminPassword === LOCAL_DEFAULT_ADMIN_PASSWORD
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectivePassword =
|
||||||
|
adminPassword || LOCAL_DEFAULT_ADMIN_PASSWORD;
|
||||||
|
|
||||||
|
if (email === adminEmail && password === effectivePassword) {
|
||||||
return { email, role: 'admin' };
|
return { email, role: 'admin' };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -38,68 +54,54 @@ export class AdminModule {
|
|||||||
config.get<string>('jwt.secret') ||
|
config.get<string>('jwt.secret') ||
|
||||||
config.get('JWT_SECRET') ||
|
config.get('JWT_SECRET') ||
|
||||||
'secret-key-change-this';
|
'secret-key-change-this';
|
||||||
if (
|
|
||||||
process.env.NODE_ENV === 'production' &&
|
if (process.env.NODE_ENV === 'production') {
|
||||||
!process.env.ADMINJS_PASSWORD
|
const adminPassword = process.env.ADMINJS_PASSWORD;
|
||||||
) {
|
if (
|
||||||
console.warn(
|
!adminPassword ||
|
||||||
'⚠️ ADMINJS_PASSWORD is unset; AdminJS is using the local default. Set it in backend.env.',
|
adminPassword === LOCAL_DEFAULT_ADMIN_PASSWORD
|
||||||
);
|
) {
|
||||||
|
console.error(
|
||||||
|
'❌ AdminJS: ADMINJS_PASSWORD is missing or still the local default. Login is disabled until you set a strong password in backend.env.',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
adminJsOptions: {
|
adminJsOptions: {
|
||||||
rootPath: '/admin',
|
rootPath: '/admin',
|
||||||
resources: [
|
resources: buildAdminResources(prisma),
|
||||||
// ✅ Use getModelByName helper
|
|
||||||
{
|
|
||||||
resource: {
|
|
||||||
model: getModelByName('User'),
|
|
||||||
client: prisma,
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
properties: {
|
|
||||||
passwordHash: { isVisible: false },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
resource: {
|
|
||||||
model: getModelByName('Organization'),
|
|
||||||
client: prisma,
|
|
||||||
},
|
|
||||||
options: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
resource: {
|
|
||||||
model: getModelByName('OrganizationType'),
|
|
||||||
client: prisma,
|
|
||||||
},
|
|
||||||
options: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
resource: {
|
|
||||||
model: getModelByName('Plan'),
|
|
||||||
client: prisma,
|
|
||||||
},
|
|
||||||
options: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
resource: {
|
|
||||||
model: getModelByName('Membership'),
|
|
||||||
client: prisma,
|
|
||||||
},
|
|
||||||
options: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
resource: {
|
|
||||||
model: getModelByName('Session'),
|
|
||||||
client: prisma,
|
|
||||||
},
|
|
||||||
options: {},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
componentLoader,
|
componentLoader,
|
||||||
dashboard: { component: Components.Dashboard },
|
dashboard: {
|
||||||
|
component: Components.Dashboard,
|
||||||
|
handler: async () => {
|
||||||
|
const [clinicType, labType] = await Promise.all([
|
||||||
|
prisma.organizationType.findUnique({
|
||||||
|
where: { name: 'CLINIC' },
|
||||||
|
}),
|
||||||
|
prisma.organizationType.findUnique({
|
||||||
|
where: { name: 'LAB' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [clinics, labs, users, labCases] = await Promise.all([
|
||||||
|
clinicType
|
||||||
|
? prisma.organization.count({
|
||||||
|
where: { typeId: clinicType.id },
|
||||||
|
})
|
||||||
|
: Promise.resolve(0),
|
||||||
|
labType
|
||||||
|
? prisma.organization.count({
|
||||||
|
where: { typeId: labType.id },
|
||||||
|
})
|
||||||
|
: Promise.resolve(0),
|
||||||
|
prisma.user.count(),
|
||||||
|
prisma.labCase.count(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { clinics, labs, users, labCases };
|
||||||
|
},
|
||||||
|
},
|
||||||
branding: {
|
branding: {
|
||||||
companyName: 'DyoLink Admin',
|
companyName: 'DyoLink Admin',
|
||||||
logo: false,
|
logo: false,
|
||||||
@@ -127,4 +129,4 @@ export class AdminModule {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ const componentLoader = new ComponentLoader();
|
|||||||
|
|
||||||
const Components = {
|
const Components = {
|
||||||
Dashboard: componentLoader.add('Dashboard', './dashboard'),
|
Dashboard: componentLoader.add('Dashboard', './dashboard'),
|
||||||
// You can add more components here as needed
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export { componentLoader, Components };
|
export { componentLoader, Components };
|
||||||
@@ -1,32 +1,81 @@
|
|||||||
// backend/src/admin/dashboard-simple.tsx
|
|
||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Box, H2, Text, Badge } from '@adminjs/design-system';
|
import { Box, H2, Text } from '@adminjs/design-system';
|
||||||
|
import { ApiClient } from 'adminjs';
|
||||||
|
|
||||||
|
type DashboardStats = {
|
||||||
|
clinics: number;
|
||||||
|
labs: number;
|
||||||
|
users: number;
|
||||||
|
labCases: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const StatCard = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value?: number;
|
||||||
|
loading: boolean;
|
||||||
|
}) => (
|
||||||
|
<Box p="lg" bg="primary20" style={{ flex: 1, minWidth: '140px' }}>
|
||||||
|
<Text>{label}</Text>
|
||||||
|
<Box mt="default" style={{ fontSize: '2rem', fontWeight: 'bold' }}>
|
||||||
|
{loading ? '…' : (value ?? '—')}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
|
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const api = new ApiClient();
|
||||||
|
api
|
||||||
|
.getDashboard()
|
||||||
|
.then((response) => {
|
||||||
|
setStats(response.data as DashboardStats);
|
||||||
|
setError(null);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setError('Could not load dashboard stats.');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box variant="grey">
|
<Box variant="grey">
|
||||||
<Box variant="white" p="xl">
|
<Box variant="white" p="xl">
|
||||||
<H2>Welcome to DyoLink Admin Panel</H2>
|
<H2>DyoLink Admin</H2>
|
||||||
<Text>Manage your dental clinics, labs, users, and subscriptions.</Text>
|
<Text>Manage clinics, labs, users, and production data.</Text>
|
||||||
|
|
||||||
<Box mt="xl" style={{ display: 'flex', gap: '20px' }}>
|
{error ? (
|
||||||
<Box p="lg" bg="primary20" style={{ flex: 1 }}>
|
<Box mt="xl">
|
||||||
<div style={{ fontSize: '1.5rem' }}>🏥 Clinics</div>
|
<Text>{error}</Text>
|
||||||
<div style={{ fontSize: '2rem', fontWeight: 'bold' }}>12</div>
|
|
||||||
</Box>
|
</Box>
|
||||||
<Box p="lg" bg="secondary20" style={{ flex: 1 }}>
|
) : (
|
||||||
<div style={{ fontSize: '1.5rem' }}>🔬 Labs</div>
|
<Box
|
||||||
<div style={{ fontSize: '2rem', fontWeight: 'bold' }}>8</div>
|
mt="xl"
|
||||||
|
style={{ display: 'flex', gap: '20px', flexWrap: 'wrap' }}
|
||||||
|
>
|
||||||
|
<StatCard label="Clinics" value={stats?.clinics} loading={loading} />
|
||||||
|
<StatCard label="Labs" value={stats?.labs} loading={loading} />
|
||||||
|
<StatCard label="Users" value={stats?.users} loading={loading} />
|
||||||
|
<StatCard
|
||||||
|
label="Lab cases"
|
||||||
|
value={stats?.labCases}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box p="lg" bg="info20" style={{ flex: 1 }}>
|
)}
|
||||||
<div style={{ fontSize: '1.5rem' }}>👥 Users</div>
|
|
||||||
<div style={{ fontSize: '2rem', fontWeight: 'bold' }}>45</div>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Dashboard;
|
export default Dashboard;
|
||||||
|
|||||||
166
backend/src/admin/resources.ts
Normal file
166
backend/src/admin/resources.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { getModelByName } from '@adminjs/prisma';
|
||||||
|
import type { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
|
||||||
|
type ResourceOptions = {
|
||||||
|
navigation?: { name: string; icon?: string };
|
||||||
|
properties?: Record<string, { isVisible?: boolean | { list?: boolean; filter?: boolean; show?: boolean; edit?: boolean } }>;
|
||||||
|
actions?: Record<
|
||||||
|
string,
|
||||||
|
{ isAccessible?: boolean }
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AdminResource = {
|
||||||
|
resource: { model: ReturnType<typeof getModelByName>; client: PrismaService };
|
||||||
|
options: ResourceOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hide = (...propertyNames: string[]): ResourceOptions['properties'] =>
|
||||||
|
Object.fromEntries(
|
||||||
|
propertyNames.map((name) => [
|
||||||
|
name,
|
||||||
|
{ isVisible: { list: false, filter: false, show: false, edit: false } },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const catalogActions: ResourceOptions['actions'] = {
|
||||||
|
new: { isAccessible: false },
|
||||||
|
delete: { isAccessible: false },
|
||||||
|
bulkDelete: { isAccessible: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
function resource(
|
||||||
|
client: PrismaService,
|
||||||
|
modelName: string,
|
||||||
|
options: ResourceOptions = {},
|
||||||
|
): AdminResource {
|
||||||
|
return {
|
||||||
|
resource: {
|
||||||
|
model: getModelByName(modelName),
|
||||||
|
client,
|
||||||
|
},
|
||||||
|
options,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Curated AdminJS allowlist — keep in sync when adding ops-relevant Prisma models. */
|
||||||
|
export function buildAdminResources(prisma: PrismaService): AdminResource[] {
|
||||||
|
return [
|
||||||
|
// Identity
|
||||||
|
resource(prisma, 'User', {
|
||||||
|
navigation: { name: 'Identity', icon: 'User' },
|
||||||
|
properties: hide('passwordHash'),
|
||||||
|
}),
|
||||||
|
resource(prisma, 'Session', {
|
||||||
|
navigation: { name: 'Identity', icon: 'User' },
|
||||||
|
properties: hide('token', 'refreshToken'),
|
||||||
|
}),
|
||||||
|
resource(prisma, 'PhoneVerificationCode', {
|
||||||
|
navigation: { name: 'Identity', icon: 'User' },
|
||||||
|
properties: hide('codeHash'),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Orgs & access
|
||||||
|
resource(prisma, 'Organization', {
|
||||||
|
navigation: { name: 'Orgs & access', icon: 'Settings' },
|
||||||
|
}),
|
||||||
|
resource(prisma, 'OrganizationType', {
|
||||||
|
navigation: { name: 'Orgs & access', icon: 'Settings' },
|
||||||
|
}),
|
||||||
|
resource(prisma, 'Plan', {
|
||||||
|
navigation: { name: 'Orgs & access', icon: 'Settings' },
|
||||||
|
}),
|
||||||
|
resource(prisma, 'Membership', {
|
||||||
|
navigation: { name: 'Orgs & access', icon: 'Settings' },
|
||||||
|
}),
|
||||||
|
resource(prisma, 'Permission', {
|
||||||
|
navigation: { name: 'Orgs & access', icon: 'Settings' },
|
||||||
|
}),
|
||||||
|
// MembershipPermission omitted: composite @@id — AdminJS list 500s without a single id
|
||||||
|
resource(prisma, 'Feature', {
|
||||||
|
navigation: { name: 'Orgs & access', icon: 'Settings' },
|
||||||
|
}),
|
||||||
|
resource(prisma, 'StaffInvitation', {
|
||||||
|
navigation: { name: 'Orgs & access', icon: 'Settings' },
|
||||||
|
properties: hide('tokenHash'),
|
||||||
|
}),
|
||||||
|
resource(prisma, 'OrganizationLink', {
|
||||||
|
navigation: { name: 'Orgs & access', icon: 'Settings' },
|
||||||
|
}),
|
||||||
|
resource(prisma, 'OrganizationInvitation', {
|
||||||
|
navigation: { name: 'Orgs & access', icon: 'Settings' },
|
||||||
|
properties: hide('tokenHash'),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Clinic
|
||||||
|
resource(prisma, 'Patient', {
|
||||||
|
navigation: { name: 'Clinic', icon: 'Healthcare' },
|
||||||
|
}),
|
||||||
|
resource(prisma, 'Appointment', {
|
||||||
|
navigation: { name: 'Clinic', icon: 'Healthcare' },
|
||||||
|
}),
|
||||||
|
resource(prisma, 'Treatment', {
|
||||||
|
navigation: { name: 'Clinic', icon: 'Healthcare' },
|
||||||
|
}),
|
||||||
|
resource(prisma, 'TreatmentDetail', {
|
||||||
|
navigation: { name: 'Clinic', icon: 'Healthcare' },
|
||||||
|
}),
|
||||||
|
resource(prisma, 'TreatmentDetailAttachment', {
|
||||||
|
navigation: { name: 'Clinic', icon: 'Healthcare' },
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Lab
|
||||||
|
resource(prisma, 'LabCase', {
|
||||||
|
navigation: { name: 'Lab', icon: 'Archive' },
|
||||||
|
properties: hide('accessToken'),
|
||||||
|
}),
|
||||||
|
resource(prisma, 'LabCaseLine', {
|
||||||
|
navigation: { name: 'Lab', icon: 'Archive' },
|
||||||
|
}),
|
||||||
|
// LabCaseDetail omitted: composite @@id — AdminJS list 500s without a single id
|
||||||
|
// (inspect LabCase + TreatmentDetail instead)
|
||||||
|
resource(prisma, 'LabCaseSend', {
|
||||||
|
navigation: { name: 'Lab', icon: 'Archive' },
|
||||||
|
}),
|
||||||
|
resource(prisma, 'LabCaseToothProsthesis', {
|
||||||
|
navigation: { name: 'Lab', icon: 'Archive' },
|
||||||
|
}),
|
||||||
|
resource(prisma, 'LabCaseTask', {
|
||||||
|
navigation: { name: 'Lab', icon: 'Archive' },
|
||||||
|
}),
|
||||||
|
resource(prisma, 'LabCaseTaskStatusEvent', {
|
||||||
|
navigation: { name: 'Lab', icon: 'Archive' },
|
||||||
|
}),
|
||||||
|
resource(prisma, 'LabCaseComment', {
|
||||||
|
navigation: { name: 'Lab', icon: 'Archive' },
|
||||||
|
}),
|
||||||
|
resource(prisma, 'LabCaseActivity', {
|
||||||
|
navigation: { name: 'Lab', icon: 'Archive' },
|
||||||
|
}),
|
||||||
|
resource(prisma, 'UserNotification', {
|
||||||
|
navigation: { name: 'Lab', icon: 'Archive' },
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Catalog — edit OK; create/delete via seed/migrations
|
||||||
|
resource(prisma, 'TreatmentType', {
|
||||||
|
navigation: { name: 'Catalog', icon: 'Catalog' },
|
||||||
|
actions: catalogActions,
|
||||||
|
}),
|
||||||
|
resource(prisma, 'ProsthesisType', {
|
||||||
|
navigation: { name: 'Catalog', icon: 'Catalog' },
|
||||||
|
actions: catalogActions,
|
||||||
|
}),
|
||||||
|
resource(prisma, 'LabWorkflowStep', {
|
||||||
|
navigation: { name: 'Catalog', icon: 'Catalog' },
|
||||||
|
actions: catalogActions,
|
||||||
|
}),
|
||||||
|
resource(prisma, 'ProsthesisTypeStep', {
|
||||||
|
navigation: { name: 'Catalog', icon: 'Catalog' },
|
||||||
|
actions: catalogActions,
|
||||||
|
}),
|
||||||
|
resource(prisma, 'CatalogTranslation', {
|
||||||
|
navigation: { name: 'Catalog', icon: 'Catalog' },
|
||||||
|
actions: catalogActions,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -7,15 +7,15 @@ export const LAB_CASES_TAB_ACTIVITY_TYPES: LabCaseActivityType[] = [
|
|||||||
LabCaseActivityType.CASE_IMPORTANT,
|
LabCaseActivityType.CASE_IMPORTANT,
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Lab Tasks tab — task completions, lab-side comments, and assignments (assignee-scoped in counts). */
|
/** Lab Tasks tab — case fully completed, lab-side comments, and assignments (assignee-scoped in counts). */
|
||||||
export const LAB_TASKS_TAB_ACTIVITY_TYPES: LabCaseActivityType[] = [
|
export const LAB_TASKS_TAB_ACTIVITY_TYPES: LabCaseActivityType[] = [
|
||||||
LabCaseActivityType.TASK_COMPLETED,
|
LabCaseActivityType.CASE_COMPLETED,
|
||||||
LabCaseActivityType.LAB_COMMENT,
|
LabCaseActivityType.LAB_COMMENT,
|
||||||
LabCaseActivityType.TASK_ASSIGNED,
|
LabCaseActivityType.TASK_ASSIGNED,
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Clinic Treatment tab — visible lab comments and task progress. */
|
/** Clinic Treatment tab — visible lab comments and case fully completed. */
|
||||||
export const CLINIC_TREATMENT_TAB_ACTIVITY_TYPES: LabCaseActivityType[] = [
|
export const CLINIC_TREATMENT_TAB_ACTIVITY_TYPES: LabCaseActivityType[] = [
|
||||||
LabCaseActivityType.LAB_COMMENT,
|
LabCaseActivityType.LAB_COMMENT,
|
||||||
LabCaseActivityType.TASK_COMPLETED,
|
LabCaseActivityType.CASE_COMPLETED,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -303,6 +303,7 @@ export class TasksService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let caseCompleted = false;
|
||||||
if (dto.status === LabTaskStatus.COMPLETED && task.status !== LabTaskStatus.COMPLETED) {
|
if (dto.status === LabTaskStatus.COMPLETED && task.status !== LabTaskStatus.COMPLETED) {
|
||||||
await this.labCaseActivity.record(
|
await this.labCaseActivity.record(
|
||||||
{
|
{
|
||||||
@@ -321,18 +322,37 @@ export class TasksService {
|
|||||||
},
|
},
|
||||||
tx,
|
tx,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const remainingIncomplete = await tx.labCaseTask.count({
|
||||||
|
where: {
|
||||||
|
labCaseId: task.labCaseId,
|
||||||
|
status: { not: LabTaskStatus.COMPLETED },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
caseCompleted = remainingIncomplete === 0;
|
||||||
|
if (caseCompleted) {
|
||||||
|
await this.labCaseActivity.record(
|
||||||
|
{
|
||||||
|
labCaseId: task.labCaseId,
|
||||||
|
type: LabCaseActivityType.CASE_COMPLETED,
|
||||||
|
actorUserId,
|
||||||
|
payload: { labCaseId: task.labCaseId },
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return { result, caseCompleted };
|
||||||
});
|
});
|
||||||
|
|
||||||
if (dto.status === LabTaskStatus.COMPLETED && task.status !== LabTaskStatus.COMPLETED) {
|
if (updated.caseCompleted) {
|
||||||
void this.userNotifications.notify({
|
void this.userNotifications.notify({
|
||||||
organizationId: labOrganizationId,
|
organizationId: labOrganizationId,
|
||||||
type: UserNotificationType.TASK_COMPLETED,
|
type: UserNotificationType.CASE_COMPLETED,
|
||||||
href: `/tasks?taskId=${encodeURIComponent(taskId)}&labCaseId=${encodeURIComponent(task.labCaseId)}`,
|
href: `/cases?caseId=${encodeURIComponent(task.labCaseId)}`,
|
||||||
actorUserId,
|
actorUserId,
|
||||||
payload: { labCaseId: task.labCaseId, taskId },
|
payload: { labCaseId: task.labCaseId },
|
||||||
requiredPermission: 'TAB_TASKS_READ',
|
requiredPermission: 'TAB_TASKS_READ',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -340,10 +360,10 @@ export class TasksService {
|
|||||||
if (clinicOrgId) {
|
if (clinicOrgId) {
|
||||||
void this.userNotifications.notify({
|
void this.userNotifications.notify({
|
||||||
organizationId: clinicOrgId,
|
organizationId: clinicOrgId,
|
||||||
type: UserNotificationType.TASK_COMPLETED,
|
type: UserNotificationType.CASE_COMPLETED,
|
||||||
href: `/treatment?labCaseId=${encodeURIComponent(task.labCaseId)}`,
|
href: `/treatment?labCaseId=${encodeURIComponent(task.labCaseId)}`,
|
||||||
actorUserId,
|
actorUserId,
|
||||||
payload: { labCaseId: task.labCaseId, taskId },
|
payload: { labCaseId: task.labCaseId },
|
||||||
requiredPermission: 'TAB_TREATMENT_READ',
|
requiredPermission: 'TAB_TREATMENT_READ',
|
||||||
labCaseIdForProviderScope: task.labCaseId,
|
labCaseIdForProviderScope: task.labCaseId,
|
||||||
});
|
});
|
||||||
@@ -353,11 +373,11 @@ export class TasksService {
|
|||||||
const locale = normalizeCatalogLocale(localeInput);
|
const locale = normalizeCatalogLocale(localeInput);
|
||||||
const prosthesisLabels = await this.catalogLabels.resolveLabels(
|
const prosthesisLabels = await this.catalogLabels.resolveLabels(
|
||||||
CatalogEntityKind.PROSTHESIS_TYPE,
|
CatalogEntityKind.PROSTHESIS_TYPE,
|
||||||
atomicProsthesisCodes([updated.prosthesisTypeCode]),
|
atomicProsthesisCodes([updated.result.prosthesisTypeCode]),
|
||||||
locale,
|
locale,
|
||||||
);
|
);
|
||||||
|
|
||||||
return { success: true, data: this.mapTaskListItem(updated, prosthesisLabels) };
|
return { success: true, data: this.mapTaskListItem(updated.result, prosthesisLabels) };
|
||||||
}
|
}
|
||||||
|
|
||||||
async listFilterOptions(
|
async listFilterOptions(
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ ENV HOSTNAME=0.0.0.0
|
|||||||
|
|
||||||
COPY --from=builder --chown=dyolink:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=dyolink:nodejs /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=dyolink:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=dyolink:nodejs /app/.next/static ./.next/static
|
||||||
|
# Standalone does not include public/; catalog icons are <img src="/prosthesis-catalog/*.svg">.
|
||||||
|
COPY --from=builder --chown=dyolink:nodejs /app/public ./public
|
||||||
|
|
||||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
# Windows git/build context may use CRLF; strip before chmod (fixes dumb-init "No such file or directory").
|
# Windows git/build context may use CRLF; strip before chmod (fixes dumb-init "No such file or directory").
|
||||||
|
|||||||
@@ -60,4 +60,4 @@ npm install
|
|||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
Image build and build-args (`NEXT_PUBLIC_*`) are documented in the **repository root `README.md`**.
|
Image build and build-args (`NEXT_PUBLIC_*`) are documented in the **repository root `README.md`**. Next `standalone` does not include `public/` — `frontend/Dockerfile` copies it so `/prosthesis-catalog/*.svg` is served in production.
|
||||||
|
|||||||
@@ -159,9 +159,8 @@
|
|||||||
"verifyFailed": "Invalid or expired verification code."
|
"verifyFailed": "Invalid or expired verification code."
|
||||||
},
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
"heroTitle": "Connect Dental Clinics & Labs",
|
"heroTitle": "Nudentic is a digital workflow platform for modern dentistry.",
|
||||||
"heroHighlight": "Seamlessly",
|
"heroSubtitle": "It brings clinical and laboratory workflows, case information, communication, and patient records into one structured environment.",
|
||||||
"heroSubtitle": "Streamline communication between dental professionals. Start with a 30-day free trial, no credit card required.",
|
|
||||||
"featureClinicsTitle": "For Clinics",
|
"featureClinicsTitle": "For Clinics",
|
||||||
"featureClinicsDescription": "Manage patients, appointments, and send cases to labs instantly.",
|
"featureClinicsDescription": "Manage patients, appointments, and send cases to labs instantly.",
|
||||||
"featureLabsTitle": "For Labs",
|
"featureLabsTitle": "For Labs",
|
||||||
@@ -847,6 +846,7 @@
|
|||||||
"activityTaskCompleted": "{step} completed by {actor} · {date}",
|
"activityTaskCompleted": "{step} completed by {actor} · {date}",
|
||||||
"activityTaskAssigned": "{step} assigned by {actor} · {date}",
|
"activityTaskAssigned": "{step} assigned by {actor} · {date}",
|
||||||
"activityCaseImportant": "Marked important by {actor} · {date}",
|
"activityCaseImportant": "Marked important by {actor} · {date}",
|
||||||
|
"activityCaseCompleted": "All lab work completed by {actor} · {date}",
|
||||||
"activityCaseAmended": "Case updated by {actor} · {date}",
|
"activityCaseAmended": "Case updated by {actor} · {date}",
|
||||||
"activityGeneric": "Update · {date}",
|
"activityGeneric": "Update · {date}",
|
||||||
"loadingHistory": "Loading history…",
|
"loadingHistory": "Loading history…",
|
||||||
@@ -1106,6 +1106,7 @@
|
|||||||
"typeCaseImportant": "Case marked as important",
|
"typeCaseImportant": "Case marked as important",
|
||||||
"typeTaskCompleted": "Lab task completed",
|
"typeTaskCompleted": "Lab task completed",
|
||||||
"typeTaskAssigned": "A task was assigned to you",
|
"typeTaskAssigned": "A task was assigned to you",
|
||||||
|
"typeCaseCompleted": "Lab case completed",
|
||||||
"typeConnectionRequest": "New organization connection request",
|
"typeConnectionRequest": "New organization connection request",
|
||||||
"typeStaffInvite": "Staff invitation created",
|
"typeStaffInvite": "Staff invitation created",
|
||||||
"typeUnknown": "Notification",
|
"typeUnknown": "Notification",
|
||||||
|
|||||||
@@ -159,9 +159,8 @@
|
|||||||
"verifyFailed": "کد تأیید نامعتبر یا منقضی شده است."
|
"verifyFailed": "کد تأیید نامعتبر یا منقضی شده است."
|
||||||
},
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
"heroTitle": "اتصال کلینیکها و لابراتوارهای دندانپزشکی",
|
"heroTitle": "Nudentic یک پلتفرم گردشکار دیجیتال برای دندانپزشکی مدرن است.",
|
||||||
"heroHighlight": "بهصورت یکپارچه",
|
"heroSubtitle": "این پلتفرم گردشکارهای بالینی و لابراتواری، اطلاعات پرونده، ارتباط و سوابق بیمار را در یک محیط ساختاریافته کنار هم میآورد.",
|
||||||
"heroSubtitle": "ارتباط بین متخصصان دندانپزشکی را ساده و سریع کنید. با یک دوره آزمایشی رایگان ۳۰ روزه، بدون نیاز به کارت اعتباری، شروع کنید.",
|
|
||||||
"featureClinicsTitle": "برای کلینیکها",
|
"featureClinicsTitle": "برای کلینیکها",
|
||||||
"featureClinicsDescription": "بیماران و نوبتها را مدیریت کنید و پروندهها را فوراً به لابراتوارها ارسال کنید.",
|
"featureClinicsDescription": "بیماران و نوبتها را مدیریت کنید و پروندهها را فوراً به لابراتوارها ارسال کنید.",
|
||||||
"featureLabsTitle": "برای لابراتوارها",
|
"featureLabsTitle": "برای لابراتوارها",
|
||||||
@@ -848,6 +847,7 @@
|
|||||||
"activityTaskCompleted": "{step} توسط {actor} تکمیل شد · {date}",
|
"activityTaskCompleted": "{step} توسط {actor} تکمیل شد · {date}",
|
||||||
"activityTaskAssigned": "{step} توسط {actor} اختصاص داده شد · {date}",
|
"activityTaskAssigned": "{step} توسط {actor} اختصاص داده شد · {date}",
|
||||||
"activityCaseImportant": "مهم علامتگذاری شد توسط {actor} · {date}",
|
"activityCaseImportant": "مهم علامتگذاری شد توسط {actor} · {date}",
|
||||||
|
"activityCaseCompleted": "تمام کارهای لابراتوار توسط {actor} تکمیل شد · {date}",
|
||||||
"activityCaseAmended": "پرونده بهروزرسانی شد توسط {actor} · {date}",
|
"activityCaseAmended": "پرونده بهروزرسانی شد توسط {actor} · {date}",
|
||||||
"activityGeneric": "بهروزرسانی · {date}",
|
"activityGeneric": "بهروزرسانی · {date}",
|
||||||
"loadingHistory": "در حال بارگذاری تاریخچه...",
|
"loadingHistory": "در حال بارگذاری تاریخچه...",
|
||||||
@@ -1107,6 +1107,7 @@
|
|||||||
"typeCaseImportant": "پرونده بهعنوان مهم علامت خورد",
|
"typeCaseImportant": "پرونده بهعنوان مهم علامت خورد",
|
||||||
"typeTaskCompleted": "وظیفه لابراتوار تکمیل شد",
|
"typeTaskCompleted": "وظیفه لابراتوار تکمیل شد",
|
||||||
"typeTaskAssigned": "یک وظیفه به شما اختصاص داده شد",
|
"typeTaskAssigned": "یک وظیفه به شما اختصاص داده شد",
|
||||||
|
"typeCaseCompleted": "پرونده لابراتوار تکمیل شد",
|
||||||
"typeConnectionRequest": "درخواست اتصال سازمان جدید",
|
"typeConnectionRequest": "درخواست اتصال سازمان جدید",
|
||||||
"typeStaffInvite": "دعوتنامه کارکنان ایجاد شد",
|
"typeStaffInvite": "دعوتنامه کارکنان ایجاد شد",
|
||||||
"typeUnknown": "اعلان",
|
"typeUnknown": "اعلان",
|
||||||
|
|||||||
@@ -159,9 +159,8 @@
|
|||||||
"verifyFailed": "Ongeldige of verlopen verificatiecode."
|
"verifyFailed": "Ongeldige of verlopen verificatiecode."
|
||||||
},
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
"heroTitle": "Verbind Tandheelkundige Klinieken & Laboratoria",
|
"heroTitle": "Nudentic is een digitaal workflowplatform voor de moderne tandheelkunde.",
|
||||||
"heroHighlight": "Naadloos",
|
"heroSubtitle": "Het brengt klinische en laboratoriumworkflows, casusinformatie, communicatie en patiëntendossiers samen in één gestructureerde omgeving.",
|
||||||
"heroSubtitle": "Stroomlijn de communicatie tussen tandheelkundige professionals. Start met een gratis proefperiode van 30 dagen, zonder creditcard.",
|
|
||||||
"featureClinicsTitle": "Voor Klinieken",
|
"featureClinicsTitle": "Voor Klinieken",
|
||||||
"featureClinicsDescription": "Beheer patiënten, afspraken en stuur casussen direct naar laboratoria.",
|
"featureClinicsDescription": "Beheer patiënten, afspraken en stuur casussen direct naar laboratoria.",
|
||||||
"featureLabsTitle": "Voor Laboratoria",
|
"featureLabsTitle": "Voor Laboratoria",
|
||||||
@@ -847,6 +846,7 @@
|
|||||||
"activityTaskCompleted": "{step} voltooid door {actor} · {date}",
|
"activityTaskCompleted": "{step} voltooid door {actor} · {date}",
|
||||||
"activityTaskAssigned": "{step} toegewezen door {actor} · {date}",
|
"activityTaskAssigned": "{step} toegewezen door {actor} · {date}",
|
||||||
"activityCaseImportant": "Als belangrijk gemarkeerd door {actor} · {date}",
|
"activityCaseImportant": "Als belangrijk gemarkeerd door {actor} · {date}",
|
||||||
|
"activityCaseCompleted": "Al het labwerk voltooid door {actor} · {date}",
|
||||||
"activityCaseAmended": "Case bijgewerkt door {actor} · {date}",
|
"activityCaseAmended": "Case bijgewerkt door {actor} · {date}",
|
||||||
"activityGeneric": "Update · {date}",
|
"activityGeneric": "Update · {date}",
|
||||||
"loadingHistory": "Geschiedenis laden...",
|
"loadingHistory": "Geschiedenis laden...",
|
||||||
@@ -1106,6 +1106,7 @@
|
|||||||
"typeCaseImportant": "Case gemarkeerd als belangrijk",
|
"typeCaseImportant": "Case gemarkeerd als belangrijk",
|
||||||
"typeTaskCompleted": "Labtaak voltooid",
|
"typeTaskCompleted": "Labtaak voltooid",
|
||||||
"typeTaskAssigned": "Er is een taak aan u toegewezen",
|
"typeTaskAssigned": "Er is een taak aan u toegewezen",
|
||||||
|
"typeCaseCompleted": "Labcase voltooid",
|
||||||
"typeConnectionRequest": "Nieuw organisatieverzoek",
|
"typeConnectionRequest": "Nieuw organisatieverzoek",
|
||||||
"typeStaffInvite": "Personeelsuitnodiging aangemaakt",
|
"typeStaffInvite": "Personeelsuitnodiging aangemaakt",
|
||||||
"typeUnknown": "Melding",
|
"typeUnknown": "Melding",
|
||||||
|
|||||||
@@ -52,12 +52,11 @@ export default function HomePage() {
|
|||||||
|
|
||||||
<main className="flex-1 container mx-auto px-4 pt-28 sm:pt-32 pb-16 sm:pb-20">
|
<main className="flex-1 container mx-auto px-4 pt-28 sm:pt-32 pb-16 sm:pb-20">
|
||||||
<div className="max-w-4xl mx-auto text-center">
|
<div className="max-w-4xl mx-auto text-center">
|
||||||
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-semibold mb-4 sm:mb-6 leading-tight">
|
<h1 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-semibold mb-4 sm:mb-6 leading-tight text-balance max-w-3xl mx-auto">
|
||||||
{t('heroTitle')}
|
{t('heroTitle')}
|
||||||
<span className="text-primary"> {t('heroHighlight')}</span>
|
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="text-base sm:text-lg text-text-secondary mb-6 sm:mb-8 max-w-2xl mx-auto">
|
<p className="text-base sm:text-lg text-text-secondary mb-6 sm:mb-8 max-w-3xl mx-auto">
|
||||||
{t('heroSubtitle')}
|
{t('heroSubtitle')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export default async function LocaleLayout({
|
|||||||
setRequestLocale(locale);
|
setRequestLocale(locale);
|
||||||
const messages = await getMessages();
|
const messages = await getMessages();
|
||||||
|
|
||||||
const themeInit = `(function(){try{var k=${JSON.stringify(THEME_STORAGE_KEY)};var t=localStorage.getItem(k);document.documentElement.setAttribute('data-theme',t==='light'||t==='dark'?t:'dark');}catch(e){document.documentElement.setAttribute('data-theme','dark');}})();`;
|
const themeInit = `(function(){try{var k=${JSON.stringify(THEME_STORAGE_KEY)};var t=localStorage.getItem(k);document.documentElement.setAttribute('data-theme',t==='light'||t==='dark'?t:'light');}catch(e){document.documentElement.setAttribute('data-theme','light');}})();`;
|
||||||
const dir = isRtlLocale(locale) ? 'rtl' : 'ltr';
|
const dir = isRtlLocale(locale) ? 'rtl' : 'ltr';
|
||||||
const fontSans = isRtlLocale(locale)
|
const fontSans = isRtlLocale(locale)
|
||||||
? 'var(--font-vazirmatn), var(--font-noto-sans-arabic), system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif'
|
? 'var(--font-vazirmatn), var(--font-noto-sans-arabic), system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif'
|
||||||
@@ -65,6 +65,7 @@ export default async function LocaleLayout({
|
|||||||
lang={localeHtmlLang(locale)}
|
lang={localeHtmlLang(locale)}
|
||||||
dir={dir}
|
dir={dir}
|
||||||
data-locale={locale}
|
data-locale={locale}
|
||||||
|
data-theme="light"
|
||||||
className={`${vazirmatn.variable} ${notoSansArabic.variable}`}
|
className={`${vazirmatn.variable} ${notoSansArabic.variable}`}
|
||||||
style={{ ['--font-sans' as never]: fontSans }}
|
style={{ ['--font-sans' as never]: fontSans }}
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const TYPE_I18N: Record<UserNotificationType, string> = {
|
|||||||
CASE_IMPORTANT: 'typeCaseImportant',
|
CASE_IMPORTANT: 'typeCaseImportant',
|
||||||
TASK_COMPLETED: 'typeTaskCompleted',
|
TASK_COMPLETED: 'typeTaskCompleted',
|
||||||
TASK_ASSIGNED: 'typeTaskAssigned',
|
TASK_ASSIGNED: 'typeTaskAssigned',
|
||||||
|
CASE_COMPLETED: 'typeCaseCompleted',
|
||||||
CONNECTION_REQUEST: 'typeConnectionRequest',
|
CONNECTION_REQUEST: 'typeConnectionRequest',
|
||||||
STAFF_INVITE: 'typeStaffInvite',
|
STAFF_INVITE: 'typeStaffInvite',
|
||||||
};
|
};
|
||||||
@@ -74,6 +75,17 @@ export function notificationContextLine(
|
|||||||
if (clinicName) parts.push(clinicName);
|
if (clinicName) parts.push(clinicName);
|
||||||
if (prosthesisLabel) parts.push(prosthesisLabel);
|
if (prosthesisLabel) parts.push(prosthesisLabel);
|
||||||
break;
|
break;
|
||||||
|
case 'CASE_COMPLETED':
|
||||||
|
if (patientName) parts.push(patientName);
|
||||||
|
if (item.href.startsWith('/treatment')) {
|
||||||
|
if (labName) parts.push(labName);
|
||||||
|
} else if (clinicName) {
|
||||||
|
parts.push(clinicName);
|
||||||
|
} else if (labName) {
|
||||||
|
parts.push(labName);
|
||||||
|
}
|
||||||
|
if (prosthesisLabel) parts.push(prosthesisLabel);
|
||||||
|
break;
|
||||||
case 'LAB_COMMENT_CLINIC':
|
case 'LAB_COMMENT_CLINIC':
|
||||||
if (patientName) parts.push(patientName);
|
if (patientName) parts.push(patientName);
|
||||||
if (labName) parts.push(labName);
|
if (labName) parts.push(labName);
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export function formatLabCaseActivityLine(
|
|||||||
});
|
});
|
||||||
case 'CASE_IMPORTANT':
|
case 'CASE_IMPORTANT':
|
||||||
return t('activityCaseImportant', { actor, date });
|
return t('activityCaseImportant', { actor, date });
|
||||||
|
case 'CASE_COMPLETED':
|
||||||
|
return t('activityCaseCompleted', { actor, date });
|
||||||
case 'CASE_AMENDED':
|
case 'CASE_AMENDED':
|
||||||
return t('activityCaseAmended', { actor, date });
|
return t('activityCaseAmended', { actor, date });
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ export const THEME_STORAGE_KEY = 'dyolink-theme';
|
|||||||
export type ThemeMode = 'light' | 'dark';
|
export type ThemeMode = 'light' | 'dark';
|
||||||
|
|
||||||
export function getStoredTheme(): ThemeMode {
|
export function getStoredTheme(): ThemeMode {
|
||||||
if (typeof window === 'undefined') return 'dark';
|
if (typeof window === 'undefined') return 'light';
|
||||||
try {
|
try {
|
||||||
const v = localStorage.getItem(THEME_STORAGE_KEY);
|
const v = localStorage.getItem(THEME_STORAGE_KEY);
|
||||||
if (v === 'light' || v === 'dark') return v;
|
if (v === 'light' || v === 'dark') return v;
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
return 'dark';
|
return 'light';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyTheme(mode: ThemeMode) {
|
export function applyTheme(mode: ThemeMode) {
|
||||||
|
|||||||
@@ -262,13 +262,17 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
color-scheme: dark;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme='light'] {
|
html[data-theme='light'] {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html[data-theme='dark'] {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
/* Minimal RTL layer — refine incrementally. */
|
/* Minimal RTL layer — refine incrementally. */
|
||||||
html[dir='rtl'] body {
|
html[dir='rtl'] body {
|
||||||
direction: rtl;
|
direction: rtl;
|
||||||
@@ -338,9 +342,7 @@ select option {
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme='dark'] select.form-select,
|
:root[data-theme='dark'] select.form-select,
|
||||||
:root[data-theme='dark'] select,
|
:root[data-theme='dark'] select {
|
||||||
:root:not([data-theme='light']) select.form-select,
|
|
||||||
:root:not([data-theme='light']) select {
|
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,8 +360,7 @@ select option {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme='dark'] .surface-card,
|
:root[data-theme='dark'] .surface-card {
|
||||||
:root:not([data-theme='light']) .surface-card {
|
|
||||||
background: color-mix(in srgb, var(--color-card-background) 82%, var(--color-background-primary));
|
background: color-mix(in srgb, var(--color-card-background) 82%, var(--color-background-primary));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ export type LabCaseActivityType =
|
|||||||
| 'CASE_IMPORTANT'
|
| 'CASE_IMPORTANT'
|
||||||
| 'CASE_AMENDED'
|
| 'CASE_AMENDED'
|
||||||
| 'TASK_COMPLETED'
|
| 'TASK_COMPLETED'
|
||||||
| 'TASK_ASSIGNED';
|
| 'TASK_ASSIGNED'
|
||||||
|
| 'CASE_COMPLETED';
|
||||||
|
|
||||||
export interface LabCaseActivityItem {
|
export interface LabCaseActivityItem {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export type UserNotificationType =
|
|||||||
| 'CASE_IMPORTANT'
|
| 'CASE_IMPORTANT'
|
||||||
| 'TASK_COMPLETED'
|
| 'TASK_COMPLETED'
|
||||||
| 'TASK_ASSIGNED'
|
| 'TASK_ASSIGNED'
|
||||||
|
| 'CASE_COMPLETED'
|
||||||
| 'CONNECTION_REQUEST'
|
| 'CONNECTION_REQUEST'
|
||||||
| 'STAFF_INVITE';
|
| 'STAFF_INVITE';
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ https://nudentic.ir
|
|||||||
|
|
||||||
`:latest` is **staging only** (wixur.ir baked in). Production compose must pin `TAG=v1.0.1`.
|
`:latest` is **staging only** (wixur.ir baked in). Production compose must pin `TAG=v1.0.1`.
|
||||||
|
|
||||||
|
**Tags are immutable.** CI clones `--branch $tag`. Do not move/reuse an existing `v*` to pick up a Dockerfile or copy fix — cut a new version. The frontend standalone image must `COPY` `public/` (`frontend/Dockerfile`); without it, `/prosthesis-catalog/*.svg` 404s.
|
||||||
|
|
||||||
### One-time on the Linux server
|
### One-time on the Linux server
|
||||||
|
|
||||||
1. **HTTP registry** — Gitea is `http://wixur.ir:3000`. In `/etc/docker/daemon.json`:
|
1. **HTTP registry** — Gitea is `http://wixur.ir:3000`. In `/etc/docker/daemon.json`:
|
||||||
@@ -136,7 +138,7 @@ This pushes:
|
|||||||
- `dyolink/dyolink-backend:latest`
|
- `dyolink/dyolink-backend:latest`
|
||||||
- `dyolink/dyolink-frontend:latest`
|
- `dyolink/dyolink-frontend:latest`
|
||||||
|
|
||||||
**When to rebuild:** domain changes, frontend env (`NEXT_PUBLIC_*`) changes, or new app release.
|
**When to rebuild:** domain changes, frontend env (`NEXT_PUBLIC_*`) changes, files under `frontend/public/`, or a new app release.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ http://wixur.ir:3000 → Gitea (native, no Docker)
|
|||||||
| frontend | `<REGISTRY_HOST>/<owner>/dyolink-frontend:<sha>` |
|
| frontend | `<REGISTRY_HOST>/<owner>/dyolink-frontend:<sha>` |
|
||||||
| nginx | `nginx:alpine` |
|
| nginx | `nginx:alpine` |
|
||||||
|
|
||||||
Frontend public URLs are **baked in at build time** via `PUBLIC_BASE_URL`. After changing the public URL, re-run the Gitea workflow (or push to `master`) and set `FRONTEND_URL` in `C:\dyolink\secrets\backend.staging.env` to the same origin.
|
Frontend public URLs are **baked in at build time** via `PUBLIC_BASE_URL`. After changing the public URL, re-run the Gitea workflow (or push to `master`) and set `FRONTEND_URL` in `C:\dyolink\secrets\backend.staging.env` to the same origin. The same `frontend/Dockerfile` must copy `public/` into the standalone image (catalog icons).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ FRONTEND_URL=https://nudentic.ir
|
|||||||
# Change these — the code defaults are only for local development.
|
# Change these — the code defaults are only for local development.
|
||||||
ADMINJS_EMAIL=admin@nudentic.ir
|
ADMINJS_EMAIL=admin@nudentic.ir
|
||||||
ADMINJS_PASSWORD=CHANGE_ME_STRONG_ADMINJS_PASSWORD
|
ADMINJS_PASSWORD=CHANGE_ME_STRONG_ADMINJS_PASSWORD
|
||||||
|
# Optional override; Docker image sets /app/adminjs-tmp (do not use ".adminjs").
|
||||||
|
# ADMIN_JS_TMP_DIR=/app/adminjs-tmp
|
||||||
|
|
||||||
# Required for HTTPS — browsers reject Secure cookies over plain HTTP
|
# Required for HTTPS — browsers reject Secure cookies over plain HTTP
|
||||||
COOKIE_SECURE=true
|
COOKIE_SECURE=true
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ FRONTEND_URL=https://wixur.ir
|
|||||||
# AdminJS at https://wixur.ir/admin (nginx proxies /admin to the API).
|
# AdminJS at https://wixur.ir/admin (nginx proxies /admin to the API).
|
||||||
ADMINJS_EMAIL=admin@wixur.ir
|
ADMINJS_EMAIL=admin@wixur.ir
|
||||||
ADMINJS_PASSWORD=CHANGE_ME_STRONG_ADMINJS_PASSWORD
|
ADMINJS_PASSWORD=CHANGE_ME_STRONG_ADMINJS_PASSWORD
|
||||||
|
# Optional override; Docker image sets /app/adminjs-tmp (do not use ".adminjs").
|
||||||
|
# ADMIN_JS_TMP_DIR=/app/adminjs-tmp
|
||||||
|
|
||||||
# TLS is terminated on Windows nginx :443 — cookies must be Secure
|
# TLS is terminated on Windows nginx :443 — cookies must be Secure
|
||||||
COOKIE_SECURE=true
|
COOKIE_SECURE=true
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ services:
|
|||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
TZ: UTC
|
TZ: UTC
|
||||||
PORT: "3000"
|
PORT: "3000"
|
||||||
|
ADMIN_JS_TMP_DIR: /app/adminjs-tmp
|
||||||
SENTRY_ENVIRONMENT: production
|
SENTRY_ENVIRONMENT: production
|
||||||
SENTRY_RELEASE: ${TAG:-latest}
|
SENTRY_RELEASE: ${TAG:-latest}
|
||||||
expose:
|
expose:
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ services:
|
|||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
TZ: UTC
|
TZ: UTC
|
||||||
PORT: "3000"
|
PORT: "3000"
|
||||||
|
ADMIN_JS_TMP_DIR: /app/adminjs-tmp
|
||||||
SENTRY_ENVIRONMENT: staging
|
SENTRY_ENVIRONMENT: staging
|
||||||
SENTRY_RELEASE: ${IMAGE_TAG:-latest}
|
SENTRY_RELEASE: ${IMAGE_TAG:-latest}
|
||||||
expose:
|
expose:
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ services:
|
|||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
TZ: UTC
|
TZ: UTC
|
||||||
PORT: "3000"
|
PORT: "3000"
|
||||||
|
ADMIN_JS_TMP_DIR: /app/adminjs-tmp
|
||||||
SENTRY_ENVIRONMENT: staging
|
SENTRY_ENVIRONMENT: staging
|
||||||
expose:
|
expose:
|
||||||
- "3000"
|
- "3000"
|
||||||
|
|||||||
Reference in New Issue
Block a user