diff --git a/.cursor/rules/api-errors-i18n.mdc b/.cursor/rules/api-errors-i18n.mdc new file mode 100644 index 0000000..713e1f6 --- /dev/null +++ b/.cursor/rules/api-errors-i18n.mdc @@ -0,0 +1,28 @@ +--- +description: Error codes backend ↔ frontend and i18n message keys +globs: backend/src/common/errors/**,frontend/src/components/shared/formatApiError.ts,frontend/messages/** +alwaysApply: false +--- + +# API errors & translations + +## Adding a new error + +1. Add code to `backend/src/common/errors/error-codes.ts` +2. Throw via `AppException` (or validation DTO with that code) +3. Add matching key under `errors` in **all three** message files: + - `frontend/messages/en.json` + - `frontend/messages/fa.json` + - `frontend/messages/nl.json` +4. Frontend catch: `getUserFacingError(err, tErrors, t('fallbackKey'))` + +## Validation field errors + +Backend returns `{ success: false, error: { code, details: [{ field, code }] } }`. + +Frontend maps `details[].code` through the `errors` namespace. + +## Do not + +- Show raw `error.message` or stack traces to users. +- Add English-only strings inline in components. diff --git a/.cursor/rules/backend-nestjs.mdc b/.cursor/rules/backend-nestjs.mdc new file mode 100644 index 0000000..07d3bdf --- /dev/null +++ b/.cursor/rules/backend-nestjs.mdc @@ -0,0 +1,41 @@ +--- +description: Backend NestJS modules, Prisma, permissions, guards +globs: backend/src/** +alwaysApply: false +--- + +# Backend conventions + +## Module layout + +`backend/src/modules/{feature}/` → `{feature}.module.ts`, `.controller.ts`, `.service.ts`, `dto/`. + +Register new modules in `app.module.ts`. + +## Errors + +Use coded errors — not raw user-facing strings: + +```typescript +throw new AppException(ErrorCode.PERMISSION_DENIED, HttpStatus.FORBIDDEN); +``` + +- Codes: `backend/src/common/errors/error-codes.ts` +- DTO validation: `{ message: ErrorCode.VALIDATION_* }` on class-validator decorators +- Global filter: `HttpExceptionFilter` in `main.ts` + +## Permissions + +- Check access with `hasEffectivePermission(membership, 'TAB_*')` from `common/membership-permissions.ts`. +- Clinic-only routes: `ClinicOrgGuard`. Lab-only: `LabOrgGuard`. +- Feature-specific checks belong in the **service**, not only the controller. + +## Prisma + +- Schema: `backend/prisma/schema.prisma` +- 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`. + +## API responses + +Prefer `{ success: true, data: ... }` shape consistent with existing modules. diff --git a/.cursor/rules/dyolink-overview.mdc b/.cursor/rules/dyolink-overview.mdc new file mode 100644 index 0000000..47c0d4f --- /dev/null +++ b/.cursor/rules/dyolink-overview.mdc @@ -0,0 +1,39 @@ +--- +description: Dyolink project context — stack, org types, git safety, verification +alwaysApply: true +--- + +# Dyolink overview + +Monorepo: `backend/` (NestJS + Prisma), `frontend/` (Next.js + next-intl), `infrastructure/` (Docker). + +## Domain + +- **CLINIC** orgs: patients, appointments, treatment, staff. +- **LAB** orgs: cases, tasks, lab workflows. +- Tab access: `TAB_*_READ` / `TAB_*_EDIT` in `backend/src/common/permissions.ts`. EDIT implies READ. + +## Agent behavior + +- Read `AGENTS.md` and file-scoped rules before large changes. +- **Never commit or push** unless the user explicitly asks. +- Prefer minimal diffs; reuse existing components and API patterns. +- After cross-cutting changes: `backend` → `npm run build`; `frontend` → `npx tsc --noEmit`. + +## i18n + +All user-visible strings: `frontend/messages/en.json`, `fa.json`, `nl.json` — add keys to **all three**. + +Dates/times/numbers: `frontend/src/lib/i18n/format.ts` + `useLocale()`. **Form date fields:** `AppDateInput` only (no native ``); wire format `YYYY-MM-DD`. **Filter selects:** `FORM_SELECT_CLASS` from `components/shared/formSelectStyles.ts` (chevron via `globals.css`). **Tables:** `components/ui/shared/Table.tsx` — use `text-start`/`text-end`/`text-center`, never physical `text-left`/`text-right`. Appointments: `ScheduleDayPicker`. Skill: `.cursor/skills/i18n-formatting/SKILL.md`. + +## Treatment / appointment colors + +Treatment-type colors and labels: `components/shared/treatmentTypeDisplay.ts` + `catalog-type-colors.ts`. UI badges: `components/ui/treatment/TreatmentTypeBadge.tsx`. **Prosthesis** colors/labels: `components/treatment/prosthesisTypeDisplay.ts` — use on lab Cases/Tasks/Today charts. + +## Today dashboard + +CLINIC + LAB KPIs/charts in `modules/today/today.service.ts`. Deep links: `components/today/today-deep-links.ts`. Task KPIs need `TAB_TASKS_READ` (no owner bypass). Skill: `.cursor/skills/today-dashboard/SKILL.md`. + +## Lab case share link + +QR + URL for **sent** cases; focus page `/lab-case/[token]`. Auth redirect via `postAuthRedirect.ts`. Skill: `.cursor/skills/lab-case-share-link/SKILL.md`. diff --git a/.cursor/rules/frontend-components.mdc b/.cursor/rules/frontend-components.mdc new file mode 100644 index 0000000..f338867 --- /dev/null +++ b/.cursor/rules/frontend-components.mdc @@ -0,0 +1,64 @@ +--- +description: Frontend folder structure — ui vs non-ui, thin pages, feature layout +globs: frontend/src/** +alwaysApply: false +--- + +# Frontend component structure + +## Rules + +| Kind | Location | +|------|----------| +| Cross-feature UI | `components/ui/shared/` | +| Feature UI | `components/ui/{feature}/` | +| Cross-feature non-UI | `components/shared/` | +| Feature non-UI | `components/{feature}/` | +| Route logic | `components/ui/{feature}/{Feature}Page.tsx` | +| App routes | `app/**/page.tsx` — **thin wrapper only** | + +## Thin page pattern + +```tsx +'use client'; +import { PatientsPage } from '@/components/ui/patient/PatientsPage'; +export default function Page() { + return ; +} +``` + +Reference: `app/.../treatment/page.tsx` + `components/ui/treatment/TreatmentWorkspace.tsx`. + +## Do not + +- Put React components (`.tsx` with JSX) in `components/` outside `ui/`. +- Put pure helpers (`.ts`, no JSX) inside `components/ui/`. +- Put business logic, API calls, or large forms directly in `app/**/page.tsx`. + +## API & errors + +- API clients: `lib/api/`. +- Catch blocks: `getUserFacingError(err, tErrors, fallback)` from `components/shared/formatApiError.ts`. + +## Toasts + +- Use `useToast()` for transient page feedback — rendered globally by `ToastProvider` in dashboard layout (fixed bottom, above dialogs). +- Do **not** add `` inside pages or dialogs. +- Form/dialog validation: inline error text near the field or submit button, not a toast. + +## When adding UI + +1. Check `components/ui/shared/` for an existing primitive. +2. Check the feature's `ui/{feature}/` folder for an existing pattern. +3. Add i18n keys to en, fa, and nl. + +## Shared form & table primitives + +| Need | Use | +|------|-----| +| Date filter / due date field | `AppDateInput` (`ui/shared/`) — not `` | +| Filter or inline `` filters/fields — `ps-3 pe-10`, chevron from `globals.css` | +| **`FORM_SELECT_COMPACT_CLASS`** | Tiny selects (e.g. sort direction `↓`/`↑`) — symmetric `px-2`, no chevron gutter | +| **`Dropdown`** | Labeled form select — only where already used; prefer `FORM_SELECT_CLASS` for new filters | +| **`CompactSelect`** | Year/month/day sub-selects inside calendar panels | + +**`AppDateInput` behavior** + +- `fa`: Jalali display `YYYY/MM/DD` (Persian digits), parse/mask in `persianCalendar.ts` +- `en` / `nl`: Gregorian display `YYYY-MM-DD`, parse/mask in `dateInputFormat.ts` +- Calendar icon at **`end-3`** (matches select chevron inset); text uses **`text-start`** (logical — right in RTL, left in LTR) +- Popup: **`CalendarDayPartsPanel`** (shared with schedule picker) +- **Do not** add native `` — one component for all locales + +**Select chevron** + +- Defined once in `frontend/src/styles/globals.css` on `.form-select:not(.form-select-no-chevron)` +- RTL: `background-position: left 0.75rem center`; LTR: `right 0.75rem center` +- `text-align: start` on selects + +## Calendar / appointment pickers + +| Component | Use for | +|-----------|---------| +| `ScheduleDayPicker` | Appointments strip — nav arrows + today toggle + expandable panel | +| `CalendarDaySelect` | Navigator wrapper (arrows + panel) | +| `CalendarDayPartsPanel` | Year / month / day row — used by schedule picker and `AppDateInput` | + +Persian (`fa`): Jalali calendar + `arabext` digits via Intl (`usesPersianCalendar`). Internal model stays **`Date` at local midnight** (Gregorian) — APIs unchanged. + +- Conversion: `frontend/src/lib/i18n/persianCalendar.ts` +- Gregorian typing: `frontend/src/lib/i18n/dateInputFormat.ts` +- Gregorian month labels: `schedule.monthJanuary` … message keys + +## RTL + +- `dir` / `lang` on `` from `app/[locale]/layout.tsx` +- `isRtlLocale` in `frontend/src/i18n/routing.ts` +- Use **logical** CSS: `text-start`, `text-end`, `ps-*`, `pe-*`, `ms-*`, `me-*` +- **`Table`**: default `[&_th]:text-start [&_td]:text-start`; override with `text-center` or `text-end` on cells — **never** `text-left` / `text-right` on headers (causes header/body column drift in RTL) +- Minimal overrides in `globals.css` — avoid double-mirroring (no extra `row-reverse` on shells that already inherit `direction: rtl`) + +## i18n strings + +- User-facing copy: `frontend/messages/{en,fa,nl}.json` — all three locales. + +## Verify + +```bash +cd frontend && npx tsc --noEmit +``` + +Manual: switch to Persian — Cases date filters, Tasks filter row (single line + sort visible), Staff/Orgs table columns aligned; switch to English — date fields match adjacent dropdown alignment. diff --git a/.cursor/skills/lab-case-share-link/SKILL.md b/.cursor/skills/lab-case-share-link/SKILL.md new file mode 100644 index 0000000..9be6bbc --- /dev/null +++ b/.cursor/skills/lab-case-share-link/SKILL.md @@ -0,0 +1,74 @@ +--- +name: dyolink-lab-case-share-link +description: Lab case QR share link — access token, focus page, auth redirect, Cases QR UI. Use when changing share links, /lab-case/[token], lab-case-access API, or post-login redirect from share URLs. +--- + +# Lab case share link + +Shipped cases get a stable **access token** and share URL. QR + link open a focused tasks page with comments — not a public page; JWT + org context required. + +## Data & token lifecycle + +- **Schema:** `LabCase.accessToken` (`String?`, `@unique`). +- **On first ship:** `treatments.service` sets `accessToken` + `sentAt` in the same update. +- **Backfill:** `LabCaseAccessService.ensureAccessToken()` for older sent cases when building case detail `shareUrl`. +- **URL:** `buildLabCaseShareUrl(token, locale)` → `{FRONTEND_URL}/{locale}/lab-case/{token}` (`backend/src/common/lab-case-access-token.ts`). + +## Backend API (`LabCaseAccessController`) + +Base path: `/lab-cases/access/:token` (JWT + selected org required). + +| Route | Purpose | +|-------|---------| +| `GET :token` | Session metadata (access mode, permissions, patient, prosthesis groups) | +| `GET :token/tasks` | All case tasks (includes assignee for status rules) | +| `GET/POST :token/comments` | List / add comments | +| `PATCH :token/comments/:id/visibility` | Lab only — clinic visibility toggle | + +**Access resolution** (`lab-case-access.service.ts`): + +| Actor | View | Edit task status | Comments | +|-------|------|------------------|----------| +| Lab + `TAB_TASKS_READ`/`EDIT` | ✅ | ✅ if `TAB_TASKS_EDIT` + assignee rules | Post/toggle if `TAB_TASKS_EDIT` | +| Clinic + `TAB_TREATMENT_EDIT` + **treatment provider** | ✅ | ❌ read-only | Post only (no visibility toggle) | +| Everyone else | ❌ `LAB_CASE_ACCESS_DENIED` | | | + +Task status updates use **`PATCH /tasks/:id`** (not token routes) — same assignee rule as Tasks tab: unassigned or assigned-to-you only. + +## Frontend + +| Piece | Path | +|-------|------| +| Focus page | `app/[locale]/(dashboard)/lab-case/[token]/page.tsx` → `CaseTasksFocusView` | +| API client | `lib/api/lab-case-access.ts` | +| QR UI | `LabCaseShareQrCode`, `LabCaseShareQrDialog`, thumb in `CaseDetailPanel` | +| QR package | `react-qr-code` (frontend only — no backend QR generation) | + +**Cases detail header:** attachment preview **left**, QR thumb **right**, same row (`w-24 sm:w-32`). QR opens dialog (large QR + URL + copy); no inline copy on panel. Only when `shareUrl` present (sent case). + +**Share focus page:** grouped tasks (reuse `TaskRow`, `TaskCaseGroupHeader`); comments section via `LabCaseCommentsPanel` + token API adapters. Access denied → inline message (`asApiError` for `LAB_CASE_ACCESS_DENIED`). + +## Auth redirect (logged out → login → back) + +Helpers: `lib/auth/postAuthRedirect.ts` (`sessionStorage` key `authRedirect`). + +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)`. +3. After login + org ready: **one** `consumeAuthRedirect()` on login page (wait for `!isLoading` and org selected). +4. **Do not** `consumeAuthRedirect()` inside `useAuth.login()` — double consume sends user to `/today`. +5. Multi-org: redirect stays in storage until `selectOrganization()` consumes it. + +## Tasks tab interaction + +Grouped sort (`sortBy=date`): **one comments control on case header** (`expandedCommentsCaseId`), not per task row. Flat sort unchanged (`showCommentsButton={flatMode}`). + +## i18n + +- `cases.*` — QR dialog strings (`shareQrDialogTitle`, `copyShareLink`, …) +- `labCaseAccess.*` — focus page strings +- `errors.LAB_CASE_ACCESS_DENIED` — all three locales + +## Verify + +- Backend: `npm run build`; apply migration for `accessToken`. +- Frontend: `npx tsc --noEmit`; `next build` (login page Suspense for `useSearchParams`). diff --git a/.cursor/skills/lab-cases/SKILL.md b/.cursor/skills/lab-cases/SKILL.md new file mode 100644 index 0000000..586f6e6 --- /dev/null +++ b/.cursor/skills/lab-cases/SKILL.md @@ -0,0 +1,43 @@ +--- +name: dyolink-lab-cases +description: Lab Cases tab — list, filters, detail panel, assignment, share QR, card UX. Use when changing CasesPage, cases API, CaseDetailPanel, or case list cards. +--- + +# Lab Cases tab + +**UI:** [`frontend/src/components/ui/lab/CasesPage.tsx`](frontend/src/components/ui/lab/CasesPage.tsx) +**Backend:** [`backend/src/modules/cases/`](backend/src/modules/cases/) +**Shared prosthesis rows:** [`LabCaseProsthesisGroupsList.tsx`](frontend/src/components/ui/lab/LabCaseProsthesisGroupsList.tsx) (also used in Treatment lab shipments rail) + +## List behavior + +- **Default sort:** `sentAt` desc (newest first). +- **Auto-select:** On tab open / after filter reload, select first list item if none selected; keep selection when still in list; `?caseId=` URL wins. +- **Right panel:** Always shows detail for selected case when list non-empty (loading state while fetching). + +## Filters (`GET /cases`) + +| Param | Behavior | +|-------|----------| +| `prosthesisTypeCode` | Cases with **≥1 task** of that prosthesis type (`tasks.some`) — not treatment-type filter | +| `clinicOrganizationId` | From URL (`Today` case partners chart) or dropdown | +| `q`, `sentFrom`, `sentTo` | Search + date range | + +Filter options: `GET /cases/filter-options` → `clinics`, `prosthesisTypes` (distinct codes from sent-case tasks, catalog-ordered). + +## List card UI + +Match Treatment shipment cards: patient name, clinic, **colored prosthesis groups + teeth** (`LabCaseProsthesisGroupsList`), sent date, progress bar, due-date badge, unread dot. **No patient mobile** on list cards. + +List item shape: `prosthesisGroups: { prosthesisTypeCode, teeth[] }[]` from task teeth aggregation. + +## Detail panel + +- Task assignment: `PATCH /cases/:caseId/tasks/:taskId/assign` (`TAB_CASES_EDIT`) +- Comments: shared `LabCaseCommentsPanel` + `tasksApi` comment routes +- Mark read: `POST /notifications/mark-case-read` on select (Cases tab badge) +- **Share link (sent cases):** `shareUrl` on detail; attachment preview left + QR thumb right; `LabCaseShareQrDialog` (`react-qr-code`). Full flow: `.cursor/skills/lab-case-share-link/SKILL.md`. + +## Permissions + +`TAB_CASES_READ` / `TAB_CASES_EDIT`; owner always has Cases access. `LabOrgGuard` on routes. diff --git a/.cursor/skills/lab-notifications/SKILL.md b/.cursor/skills/lab-notifications/SKILL.md new file mode 100644 index 0000000..761efb8 --- /dev/null +++ b/.cursor/skills/lab-notifications/SKILL.md @@ -0,0 +1,58 @@ +--- +name: dyolink-lab-notifications +description: Lab case activity feed + sidebar tab badge counts. Use when changing notifications API, LabCaseActivity, read state, or Sidebar badges for Cases/Tasks/Treatment. +--- + +# Lab notifications (tab badges) + +Backend: [`backend/src/modules/notifications/`](backend/src/modules/notifications/) +Activity types: [`backend/src/common/lab-case-activity.ts`](backend/src/common/lab-case-activity.ts) +Frontend hook: [`frontend/src/lib/hooks/useTabBadgeCounts.ts`](frontend/src/lib/hooks/useTabBadgeCounts.ts) + +## Models + +- **`LabCaseActivity`** — append-only events: `CASE_SENT`, `CLINIC_COMMENT`, `LAB_COMMENT`, `CASE_IMPORTANT`, `CASE_AMENDED` (stub for Step 7), `TASK_COMPLETED` +- **`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 + +## Tab badge buckets (Option B — split lab counts) + +| Org | Tab | Activity types | +|-----|-----|----------------| +| LAB | Cases | `CASE_SENT`, `CLINIC_COMMENT`, `CASE_IMPORTANT` | +| LAB | Tasks | `TASK_COMPLETED`, `LAB_COMMENT` | +| CLINIC | Treatment | `LAB_COMMENT` (only `visibleToClinic`), `TASK_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`. + +## APIs + +- `GET /notifications/tab-counts` → `{ cases?, tasks?, treatment? }` — **Cases** count = number of cases with unread Cases-bucket activity (per-case read cursor) +- `GET /notifications/lab-cases/:labCaseId/activities` — activity feed for a case (clinic-safe lab comments) +- `POST /notifications/mark-tab-read` `{ tab }` — Tasks only (Cases/Treatment skip tab-level clear) +- `POST /notifications/mark-case-read` `{ labCaseId }` — opening a case clears that case’s unread dot and updates Cases tab count +- `GET /treatments/patients/:patientId/lab-cases` — patient shipment summaries for Treatment rail + tracker cards +- `GET /treatments/lab-cases/unread` — org-wide unread shipment summaries for Treatment “All updates” scope + +## Emit activity from + +| Event | Service | +|-------|---------| +| First send | `treatments.service` `sendLabCase` → `CASE_SENT` | +| Comment | `lab-case-comments.service` → `CLINIC_COMMENT` / `LAB_COMMENT` | +| Mark important | `cases.service` `updateImportant` (only when set true) → `CASE_IMPORTANT` | +| Task completed | `tasks.service` `updateStatus` → `TASK_COMPLETED` | + +After mutations, frontend calls `notifyTabBadgesChanged()` (window event). + +## Frontend pattern (same as org connections) + +- `useTabBadgeCounts()` — fetch on pathname change + `tab-badges-changed` event +- `useMarkTabReadOnVisit()` — Tasks page only (Cases/Treatment badges clear when opening unread cases) +- `NavBadgePill` in [`Sidebar.tsx`](frontend/src/components/ui/shared/Sidebar.tsx) +- **Organizations** pending connections still use `usePendingConnectionsCount` (separate pending-state API) + +## Out of scope (later steps) + +- Push / email / websockets +- `CASE_AMENDED` emit (Step 7) diff --git a/.cursor/skills/lab-tasks/SKILL.md b/.cursor/skills/lab-tasks/SKILL.md new file mode 100644 index 0000000..425d0ba --- /dev/null +++ b/.cursor/skills/lab-tasks/SKILL.md @@ -0,0 +1,100 @@ +--- +name: dyolink-lab-tasks +description: Lab Tasks tab — list, sort, filters, case grouping, prosthesis colors, step-completed filter. Use when changing TasksPage, tasks API, or lab task list UX. +--- + +# Lab Tasks + +Main UI: [`frontend/src/components/ui/lab/TasksPage.tsx`](frontend/src/components/ui/lab/TasksPage.tsx) +Backend: [`backend/src/modules/tasks/`](backend/src/modules/tasks/) + +## Default sort (backend) + +`sortBy=date` + `sortDir=desc`: + +1. `labCase.sentAt` desc (newest case first) +2. `labCaseId`, `treatmentDetailId`, `prosthesisTypeCode` asc (stable grouping) +3. `stepOrder` asc (steps 1→N within prosthesis group) +4. `id` asc + +Other sorts use flat list on the frontend; `stepOrder asc` is still a tiebreaker. + +## Case grouping (frontend) + +- **`sortBy === 'date'`** → grouped view via [`taskListGrouping.ts`](frontend/src/components/lab/taskListGrouping.ts): case header → prosthesis sub-header → task rows. +- **Other sorts** → flat list; show muted hint (`groupingOff*` i18n keys). Each row keeps clinic/patient/teeth context. + +Components: `TaskCaseGroupHeader`, `TaskProsthesisGroupHeader`, `TaskRow`. + +**Grouped comments (`sortBy=date`):** single comments button on `TaskCaseGroupHeader`; panel expands below header (`expandedCommentsCaseId`). Per-task comments button only in **flat** sort (`showCommentsButton={flatMode}`). + +## Prosthesis colors + +- Map: [`catalog-type-colors.ts`](frontend/src/components/shared/catalog-type-colors.ts) → `PROSTHESIS_TYPE_COLORS` (one hex per catalog code). +- Resolve with [`prosthesisTypeDisplay.ts`](frontend/src/components/treatment/prosthesisTypeDisplay.ts) — use `prosthesisTypeBadgeStyleFromCatalog(code, catalog)`, **not** list row index. +- Load catalog via `prosthesisCatalogApi.list()` on Tasks/Cases/Today dashboard. + +## Filters + +| Param | API | UI | +|-------|-----|-----| +| `q`, `clinicOrganizationId`, `status` | `GET /tasks` | Search, clinic, status | +| `stepCompleted` | `GET /tasks` | Workflow step dropdown | +| `pinImportant` | `GET /tasks` | Important first (sort pin) | +| `assignedToMe` | `GET /tasks` | Only tasks assigned to current user | +| `prosthesisTypeCode` | `GET /tasks` | From Today prosthesis chart deep link | +| `unassignedOnly` | `GET /tasks` | Tasks with no assignee | +| `overdue` | `GET /tasks` | Cases with due date before today and at least one in-progress task | +| `sortBy=dueDate` | `GET /tasks` | Sort by `LabCase.dueDate` (flat list; grouping off) | +| Clinics + steps options | `GET /tasks/filter-options` | Populates dropdowns (not from current page) | + +**Task assignment:** Managed in **Cases** (`TAB_CASES_EDIT`), not on Tasks tab. `PATCH /cases/:caseId/tasks/:taskId/assign`; assignable staff via `GET /cases/assignable-staff` (members with `TAB_TASKS_EDIT`, including participating owner). Case detail task row: step label, status badge, assign dropdown, and last-updated line on one compact row. + +**Tasks visibility & status edit:** All tasks remain visible to every user with task access (no hiding assigned tasks). **Unassigned** tasks or tasks **assigned to you** → status dropdown when `TAB_TASKS_EDIT`. **Assigned to someone else** → read-only “Assigned to {name}” badge instead of the dropdown (backend rejects status PATCH). Same rules on **lab case share link** page (`CaseTasksFocusView` + `canEditLabTaskStatus`). Managers assign/monitor in Cases. + +**Step completed filter:** Restricts to prosthesis groups `(labCaseId, treatmentDetailId, prosthesisTypeCode)` where that `workflowStepCode` task is `COMPLETED`. Combined with `status=IN_PROGRESS`, returns only in-progress tasks in those groups (completed step row hidden). + +- **Important first:** `pinImportant=true` prepends important cases in sort order. +- **Assigned to me:** `assignedToMe=true` filters to current user's assigned tasks only. +- **Overdue cases:** `overdue=true` — `LabCase.dueDate` before start of UTC day **and** at least one task still `IN_PROGRESS`. Shown with error badge on Cases list/detail and task case headers. +- **Sort by due date:** `sortBy=dueDate` — flat list (grouping off); tiebreakers match other non-date sorts. +- **Reset view:** `resetView` restores `DEFAULT_TASKS_VIEW` from `tasksViewDefaults.ts`. +- **URL state:** `parseTasksSearchParams` applies Today deep-link query params on mount (`importantOnly`, `overdueOnly`, `unassignedOnly`, `prosthesisTypeCode`, `status`, sort). +- **Show in case:** flat-sort rows only; resets filters/sort, calls `GET /tasks/locate-page` to find the correct page in the full default-sorted list, then highlights + scrolls to the task. +- **Complete animation:** when marking done under in-progress filter, row plays exit animation + success toast before refetch. + +## APIs + +- `GET /tasks` — paginated flat task list (grouping is client-side when `sortBy=date`) +- `PATCH /tasks/:taskId` — update status (only assignee or unassigned task) +- `GET /tasks/filter-options` — clinics + workflow steps (localized) +- `GET /tasks/locate-page` — page number for a task in the sorted filtered list +- `GET /cases/assignable-staff` — staff eligible for task assignment +- `PATCH /cases/:caseId/tasks/:taskId/assign` — assign or unassign (`assigneeUserId` nullable) + +List items include `caseSentAt`, `caseDueDate`, `isCaseOverdue`, `assignee`, `assignedAt` for case headers / flat rows. + +## Case due dates (clinic → lab) + +- **Schema:** `LabCase.dueDate` (optional `DateTime`). +- **Clinic set:** Treatment lab dispatch panel — date input on unsent shipment (saved with draft/send); on sent cases, blur saves via `PATCH /treatments/lab-cases/:labCaseId/due-date`. +- **Edit lock:** Clinic cannot change due date after **all** tasks are completed (`taskProgress.completed === taskProgress.total`). +- **Lab display:** Cases list + detail; Tasks case group header when grouped by date. +- **Utils:** `backend/src/common/lab-case-due-date.ts`, `frontend/src/components/lab/labCaseDueDateDisplay.ts`, `LabCaseDueDateBadge`. + +## Mobile UX (Tasks + Treatment dispatch) + +Keep changes minimal — match existing `sm:` breakpoint patterns elsewhere in the app. + +- **Task status control:** `LAB_TASK_STATUS_SELECT_CLASS` in [`formSelectStyles.ts`](frontend/src/components/shared/formSelectStyles.ts) — full-width, `min-h-[44px]`, `text-base` on mobile; compact on `sm+`. Read-only status / assignee badges match height on mobile. +- **Sticky case header:** `TaskCaseGroupHeader` uses `sticky top-0 z-10` + translucent background when `sortBy=date` (grouped view). Sticks within dashboard `
` scroll. +- **Tasks filters:** filter ``) to avoid focus-driven scroll jumps. + + + +## Backend APIs + + + +| Endpoint | Purpose | + +|----------|---------| + +| `GET /appointments?from&to` | Strip | + +| `GET /treatments/patients/:patientId/history` | History (patient + org; filtered by provider) | + +| `GET /treatments/appointments/:id/draft` | Load form on appointment select | + +| `PUT .../draft`, `PUT .../lab-cases` | Autosave (600ms debounce) | + + + +Draft writes require provider match (`ensureAppointmentProvider`) unless org owner. + + + +## Edit gating + + + +```typescript + +canEditTreatmentForDay = canEdit && selectedAppointment && !isViewingPastDay && workspaceMode === 'live' + +``` + + + +## Optional fields + + + +- `@IsOptional()` email: use `@Transform` empty string → `undefined` before `@IsEmail` (see patients DTO). + +- Form validation → inline errors; transient feedback → global `useToast()` via `ToastProvider`. + + + +## When changing history scope + + + +Filter in **backend** `listPatientHistory` / lab-case lists on patient + org + **provider scope** (`common/treatment-provider-scope.ts`). History is **per selected patient and per clinician**, not per day or all org plans. + +**UI filters** (not shipped, date) are client-side only — do not add API params unless product explicitly requires server-side filtering. + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e248ce1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,119 @@ +# Dyolink — Agent guide + +This file orients Cursor agents at the start of a **new chat**. Project conventions live in **`.cursor/rules/`** (auto-loaded). Workflow playbooks live in **`.cursor/skills/`**. + +## What Dyolink is + +Dental clinic ↔ lab platform (monorepo): + +| Path | Stack | +|------|--------| +| `backend/` | NestJS, Prisma, PostgreSQL | +| `frontend/` | Next.js 16, React 19, next-intl, Tailwind | +| `infrastructure/` | Docker, nginx, deploy scripts | + +**Organization types:** `CLINIC` (patients, appointments, treatment) and `LAB` (cases, tasks). Many features are org-type-specific. Permissions use `TAB_*_READ` / `TAB_*_EDIT` codes — see `backend/src/common/permissions.ts`. + +## Before you code + +1. **Read applicable rules** in `.cursor/rules/` (especially `dyolink-overview` and the file-scoped rule for the area you touch). +2. **Match existing patterns** in the nearest feature folder — do not invent parallel structures. +3. **Keep diffs small** — one concern per change unless the user asks for a refactor. +4. **Verify:** `npm run build` (backend) and `npx tsc --noEmit` (frontend) when you change types or cross-cutting code. + +## Frontend layout (critical) + +``` +frontend/src/ + app/ → thin page.tsx only; compose from ui/ + components/ + ui/shared/ → cross-feature UI (Button, Sidebar, …) + ui/{feature}/ → feature UI (+ {Feature}Page.tsx for route logic) + shared/ → cross-feature non-UI (formatApiError, permissions, …) + {feature}/ → feature non-UI (helpers, config, pure functions) + lib/ → api clients, hooks + types/ → shared TS types + messages/{en,fa,nl}.json → all user-facing strings +``` + +**Example thin page:** `app/.../treatment/page.tsx` → imports `TreatmentWorkspace` from `components/ui/treatment/`. + +**i18n formatting:** Display dates/times/numbers via `lib/i18n/format.ts` + `useLocale()`. Form dates: **`AppDateInput`** (all locales — same component, masked typing + calendar popup). Appointments strip: **`ScheduleDayPicker`**. Filter ` { - setClinicId(e.target.value); - setPage(1); - }} - className={filterSelectClass} - > - - {filterOptions.clinics.map((clinic) => ( - - ))} - - - - - - - - - - - {hasActiveFilters ? ( - - ) : null} - -
- {loadingList ? ( -

{tCommon('loading')}

- ) : cases.length === 0 ? ( -

{t('emptyList')}

- ) : ( -
    - {cases.map((item) => { - const isActive = item.id === selectedCaseId; - - return ( -
  • - -
  • - ); - })} -
- )} -
- - {pagination.totalPages > 1 ? ( -
- - - {t('pageSummary', { - page: pagination.page, - totalPages: pagination.totalPages, - total: pagination.total, - })} - - -
- ) : null} - - -
- {mobileDetailOpen && selectedCaseId ? ( - setMobileDetailOpen(false)} /> - ) : null} - {!selectedCaseId ? ( -

{t('selectCaseHint')}

- ) : loadingDetail || !selectedCase ? ( -

{tCommon('loading')}

- ) : ( - void handleCaseImportantToggle(checked)} - headerMetaLines={ -

- {t('fromClinic', { name: selectedCase.clinic.name })} -

- } - commentsSection={ - selectedCaseId ? ( -
- { - const r = await tasksApi.listComments(selectedCaseId); - setCommentCount(r.data.length); - return r.data; - }} - onPost={async (body, visibleToClinic) => { - const r = await tasksApi.addComment(selectedCaseId, { - body, - visibleToClinic, - }); - setCommentCount((n) => n + 1); - return r.data; - }} - onToggleVisibility={async (commentId, visible) => { - const r = await tasksApi.setCommentVisibility(commentId, visible); - return r.data; - }} - onError={toast.showError} - /> -
- ) : null - } - /> - )} -
- - - - - ); -} +export default function Page() { + return ; +} \ No newline at end of file diff --git a/frontend/src/app/[locale]/(dashboard)/lab-case/[token]/page.tsx b/frontend/src/app/[locale]/(dashboard)/lab-case/[token]/page.tsx new file mode 100644 index 0000000..8568937 --- /dev/null +++ b/frontend/src/app/[locale]/(dashboard)/lab-case/[token]/page.tsx @@ -0,0 +1,10 @@ +import { CaseTasksFocusView } from '@/components/ui/lab/CaseTasksFocusView'; + +interface LabCaseAccessPageProps { + params: Promise<{ token: string }>; +} + +export default async function LabCaseAccessPage({ params }: LabCaseAccessPageProps) { + const { token } = await params; + return ; +} diff --git a/frontend/src/app/[locale]/(dashboard)/layout.tsx b/frontend/src/app/[locale]/(dashboard)/layout.tsx index 4d7ffbe..c3dc289 100644 --- a/frontend/src/app/[locale]/(dashboard)/layout.tsx +++ b/frontend/src/app/[locale]/(dashboard)/layout.tsx @@ -5,9 +5,11 @@ import { useTranslations } from 'next-intl'; import { Menu } from 'lucide-react'; import { usePathname, useRouter } from '@/i18n/navigation'; import { useAuth } from '@/lib/hooks/useAuth'; +import { storeAuthRedirectFromPath } from '@/lib/auth/postAuthRedirect'; import Sidebar from '@/components/ui/shared/Sidebar'; import { TopBarControls } from '@/components/ui/shared/TopBarControls'; import { DashboardAccountMenu } from '@/components/ui/dashboard/DashboardAccountMenu'; +import { ToastProvider } from '@/components/ui/shared/ToastProvider'; import { canAccessDashboardRoute, firstAccessibleDashboardPath, @@ -39,7 +41,12 @@ export default function DashboardLayout({ children }: { children: React.ReactNod if (!isAuthReady) return; if (!user) { - router.replace('/login'); + if (pathname && pathname !== '/login') { + storeAuthRedirectFromPath(pathname); + router.replace(`/login?from=${encodeURIComponent(pathname)}`); + } else { + router.replace('/login'); + } return; } @@ -70,7 +77,8 @@ export default function DashboardLayout({ children }: { children: React.ReactNod } return ( -
+ +
{sidebarOpen ? (
-
+ + ); } diff --git a/frontend/src/app/[locale]/(dashboard)/organizations/page.tsx b/frontend/src/app/[locale]/(dashboard)/organizations/page.tsx index 954960e..f4c5a8b 100644 --- a/frontend/src/app/[locale]/(dashboard)/organizations/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/organizations/page.tsx @@ -1,602 +1,7 @@ -'use client'; +'use client'; -import { useCallback, useEffect, useState } from 'react'; -import { useTranslations } from 'next-intl'; -import { useToast } from '@/lib/hooks/useToast'; -import { Check, History, Trash2, UserPlus, X } from 'lucide-react'; -import { useAuth } from '@/lib/hooks/useAuth'; -import { notifyPendingConnectionsChanged } from '@/lib/hooks/usePendingConnectionsCount'; -import { useOrganizationInviteLinkCopy } from '@/lib/hooks/useOrganizationInviteLinkCopy'; -import { - organizationApi, - type CounterpartItemDto, - type CounterpartSearchResultDto, - type OrganizationInvitationHistoryItemDto, -} from '@/lib/api/organization'; -import { invitationTargetFromConnectionRow } from '@/components/invitations/organizationInviteLinks'; -import { CopyInvitationLinkButton } from '@/components/ui/organizations/CopyInvitationLinkButton'; -import { OrganizationConnectionsMobileList } from '@/components/ui/organizations/OrganizationConnectionsMobileList'; -import { InvitationHistoryDialog } from '@/components/ui/organizations/InvitationHistoryDialog'; -import { ConnectionCaseHistoryContent } from '@/components/ui/organizations/ConnectionCaseHistoryContent'; -import { Button } from '@/components/ui/shared/Button'; -import { Badge, organizationConnectionStatusVariant } from '@/components/ui/shared/Badge'; -import { Input } from '@/components/ui/shared/Input'; -import { SearchBar } from '@/components/ui/shared/SearchBar'; -import { Table } from '@/components/ui/shared/Table'; -import { ToastStack } from '@/components/ui/shared/Toast'; -import type { ApiError } from '@/types/api'; +import { OrganizationsPage } from '@/components/ui/organizations/OrganizationsPage'; -function formatOrganizationStatusLabel(status: string): string { - if (!status) return status; - const lower = status.toLowerCase(); - return lower.charAt(0).toUpperCase() + lower.slice(1); -} - -function formatTableDate(value: string): string { - const d = new Date(value); - if (Number.isNaN(d.getTime())) return '\u2014'; - return d.toLocaleDateString(); -} - -type TableMode = 'existing' | 'search'; - -export default function OrganizationsPage() { - const t = useTranslations('organizations'); - const tNav = useTranslations('nav'); - const tCommon = useTranslations('common'); - const { currentOrganization } = useAuth(); - const [loading, setLoading] = useState(true); - const toast = useToast(); - - const formatApiMessage = useCallback( - (err: unknown): string => { - if (!err || typeof err !== 'object') return tCommon('errorGeneric'); - const m = (err as ApiError).message; - if (Array.isArray(m)) return m.join(', '); - if (typeof m === 'string') return m; - return tCommon('errorGeneric'); - }, - [tCommon], - ); - - const formatConnectionStatusLabel = useCallback( - (row: CounterpartItemDto, currentOrganizationId: string): string => { - if (row.status === 'PENDING') { - if ( - row.pendingInvitationId && - row.requestedByOrganizationId === currentOrganizationId - ) { - return t('statusInvitationPending'); - } - return t('statusConnectionPending'); - } - if (row.status === 'ACTIVE') return t('statusConnected'); - if (row.status === 'REJECTED') return t('statusDeclined'); - return formatOrganizationStatusLabel(row.status); - }, - [t], - ); - - const [query, setQuery] = useState(''); - const [mode, setMode] = useState('existing'); - const [searching, setSearching] = useState(false); - const [searchResults, setSearchResults] = useState([]); - const [pendingConnectionRowId, setPendingConnectionRowId] = useState(null); - const [deleteConnectionRowId, setDeleteConnectionRowId] = useState(null); - - const [items, setItems] = useState([]); - const [manualOrganizationName, setManualOrganizationName] = useState(''); - const [manualOwnerEmail, setManualOwnerEmail] = useState(''); - const [inviteLoading, setInviteLoading] = useState(false); - const [showInviteForm, setShowInviteForm] = useState(false); - const [historyOpen, setHistoryOpen] = useState(false); - const [historyLoading, setHistoryLoading] = useState(false); - const [historyItems, setHistoryItems] = useState([]); - const [caseHistoryConnection, setCaseHistoryConnection] = useState( - null, - ); - - const { - copiedId, - copyingInvitationId, - storeInviteLink, - copyInvitationLink, - pruneAcceptedLinks, - } = useOrganizationInviteLinkCopy(currentOrganization?.id); - - const counterpart = - currentOrganization?.type === 'LAB' ? t('counterpartClinic') : t('counterpartLab'); - const tabLabel = currentOrganization?.type === 'LAB' ? tNav('clinics') : tNav('labs'); - - const existingRows = items; - - async function loadList() { - setLoading(true); - toast.setError(''); - try { - const res = await organizationApi.list(); - setItems(res.data.items); - } catch (e) { - toast.showError(formatApiMessage(e)); - } finally { - setLoading(false); - } - } - - useEffect(() => { - void loadList(); - }, []); - - async function runSearch() { - const q = query.trim(); - if (!q) { - setMode('existing'); - setSearchResults([]); - setShowInviteForm(false); - return; - } - - setSearching(true); - toast.setError(''); - setMode('search'); - setShowInviteForm(false); - try { - const res = await organizationApi.search(q); - setSearchResults(res.data); - } catch (e) { - toast.showError(formatApiMessage(e)); - setSearchResults([]); - } finally { - setSearching(false); - } - } - - async function submitConnectionRequest(targetOrganizationId: string) { - setPendingConnectionRowId(targetOrganizationId); - toast.setError(''); - try { - await organizationApi.createConnectionRequest(targetOrganizationId); - toast.showSuccess(t('successConnectionSent', { counterpart })); - setSearchResults([]); - setQuery(''); - setMode('existing'); - await loadList(); - } catch (e) { - toast.showError(formatApiMessage(e)); - } finally { - setPendingConnectionRowId(null); - } - } - - async function sendInvite() { - setInviteLoading(true); - toast.setError(''); - try { - const res = await organizationApi.invite({ - organizationName: manualOrganizationName.trim(), - ownerEmail: manualOwnerEmail.trim(), - }); - storeInviteLink(res.data.invitationId, manualOwnerEmail, res.data.invitationUrl); - toast.showSuccess(t('successInviteCreated', { email: manualOwnerEmail.trim() })); - setManualOrganizationName(''); - setManualOwnerEmail(''); - setShowInviteForm(false); - setMode('existing'); - setQuery(''); - setSearchResults([]); - await loadList(); - } catch (e) { - toast.showError(formatApiMessage(e)); - } finally { - setInviteLoading(false); - } - } - - async function loadInvitationHistory() { - const res = await organizationApi.listInvitations(); - setHistoryItems(res.data.items); - pruneAcceptedLinks(res.data.items); - return res.data.items; - } - - async function openInvitationHistory() { - setHistoryOpen(true); - setHistoryLoading(true); - toast.clear(); - try { - await loadInvitationHistory(); - } catch (e) { - toast.showError(formatApiMessage(e)); - } finally { - setHistoryLoading(false); - } - } - - async function handleHistoryCopy(invitation: OrganizationInvitationHistoryItemDto) { - toast.setError(''); - try { - await copyInvitationLink(invitation, { - onRegenerated: async () => { - await loadInvitationHistory(); - }, - }); - toast.showSuccess(t('successLinkCopied')); - } catch (e) { - toast.showError(formatApiMessage(e)); - } - } - - async function handleCopyInvitationFromRow(row: CounterpartItemDto) { - const target = invitationTargetFromConnectionRow(row, currentOrganization!.id); - if (!target) return; - toast.setError(''); - try { - await copyInvitationLink( - { - id: target.id, - organizationName: row.organizationName, - ownerEmail: target.ownerEmail, - status: target.status, - createdAt: row.createdAt, - acceptedAt: target.acceptedAt, - }, - { - onRegenerated: async () => { - await loadList(); - }, - }, - ); - toast.showSuccess(t('successLinkCopied')); - } catch (e) { - toast.showError(formatApiMessage(e)); - } - } - - async function respondToPendingConnection(connectionId: string, action: 'ACCEPT' | 'REJECT') { - setPendingConnectionRowId(connectionId); - toast.setError(''); - try { - await organizationApi.respondToConnectionRequest(connectionId, action); - toast.showSuccess( - action === 'ACCEPT' ? t('successAccepted') : t('successDeclined'), - ); - notifyPendingConnectionsChanged(); - await loadList(); - } catch (e) { - toast.showError(formatApiMessage(e)); - } finally { - setPendingConnectionRowId(null); - } - } - - async function deleteConnection(connectionId: string) { - setDeleteConnectionRowId(connectionId); - toast.setError(''); - try { - await organizationApi.deleteConnection(connectionId); - toast.showSuccess(t('successRemoved')); - await loadList(); - } catch (e) { - toast.showError(formatApiMessage(e)); - } finally { - setDeleteConnectionRowId(null); - } - } - - function clearSearchView() { - setMode('existing'); - setQuery(''); - setSearchResults([]); - setShowInviteForm(false); - } - - if (!currentOrganization) { - return

{t('loadingOrganization')}

; - } - - if (caseHistoryConnection) { - return ( - setCaseHistoryConnection(null)} - /> - ); - } - - return ( -
-
-
-

{tabLabel}

-

{t('subtitle')}

-
- -
- - {!historyOpen && } - - void runSearch()} - placeholder={t('searchPlaceholder', { counterpart: counterpart.toLowerCase() })} - actions={ - <> - - {mode === 'search' && ( - - )} - - } - /> - - invitationTargetFromConnectionRow(row, currentOrganization.id)} - onCopyInvitation={(row) => void handleCopyInvitationFromRow(row)} - onRespond={(rowId, action) => void respondToPendingConnection(rowId, action)} - onViewCaseHistory={setCaseHistoryConnection} - onDeleteConnection={(rowId) => void deleteConnection(rowId)} - onSendConnectionRequest={(orgId) => void submitConnectionRequest(orgId)} - onToggleInviteForm={() => setShowInviteForm((v) => !v)} - onManualOrganizationNameChange={setManualOrganizationName} - onManualOwnerEmailChange={setManualOwnerEmail} - onSendInvite={() => void sendInvite()} - labels={{ - loading: tCommon('loadingEllipsis'), - emptyConnections: t('emptyConnections'), - noDirectoryResults: t('noDirectoryResults'), - hideInvitationFields: t('hideInvitationFields'), - sendInvitationLink: t('sendInvitationLink'), - counterpartNameLabel: t('counterpartNameLabel', { counterpart }), - ownerEmailLabel: t('ownerEmailLabel'), - sendInvitation: t('sendInvitation'), - sendRequest: t('sendRequest'), - acceptRequest: t('acceptRequest'), - declineRequest: t('declineRequest'), - viewCaseHistory: t('viewCaseHistory'), - removeConnection: t('removeConnection'), - statusToday: t('statusToday'), - statusFound: t('statusFound'), - }} - /> - -
- - - - - - - - } - body={ - <> - {loading ? ( - - - - ) : mode === 'existing' ? ( - existingRows.length === 0 ? ( - - - - ) : ( - existingRows.map((row) => { - const canRespond = - row.status === 'PENDING' && - row.requestedByOrganizationId !== null && - row.requestedByOrganizationId !== currentOrganization.id; - const invitationTarget = invitationTargetFromConnectionRow( - row, - currentOrganization.id, - ); - - return ( - - - - - - - - ); - }) - ) - ) : searchResults.length > 0 ? ( - searchResults.map((r) => ( - - - - - - - - )) - ) : ( - - - - )} - - } - /> - - - setHistoryOpen(false)} - loading={historyLoading} - items={historyItems} - copiedId={copiedId} - copyingInvitationId={copyingInvitationId} - onCopy={(invitation) => void handleHistoryCopy(invitation)} - toastMessages={toast.messages} - /> - - ); -} +export default function Page() { + return ; +} \ No newline at end of file diff --git a/frontend/src/app/[locale]/(dashboard)/patients/page.tsx b/frontend/src/app/[locale]/(dashboard)/patients/page.tsx index 9184d3e..1a49a82 100644 --- a/frontend/src/app/[locale]/(dashboard)/patients/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/patients/page.tsx @@ -1,160 +1,7 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; -import { useTranslations } from 'next-intl'; -import { Button } from '@/components/ui/shared/Button'; -import { ToastStack } from '@/components/ui/shared/Toast'; -import { patientsApi } from '@/lib/api/patients'; -import { formatApiErrorMessage } from '@/components/shared/formatApiError'; -import { useAuth } from '@/lib/hooks/useAuth'; -import { useToast } from '@/lib/hooks/useToast'; -import { hasPermission } from '@/components/shared/permissions'; -import { CreatePatientInput, Patient } from '@/types/patient'; -import { PatientSearchSelect } from '@/components/ui/patient/PatientSearchSelect'; -import { CreatePatientModal } from '@/components/ui/patient/CreatePatientModal'; -import { PatientSummaryCard } from '@/components/ui/patient/PatientSummaryCard'; +import { PatientsPage } from '@/components/ui/patient/PatientsPage'; -const EMPTY_PATIENT_FORM: CreatePatientInput = { - firstName: '', - lastName: '', - mobile: '', - email: '', -}; - -export default function PatientsPage() { - const t = useTranslations('patients'); - const tCommon = useTranslations('common'); - const { currentOrganization } = useAuth(); - const toast = useToast(); - const [search, setSearch] = useState(''); - const [patients, setPatients] = useState([]); - const [selectedPatient, setSelectedPatient] = useState(); - const [loadingPatients, setLoadingPatients] = useState(false); - const [isCreateOpen, setIsCreateOpen] = useState(false); - const [savingPatient, setSavingPatient] = useState(false); - const [patientForm, setPatientForm] = useState(EMPTY_PATIENT_FORM); - const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT'); - - const sortedPatients = useMemo( - () => - [...patients].sort((a, b) => - `${a.firstName} ${a.lastName}`.localeCompare(`${b.firstName} ${b.lastName}`), - ), - [patients], - ); - - useEffect(() => { - const timeout = setTimeout(() => { - void loadPatients(search); - }, 300); - return () => clearTimeout(timeout); - }, [search]); - - useEffect(() => { - void loadPatients(''); - }, []); - - async function loadPatients(q: string) { - setLoadingPatients(true); - toast.setError(''); - try { - const response = await patientsApi.list({ q, page: 1, limit: 25 }); - const items = response.data.items; - setPatients(items); - - if (selectedPatient) { - const freshSelected = items.find((item) => item.id === selectedPatient.id); - setSelectedPatient(freshSelected); - } - } catch (error: unknown) { - toast.showError(formatApiErrorMessage(error, t('errorLoadPatients'))); - } finally { - setLoadingPatients(false); - } - } - - async function handleCreatePatient() { - setSavingPatient(true); - toast.setError(''); - try { - const response = await patientsApi.create(patientForm); - setIsCreateOpen(false); - setPatientForm(EMPTY_PATIENT_FORM); - await loadPatients(search); - setSelectedPatient(response.data); - if (response.existing) { - toast.showInfo( - t('patientAlreadyExists', { - firstName: response.data.firstName, - lastName: response.data.lastName, - }), - ); - } else { - toast.showSuccess( - t('successPatientSaved', { - firstName: response.data.firstName, - lastName: response.data.lastName, - }), - ); - } - } catch (error: unknown) { - toast.showError(formatApiErrorMessage(error, t('errorSavePatient'))); - } finally { - setSavingPatient(false); - } - } - - return ( -
-
-

{t('title')}

- -
- - - - {isCreateOpen && ( - setPatientForm((prev) => ({ ...prev, ...patch }))} - onSubmit={() => void handleCreatePatient()} - onClose={() => { - setIsCreateOpen(false); - setPatientForm(EMPTY_PATIENT_FORM); - }} - loading={savingPatient} - /> - )} - -
-
- -
- -
- -
-
-
- ); -} +export default function Page() { + return ; +} \ No newline at end of file diff --git a/frontend/src/app/[locale]/(dashboard)/settings/account/page.tsx b/frontend/src/app/[locale]/(dashboard)/settings/account/page.tsx index a8ae347..658b73c 100644 --- a/frontend/src/app/[locale]/(dashboard)/settings/account/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/settings/account/page.tsx @@ -1,188 +1,7 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; -import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import * as z from 'zod'; -import { useTranslations } from 'next-intl'; -import { Link, useRouter } from '@/i18n/navigation'; -import { useSearchParams } from 'next/navigation'; -import { Lock } from 'lucide-react'; -import { useAuth } from '@/lib/hooks/useAuth'; -import { authApi } from '@/lib/api/auth'; -import { Button } from '@/components/ui/shared/Button'; -import { Input } from '@/components/ui/shared/Input'; -import { Toast } from '@/components/ui/shared/Toast'; +import { AccountSettingsPage } from '@/components/ui/settings/AccountSettingsPage'; -type PasswordForm = { - currentPassword: string; - newPassword: string; - confirmPassword: string; -}; - -export default function AccountSettingsPage() { - const t = useTranslations('settings'); - const tAuth = useTranslations('auth'); - const tCommon = useTranslations('common'); - const tValidation = useTranslations('validation'); - const { user, isAuthReady } = useAuth(); - const router = useRouter(); - const searchParams = useSearchParams(); - const isResetFlow = searchParams.get('reset') === '1'; - const [error, setError] = useState(null); - const [successMessage, setSuccessMessage] = useState(null); - const [isSubmitting, setIsSubmitting] = useState(false); - - const passwordSchema = useMemo( - () => - z - .object({ - currentPassword: z.string(), - newPassword: z - .string() - .min(8, tValidation('passwordMinLength')) - .regex(/[A-Z]/, tValidation('passwordUppercase')) - .regex(/[0-9]/, tValidation('passwordNumber')), - confirmPassword: z.string(), - }) - .refine((data) => data.newPassword === data.confirmPassword, { - message: tValidation('passwordsDoNotMatch'), - path: ['confirmPassword'], - }) - .superRefine((data, ctx) => { - if (!isResetFlow && !data.currentPassword.trim()) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: tValidation('passwordRequired'), - path: ['currentPassword'], - }); - } - }), - [isResetFlow, tValidation], - ); - - const { - register, - handleSubmit, - reset, - formState: { errors }, - } = useForm({ - resolver: zodResolver(passwordSchema), - defaultValues: { - currentPassword: '', - newPassword: '', - confirmPassword: '', - }, - }); - - useEffect(() => { - if (isAuthReady && !user) { - router.replace('/login'); - } - }, [isAuthReady, user, router]); - - const onSubmit = async (data: PasswordForm) => { - try { - setError(null); - setSuccessMessage(null); - setIsSubmitting(true); - - await authApi.changePassword({ - ...(isResetFlow ? {} : { currentPassword: data.currentPassword }), - newPassword: data.newPassword, - }); - - reset(); - setSuccessMessage(t('passwordChanged')); - router.replace('/login'); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : t('passwordChangeFailed'); - setError(message || t('passwordChangeFailed')); - } finally { - setIsSubmitting(false); - } - }; - - const passwordToggleLabels = { - show: tAuth('showPassword'), - hide: tAuth('hidePassword'), - }; - - if (!isAuthReady || !user) { - return ( -

{tCommon('loadingEllipsis')}

- ); - } - - return ( -
-
- - {tCommon('backToApp')} - -

{t('accountTitle')}

-

- {isResetFlow ? t('resetPasswordSubtitle') : t('accountSubtitle')} -

-
- -
-

- {isResetFlow ? t('resetPasswordTitle') : t('changePasswordTitle')} -

-

- {user.email} - {user.mobile ? ` · ${user.mobile}` : ''} -

- -
- {!isResetFlow && ( - } - passwordToggleLabels={passwordToggleLabels} - /> - )} - - } - passwordToggleLabels={passwordToggleLabels} - /> - - } - passwordToggleLabels={passwordToggleLabels} - /> - - {error && ( -
-

{error}

-
- )} - - - -
- - {successMessage && ( - {successMessage} - )} -
- ); -} +export default function Page() { + return ; +} \ No newline at end of file diff --git a/frontend/src/app/[locale]/(dashboard)/settings/subscriptions/page.tsx b/frontend/src/app/[locale]/(dashboard)/settings/subscriptions/page.tsx index 7888c07..9c19c20 100644 --- a/frontend/src/app/[locale]/(dashboard)/settings/subscriptions/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/settings/subscriptions/page.tsx @@ -1,204 +1,7 @@ 'use client'; -import { useEffect, useState } from 'react'; -import { useTranslations } from 'next-intl'; -import { Link, useRouter } from '@/i18n/navigation'; -import { useAuth } from '@/lib/hooks/useAuth'; -import { authApi } from '@/lib/api/auth'; -import { Button } from '@/components/ui/shared/Button'; -import { Toast } from '@/components/ui/shared/Toast'; -import type { SubscriptionAlertData } from '@/types/subscription'; +import { SubscriptionsPage } from '@/components/ui/settings/SubscriptionsPage'; -const PLAN_OPTIONS = [ - { id: 'solo', nameKey: 'planSolo' as const, maxUsers: 1, price: 19 }, - { id: 'small', nameKey: 'planSmall' as const, maxUsers: 5, price: 49 }, - { id: 'medium', nameKey: 'planMedium' as const, maxUsers: 10, price: 89 }, - { id: 'large', nameKey: 'planLarge' as const, maxUsers: 15, price: 129 }, - { id: 'enterprise', nameKey: 'planEnterprise' as const, maxUsers: null, price: 199 }, -] as const; - -export default function SubscriptionsSettingsPage() { - const t = useTranslations('settings'); - const tCommon = useTranslations('common'); - const { currentOrganization } = useAuth(); - const router = useRouter(); - const [alert, setAlert] = useState(null); - const [selectedPlanId, setSelectedPlanId] = useState(PLAN_OPTIONS[0].id); - const [purchaseNotice, setPurchaseNotice] = useState(null); - - useEffect(() => { - if (currentOrganization && !currentOrganization.isOwner) { - router.replace('/today'); - } - }, [currentOrganization, router]); - - useEffect(() => { - if (!currentOrganization?.isOwner) return; - void authApi.getSubscriptionAlert().then((r) => { - if (r.success) setAlert(r.data); - }); - }, [currentOrganization?.id, currentOrganization?.isOwner]); - - if (!currentOrganization) { - return ( -

{tCommon('loadingEllipsis')}

- ); - } - - if (!currentOrganization.isOwner) { - return ( -

{tCommon('redirecting')}

- ); - } - - const plan = currentOrganization.plan; - const hasActiveSubscription = Boolean(plan); - const selectedPlan = PLAN_OPTIONS.find((option) => option.id === selectedPlanId); - const maxUsers = plan?.maxUsers; - const isUnlimited = typeof maxUsers === 'number' && maxUsers >= 999999; - const seatsUsed = alert?.seatsUsed; - const seatsRemaining = - typeof seatsUsed === 'number' && typeof maxUsers === 'number' && !isUnlimited - ? Math.max(0, maxUsers - seatsUsed) - : null; - const daysUntilPlanEnd = alert?.daysUntilPlanEnd ?? null; - const planDayTone = - daysUntilPlanEnd == null - ? 'text-text-primary' - : daysUntilPlanEnd > 20 - ? 'text-emerald-400' - : daysUntilPlanEnd >= 10 - ? 'text-amber-300' - : 'text-red-400'; - - return ( -
-
- - {tCommon('backToApp')} - -

{t('subscriptionsTitle')}

-

- {t('subscriptionsSubtitle', { orgName: currentOrganization.name })} -

-
- -
- {!hasActiveSubscription && ( -
-

{t('noSubscriptionNotice')}

-
- )} - -
-
-

{t('currentPlan')}

-

- {plan?.name ?? '—'} -

-
-
-

{t('planPrice')}

-

- {typeof plan?.price === 'number' ? `$${plan.price}` : '—'} -

-
-
-

{t('seatsUsed')}

-

- {typeof seatsUsed === 'number' ? seatsUsed : '—'} - {typeof maxUsers === 'number' - ? ` / ${isUnlimited ? t('unlimited') : maxUsers}` - : ''} -

-
-
-

{t('seatsRemaining')}

-

- {isUnlimited ? t('unlimited') : seatsRemaining ?? '—'} -

-
-
-

{t('daysRemaining')}

-

- {daysUntilPlanEnd ?? '—'} -

-
-
- - {alert?.showWarning && ( -
- {alert.noActiveSubscription && ( -

{t('noActiveSubscription')}

- )} - {alert.trialExpired && ( -

{t('trialEnded')}

- )} - {!alert.trialExpired && alert.trialEndingSoon && ( -

- {t('trialEndsIn', { days: alert.daysUntilTrialEnd ?? '—' })} -

- )} - {!alert.trialExpired && !alert.trialEndingSoon && alert.seatsLow && ( -

{t('seatsLow')}

- )} -
- )} - -
-

{t('choosePlanIntro')}

-
- {PLAN_OPTIONS.map((option) => { - const selected = selectedPlanId === option.id; - return ( - - ); - })} -
- -
-
- - {purchaseNotice && ( -
-
- {purchaseNotice} -
-
- )} -
- ); -} +export default function Page() { + return ; +} \ No newline at end of file diff --git a/frontend/src/app/[locale]/(dashboard)/staff/page.tsx b/frontend/src/app/[locale]/(dashboard)/staff/page.tsx index bdfe947..42d7975 100644 --- a/frontend/src/app/[locale]/(dashboard)/staff/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/staff/page.tsx @@ -1,1121 +1,7 @@ 'use client'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useTranslations } from 'next-intl'; -import { useRouter } from '@/i18n/navigation'; -import { - firstAccessibleDashboardPath, - canEditStaff, - canViewStaff, -} from '@/components/shared/permissions'; -import { - permissionNamesFromFeatureState, - emptyFeaturePermissionState, - featureStateFromPermissionNames, - featureStateHasTreatmentEdit, - resolveStaffFeatureLabel, - formatAccessSummary, - staffFeatureGroupsForOrgType, - type FeaturePermState, -} from '@/components/staff/staff-permission-form'; -import { - StaffWorkingHoursStep, - createDefaultWorkingHoursState, - workingHoursPayloadFromState, - workingHoursStateFromApi, -} from '@/components/staff/StaffWorkingHoursStep'; -import { validateEditorDays, type WorkingHoursEditorDay } from '@/components/staff/workingHours'; -import { Pencil, Trash2, Copy, Check, X, UserX, UserCheck } from 'lucide-react'; -import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; -import { useAuth } from '@/lib/hooks/useAuth'; -import { staffApi, type StaffMemberDto } from '@/lib/api/staff'; -import { Button } from '@/components/ui/shared/Button'; -import { Badge } from '@/components/ui/shared/Badge'; -import { Input } from '@/components/ui/shared/Input'; -import { Checkbox } from '@/components/ui/shared/Checkbox'; -import { Table } from '@/components/ui/shared/Table'; -import { ToastStack } from '@/components/ui/shared/Toast'; -import { formatApiErrorMessage } from '@/components/shared/formatApiError'; -import { StaffMembersMobileList } from '@/components/staff/StaffMembersMobileList'; -import { useToast } from '@/lib/hooks/useToast'; +import { StaffPage } from '@/components/ui/staff/StaffPage'; -type StoredInviteLink = { - membershipId: string; - email: string; - invitationUrl: string; -}; - -function inviteLinksStorageKey(orgId: string): string { - return `staffInviteLinks:${orgId}`; -} - -function readStoredInviteLinks(orgId: string): Record { - if (typeof window === 'undefined') return {}; - try { - const raw = window.localStorage.getItem(inviteLinksStorageKey(orgId)); - if (!raw) return {}; - const parsed = JSON.parse(raw) as Record; - return parsed && typeof parsed === 'object' ? parsed : {}; - } catch { - return {}; - } -} - -function writeStoredInviteLinks(orgId: string, links: Record) { - if (typeof window === 'undefined') return; - window.localStorage.setItem(inviteLinksStorageKey(orgId), JSON.stringify(links)); -} - -function canShareStaffInviteLink(member: StaffMemberDto): boolean { - return ( - !member.isOwner && - (member.invitationStatus === 'PENDING' || member.invitationStatus === 'EXPIRED') - ); -} - -function canDisableStaff(member: StaffMemberDto): boolean { - return !member.isOwner && member.isActive; -} - -function canEnableStaff(member: StaffMemberDto): boolean { - return !member.isOwner && member.invitationStatus === 'DISABLED'; -} - -function PermissionGrid({ - state, - onChange, - disabled, - organizationType, -}: { - state: FeaturePermState; - onChange: (next: FeaturePermState) => void; - disabled?: boolean; - organizationType?: 'CLINIC' | 'LAB'; -}) { - const t = useTranslations('staff'); - const tFeatures = useTranslations('staff.features'); - - const setRead = (editKey: string, read: boolean) => { - const cur = state[editKey] ?? { read: false, edit: false }; - onChange({ - ...state, - [editKey]: { read, edit: read ? cur.edit : false }, - }); - }; - - const setEdit = (editKey: string, edit: boolean) => { - const cur = state[editKey] ?? { read: false, edit: false }; - onChange({ - ...state, - [editKey]: { read: edit || cur.read, edit }, - }); - }; - - return ( -
- {staffFeatureGroupsForOrgType(organizationType).map((g) => { - const cell = state[g.edit] ?? { read: false, edit: false }; - return ( -
- - {resolveStaffFeatureLabel(g, organizationType, tFeatures)} - -
- setRead(g.edit, v)} - /> - setEdit(g.edit, v)} - /> -
-
- ); - })} -
- ); -} - -export default function StaffPage() { - const router = useRouter(); - const t = useTranslations('staff'); - const tCommon = useTranslations('common'); - const tFeatures = useTranslations('staff.features'); - const tWorkingHours = useTranslations('staff.workingHours'); - const { currentOrganization, user } = useAuth(); - const [members, setMembers] = useState([]); - const [seats, setSeats] = useState<{ - used: number; - limit: number | null; - unlimited: boolean; - } | null>(null); - const [loading, setLoading] = useState(true); - const toast = useToast(); - - const [inviteOpen, setInviteOpen] = useState(false); - const [inviteStep, setInviteStep] = useState<1 | 2>(1); - const [inviteEmail, setInviteEmail] = useState(''); - const [inviteName, setInviteName] = useState(''); - const [invitePerms, setInvitePerms] = useState(() => emptyFeaturePermissionState()); - const [inviteWorkingHoursDays, setInviteWorkingHoursDays] = useState( - () => createDefaultWorkingHoursState().days, - ); - const [inviteAutoRepeatWeekly, setInviteAutoRepeatWeekly] = useState(true); - const [inviteHoursValidationError, setInviteHoursValidationError] = useState(null); - const [inviteLoading, setInviteLoading] = useState(false); - const [copiedInviteMembershipId, setCopiedInviteMembershipId] = useState(null); - const [copyingInviteMembershipId, setCopyingInviteMembershipId] = useState(null); - const [lastInviteInfo, setLastInviteInfo] = useState<{ - membershipId: string; - name: string; - email: string; - invitationUrl: string | null; - invitationStatus: 'PENDING' | 'ACCEPTED'; - } | null>(null); - const [pendingInviteLinks, setPendingInviteLinks] = useState>({}); - - const [editing, setEditing] = useState(null); - const [editStep, setEditStep] = useState<1 | 2>(1); - const [editName, setEditName] = useState(''); - const [editPerms, setEditPerms] = useState(() => emptyFeaturePermissionState()); - const [editWorkingHoursDays, setEditWorkingHoursDays] = useState( - () => createDefaultWorkingHoursState().days, - ); - const [editAutoRepeatWeekly, setEditAutoRepeatWeekly] = useState(true); - const [editHoursValidationError, setEditHoursValidationError] = useState(null); - const [editLoadingWorkingHours, setEditLoadingWorkingHours] = useState(false); - const [editLoading, setEditLoading] = useState(false); - const [disableTarget, setDisableTarget] = useState(null); - const [disablingMembershipId, setDisablingMembershipId] = useState(null); - const [enableTarget, setEnableTarget] = useState(null); - const [enablingMembershipId, setEnablingMembershipId] = useState(null); - - const canEdit = useMemo(() => canEditStaff(currentOrganization), [currentOrganization]); - const inviteHasTreatmentEdit = useMemo( - () => - currentOrganization?.type === 'CLINIC' && featureStateHasTreatmentEdit(invitePerms), - [currentOrganization?.type, invitePerms], - ); - const editHasTreatmentEdit = useMemo( - () => currentOrganization?.type === 'CLINIC' && featureStateHasTreatmentEdit(editPerms), - [currentOrganization?.type, editPerms], - ); - const hasActivePlan = Boolean(currentOrganization?.plan); - const atSeatLimit = useMemo(() => { - if (!seats || seats.unlimited) return false; - if (seats.limit == null) return false; - return seats.used >= seats.limit; - }, [seats]); - - const hasAvailableSeat = useMemo(() => { - if (!seats || seats.unlimited) return true; - if (seats.limit == null) return true; - return seats.used < seats.limit; - }, [seats]); - - const load = useCallback(async () => { - toast.setError(''); - setLoading(true); - try { - const res = await staffApi.list(); - setMembers(res.data.members); - setSeats(res.data.seats); - } catch (e) { - toast.showError(formatApiErrorMessage(e, t('errorLoadStaff'))); - } finally { - setLoading(false); - } - }, [t]); - - useEffect(() => { - if (!currentOrganization?.id) return; - setPendingInviteLinks(readStoredInviteLinks(currentOrganization.id)); - }, [currentOrganization?.id]); - - useEffect(() => { - if (!currentOrganization?.id || loading) return; - - const activeMemberIds = new Set( - members - .filter((m) => m.isOwner || m.invitationStatus === 'ACTIVE') - .map((m) => m.id), - ); - - let changed = false; - const nextLinks: Record = { ...pendingInviteLinks }; - for (const memberId of Object.keys(nextLinks)) { - if (activeMemberIds.has(memberId)) { - delete nextLinks[memberId]; - changed = true; - } - } - if (!changed) return; - - setPendingInviteLinks(nextLinks); - writeStoredInviteLinks(currentOrganization.id, nextLinks); - }, [currentOrganization?.id, loading, members, pendingInviteLinks]); - - useEffect(() => { - void load(); - }, [load]); - - useEffect(() => { - if (!currentOrganization) return; - if (!canViewStaff(currentOrganization)) { - router.replace(firstAccessibleDashboardPath(currentOrganization)); - } - }, [currentOrganization, router]); - - async function copyStaffInviteLink(member: StaffMemberDto) { - if (!canShareStaffInviteLink(member)) return; - - setCopyingInviteMembershipId(member.id); - toast.setError(''); - try { - let invitationUrl = pendingInviteLinks[member.id]?.invitationUrl; - if (!invitationUrl || member.invitationStatus === 'EXPIRED') { - const res = await staffApi.getInvitationLink(member.id); - invitationUrl = res.data.invitationUrl; - if (currentOrganization?.id) { - const nextLinks = { - ...pendingInviteLinks, - [member.id]: { - membershipId: member.id, - email: member.email, - invitationUrl, - }, - }; - setPendingInviteLinks(nextLinks); - writeStoredInviteLinks(currentOrganization.id, nextLinks); - } - } - await navigator.clipboard.writeText(invitationUrl); - setCopiedInviteMembershipId(member.id); - setTimeout(() => setCopiedInviteMembershipId(null), 1500); - if (member.invitationStatus === 'EXPIRED') { - await load(); - } - } catch (e) { - toast.showError(formatApiErrorMessage(e, t('errorCopyInvite'))); - } finally { - setCopyingInviteMembershipId(null); - } - } - - function resetInviteForm() { - setInviteStep(1); - setInviteEmail(''); - setInviteName(''); - setInvitePerms(emptyFeaturePermissionState(currentOrganization?.type)); - const defaults = createDefaultWorkingHoursState(); - setInviteWorkingHoursDays(defaults.days); - setInviteAutoRepeatWeekly(defaults.autoRepeatWeekly); - setInviteHoursValidationError(null); - } - - async function saveInviteWorkingHours(membershipId: string, includeHours: boolean) { - if (!includeHours || !inviteHasTreatmentEdit) { - return; - } - const validationError = validateEditorDays(inviteWorkingHoursDays, tWorkingHours); - if (validationError) { - throw new Error(validationError); - } - await staffApi.upsertWorkingHours( - membershipId, - workingHoursPayloadFromState({ - days: inviteWorkingHoursDays, - autoRepeatWeekly: inviteAutoRepeatWeekly, - }), - ); - } - - async function submitInvite(includeWorkingHours: boolean) { - setInviteLoading(true); - toast.setError(''); - setLastInviteInfo(null); - const displayName = inviteName.trim(); - const displayEmail = inviteEmail.trim(); - try { - if (includeWorkingHours && inviteHasTreatmentEdit) { - const validationError = validateEditorDays(inviteWorkingHoursDays, tWorkingHours); - if (validationError) { - toast.showError(validationError); - return; - } - } - - const permissionNames = permissionNamesFromFeatureState(invitePerms); - const res = await staffApi.invite({ - email: displayEmail, - name: displayName, - permissionNames, - }); - - if (includeWorkingHours) { - await saveInviteWorkingHours(res.data.membershipId, true); - } - - setLastInviteInfo({ - membershipId: res.data.membershipId, - name: displayName, - email: res.data.email, - invitationUrl: res.data.invitationUrl, - invitationStatus: res.data.invitationStatus, - }); - if (currentOrganization?.id && res.data.invitationUrl) { - const nextLinks = { - ...pendingInviteLinks, - [res.data.membershipId]: { - membershipId: res.data.membershipId, - email: res.data.email, - invitationUrl: res.data.invitationUrl, - }, - }; - setPendingInviteLinks(nextLinks); - writeStoredInviteLinks(currentOrganization.id, nextLinks); - } - setInviteOpen(false); - resetInviteForm(); - await load(); - } catch (e) { - toast.showError(formatApiErrorMessage(e, t('errorSendInvite'))); - } finally { - setInviteLoading(false); - } - } - - async function openEdit(m: StaffMemberDto) { - if (m.isOwner) return; - setEditing(m); - setEditStep(1); - setEditName(m.name); - setEditPerms(featureStateFromPermissionNames(m.permissions ?? [], currentOrganization?.type)); - setEditHoursValidationError(null); - const defaults = createDefaultWorkingHoursState(); - setEditWorkingHoursDays(defaults.days); - setEditAutoRepeatWeekly(defaults.autoRepeatWeekly); - setEditLoadingWorkingHours(true); - try { - const res = await staffApi.getWorkingHours(m.id); - const state = workingHoursStateFromApi(res.data); - setEditWorkingHoursDays(state.days); - setEditAutoRepeatWeekly(state.autoRepeatWeekly); - } catch (e) { - toast.showError(formatApiErrorMessage(e, t('errorLoadWorkingHours'))); - } finally { - setEditLoadingWorkingHours(false); - } - } - - async function submitEdit() { - if (!editing) return; - if (editHasTreatmentEdit) { - const validationError = validateEditorDays(editWorkingHoursDays, tWorkingHours); - if (validationError) { - toast.showError(validationError); - return; - } - } - - setEditLoading(true); - toast.setError(''); - try { - if (editHasTreatmentEdit) { - await staffApi.upsertWorkingHours( - editing.id, - workingHoursPayloadFromState({ - days: editWorkingHoursDays, - autoRepeatWeekly: editAutoRepeatWeekly, - }), - ); - } - - await staffApi.updateMember(editing.id, { - name: editName.trim(), - permissionNames: permissionNamesFromFeatureState(editPerms), - }); - - toast.showSuccess(t('successMemberUpdated')); - setEditing(null); - setEditStep(1); - await load(); - } catch (e) { - toast.showError(formatApiErrorMessage(e, t('errorUpdateMember'))); - } finally { - setEditLoading(false); - } - } - - function handleDeleteMember() { - toast.showError(t('errorDeleteNotImplemented')); - } - - async function confirmDisableMember() { - if (!disableTarget || !canDisableStaff(disableTarget)) return; - - setDisablingMembershipId(disableTarget.id); - toast.setError(''); - try { - await staffApi.disableMember(disableTarget.id); - toast.showSuccess(t('successMemberDisabled', { name: disableTarget.name })); - setDisableTarget(null); - await load(); - } catch (e) { - toast.showError(formatApiErrorMessage(e, t('errorDisableMember'))); - } finally { - setDisablingMembershipId(null); - } - } - - async function confirmEnableMember() { - if (!enableTarget || !canEnableStaff(enableTarget) || !hasAvailableSeat) return; - - setEnablingMembershipId(enableTarget.id); - toast.setError(''); - try { - await staffApi.enableMember(enableTarget.id); - toast.showSuccess(t('successMemberEnabled', { name: enableTarget.name })); - setEnableTarget(null); - await load(); - } catch (e) { - toast.showError(formatApiErrorMessage(e, t('errorEnableMember'))); - } finally { - setEnablingMembershipId(null); - } - } - - if (!currentOrganization || !canViewStaff(currentOrganization)) { - return ( -

{t('redirecting')}

- ); - } - - return ( -
-
-
-

{t('title')}

-

{t('subtitle')}

-
- -
- - - - {seats && ( -

- {t('seatsLabel')}{' '} - - {seats.used} - {seats.unlimited ? ` ${t('unlimitedPlan')}` : ` / ${seats.limit}`} - - {!seats.unlimited && atSeatLimit && ( - - {hasActivePlan ? t('seatLimitReached') : t('noActivePlan')} - - )} -

- )} - - {lastInviteInfo && ( -
- -

- {t('successInvited', { name: lastInviteInfo.name, email: lastInviteInfo.email })} - {lastInviteInfo.invitationStatus === 'PENDING' - ? ` ${t('invitedPending')}` - : ` ${t('invitedAccepted')}`} -

- {lastInviteInfo.invitationStatus === 'PENDING' && ( -
-

- {t('inviteLinkHeading')} -

- {lastInviteInfo.invitationUrl && ( - - {lastInviteInfo.invitationUrl} - - )} - -

{t('shareLinkHint')}

-
- )} -
- )} - - {loading ? ( -

{t('loadingTeam')}

- ) : ( - <> - - formatAccessSummary(member.permissions, currentOrganization?.type, tFeatures) - } - canShareInviteLink={canShareStaffInviteLink} - canEnable={canEnableStaff} - canDisable={canDisableStaff} - onCopyInviteLink={(member) => void copyStaffInviteLink(member)} - onEnable={setEnableTarget} - onDisable={setDisableTarget} - onEdit={openEdit} - onDelete={() => handleDeleteMember()} - labels={{ - roleOwner: t('roleOwner'), - roleStaff: t('roleStaff'), - statusActive: t('statusActive'), - statusPending: t('statusPending'), - statusDisabled: t('statusDisabled'), - statusExpired: t('statusExpired'), - allFeatures: t('allFeatures'), - copyInviteLink: t('copyInviteLinkTitle'), - enableMemberTitle: t('enableMemberTitle'), - disableMemberTitle: t('disableMemberTitle'), - editMemberAria: t('editMemberAria'), - deleteMemberAria: t('deleteMemberAria'), - }} - /> -
-
- {t('tableOrganization')} - - {t('tableOwnerEmail')} - - {t('tableDate')} - - {t('tableStatus')} - - {t('tableAction')} -
- {tCommon('loadingEllipsis')} -
- {t('emptyConnections')} -
- {row.organizationName} - {row.ownerEmail} - {formatTableDate(row.createdAt)} - - - {formatConnectionStatusLabel(row, currentOrganization.id)} - - -
- {invitationTarget && ( - void handleCopyInvitationFromRow(row)} - /> - )} - {canRespond && ( - <> - - - - )} - {row.status === 'ACTIVE' && ( - <> - - - - )} -
-
{r.name}{r.owner.email}{t('statusToday')} - {t('statusFound')} - - -
-
-

- {t('noDirectoryResults')} -

-
- -
- {showInviteForm && ( -
- setManualOrganizationName(e.target.value)} - /> - setManualOwnerEmail(e.target.value)} - /> -
- -
-
- )} -
-
- - - - - - - - } - body={ - <> - {members.map((m) => ( - - - - - - - - - ))} - - } - /> - - - )} - - {inviteOpen && ( -
-
-
-
-

- {t('inviteModalTitle')} -

- {inviteHasTreatmentEdit && ( -

{t('stepOf', { step: inviteStep })}

- )} -
- { - setInviteOpen(false); - resetInviteForm(); - }} - /> -
- - {inviteStep === 1 ? ( - <> - setInviteEmail(e.target.value)} - autoComplete="off" - /> - setInviteName(e.target.value)} - /> -
-

{t('tabAccess')}

- -
- - ) : ( - - )} - -
- - {inviteStep === 1 ? ( - inviteHasTreatmentEdit ? ( - - ) : ( - - ) - ) : ( - <> - - - - )} -
-
-
- )} - - {enableTarget && ( -
-
-
-

- {t('enableModalTitle')} -

- { - if (enablingMembershipId) return; - setEnableTarget(null); - }} - /> -
-

- {t('enableConfirm', { name: enableTarget.name, email: enableTarget.email })} -

-
    -
  • {t('enableBullet1')}
  • -
  • {t('enableBullet2')}
  • -
  • {t('enableBullet3')}
  • -
- {!hasAvailableSeat && ( -

{t('noSeatsAvailable')}

- )} -
- - -
-
-
- )} - - {disableTarget && ( -
-
-
-

- {t('disableModalTitle')} -

- { - if (disablingMembershipId) return; - setDisableTarget(null); - }} - /> -
-

- {t('disableConfirm', { name: disableTarget.name, email: disableTarget.email })} -

-
    -
  • {t('disableBullet1')}
  • -
  • {t('disableBullet2')}
  • -
  • {t('disableBullet3')}
  • -
-
- - -
-
-
- )} - - {editing && ( -
-
-
-
-

{t('editModalTitle')}

- {editHasTreatmentEdit && ( -

{t('stepOf', { step: editStep })}

- )} -
- { - setEditing(null); - setEditStep(1); - }} - /> -
-

{editing.email}

- - {editStep === 1 ? ( - <> - setEditName(e.target.value)} - /> -
-

{t('tabAccess')}

- -
- - ) : editLoadingWorkingHours ? ( -

{t('loadingWorkingHours')}

- ) : ( - - )} - -
- - {editStep === 1 ? ( - editHasTreatmentEdit ? ( - - ) : ( - - ) - ) : ( - - )} -
-
-
- )} - - ); -} +export default function Page() { + return ; +} \ No newline at end of file diff --git a/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx b/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx index d3f2b24..d293eb1 100644 --- a/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx @@ -1,402 +1,7 @@ 'use client'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useTranslations } from 'next-intl'; -import { MessageSquare } from 'lucide-react'; -import { ToastStack } from '@/components/ui/shared/Toast'; -import { Badge } from '@/components/ui/shared/Badge'; -import { Button } from '@/components/ui/shared/Button'; -import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles'; -import { SearchBar } from '@/components/ui/shared/SearchBar'; -import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel'; -import { - labTaskStatusSelectStyle, - labTaskStatusVariant, -} from '@/components/ui/lab/labTaskStatusDisplay'; -import { - formatToothList, - prosthesisTypeBadgeStyle, -} from '@/components/ui/treatment/prosthesisTypeDisplay'; -import { formatApiErrorMessage } from '@/components/shared/formatApiError'; -import { canEditTasks, canViewTasks } from '@/components/shared/permissions'; -import { useAuth } from '@/lib/hooks/useAuth'; -import { useToast } from '@/lib/hooks/useToast'; -import { tasksApi } from '@/lib/api/tasks'; -import type { - LabTaskListItem, - LabTaskStatus, - ListLabTasksParams, - PaginatedLabTasks, - TaskSortField, -} from '@/types/cases'; +import { TasksPage } from '@/components/ui/lab/TasksPage'; -const PAGE_SIZE = 50; - -function formatPatientName(patient: { firstName: string; lastName: string }) { - return `${patient.firstName} ${patient.lastName}`.trim(); -} - -export default function TasksPage() { - const t = useTranslations('tasks'); - const { currentOrganization, user, isAuthReady } = useAuth(); - const { showError, setError, messages: toastMessages } = useToast(); - - const [tasks, setTasks] = useState([]); - const [pagination, setPagination] = useState({ - page: 1, - limit: PAGE_SIZE, - total: 0, - totalPages: 1, - }); - const [page, setPage] = useState(1); - const [loading, setLoading] = useState(false); - const [updatingTaskId, setUpdatingTaskId] = useState(null); - const [expandedCommentsTaskId, setExpandedCommentsTaskId] = useState(null); - - const [search, setSearch] = useState(''); - const [clinicId, setClinicId] = useState(''); - const [statusFilter, setStatusFilter] = useState<'' | LabTaskStatus>('IN_PROGRESS'); - const [sentFrom, setSentFrom] = useState(''); - const [sentTo, setSentTo] = useState(''); - const [sortBy, setSortBy] = useState('date'); - const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc'); - - const canView = canViewTasks(currentOrganization); - const canEdit = canEditTasks(currentOrganization); - const locale = user?.language ?? 'en'; - - const tRef = useRef(t); - tRef.current = t; - - const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo( - () => [ - { value: 'IN_PROGRESS', label: t('statusInProgress') }, - { value: 'COMPLETED', label: t('statusCompleted') }, - ], - [t], - ); - - const listParams = useMemo((): ListLabTasksParams => { - const params: ListLabTasksParams = { - page, - limit: PAGE_SIZE, - sortBy, - sortDir, - }; - if (search.trim()) params.q = search.trim(); - if (clinicId) params.clinicOrganizationId = clinicId; - if (statusFilter) params.status = statusFilter; - if (sentFrom) params.sentFrom = sentFrom; - if (sentTo) params.sentTo = sentTo; - return params; - }, [page, search, clinicId, statusFilter, sentFrom, sentTo, sortBy, sortDir]); - - const clinicOptions = useMemo(() => { - const map = new Map(); - for (const task of tasks) { - map.set(task.clinic.id, task.clinic.name); - } - return [...map.entries()].map(([id, name]) => ({ id, name })); - }, [tasks]); - - const loadTasks = useCallback(async () => { - setLoading(true); - setError(''); - try { - const response = await tasksApi.list(listParams); - setTasks(response.data.items); - setPagination(response.data.pagination); - } catch (error: unknown) { - showError(formatApiErrorMessage(error, tRef.current('errorLoadList'))); - } finally { - setLoading(false); - } - }, [listParams, showError, setError]); - - useEffect(() => { - if (!canView) return; - const timeout = setTimeout(() => void loadTasks(), search ? 300 : 0); - return () => clearTimeout(timeout); - }, [canView, loadTasks, search]); - - async function handleStatusUpdate(taskId: string, status: LabTaskStatus) { - if (!canEdit) return; - setUpdatingTaskId(taskId); - setError(''); - try { - await tasksApi.updateStatus(taskId, status); - await loadTasks(); - } catch (error: unknown) { - showError(formatApiErrorMessage(error, t('errorUpdateTask'))); - } finally { - setUpdatingTaskId(null); - } - } - - function formatTaskDate(value: string) { - return new Intl.DateTimeFormat(locale, { - year: 'numeric', - month: 'short', - day: 'numeric', - }).format(new Date(value)); - } - - const filterSelectClass = `${FORM_SELECT_CLASS} w-full rounded-md px-2 py-1.5 text-sm`; - - if (!isAuthReady) { - return
{t('loading')}
; - } - - if (!canView) { - return ( -
-

{t('noPermissionTitle')}

-

{t('noPermissionBody')}

-
- ); - } - - return ( -
-
-

{t('title')}

-

{t('subtitle')}

-
- -
- { - setSearch(v); - setPage(1); - }} - placeholder={t('searchPlaceholder')} - /> -
- - - -
-
- -
- {loading && tasks.length === 0 ? ( -

{t('loading')}

- ) : tasks.length === 0 ? ( -

{t('emptyList')}

- ) : ( -
    - {tasks.map((task, index) => { - const commentsOpen = expandedCommentsTaskId === task.id; - - return ( -
  • -
    -
    -
    -

    - {task.stepOrder}. {task.stepLabel} -

    - {task.isImportant ? ( - - {t('importantBadge')} - - ) : null} -
    -

    - {t('fromClinic', { name: task.clinic.name })} ·{' '} - {formatPatientName(task.patient)} ·{' '} - {t('teethLabel', { teeth: formatToothList(task.teeth) })} -

    -

    - {t('taskDate', { date: formatTaskDate(task.createdAt) })} - {task.lastStatusChangedBy ? ( - <> - · - - {t('lastUpdatedBy', { name: task.lastStatusChangedBy.name })} - - - ) : null} -

    -
    - -
    - {canEdit ? ( - - ) : ( - - {statusOptions.find((opt) => opt.value === task.status)?.label ?? - task.status} - - )} -
    - -
    - {canEdit ? ( - - ) : null} - - {task.prosthesisTypeLabel} - -
    -
    - - {commentsOpen && canEdit ? ( -
    - { - const r = await tasksApi.listComments(task.labCaseId); - return r.data; - }} - onPost={async (body, visibleToClinic) => { - const r = await tasksApi.addComment(task.labCaseId, { - body, - visibleToClinic, - }); - return r.data; - }} - onToggleVisibility={async (commentId, visible) => { - const r = await tasksApi.setCommentVisibility(commentId, visible); - return r.data; - }} - onError={showError} - /> -
    - ) : null} -
  • - ); - })} -
- )} -
- - {pagination.totalPages > 1 && ( -
-

- {t('pageSummary', { - page: pagination.page, - totalPages: pagination.totalPages, - total: pagination.total, - })} -

-
- - -
-
- )} - - -
- ); -} +export default function Page() { + return ; +} \ No newline at end of file diff --git a/frontend/src/app/[locale]/(dashboard)/today/page.tsx b/frontend/src/app/[locale]/(dashboard)/today/page.tsx index b51a2a2..9ca7f39 100644 --- a/frontend/src/app/[locale]/(dashboard)/today/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/today/page.tsx @@ -1,79 +1,7 @@ 'use client'; -import { useMemo } from 'react'; -import { useTranslations } from 'next-intl'; -import { Link } from '@/i18n/navigation'; -import { useAuth } from '@/lib/hooks/useAuth'; -import { formatApiErrorMessage } from '@/components/shared/formatApiError'; -import { TodayDashboard } from '@/components/today/TodayDashboard'; -import { TodayLoadErrorBanner } from '@/components/today/TodayLoadErrorBanner'; -import { TodaySectionErrorFallback } from '@/components/today/TodaySectionErrorFallback'; -import { TodayWidgetErrorBoundary } from '@/components/today/TodayWidgetErrorBoundary'; -import { useTodaySummary } from '@/lib/hooks/useTodaySummary'; +import { TodayPage } from '@/components/ui/today/TodayPage'; -export default function TodayPage() { - const t = useTranslations('today'); - const { currentOrganization } = useAuth(); - const orgId = currentOrganization?.id; - const { data, loading, isInitialLoad, error, reload } = useTodaySummary(orgId); - - const showNoSubscriptionNotice = useMemo( - () => Boolean(currentOrganization?.isOwner) && !currentOrganization?.plan, - [currentOrganization], - ); - - const sectionErrorMessage = t('sectionLoadError'); - - return ( -
-
-

{t('welcomeBack')}

- {data?.generatedAt && !isInitialLoad ? ( -

- {t('lastUpdated', { - time: new Intl.DateTimeFormat(undefined, { - hour: 'numeric', - minute: '2-digit', - }).format(new Date(data.generatedAt)), - })} -

- ) : null} -
- - {showNoSubscriptionNotice && ( -
-

- {t('noSubscriptionNotice')}{' '} - - {t('choosePlanLink')} - {' '} - {t('noSubscriptionCta')} -

-
- )} - - {error ? ( - void reload()} - isRetrying={loading && Boolean(data)} - /> - ) : null} - - } - > - - -
- ); -} +export default function Page() { + return ; +} \ No newline at end of file diff --git a/frontend/src/app/[locale]/(public)/accept-invite/page.tsx b/frontend/src/app/[locale]/(public)/accept-invite/page.tsx index 15b79e2..443a137 100644 --- a/frontend/src/app/[locale]/(public)/accept-invite/page.tsx +++ b/frontend/src/app/[locale]/(public)/accept-invite/page.tsx @@ -7,10 +7,12 @@ import { Link, useRouter } from '@/i18n/navigation'; import { useSearchParams } from 'next/navigation'; import { Button } from '@/components/ui/shared/Button'; import { Input } from '@/components/ui/shared/Input'; +import { getUserFacingError } from '@/components/shared/formatApiError'; import { staffApi } from '@/lib/api/staff'; function AcceptInviteContent() { const t = useTranslations('auth'); + const tErrors = useTranslations('errors'); const params = useSearchParams(); const router = useRouter(); const token = useMemo(() => params.get('token') || '', [params]); @@ -49,8 +51,7 @@ function AcceptInviteContent() { setSuccess(t('invitationAlreadyAccepted')); } } catch (e: unknown) { - const message = e && typeof e === 'object' && 'message' in e ? String(e.message) : ''; - setError(message || t('errorLoadInvitation')); + setError(getUserFacingError(e, tErrors, t('errorLoadInvitation'))); } finally { setLoading(false); } @@ -86,8 +87,7 @@ function AcceptInviteContent() { router.replace('/login'); }, 1000); } catch (e: unknown) { - const message = e && typeof e === 'object' && 'message' in e ? String(e.message) : ''; - setError(message || t('errorAcceptInvitation')); + setError(getUserFacingError(e, tErrors, t('errorAcceptInvitation'))); } finally { setSubmitting(false); } diff --git a/frontend/src/app/[locale]/(public)/accept-organization-invite/page.tsx b/frontend/src/app/[locale]/(public)/accept-organization-invite/page.tsx index afe6515..6362110 100644 --- a/frontend/src/app/[locale]/(public)/accept-organization-invite/page.tsx +++ b/frontend/src/app/[locale]/(public)/accept-organization-invite/page.tsx @@ -14,6 +14,7 @@ import { Button } from '@/components/ui/shared/Button'; import { Input } from '@/components/ui/shared/Input'; import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields'; import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps'; +import { getUserFacingError } from '@/components/shared/formatApiError'; import { organizationApi } from '@/lib/api/organization'; type AcceptOrganizationInviteForm = { @@ -27,6 +28,7 @@ type AcceptOrganizationInviteForm = { function AcceptOrganizationInviteContent() { const t = useTranslations('auth'); + const tErrors = useTranslations('errors'); const tCommon = useTranslations('common'); const tValidation = useTranslations('validation'); const params = useSearchParams(); @@ -115,8 +117,7 @@ function AcceptOrganizationInviteContent() { setSuccess(t('invitationAlreadyAccepted')); } } catch (e: unknown) { - const message = e && typeof e === 'object' && 'message' in e ? String(e.message) : ''; - setError(message || t('errorLoadInvitation')); + setError(getUserFacingError(e, tErrors, t('errorLoadInvitation'))); } finally { setLoading(false); } @@ -148,8 +149,7 @@ function AcceptOrganizationInviteContent() { setSuccess(t('organizationAcceptedRedirect')); setTimeout(() => router.replace('/login'), 1000); } catch (e: unknown) { - const message = e && typeof e === 'object' && 'message' in e ? String(e.message) : ''; - setError(message || t('errorAcceptInvitation')); + setError(getUserFacingError(e, tErrors, t('errorAcceptInvitation'))); } finally { setSubmitting(false); } diff --git a/frontend/src/app/[locale]/(public)/forgot-password/page.tsx b/frontend/src/app/[locale]/(public)/forgot-password/page.tsx index 30442e7..29eca1d 100644 --- a/frontend/src/app/[locale]/(public)/forgot-password/page.tsx +++ b/frontend/src/app/[locale]/(public)/forgot-password/page.tsx @@ -8,6 +8,7 @@ import { useTranslations } from 'next-intl'; import { Link, useRouter } from '@/i18n/navigation'; import { Phone, ShieldCheck } from 'lucide-react'; import { authApi } from '@/lib/api/auth'; +import { getUserFacingError } from '@/components/shared/formatApiError'; import { useAuth } from '@/lib/hooks/useAuth'; import { AuthPageShell } from '@/components/ui/auth/AuthPageShell'; import { Button } from '@/components/ui/shared/Button'; @@ -29,6 +30,7 @@ export default function ForgotPasswordPage() { const t = useTranslations('auth'); const tCommon = useTranslations('common'); const tValidation = useTranslations('validation'); + const tErrors = useTranslations('errors'); const router = useRouter(); const { refreshSession } = useAuth(); const [step, setStep] = useState<'mobile' | 'code'>('mobile'); @@ -76,8 +78,7 @@ export default function ForgotPasswordPage() { setSentMobile(mobile); setStep('code'); } catch (err: unknown) { - const message = err instanceof Error ? err.message : t('codeSendFailed'); - setError(message || t('codeSendFailed')); + setError(getUserFacingError(err, tErrors, t('codeSendFailed'))); } finally { setIsSending(false); } @@ -116,8 +117,7 @@ export default function ForgotPasswordPage() { await refreshSession(); router.push('/settings/account?reset=1'); } catch (err: unknown) { - const message = err instanceof Error ? err.message : t('verifyFailed'); - setError(message || t('verifyFailed')); + setError(getUserFacingError(err, tErrors, t('verifyFailed'))); } finally { setIsVerifying(false); } diff --git a/frontend/src/app/[locale]/(public)/login/page.tsx b/frontend/src/app/[locale]/(public)/login/page.tsx index b37e80a..1cd8f93 100644 --- a/frontend/src/app/[locale]/(public)/login/page.tsx +++ b/frontend/src/app/[locale]/(public)/login/page.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useState, useEffect, useMemo } from 'react'; +import { Suspense, useState, useEffect, useMemo } from 'react'; +import { useSearchParams } from 'next/navigation'; import { useRouter } from '@/i18n/navigation'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -8,12 +9,17 @@ import * as z from 'zod'; import { useTranslations } from 'next-intl'; import { Link } from '@/i18n/navigation'; import { Mail, Lock } from 'lucide-react'; +import { getUserFacingError } from '@/components/shared/formatApiError'; import { useAuth } from '@/lib/hooks/useAuth'; import { getRememberedEmail } from '@/lib/auth/rememberMe'; import { AuthPageShell } from '@/components/ui/auth/AuthPageShell'; import { Button } from '@/components/ui/shared/Button'; import { Checkbox } from '@/components/ui/shared/Checkbox'; import { Input } from '@/components/ui/shared/Input'; +import { + consumeAuthRedirect, + storeAuthRedirectFromPath, +} from '@/lib/auth/postAuthRedirect'; type LoginForm = { email: string; @@ -21,12 +27,14 @@ type LoginForm = { rememberMe: boolean; }; -export default function LoginPage() { +function LoginPageContent() { const t = useTranslations('auth'); const tCommon = useTranslations('common'); const tValidation = useTranslations('validation'); - const { login, isLoading, user, isAuthReady } = useAuth(); + const tErrors = useTranslations('errors'); + const { login, isLoading, user, isAuthReady, organizations, currentOrganization } = useAuth(); const router = useRouter(); + const searchParams = useSearchParams(); const [error, setError] = useState(null); const [savedEmail] = useState(() => getRememberedEmail()); @@ -41,10 +49,18 @@ export default function LoginPage() { ); useEffect(() => { - if (isAuthReady && user) { - router.push('/today'); + const from = searchParams.get('from'); + if (from) { + storeAuthRedirectFromPath(from); } - }, [user, isAuthReady, router]); + }, [searchParams]); + + useEffect(() => { + if (!isAuthReady || !user || isLoading) return; + const orgReady = organizations.length <= 1 || currentOrganization; + if (!orgReady) return; + router.push(consumeAuthRedirect() ?? '/today'); + }, [isAuthReady, user, isLoading, organizations, currentOrganization, router]); const { register, @@ -67,8 +83,7 @@ export default function LoginPage() { setError(null); await login(data.email, data.password, data.rememberMe); } catch (err: unknown) { - const message = err instanceof Error ? err.message : t('invalidCredentials'); - setError(message || t('invalidCredentials')); + setError(getUserFacingError(err, tErrors, t('invalidCredentials'))); } }; @@ -151,3 +166,21 @@ export default function LoginPage() { ); } + +function LoginPageFallback() { + const tCommon = useTranslations('common'); + + return ( +
+

{tCommon('loading')}

+
+ ); +} + +export default function LoginPage() { + return ( + }> + + + ); +} diff --git a/frontend/src/app/[locale]/(public)/register/page.tsx b/frontend/src/app/[locale]/(public)/register/page.tsx index 7ebe926..36ec6ae 100644 --- a/frontend/src/app/[locale]/(public)/register/page.tsx +++ b/frontend/src/app/[locale]/(public)/register/page.tsx @@ -7,6 +7,7 @@ import * as z from 'zod'; import { useTranslations } from 'next-intl'; import { Link } from '@/i18n/navigation'; import { Mail, Lock, User, Phone } from 'lucide-react'; +import { getUserFacingError } from '@/components/shared/formatApiError'; import { useAuth } from '@/lib/hooks/useAuth'; import { AuthPageShell } from '@/components/ui/auth/AuthPageShell'; import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields'; @@ -36,6 +37,7 @@ export default function RegisterPage() { const t = useTranslations('auth'); const tCommon = useTranslations('common'); const tValidation = useTranslations('validation'); + const tErrors = useTranslations('errors'); const { registerTrial, isLoading } = useAuth(); const [step, setStep] = useState(1); const [error, setError] = useState(null); @@ -110,8 +112,7 @@ export default function RegisterPage() { data.organizationType, ); } catch (err: unknown) { - const message = err instanceof Error ? err.message : t('registrationFailed'); - setError(message || t('registrationFailed')); + setError(getUserFacingError(err, tErrors, t('registrationFailed'))); } }; diff --git a/frontend/src/app/[locale]/layout.tsx b/frontend/src/app/[locale]/layout.tsx index adb377f..5fffee5 100644 --- a/frontend/src/app/[locale]/layout.tsx +++ b/frontend/src/app/[locale]/layout.tsx @@ -4,11 +4,12 @@ import { getMessages, setRequestLocale } from 'next-intl/server'; import { hasLocale } from 'next-intl'; import { notFound } from 'next/navigation'; import Script from 'next/script'; +import { Noto_Sans_Arabic, Vazirmatn } from 'next/font/google'; import '@/styles/globals.css'; import '@/styles/background-web.css'; import { AuthProvider } from '@/lib/hooks/useAuth'; import { THEME_STORAGE_KEY } from '@/lib/theme'; -import { routing, localeHtmlLang } from '@/i18n/routing'; +import { routing, isRtlLocale, localeHtmlLang } from '@/i18n/routing'; import { LocaleSync } from '@/components/i18n/LocaleSync'; export const metadata: Metadata = { @@ -25,6 +26,18 @@ export function generateStaticParams() { return routing.locales.map((locale) => ({ locale })); } +const vazirmatn = Vazirmatn({ + subsets: ['arabic'], + variable: '--font-vazirmatn', + display: 'swap', +}); + +const notoSansArabic = Noto_Sans_Arabic({ + subsets: ['arabic'], + variable: '--font-noto-sans-arabic', + display: 'swap', +}); + export default async function LocaleLayout({ children, params, @@ -42,9 +55,20 @@ export default async function LocaleLayout({ 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 dir = isRtlLocale(locale) ? 'rtl' : 'ltr'; + const fontSans = isRtlLocale(locale) + ? 'var(--font-vazirmatn), var(--font-noto-sans-arabic), system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif' + : 'system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif'; return ( - +
{t('tableName')}{t('tableEmail')}{t('tableRole')}{t('tableStatus')}{t('tableAccess')} - {t('tableAction')} -
{m.name}{m.email} - {m.isOwner ? ( - {t('roleOwner')} - ) : ( - {t('roleStaff')} - )} - - {m.isOwner || m.invitationStatus === 'ACTIVE' ? ( - {t('statusActive')} - ) : m.invitationStatus === 'PENDING' ? ( - {t('statusPending')} - ) : m.invitationStatus === 'DISABLED' ? ( - {t('statusDisabled')} - ) : ( - {t('statusExpired')} - )} - - {m.isOwner ? ( - {t('allFeatures')} - ) : ( - - {formatAccessSummary(m.permissions, currentOrganization?.type, tFeatures)} - - )} - - {!m.isOwner && ( -
- {canShareStaffInviteLink(m) && ( - - )} - {canEnableStaff(m) && ( - - )} - {canDisableStaff(m) && ( - - )} - - -
- )} -