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..581523d --- /dev/null +++ b/.cursor/rules/dyolink-overview.mdc @@ -0,0 +1,29 @@ +--- +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**. + +## Treatment / appointment colors + +Treatment-type colors and labels: `components/shared/treatmentTypeDisplay.ts` + `catalog-type-colors.ts`. UI badges: `components/ui/treatment/TreatmentTypeBadge.tsx`. diff --git a/.cursor/rules/frontend-components.mdc b/.cursor/rules/frontend-components.mdc new file mode 100644 index 0000000..aecfc52 --- /dev/null +++ b/.cursor/rules/frontend-components.mdc @@ -0,0 +1,47 @@ +--- +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`. + +## 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. diff --git a/.cursor/rules/maintain-agent-docs.mdc b/.cursor/rules/maintain-agent-docs.mdc new file mode 100644 index 0000000..b57d3c8 --- /dev/null +++ b/.cursor/rules/maintain-agent-docs.mdc @@ -0,0 +1,35 @@ +--- +description: When and how to update AGENTS.md, rules, and skills after new conventions +alwaysApply: true +--- + +# Maintaining agent docs + +Rules and skills **load automatically** but **do not self-update**. Update them when the user establishes a durable convention. + +## Update when the user says (or clearly means) + +- "Remember this" / "Save as convention" / "Add to project rules" +- "Document this for future agents" +- "We always do X in this project" (and it is not already in rules/skills) + +## Where to put new knowledge + +| Kind of knowledge | Update | +|-------------------|--------| +| Always true, 1–5 bullets | `.cursor/rules/*.mdc` (pick existing file or create new, <50 lines) | +| Multi-step workflow | `.cursor/skills/{name}/SKILL.md` | +| Project map / onboarding | `AGENTS.md` (index only — link to rules/skills) | + +## Do not auto-update when + +- One-off task instructions ("fix this bug today") +- Experimental code not yet agreed as standard +- User did not ask to persist the pattern + +## After updating + +- Keep rules concise; split if a file grows past ~50 lines. +- Tell the user which file(s) changed in one sentence. + +Use skill `.cursor/skills/capture-convention/` for the full workflow. diff --git a/.cursor/skills/add-feature/SKILL.md b/.cursor/skills/add-feature/SKILL.md new file mode 100644 index 0000000..bc97fbe --- /dev/null +++ b/.cursor/skills/add-feature/SKILL.md @@ -0,0 +1,69 @@ +--- +name: dyolink-add-feature +description: Adds a new Dyolink feature end-to-end (permission, backend module, frontend tab, i18n). Use when the user asks for a new tab, module, screen, or CRUD feature in Dyolink. +--- + +# Add a Dyolink feature + +Follow this checklist. Adapt steps if the feature is read-only or org-type-specific. + +## Checklist + +``` +- [ ] 1. Permissions & org type +- [ ] 2. Backend module +- [ ] 3. Frontend UI + thin page +- [ ] 4. i18n (en, fa, nl) +- [ ] 5. Verify build / tsc +``` + +## 1. Permissions & org type + +- Add `TAB_{FEATURE}_READ` and `TAB_{FEATURE}_EDIT` to: + - `backend/src/common/permissions.ts` (`ALL_TAB_PERMISSIONS`, `EDIT_TO_READ`) + - `backend/prisma/seed.ts` (owner defaults per org type) + - `backend/src/modules/auth/auth.service.ts` if listed there +- Frontend: `components/staff/staff-permission-form.ts`, `components/shared/permissions.ts` route prefix if needed. +- Sidebar: `components/ui/shared/Sidebar.tsx` with `orgTypes` filter. + +## 2. Backend module + +``` +backend/src/modules/{feature}/ + {feature}.module.ts + {feature}.controller.ts + {feature}.service.ts + dto/ +``` + +- Apply guards (`JwtAuthGuard`, org-type guard as needed). +- Service-level permission checks with `hasEffectivePermission`. +- DTOs use `ErrorCode` validation messages. +- Register in `app.module.ts`. + +## 3. Frontend + +- API client: `frontend/src/lib/api/{feature}.ts` +- Types: `frontend/src/types/{feature}.ts` +- UI: `frontend/src/components/ui/{feature}/` +- Non-UI helpers: `frontend/src/components/{feature}/` +- Page: thin `app/[locale]/(dashboard)/{feature}/page.tsx` → `{Feature}Page.tsx` + +## 4. i18n + +Add keys to `en.json`, `fa.json`, `nl.json` under a feature namespace (e.g. `"patients": { ... }`). + +## 5. Verify + +```bash +cd backend && npm run build +cd frontend && npx tsc --noEmit +``` + +## Reference implementations + +| Pattern | Look at | +|---------|---------| +| Thin page + workspace | `treatment/page.tsx`, `TreatmentWorkspace.tsx` | +| CRUD + permissions | `modules/patients/` | +| Lab feature | `modules/cases/`, `ui/lab/` | diff --git a/.cursor/skills/api-errors/SKILL.md b/.cursor/skills/api-errors/SKILL.md new file mode 100644 index 0000000..2e95e6d --- /dev/null +++ b/.cursor/skills/api-errors/SKILL.md @@ -0,0 +1,40 @@ +--- +name: dyolink-api-errors +description: Adds or migrates Dyolink API error codes with frontend translations. Use when adding backend validation errors, permission errors, or migrating catch blocks to getUserFacingError. +--- + +# Dyolink API errors + +## Backend + +1. Add to `ErrorCode` in `backend/src/common/errors/error-codes.ts`. +2. Throw with `AppException`: + +```typescript +throw new AppException(ErrorCode.MY_CODE, HttpStatus.BAD_REQUEST, [ + { field: 'email', code: ErrorCode.VALIDATION_EMAIL_INVALID }, +]); +``` + +3. DTOs: `@IsEmail({}, { message: ErrorCode.VALIDATION_EMAIL_INVALID })` + +## Frontend + +1. Add key under `"errors"` in `en.json`, `fa.json`, `nl.json` (key = error code string). +2. In components: + +```typescript +const tErrors = useTranslations('errors'); +// ... +catch (err: unknown) { + toast.showError(getUserFacingError(err, tErrors, t('fallbackKey'))); +} +``` + +3. Do not use `err.message` or `(err as Error).message` for user display. + +## Axios shape + +Parsed in `lib/api/client` — expects `{ success: false, error: { code, details? } }`. + +See rule: `.cursor/rules/api-errors-i18n.mdc` diff --git a/.cursor/skills/capture-convention/SKILL.md b/.cursor/skills/capture-convention/SKILL.md new file mode 100644 index 0000000..bdddf2d --- /dev/null +++ b/.cursor/skills/capture-convention/SKILL.md @@ -0,0 +1,53 @@ +--- +name: dyolink-capture-convention +description: Saves a new Dyolink project convention into AGENTS.md, .cursor/rules, or .cursor/skills. Use when the user says remember this, save as convention, add to project rules, document for future agents, or asks to update agent docs after a task. +--- + +# Capture convention + +Persist a **durable** project pattern so the next agent chat knows it without re-explaining. + +## Trigger phrases + +- "Remember this" +- "Save as convention" / "Add to project rules" +- "Document this for future agents" +- "Update the cursor rules/skills" + +## Workflow + +1. **Confirm it is durable** — not a one-off fix. If unclear, ask: "Should every future agent follow this?" +2. **Choose target:** + - Short rule (always or file-scoped) → `.cursor/rules/{topic}.mdc` + - Step-by-step process → `.cursor/skills/{name}/SKILL.md` (new folder if needed) + - High-level pointer only → one line in `AGENTS.md` linking to the rule/skill +3. **Write concisely** — bullets, one example, under 50 lines per rule file. +4. **Avoid duplication** — merge into an existing rule if the topic fits. +5. **Commit with the feature** — remind user these files belong in git with the code change. + +## Rule file template + +```markdown +--- +description: One-line summary +globs: frontend/src/** # omit if alwaysApply: true +alwaysApply: false +--- + +# Title + +- Bullet convention +- ✅ Do / ❌ Don't example +``` + +## What not to capture + +- Temporary deadlines or "for v1 only" unless labeled as such +- Secrets, env values, credentials +- Entire chat transcripts — distill to 3–7 bullets + +## Example + +User: "Remember: all lab task status badges use labTaskStatusDisplay helpers." + +Action: Add bullet to `frontend-components.mdc` or `backend-nestjs.mdc` (whichever fits), not a new 200-line doc. diff --git a/.cursor/skills/frontend-structure/SKILL.md b/.cursor/skills/frontend-structure/SKILL.md new file mode 100644 index 0000000..7d4f4f8 --- /dev/null +++ b/.cursor/skills/frontend-structure/SKILL.md @@ -0,0 +1,39 @@ +--- +name: dyolink-frontend-structure +description: Audits or refactors Dyolink frontend folder layout (components vs components/ui, thin pages). Use when moving components, fixing structure violations, or when the user mentions folder rules, page.tsx bloat, or component organization. +--- + +# Frontend structure audit + +## Target layout + +``` +components/ui/shared/ → reusable UI (Button, Dialog, …) +components/ui/{feature}/ → feature UI + {Feature}Page.tsx +components/shared/ → cross-feature non-UI +components/{feature}/ → feature non-UI (helpers, config) +app/**/page.tsx → thin wrapper importing ui/{feature} page component +``` + +## Audit steps + +1. List files in `components/` **outside** `ui/` — any `.tsx` with JSX → move to `components/ui/{feature}/`. +2. List files in `components/ui/` — any pure `.ts` helper → move to `components/{feature}/` or `components/shared/`. +3. List `app/**/page.tsx` — if > ~30 lines of logic/state, extract to `components/ui/{feature}/{Feature}Page.tsx`. +4. Update all `@/components/...` imports. +5. Run `npx tsc --noEmit` in `frontend/`. + +## Common mistakes + +| Wrong | Right | +|-------|-------| +| `components/today/TodayDashboard.tsx` | `components/ui/today/TodayDashboard.tsx` | +| `components/ui/treatment/treatmentTypeDisplay.ts` | `components/shared/treatmentTypeDisplay.ts` | +| Logic in `app/.../staff/page.tsx` | `components/ui/staff/StaffPage.tsx` | + +## Non-UI that stays outside ui/ + +- `components/today/widget-registry.ts`, `chart-theme.ts` (config) +- `components/staff/workingHours.ts` +- `components/appointments/appointmentTime.ts` +- `components/i18n/LocaleSync.tsx` (null-render side effect for layout) diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a229352 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,89 @@ +# 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/`. + +## Backend layout + +``` +backend/src/ + modules/{feature}/ → controller, service, dto, module + common/ → guards, permissions, errors, utils + prisma/ → schema, migrations, seed +``` + +Errors: `AppException` + `ErrorCode` → frontend `getUserFacingError()`. Never throw raw strings for user-facing failures. + +## Git & commits + +- **Do not commit or push** unless the user explicitly asks. +- **Do not** amend commits, force-push, or skip hooks unless explicitly requested. + +## Skills (workflows) + +| Skill | When to use | +|-------|-------------| +| `.cursor/skills/add-feature/` | New tab, API module, or end-to-end feature | +| `.cursor/skills/frontend-structure/` | Moving components, auditing folder layout | +| `.cursor/skills/api-errors/` | New backend errors + frontend translations | + +## Subagents (Task tool) + +Use subagents to **save context**, not to avoid work: + +| Type | Use for | +|------|---------| +| `explore` | Broad codebase search, unfamiliar areas | +| `shell` | Git, npm, long command sequences | +| `generalPurpose` | Multi-step research when parent context is large | + +Do **not** delegate the user's main task to a subagent and return its summary — implement in the parent unless the user asked for exploration only. + +## Improving this setup + +When you and the user agree on a new convention, **add or update a rule** in `.cursor/rules/` (keep each rule under ~50 lines, one topic). For multi-step workflows, extend `.cursor/skills/`. + +**To save a convention mid-task**, say: *"Remember this"* or *"Add to project rules"* — the agent uses the `capture-convention` skill and updates the repo (commit with your code). + +| You say | Agent does | +|---------|------------| +| "Remember this: …" | Updates the right `.mdc` rule or skill | +| "Add a skill for …" | Creates `.cursor/skills/{name}/SKILL.md` | +| "This rule is wrong" | Edits the rule file; you commit | + +Rules/skills **load automatically** in new chats; they do **not** update themselves unless you ask. diff --git a/README.md b/README.md index 3ce8ea7..a412280 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ Monorepo: **NestJS** backend (`backend/`), **Next.js** frontend (`frontend/`), * Local development: see **`backend/README.md`** and **`frontend/README.md`**. +**Cursor AI:** project conventions for agents are in [`AGENTS.md`](AGENTS.md), [`.cursor/rules/`](.cursor/rules/), and [`.cursor/skills/`](.cursor/skills/). + --- ## Production deploy (Docker Hub + HTTPS + Let's Encrypt) diff --git a/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx b/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx index 517c6b3..7822f52 100644 --- a/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx @@ -1,373 +1,7 @@ 'use client'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useTranslations } from 'next-intl'; -import { appointmentsApi } from '@/lib/api/appointments'; -import { patientsApi } from '@/lib/api/patients'; -import { treatmentCatalogApi } from '@/lib/api/treatment-catalog'; -import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; -import { useAuth } from '@/lib/hooks/useAuth'; -import { canEditAppointments, hasPermission } from '@/components/shared/permissions'; -import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment'; -import type { CreatePatientInput, Patient } from '@/types/patient'; -import { CreatePatientModal } from '@/components/ui/patient/CreatePatientModal'; -import { PatientSummaryCard } from '@/components/ui/patient/PatientSummaryCard'; -import { AppointmentBookingModal } from '@/components/ui/appointments/AppointmentBookingModal'; -import { AppointmentScheduleGrid } from '@/components/ui/appointments/AppointmentScheduleGrid'; -import { AppointmentsPatientSearch } from '@/components/ui/appointments/AppointmentsPatientSearch'; -import { AppointmentScheduleLegend } from '@/components/ui/appointments/AppointmentScheduleLegend'; -import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker'; -import { ToastStack } from '@/components/ui/shared/Toast'; -import { useToast } from '@/lib/hooks/useToast'; -import type { AppointmentPurpose } from '@/types/appointment'; -import { getUserFacingError } from '@/components/shared/formatApiError'; -import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/components/appointments/appointmentTime'; +import { AppointmentsPage } from '@/components/ui/appointments/AppointmentsPage'; -const EMPTY_PATIENT_FORM: CreatePatientInput = { - firstName: '', - lastName: '', - mobile: '', - email: '', -}; - -export default function AppointmentsPage() { - const t = useTranslations('appointments'); - const tErrors = useTranslations('errors'); - const tPatients = useTranslations('patients'); - const { currentOrganization } = useAuth(); - const [scheduleDate, setScheduleDate] = useState(() => startOfLocalDay(new Date())); - - const [providers, setProviders] = useState([]); - const [appointments, setAppointments] = useState([]); - const [treatmentCatalog, setTreatmentCatalog] = useState([]); - const [loadingSchedule, setLoadingSchedule] = useState(false); - 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 [bookingOpen, setBookingOpen] = useState(false); - const [bookingStartMinute, setBookingStartMinute] = useState(9 * 60); - const [bookingProviderId, setBookingProviderId] = useState(null); - const [bookingProviderName, setBookingProviderName] = useState(''); - const [editingAppointmentId, setEditingAppointmentId] = useState(null); - const [savingAppointment, setSavingAppointment] = useState(false); - const [deletingAppointment, setDeletingAppointment] = useState(false); - - - const canManageAppointments = canEditAppointments(currentOrganization); - const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT'); - - const todayStart = useMemo(() => startOfLocalDay(new Date()), []); - const isViewingPastDay = useMemo( - () => compareLocalDayStart(scheduleDate, todayStart) < 0, - [scheduleDate, todayStart], - ); - const activeEditingAppointment = useMemo( - () => appointments.find((a) => a.id === editingAppointmentId) ?? null, - [appointments, editingAppointmentId], - ); - - const scheduleLoadGen = useRef(0); - - const sortedPatients = useMemo( - () => - [...patients].sort((a, b) => - `${a.firstName} ${a.lastName}`.localeCompare(`${b.firstName} ${b.lastName}`), - ), - [patients], - ); - - const loadSchedule = useCallback(async () => { - if (!currentOrganization?.id) { - return; - } - const gen = ++scheduleLoadGen.current; - setLoadingSchedule(true); - toast.setError(''); - try { - const range = getLocalDayIsoRange(scheduleDate); - const [pRes, aRes] = await Promise.all([ - appointmentsApi.columnProviders(scheduleDate), - appointmentsApi.list(range), - ]); - if (gen !== scheduleLoadGen.current) { - return; - } - setProviders(pRes.data); - setAppointments(aRes.data); - } catch (err: unknown) { - if (gen !== scheduleLoadGen.current) { - return; - } - toast.showError(getUserFacingError(err, tErrors, t('errorLoadSchedule'))); - } finally { - if (gen === scheduleLoadGen.current) { - setLoadingSchedule(false); - } - } - }, [currentOrganization?.id, scheduleDate, t]); - - useEffect(() => { - void loadSchedule(); - }, [loadSchedule]); - - useEffect(() => { - void treatmentCatalogApi - .list('appointment') - .then((r) => setTreatmentCatalog(r.data)) - .catch(() => {}); - }, []); - - useEffect(() => { - const t = setTimeout(() => { - void loadPatientsSearch(search); - }, 300); - return () => clearTimeout(t); - }, [search]); - - async function loadPatientsSearch(q: string) { - if (!currentOrganization) { - return; - } - setLoadingPatients(true); - try { - const response = await patientsApi.list({ q, page: 1, limit: 25 }); - const items = response.data.items; - setPatients(items); - if (selectedPatient) { - const stillThere = items.find((p) => p.id === selectedPatient.id); - if (stillThere) { - setSelectedPatient(stillThere); - } - } - } catch { - setPatients([]); - } finally { - setLoadingPatients(false); - } - } - - async function handleCreatePatient() { - setSavingPatient(true); - toast.setError(''); - try { - const response = await patientsApi.create(patientForm); - setIsCreateOpen(false); - setPatientForm(EMPTY_PATIENT_FORM); - await loadPatientsSearch(search); - setSelectedPatient(response.data); - if (response.existing) { - toast.showInfo( - tPatients('patientAlreadyExists', { - firstName: response.data.firstName, - lastName: response.data.lastName, - }), - ); - } else { - toast.showSuccess( - t('successPatientSaved', { - firstName: response.data.firstName, - lastName: response.data.lastName, - }), - ); - } - } catch (err: unknown) { - toast.showError(getUserFacingError(err, tErrors, tPatients('errorSavePatient'))); - } finally { - setSavingPatient(false); - } - } - - function handleSlotClick(startMinute: number, providerUserId: string, providerName: string) { - if (!canManageAppointments) { - return; - } - if (isViewingPastDay) { - toast.showInfo(t('infoPastViewOnly')); - return; - } - if (!selectedPatient) { - toast.showInfo(t('infoSelectPatient')); - return; - } - setBookingStartMinute(startMinute); - setBookingProviderId(providerUserId); - setBookingProviderName(providerName); - setEditingAppointmentId(null); - setBookingOpen(true); - } - - function handleAppointmentClick(appointment: AppointmentRecord) { - if (!canManageAppointments) { - return; - } - if (isViewingPastDay) { - toast.showInfo(t('infoPastViewOnly')); - return; - } - const provider = providers.find((p) => p.userId === appointment.providerUserId); - const start = new Date(appointment.startAt); - setBookingStartMinute(start.getHours() * 60 + start.getMinutes()); - setBookingProviderId(appointment.providerUserId); - setBookingProviderName(provider?.name ?? bookingProviderName); - setEditingAppointmentId(appointment.id); - setBookingOpen(true); - } - - function handleAppointmentOutsideHours(appointment: AppointmentRecord) { - toast.showError(t('errorOutsideHours')); - } - - async function handleSaveAppointment(payload: { - patientId: string; - providerUserId: string; - startAt: string; - endAt: string; - purpose: AppointmentPurpose; - }) { - setSavingAppointment(true); - toast.setError(''); - try { - if (activeEditingAppointment) { - await appointmentsApi.update(activeEditingAppointment.id, payload); - } else { - await appointmentsApi.create(payload); - } - setBookingOpen(false); - setEditingAppointmentId(null); - toast.showSuccess(activeEditingAppointment ? t('successUpdated') : t('successSaved')); - await loadSchedule(); - } catch (err: unknown) { - toast.showError( - getUserFacingError( - err, - tErrors, - activeEditingAppointment ? t('errorUpdate') : t('errorSave'), - ), - ); - } finally { - setSavingAppointment(false); - } - } - - async function handleDeleteEditingAppointment() { - if (!activeEditingAppointment) { - return; - } - if (!window.confirm(t('confirmRemove'))) { - return; - } - setDeletingAppointment(true); - toast.setError(''); - try { - await appointmentsApi.remove(activeEditingAppointment.id); - setBookingOpen(false); - setEditingAppointmentId(null); - toast.showSuccess(t('successRemoved')); - await loadSchedule(); - } catch (err: unknown) { - toast.showError(getUserFacingError(err, tErrors, t('errorDelete'))); - } finally { - setDeletingAppointment(false); - } - } - - return ( -
-
-

{t('title')}

-

{t('subtitle')}

-
- - - -
-
- { - if (!canEditPatients) { - return; - } - setPatientForm(EMPTY_PATIENT_FORM); - setIsCreateOpen(true); - }} - /> - -
- -
- - -
- setScheduleDate(startOfLocalDay(d))} - /> - {loadingSchedule && ( -

{t('loadingSchedule')}

- )} -
- - handleSlotClick(startMinute, uid, name)} - onAppointmentClick={(apt) => handleAppointmentClick(apt)} - onAppointmentOutsideHours={(apt) => handleAppointmentOutsideHours(apt)} - /> -
-
- - { - setBookingOpen(false); - setEditingAppointmentId(null); - }} - onSubmit={handleSaveAppointment} - loading={savingAppointment} - canDelete={canManageAppointments && !isViewingPastDay && !!activeEditingAppointment} - onDelete={() => void handleDeleteEditingAppointment()} - deleting={deletingAppointment} - /> - - 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)/billing/page.tsx b/frontend/src/app/[locale]/(dashboard)/billing/page.tsx index e2420c4..bed1384 100644 --- a/frontend/src/app/[locale]/(dashboard)/billing/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/billing/page.tsx @@ -1,296 +1,7 @@ -// src/app/(dashboard)/billing/page.tsx 'use client'; -import { useMemo, useState } from 'react'; -import { Pencil } from 'lucide-react'; -import { Button } from '@/components/ui/shared/Button'; -import { Badge } from '@/components/ui/shared/Badge'; -import { Card } from '@/components/ui/shared/Card'; -import { Table } from '@/components/ui/shared/Table'; -import { SearchBar } from '@/components/ui/shared/SearchBar'; -import { useAuth } from '@/lib/hooks/useAuth'; -import { hasPermission } from '@/components/shared/permissions'; +import { BillingPage } from '@/components/ui/billing/BillingPage'; -type InvoiceStatus = 'paid' | 'unpaid' | 'overdue'; - -type Invoice = { - id: string; - patient: string; - date: string; - service: string; - amount: number; - paid: number; - status: InvoiceStatus; -}; - -const invoices: Invoice[] = [ - { id: '#123456', patient: 'Ali Rahmani', date: '24/9/2026', service: 'Hygiene', amount: 300, paid: 0, status: 'unpaid' }, - { id: '#123457', patient: 'Neda Akbari', date: '01/10/2026', service: 'Filling', amount: 700, paid: 400, status: 'overdue' }, - { id: '#123458', patient: 'Nima Haghi', date: '09/12/2026', service: 'Extraction', amount: 450, paid: 450, status: 'paid' }, -]; - -const statusColors = { - paid: 'success', - unpaid: 'warning', - overdue: 'danger', -} as const; - -const statusFilters = ['all', 'paid', 'unpaid', 'overdue'] as const; - -type StatCardColor = 'blue' | 'yellow' | 'green' | 'red'; - -interface StatCardProps { - title: string; - count: number; - amount: number; - color: StatCardColor; -} - -export default function BillingPage() { - const { currentOrganization } = useAuth(); - const [search, setSearch] = useState(''); - const [statusFilter, setStatusFilter] = useState<(typeof statusFilters)[number]>('all'); - const canEditBilling = hasPermission(currentOrganization, 'TAB_BILLING_EDIT'); - - const stats = { - total: { count: 235, amount: 80900 }, - unpaid: { count: 30, amount: 2800 }, - paid: { count: 190, amount: 80900 }, - overdue: { count: 235, amount: 80900 }, - }; - - const filteredInvoices = useMemo(() => { - const query = search.trim().toLowerCase(); - - return invoices.filter((invoice) => { - const matchesStatus = statusFilter === 'all' || invoice.status === statusFilter; - const matchesSearch = - !query || - invoice.patient.toLowerCase().includes(query) || - invoice.id.toLowerCase().includes(query) || - invoice.service.toLowerCase().includes(query); - - return matchesStatus && matchesSearch; - }); - }, [search, statusFilter]); - - return ( -
-
-

Billing

- -
- -
- - - - -
- - - {statusFilters.map((status) => ( - - ))} - - )} - /> - -
- {filteredInvoices.length === 0 ? ( -
No invoices match your filters.
- ) : ( - filteredInvoices.map((invoice) => ( - - )) - )} - -
- -
- - - - - - - - - - - } - body={ - <> - {filteredInvoices.map((invoice) => ( - - - - - - - - - - - ))} - - } - footer={} - /> - - - ); -} - -function InvoiceMobileCard({ - invoice, - canEditBilling, -}: { - invoice: Invoice; - canEditBilling: boolean; -}) { - const remaining = invoice.amount - invoice.paid; - - return ( - -
-
-

{invoice.patient}

-

{invoice.id}

-
- - {invoice.status} - -
- -
- {invoice.service} - · - {invoice.date} -
- -
-
-

Total

-

${invoice.amount}

-
-
-

Paid

-

${invoice.paid}

-
-
-

Due

-

${remaining}

-
-
- -
- -
-
- ); -} - -function InvoiceEditButton({ canEditBilling }: { canEditBilling: boolean }) { - return ( - - ); -} - -function InvoicePagination({ className = '' }: { className?: string }) { - return ( -
- -
Page 1 of 10
- -
- ); -} - -function StatCard({ title, count, amount, color }: StatCardProps) { - const colors: Record = { - blue: '!bg-purpose-visit-bg !text-purpose-visit-fg !border-purpose-visit-border', - yellow: '!bg-badge-warning-bg !text-badge-warning-fg !border-badge-warning-border', - green: '!bg-badge-success-bg !text-badge-success-fg !border-badge-success-border', - red: '!bg-badge-danger-bg !text-badge-danger-fg !border-badge-danger-border', - }; - - return ( - -

{title}

-

{count}

-

- ${amount.toLocaleString()} -

-
- ); -} +export default function Page() { + return ; +} \ No newline at end of file diff --git a/frontend/src/app/[locale]/(dashboard)/cases/page.tsx b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx index 0eeadd9..bbb47cd 100644 --- a/frontend/src/app/[locale]/(dashboard)/cases/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx @@ -1,472 +1,7 @@ 'use client'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useSearchParams } from 'next/navigation'; -import { useTranslations } from 'next-intl'; -import { ToastStack } from '@/components/ui/shared/Toast'; -import { getUserFacingError } from '@/components/shared/formatApiError'; -import { useAuth } from '@/lib/hooks/useAuth'; -import { useToast } from '@/lib/hooks/useToast'; -import { canEditCases, canEditTasks } from '@/components/shared/permissions'; -import { CaseDetailPanel, CaseTaskProgressBar } from '@/components/ui/lab/CaseDetailPanel'; -import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel'; -import { - formatCaseDateTime, - formatPatientName, -} from '@/components/ui/lab/caseDetailUtils'; -import { casesApi } from '@/lib/api/cases'; -import { tasksApi } from '@/lib/api/tasks'; -import { treatmentCatalogApi } from '@/lib/api/treatment-catalog'; -import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay'; -import { Button } from '@/components/ui/shared/Button'; -import { MobileDetailBackButton } from '@/components/ui/shared/MobileDetailBackButton'; -import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles'; -import { SearchBar } from '@/components/ui/shared/SearchBar'; -import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; -import type { - CasesFilterOptions, - LabCaseDetail, - LabCaseListItem, - LabTaskStatus, - PaginatedLabCases, -} from '@/types/cases'; +import { CasesPage } from '@/components/ui/lab/CasesPage'; -const PAGE_SIZE = 20; - -export default function CasesPage() { - const t = useTranslations('cases'); - const tErrors = useTranslations('errors'); - const tCommon = useTranslations('common'); - const { currentOrganization, user } = useAuth(); - const toast = useToast(); - const searchParams = useSearchParams(); - - const [search, setSearch] = useState(''); - const [clinicId, setClinicId] = useState(''); - const [treatmentType, setTreatmentType] = useState(''); - const [sentFrom, setSentFrom] = useState(''); - const [sentTo, setSentTo] = useState(''); - const [page, setPage] = useState(1); - - const [cases, setCases] = useState([]); - const [pagination, setPagination] = useState({ - page: 1, - limit: PAGE_SIZE, - total: 0, - totalPages: 1, - }); - const [filterOptions, setFilterOptions] = useState({ - clinics: [], - treatmentTypes: [], - }); - const [treatmentCatalog, setTreatmentCatalog] = useState([]); - - const [selectedCaseId, setSelectedCaseId] = useState(null); - const [mobileDetailOpen, setMobileDetailOpen] = useState(false); - const [selectedCase, setSelectedCase] = useState(null); - const [loadingList, setLoadingList] = useState(false); - const [loadingDetail, setLoadingDetail] = useState(false); - const [updatingImportant, setUpdatingImportant] = useState(false); - const [commentCount, setCommentCount] = useState(0); - - const canEdit = canEditCases(currentOrganization); - const canEditComments = canEditTasks(currentOrganization); - const locale = user?.language ?? 'en'; - - const treatmentLabel = useCallback( - (type: string) => treatmentTypeLabelFromCatalog(type, treatmentCatalog), - [treatmentCatalog], - ); - - const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo( - () => [ - { value: 'IN_PROGRESS', label: t('statusInProgress') }, - { value: 'COMPLETED', label: t('statusCompleted') }, - ], - [t], - ); - - const hasActiveFilters = Boolean( - search.trim() || clinicId || treatmentType || sentFrom || sentTo, - ); - - const loadCases = async (params: { - q: string; - clinicOrganizationId: string; - treatmentType: string; - sentFrom: string; - sentTo: string; - page: number; - }) => { - setLoadingList(true); - toast.setError(''); - try { - const response = await casesApi.list({ - q: params.q.trim() || undefined, - clinicOrganizationId: params.clinicOrganizationId || undefined, - treatmentType: params.treatmentType || undefined, - sentFrom: params.sentFrom || undefined, - sentTo: params.sentTo || undefined, - page: params.page, - limit: PAGE_SIZE, - }); - setCases(response.data.items); - setPagination(response.data.pagination); - } catch (error: unknown) { - toast.showError(getUserFacingError(error, tErrors, t('errorLoadList'))); - } finally { - setLoadingList(false); - } - }; - - const loadDetail = async (caseId: string, options?: { silent?: boolean }) => { - if (!options?.silent) { - setLoadingDetail(true); - } - toast.setError(''); - try { - const response = await casesApi.getOne(caseId); - setSelectedCase(response.data); - } catch (error: unknown) { - toast.showError(getUserFacingError(error, tErrors, t('errorLoadDetail'))); - if (!options?.silent) { - setSelectedCase(null); - } - } finally { - if (!options?.silent) { - setLoadingDetail(false); - } - } - }; - - useEffect(() => { - void casesApi.listFilterOptions().then((r) => setFilterOptions(r.data)).catch(() => {}); - void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {}); - // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only initial fetch - }, []); - - useEffect(() => { - const caseIdFromUrl = searchParams.get('caseId'); - if (caseIdFromUrl) { - setSelectedCaseId(caseIdFromUrl); - setMobileDetailOpen(true); - } - }, [searchParams]); - - useEffect(() => { - if (!selectedCaseId) { - setMobileDetailOpen(false); - } - }, [selectedCaseId]); - - useEffect(() => { - const timeout = setTimeout(() => { - void loadCases({ - q: search, - clinicOrganizationId: clinicId, - treatmentType, - sentFrom, - sentTo, - page, - }); - }, search ? 300 : 0); - return () => clearTimeout(timeout); - // eslint-disable-next-line react-hooks/exhaustive-deps -- debounced search + filter reload - }, [search, clinicId, treatmentType, sentFrom, sentTo, page]); - - useEffect(() => { - if (selectedCaseId) { - void loadDetail(selectedCaseId); - void tasksApi - .listComments(selectedCaseId) - .then((r) => setCommentCount(r.data.length)) - .catch(() => setCommentCount(0)); - } else { - setSelectedCase(null); - setCommentCount(0); - } - // eslint-disable-next-line react-hooks/exhaustive-deps -- reload when selection changes - }, [selectedCaseId]); - - function scrollToComments() { - document.getElementById('case-comments')?.scrollIntoView({ behavior: 'smooth' }); - } - - const loadCaseAttachmentBlob = useCallback( - (caseId: string, attachmentId: string) => casesApi.getAttachmentFileBlob(caseId, attachmentId), - [], - ); - - function clearFilters() { - setSearch(''); - setClinicId(''); - setTreatmentType(''); - setSentFrom(''); - setSentTo(''); - setPage(1); - } - - async function handleCaseImportantToggle(isImportant: boolean) { - if (!selectedCaseId || !canEdit || !selectedCase) return; - - const previousCase = selectedCase; - setSelectedCase({ ...selectedCase, isImportant }); - - setUpdatingImportant(true); - toast.setError(''); - try { - const response = await casesApi.setCaseImportant(selectedCaseId, isImportant); - setSelectedCase(response.data); - } catch (error: unknown) { - setSelectedCase(previousCase); - toast.showError(getUserFacingError(error, tErrors, t('errorUpdateTask'))); - } finally { - setUpdatingImportant(false); - } - } - - const filterSelectClass = `${FORM_SELECT_CLASS} w-full rounded-md px-3 py-2`; - - return ( -
-
-

{t('title')}

-

{t('subtitle')}

-
- -
-
- { - setSearch(value); - setPage(1); - }} - placeholder={t('searchPlaceholder')} - /> - -
- - - - - - - -
- - {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)/organizations/page.tsx b/frontend/src/app/[locale]/(dashboard)/organizations/page.tsx index b61de88..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 { getUserFacingError } from '@/components/shared/formatApiError'; +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 tErrors = useTranslations('errors'); - const tNav = useTranslations('nav'); - const tCommon = useTranslations('common'); - const { currentOrganization } = useAuth(); - const [loading, setLoading] = useState(true); - const toast = useToast(); - const { showError, setError: setToastError } = toast; - - const formatApiMessage = useCallback( - (err: unknown): string => getUserFacingError(err, tErrors, tCommon('errorGeneric')), - [tCommon, tErrors], - ); - - 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(); - }, []); - - useEffect(() => { - let cancelled = false; - const q = query.trim(); - - if (!q) { - setMode('existing'); - setSearchResults([]); - setShowInviteForm(false); - setSearching(false); - return; - } - - setMode('search'); - setShowInviteForm(false); - setSearching(true); - - const timeout = setTimeout(() => { - void (async () => { - setToastError(''); - try { - const res = await organizationApi.search(q); - if (cancelled) return; - setSearchResults(res.data); - } catch (e) { - if (cancelled) return; - showError(formatApiMessage(e)); - setSearchResults([]); - } finally { - if (!cancelled) setSearching(false); - } - })(); - }, 300); - - return () => { - cancelled = true; - clearTimeout(timeout); - }; - }, [query, formatApiMessage, showError, setToastError]); - - 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 && } - - - {t('backToList')} - - ) : undefined - } - /> - - 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'), - }} - /> - -
-
- Invoice ID - - Patient name - - Date - - Service - - Total amount - - Paid - - Status - - Action -
{invoice.id}{invoice.patient}{invoice.date}{invoice.service}${invoice.amount}${invoice.paid} - - {invoice.status} - - - -
- - - - - - - } - body={ - <> - {loading || (mode === 'search' && searching) ? ( - - - - ) : 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 9266431..1a49a82 100644 --- a/frontend/src/app/[locale]/(dashboard)/patients/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/patients/page.tsx @@ -1,165 +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 { getUserFacingError } 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 { PatientAppointmentHistory } from '@/components/ui/patient/PatientAppointmentHistory'; +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 tErrors = useTranslations('errors'); - 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(getUserFacingError(error, tErrors, 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(getUserFacingError(error, tErrors, 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} - /> - )} - -
-
- -
- -
- - {selectedPatient ? ( - - ) : null} -
-
-
- ); -} +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 9585e41..658b73c 100644 --- a/frontend/src/app/[locale]/(dashboard)/settings/account/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/settings/account/page.tsx @@ -1,452 +1,7 @@ 'use client'; -import { useCallback, 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 { ChevronDown, Lock } from 'lucide-react'; -import { useAuth } from '@/lib/hooks/useAuth'; -import { authApi } from '@/lib/api/auth'; -import { accountApi } from '@/lib/api/account'; -import { Button } from '@/components/ui/shared/Button'; -import { Input } from '@/components/ui/shared/Input'; -import { Toast } from '@/components/ui/shared/Toast'; -import { Checkbox } from '@/components/ui/shared/Checkbox'; -import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; -import { getUserFacingError } from '@/components/shared/formatApiError'; -import { OwnerWorkingHoursDialog } from '@/components/settings/OwnerWorkingHoursDialog'; +import { AccountSettingsPage } from '@/components/ui/settings/AccountSettingsPage'; -type PasswordForm = { - currentPassword: string; - newPassword: string; - confirmPassword: string; -}; - -export default function AccountSettingsPage() { - const t = useTranslations('settings'); - const tErrors = useTranslations('errors'); - const tAuth = useTranslations('auth'); - const tCommon = useTranslations('common'); - const tValidation = useTranslations('validation'); - const { user, currentOrganization, isAuthReady, refreshSession } = useAuth(); - const router = useRouter(); - const searchParams = useSearchParams(); - const isResetFlow = searchParams.get('reset') === '1'; - - const isOwner = Boolean(currentOrganization?.isOwner); - const orgType = currentOrganization?.type; - const showClinicParticipation = isOwner && orgType === 'CLINIC'; - const showLabParticipation = isOwner && orgType === 'LAB'; - - const [error, setError] = useState(null); - const [successMessage, setSuccessMessage] = useState(null); - const [isSubmitting, setIsSubmitting] = useState(false); - const [passwordExpanded, setPasswordExpanded] = useState(isResetFlow); - - const [participationLoading, setParticipationLoading] = useState(false); - const [participatesInTreatments, setParticipatesInTreatments] = useState(false); - const [participatesInTasks, setParticipatesInTasks] = useState(false); - const [workingHoursOpen, setWorkingHoursOpen] = useState(false); - const [revokeConfirmOpen, setRevokeConfirmOpen] = useState(false); - const [pendingRevokeType, setPendingRevokeType] = useState<'CLINIC' | 'LAB' | null>(null); - - 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: '', - }, - }); - - const loadParticipation = useCallback(async () => { - if (!isOwner) return; - try { - const res = await accountApi.getParticipation(); - setParticipatesInTreatments(res.data.participatesInTreatments); - setParticipatesInTasks(res.data.participatesInTasks); - } catch { - /* non-owners or missing org context */ - } - }, [isOwner]); - - useEffect(() => { - if (isAuthReady && !user) { - router.replace('/login'); - } - }, [isAuthReady, user, router]); - - useEffect(() => { - if (isResetFlow) { - setPasswordExpanded(true); - } - }, [isResetFlow]); - - useEffect(() => { - void loadParticipation(); - }, [loadParticipation, currentOrganization?.id]); - - const syncSessionAfterParticipationChange = useCallback(async () => { - await refreshSession(); - }, [refreshSession]); - - const enableClinicParticipation = useCallback( - async (options: { - skipHours: boolean; - hoursPayload?: { - autoRepeatWeekly: boolean; - blocks: { dayOfWeek: number; startMinute: number; endMinute: number; sortOrder?: number }[]; - }; - }) => { - await accountApi.updateParticipation(true); - if (!options.skipHours && options.hoursPayload) { - await accountApi.upsertMyWorkingHours(options.hoursPayload); - } - setParticipatesInTreatments(true); - await syncSessionAfterParticipationChange(); - setSuccessMessage(t('participateEnabledTreatments')); - }, - [syncSessionAfterParticipationChange, t], - ); - - const enableLabParticipation = async () => { - setParticipationLoading(true); - setError(null); - try { - await accountApi.updateParticipation(true); - setParticipatesInTasks(true); - await syncSessionAfterParticipationChange(); - setSuccessMessage(t('participateEnabledTasks')); - } catch (err: unknown) { - setError(getUserFacingError(err, tErrors, t('participateUpdateFailed'))); - setParticipatesInTasks(false); - } finally { - setParticipationLoading(false); - } - }; - - const confirmRevokeParticipation = async () => { - if (!pendingRevokeType) return; - setParticipationLoading(true); - setError(null); - try { - await accountApi.updateParticipation(false); - if (pendingRevokeType === 'CLINIC') { - setParticipatesInTreatments(false); - } else { - setParticipatesInTasks(false); - } - await syncSessionAfterParticipationChange(); - setSuccessMessage( - pendingRevokeType === 'CLINIC' - ? t('participateDisabledTreatments') - : t('participateDisabledTasks'), - ); - setRevokeConfirmOpen(false); - setPendingRevokeType(null); - } catch (err: unknown) { - setError(getUserFacingError(err, tErrors, t('participateUpdateFailed'))); - } finally { - setParticipationLoading(false); - } - }; - - const handleClinicParticipationChange = (checked: boolean) => { - setError(null); - if (checked) { - setWorkingHoursOpen(true); - return; - } - setPendingRevokeType('CLINIC'); - setRevokeConfirmOpen(true); - }; - - const handleWorkingHoursClose = useCallback(() => { - setWorkingHoursOpen(false); - }, []); - - const handleWorkingHoursComplete = useCallback( - async (options: { - skipHours: boolean; - hoursPayload?: { - autoRepeatWeekly: boolean; - blocks: { dayOfWeek: number; startMinute: number; endMinute: number; sortOrder?: number }[]; - }; - }) => { - setParticipationLoading(true); - setError(null); - try { - await enableClinicParticipation(options); - } catch (err: unknown) { - setError(getUserFacingError(err, tErrors, t('participateUpdateFailed'))); - throw err; - } finally { - setParticipationLoading(false); - } - }, - [enableClinicParticipation, t], - ); - - const handleLabParticipationChange = (checked: boolean) => { - setError(null); - if (checked) { - void enableLabParticipation(); - return; - } - setPendingRevokeType('LAB'); - setRevokeConfirmOpen(true); - }; - - 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) { - setError(getUserFacingError(err, tErrors, t('passwordChangeFailed'))); - } finally { - setIsSubmitting(false); - } - }; - - const passwordToggleLabels = { - show: tAuth('showPassword'), - hide: tAuth('hidePassword'), - }; - - if (!isAuthReady || !user) { - return ( -

{tCommon('loadingEllipsis')}

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

{t('accountTitle')}

-

{t('accountSubtitle')}

-
- - {(showClinicParticipation || showLabParticipation) && ( -
-
-

{t('participationSectionTitle')}

-

{t('participationSectionSubtitle')}

-
- - {showClinicParticipation && ( - - )} - - {showLabParticipation && ( - - )} -
- )} - -
- - - {passwordExpanded && ( -
-

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

- {isResetFlow && ( -

{t('resetPasswordSubtitle')}

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

{error}

-
- )} - - - -
- )} -
- - {error && !passwordExpanded && ( -
-

{error}

-
- )} - - - - {revokeConfirmOpen && ( -
-
-
-

- {t('participateConfirmRevokeTitle')} -

- { - if (participationLoading) return; - setRevokeConfirmOpen(false); - setPendingRevokeType(null); - }} - /> -
-

- {pendingRevokeType === 'CLINIC' - ? t('participateConfirmRevokeBodyTreatments') - : t('participateConfirmRevokeBodyTasks')} -

-
- - -
-
-
- )} - - {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 6f4ccef..42d7975 100644 --- a/frontend/src/app/[locale]/(dashboard)/staff/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/staff/page.tsx @@ -1,1122 +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 { getUserFacingError } 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 tErrors = useTranslations('errors'); - 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(getUserFacingError(e, tErrors, 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(getUserFacingError(e, tErrors, 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(getUserFacingError(e, tErrors, 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(getUserFacingError(e, tErrors, 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(getUserFacingError(e, tErrors, 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(getUserFacingError(e, tErrors, 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(getUserFacingError(e, tErrors, 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 9898c9d..d293eb1 100644 --- a/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx @@ -1,403 +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 { getUserFacingError } 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 tErrors = useTranslations('errors'); - 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(getUserFacingError(error, tErrors, 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(getUserFacingError(error, tErrors, 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 65e938c..9ca7f39 100644 --- a/frontend/src/app/[locale]/(dashboard)/today/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/today/page.tsx @@ -1,80 +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 { getUserFacingError } 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 tErrors = useTranslations('errors'); - 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/components/ui/appointments/appointmentPurposeStyles.ts b/frontend/src/components/appointments/appointmentPurposeStyles.ts similarity index 95% rename from frontend/src/components/ui/appointments/appointmentPurposeStyles.ts rename to frontend/src/components/appointments/appointmentPurposeStyles.ts index 71109e5..245179c 100644 --- a/frontend/src/components/ui/appointments/appointmentPurposeStyles.ts +++ b/frontend/src/components/appointments/appointmentPurposeStyles.ts @@ -4,7 +4,7 @@ import { treatmentTypeBannerStyle, treatmentTypeLabelFromCatalog, treatmentTypeSwatchStyle, -} from '@/components/ui/treatment/treatmentTypeDisplay'; +} from '@/components/shared/treatmentTypeDisplay'; /** * Appointment purposes are treatment-type codes. Labels and colors now come from diff --git a/frontend/src/components/ui/lab/caseDetailUtils.ts b/frontend/src/components/lab/caseDetailUtils.ts similarity index 100% rename from frontend/src/components/ui/lab/caseDetailUtils.ts rename to frontend/src/components/lab/caseDetailUtils.ts diff --git a/frontend/src/components/ui/lab/labTaskStatusDisplay.ts b/frontend/src/components/lab/labTaskStatusDisplay.ts similarity index 100% rename from frontend/src/components/ui/lab/labTaskStatusDisplay.ts rename to frontend/src/components/lab/labTaskStatusDisplay.ts diff --git a/frontend/src/components/organizations/connectionStatusVariant.ts b/frontend/src/components/organizations/connectionStatusVariant.ts new file mode 100644 index 0000000..de21080 --- /dev/null +++ b/frontend/src/components/organizations/connectionStatusVariant.ts @@ -0,0 +1,16 @@ +import type { BadgeVariant } from '@/components/ui/shared/Badge'; + +/** Map organization connection / invitation row status to badge variant. */ +export function organizationConnectionStatusVariant(status: string): BadgeVariant { + switch (status) { + case 'ACTIVE': + return 'success'; + case 'PENDING': + return 'warning'; + case 'REJECTED': + case 'EXPIRED': + return 'danger'; + default: + return 'default'; + } +} diff --git a/frontend/src/components/ui/treatment/catalog-type-colors.ts b/frontend/src/components/shared/catalog-type-colors.ts similarity index 100% rename from frontend/src/components/ui/treatment/catalog-type-colors.ts rename to frontend/src/components/shared/catalog-type-colors.ts diff --git a/frontend/src/components/ui/shared/formSelectStyles.ts b/frontend/src/components/shared/formSelectStyles.ts similarity index 100% rename from frontend/src/components/ui/shared/formSelectStyles.ts rename to frontend/src/components/shared/formSelectStyles.ts diff --git a/frontend/src/components/ui/treatment/treatmentTypeDisplay.ts b/frontend/src/components/shared/treatmentTypeDisplay.ts similarity index 97% rename from frontend/src/components/ui/treatment/treatmentTypeDisplay.ts rename to frontend/src/components/shared/treatmentTypeDisplay.ts index ff2ca0a..3876993 100644 --- a/frontend/src/components/ui/treatment/treatmentTypeDisplay.ts +++ b/frontend/src/components/shared/treatmentTypeDisplay.ts @@ -3,7 +3,7 @@ import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; import { resolveCatalogTypeColor, TREATMENT_TYPE_COLORS, -} from '@/components/ui/treatment/catalog-type-colors'; +} from '@/components/shared/catalog-type-colors'; /** * Single source of truth for treatment-type colors across the app diff --git a/frontend/src/components/staff/staffPermissions.ts b/frontend/src/components/staff/staffPermissions.ts deleted file mode 100644 index 383c44d..0000000 --- a/frontend/src/components/staff/staffPermissions.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** Feature groups for staff invite/edit UI — matches backend seed */ -export const STAFF_FEATURE_GROUPS = [ - { label: 'Today', read: 'TAB_TODAY_READ', edit: 'TAB_TODAY_EDIT' }, - { label: 'Staff', read: 'TAB_STAFF_READ', edit: 'TAB_STAFF_EDIT' }, - { - label: 'Organizations', - read: 'TAB_ORGANIZATIONS_READ', - edit: 'TAB_ORGANIZATIONS_EDIT', - }, - { label: 'Patients', read: 'TAB_PATIENTS_READ', edit: 'TAB_PATIENTS_EDIT' }, - { label: 'Appointment', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT' }, - { label: 'Treatment', read: 'TAB_TREATMENT_READ', edit: 'TAB_TREATMENT_EDIT' }, - { label: 'Billing', read: 'TAB_BILLING_READ', edit: 'TAB_BILLING_EDIT' }, - { label: 'Reports', read: 'TAB_REPORTS_READ', edit: 'TAB_REPORTS_EDIT' }, -] as const; - -/** Map EDIT key -> { read, edit } for checkbox grid */ -export type FeaturePermState = Record; - -export function emptyFeaturePermissionState(): FeaturePermState { - const s: FeaturePermState = {}; - for (const g of STAFF_FEATURE_GROUPS) { - s[g.edit] = { read: false, edit: false }; - } - return s; -} - -export function featureStateFromPermissionNames(names: string[]): FeaturePermState { - const set = new Set(names); - const s = emptyFeaturePermissionState(); - for (const g of STAFF_FEATURE_GROUPS) { - const hasEdit = set.has(g.edit); - const hasRead = set.has(g.read) || hasEdit; - s[g.edit] = { read: hasRead, edit: hasEdit }; - } - return s; -} - -export function permissionNamesFromFeatureState(state: FeaturePermState): string[] { - const out: string[] = []; - for (const g of STAFF_FEATURE_GROUPS) { - const cell = state[g.edit]; - if (!cell) continue; - if (cell.edit) out.push(g.edit); - else if (cell.read) out.push(g.read); - } - return out; -} diff --git a/frontend/src/components/today/chart-theme.ts b/frontend/src/components/today/chart-theme.ts index 50bef8c..8356f78 100644 --- a/frontend/src/components/today/chart-theme.ts +++ b/frontend/src/components/today/chart-theme.ts @@ -1,4 +1,4 @@ -import { CATALOG_PALETTE_COLORS } from '@/components/ui/treatment/catalog-type-colors'; +import { CATALOG_PALETTE_COLORS } from '@/components/shared/catalog-type-colors'; /** Chart series colors — same palette as treatment / prosthesis catalog types. */ export const TODAY_CHART_COLORS = CATALOG_PALETTE_COLORS; diff --git a/frontend/src/components/ui/treatment/prosthesisTypeDisplay.ts b/frontend/src/components/treatment/prosthesisTypeDisplay.ts similarity index 95% rename from frontend/src/components/ui/treatment/prosthesisTypeDisplay.ts rename to frontend/src/components/treatment/prosthesisTypeDisplay.ts index 278c83e..4f46f07 100644 --- a/frontend/src/components/ui/treatment/prosthesisTypeDisplay.ts +++ b/frontend/src/components/treatment/prosthesisTypeDisplay.ts @@ -3,7 +3,7 @@ import { PROSTHESIS_FALLBACK_COLORS, PROSTHESIS_TYPE_COLORS, resolveCatalogTypeColor, -} from '@/components/ui/treatment/catalog-type-colors'; +} from '@/components/shared/catalog-type-colors'; /** * Prosthesis-type colors for lab-facing surfaces (Tasks list, Cases detail group diff --git a/frontend/src/components/ui/treatment/toothPathModel.ts b/frontend/src/components/treatment/toothPathModel.ts similarity index 100% rename from frontend/src/components/ui/treatment/toothPathModel.ts rename to frontend/src/components/treatment/toothPathModel.ts diff --git a/frontend/src/components/ui/treatment/treatmentStatusStyles.ts b/frontend/src/components/treatment/treatmentStatusStyles.ts similarity index 100% rename from frontend/src/components/ui/treatment/treatmentStatusStyles.ts rename to frontend/src/components/treatment/treatmentStatusStyles.ts diff --git a/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx b/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx index a6670a1..d122b7a 100644 --- a/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx +++ b/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx @@ -11,7 +11,7 @@ import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; import { DROPDOWN_OPTION_BG, treatmentTypeColor, -} from '@/components/ui/treatment/treatmentTypeDisplay'; +} from '@/components/shared/treatmentTypeDisplay'; import type { Patient } from '@/types/patient'; import { combineLocalDateAndTime, diff --git a/frontend/src/components/ui/appointments/AppointmentOverlapPopover.tsx b/frontend/src/components/ui/appointments/AppointmentOverlapPopover.tsx index 04b4efd..9b6763d 100644 --- a/frontend/src/components/ui/appointments/AppointmentOverlapPopover.tsx +++ b/frontend/src/components/ui/appointments/AppointmentOverlapPopover.tsx @@ -6,7 +6,7 @@ import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; import { purposeBannerStyle, purposeLabel, -} from '@/components/ui/appointments/appointmentPurposeStyles'; +} from '@/components/appointments/appointmentPurposeStyles'; import type { AppointmentRecord } from '@/types/appointment'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; diff --git a/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx b/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx index 9d07db9..becbe99 100644 --- a/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx +++ b/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx @@ -18,7 +18,7 @@ import { findOverlapCluster, lanePositionStyles, } from '@/components/appointments/appointmentOverlapLayout'; -import { purposeBannerStyle } from '@/components/ui/appointments/appointmentPurposeStyles'; +import { purposeBannerStyle } from '@/components/appointments/appointmentPurposeStyles'; import { AppointmentOverlapPopover } from '@/components/ui/appointments/AppointmentOverlapPopover'; import { formatMobileForDisplay } from '@/lib/phone'; import { startOfLocalDay } from '@/components/appointments/appointmentTime'; diff --git a/frontend/src/components/ui/appointments/AppointmentScheduleLegend.tsx b/frontend/src/components/ui/appointments/AppointmentScheduleLegend.tsx index 041a2e6..aa471be 100644 --- a/frontend/src/components/ui/appointments/AppointmentScheduleLegend.tsx +++ b/frontend/src/components/ui/appointments/AppointmentScheduleLegend.tsx @@ -1,7 +1,7 @@ 'use client'; import { useTranslations } from 'next-intl'; -import { purposeSwatchStyle } from '@/components/ui/appointments/appointmentPurposeStyles'; +import { purposeSwatchStyle } from '@/components/appointments/appointmentPurposeStyles'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; interface AppointmentScheduleLegendProps { diff --git a/frontend/src/components/ui/appointments/AppointmentsPage.tsx b/frontend/src/components/ui/appointments/AppointmentsPage.tsx new file mode 100644 index 0000000..6dc3a7e --- /dev/null +++ b/frontend/src/components/ui/appointments/AppointmentsPage.tsx @@ -0,0 +1,373 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { appointmentsApi } from '@/lib/api/appointments'; +import { patientsApi } from '@/lib/api/patients'; +import { treatmentCatalogApi } from '@/lib/api/treatment-catalog'; +import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { canEditAppointments, hasPermission } from '@/components/shared/permissions'; +import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment'; +import type { CreatePatientInput, Patient } from '@/types/patient'; +import { CreatePatientModal } from '@/components/ui/patient/CreatePatientModal'; +import { PatientSummaryCard } from '@/components/ui/patient/PatientSummaryCard'; +import { AppointmentBookingModal } from '@/components/ui/appointments/AppointmentBookingModal'; +import { AppointmentScheduleGrid } from '@/components/ui/appointments/AppointmentScheduleGrid'; +import { AppointmentsPatientSearch } from '@/components/ui/appointments/AppointmentsPatientSearch'; +import { AppointmentScheduleLegend } from '@/components/ui/appointments/AppointmentScheduleLegend'; +import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker'; +import { ToastStack } from '@/components/ui/shared/Toast'; +import { useToast } from '@/lib/hooks/useToast'; +import type { AppointmentPurpose } from '@/types/appointment'; +import { getUserFacingError } from '@/components/shared/formatApiError'; +import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/components/appointments/appointmentTime'; + +const EMPTY_PATIENT_FORM: CreatePatientInput = { + firstName: '', + lastName: '', + mobile: '', + email: '', +}; + +export function AppointmentsPage() { + const t = useTranslations('appointments'); + const tErrors = useTranslations('errors'); + const tPatients = useTranslations('patients'); + const { currentOrganization } = useAuth(); + const [scheduleDate, setScheduleDate] = useState(() => startOfLocalDay(new Date())); + + const [providers, setProviders] = useState([]); + const [appointments, setAppointments] = useState([]); + const [treatmentCatalog, setTreatmentCatalog] = useState([]); + const [loadingSchedule, setLoadingSchedule] = useState(false); + 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 [bookingOpen, setBookingOpen] = useState(false); + const [bookingStartMinute, setBookingStartMinute] = useState(9 * 60); + const [bookingProviderId, setBookingProviderId] = useState(null); + const [bookingProviderName, setBookingProviderName] = useState(''); + const [editingAppointmentId, setEditingAppointmentId] = useState(null); + const [savingAppointment, setSavingAppointment] = useState(false); + const [deletingAppointment, setDeletingAppointment] = useState(false); + + + const canManageAppointments = canEditAppointments(currentOrganization); + const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT'); + + const todayStart = useMemo(() => startOfLocalDay(new Date()), []); + const isViewingPastDay = useMemo( + () => compareLocalDayStart(scheduleDate, todayStart) < 0, + [scheduleDate, todayStart], + ); + const activeEditingAppointment = useMemo( + () => appointments.find((a) => a.id === editingAppointmentId) ?? null, + [appointments, editingAppointmentId], + ); + + const scheduleLoadGen = useRef(0); + + const sortedPatients = useMemo( + () => + [...patients].sort((a, b) => + `${a.firstName} ${a.lastName}`.localeCompare(`${b.firstName} ${b.lastName}`), + ), + [patients], + ); + + const loadSchedule = useCallback(async () => { + if (!currentOrganization?.id) { + return; + } + const gen = ++scheduleLoadGen.current; + setLoadingSchedule(true); + toast.setError(''); + try { + const range = getLocalDayIsoRange(scheduleDate); + const [pRes, aRes] = await Promise.all([ + appointmentsApi.columnProviders(scheduleDate), + appointmentsApi.list(range), + ]); + if (gen !== scheduleLoadGen.current) { + return; + } + setProviders(pRes.data); + setAppointments(aRes.data); + } catch (err: unknown) { + if (gen !== scheduleLoadGen.current) { + return; + } + toast.showError(getUserFacingError(err, tErrors, t('errorLoadSchedule'))); + } finally { + if (gen === scheduleLoadGen.current) { + setLoadingSchedule(false); + } + } + }, [currentOrganization?.id, scheduleDate, t]); + + useEffect(() => { + void loadSchedule(); + }, [loadSchedule]); + + useEffect(() => { + void treatmentCatalogApi + .list('appointment') + .then((r) => setTreatmentCatalog(r.data)) + .catch(() => {}); + }, []); + + useEffect(() => { + const t = setTimeout(() => { + void loadPatientsSearch(search); + }, 300); + return () => clearTimeout(t); + }, [search]); + + async function loadPatientsSearch(q: string) { + if (!currentOrganization) { + return; + } + setLoadingPatients(true); + try { + const response = await patientsApi.list({ q, page: 1, limit: 25 }); + const items = response.data.items; + setPatients(items); + if (selectedPatient) { + const stillThere = items.find((p) => p.id === selectedPatient.id); + if (stillThere) { + setSelectedPatient(stillThere); + } + } + } catch { + setPatients([]); + } finally { + setLoadingPatients(false); + } + } + + async function handleCreatePatient() { + setSavingPatient(true); + toast.setError(''); + try { + const response = await patientsApi.create(patientForm); + setIsCreateOpen(false); + setPatientForm(EMPTY_PATIENT_FORM); + await loadPatientsSearch(search); + setSelectedPatient(response.data); + if (response.existing) { + toast.showInfo( + tPatients('patientAlreadyExists', { + firstName: response.data.firstName, + lastName: response.data.lastName, + }), + ); + } else { + toast.showSuccess( + t('successPatientSaved', { + firstName: response.data.firstName, + lastName: response.data.lastName, + }), + ); + } + } catch (err: unknown) { + toast.showError(getUserFacingError(err, tErrors, tPatients('errorSavePatient'))); + } finally { + setSavingPatient(false); + } + } + + function handleSlotClick(startMinute: number, providerUserId: string, providerName: string) { + if (!canManageAppointments) { + return; + } + if (isViewingPastDay) { + toast.showInfo(t('infoPastViewOnly')); + return; + } + if (!selectedPatient) { + toast.showInfo(t('infoSelectPatient')); + return; + } + setBookingStartMinute(startMinute); + setBookingProviderId(providerUserId); + setBookingProviderName(providerName); + setEditingAppointmentId(null); + setBookingOpen(true); + } + + function handleAppointmentClick(appointment: AppointmentRecord) { + if (!canManageAppointments) { + return; + } + if (isViewingPastDay) { + toast.showInfo(t('infoPastViewOnly')); + return; + } + const provider = providers.find((p) => p.userId === appointment.providerUserId); + const start = new Date(appointment.startAt); + setBookingStartMinute(start.getHours() * 60 + start.getMinutes()); + setBookingProviderId(appointment.providerUserId); + setBookingProviderName(provider?.name ?? bookingProviderName); + setEditingAppointmentId(appointment.id); + setBookingOpen(true); + } + + function handleAppointmentOutsideHours(appointment: AppointmentRecord) { + toast.showError(t('errorOutsideHours')); + } + + async function handleSaveAppointment(payload: { + patientId: string; + providerUserId: string; + startAt: string; + endAt: string; + purpose: AppointmentPurpose; + }) { + setSavingAppointment(true); + toast.setError(''); + try { + if (activeEditingAppointment) { + await appointmentsApi.update(activeEditingAppointment.id, payload); + } else { + await appointmentsApi.create(payload); + } + setBookingOpen(false); + setEditingAppointmentId(null); + toast.showSuccess(activeEditingAppointment ? t('successUpdated') : t('successSaved')); + await loadSchedule(); + } catch (err: unknown) { + toast.showError( + getUserFacingError( + err, + tErrors, + activeEditingAppointment ? t('errorUpdate') : t('errorSave'), + ), + ); + } finally { + setSavingAppointment(false); + } + } + + async function handleDeleteEditingAppointment() { + if (!activeEditingAppointment) { + return; + } + if (!window.confirm(t('confirmRemove'))) { + return; + } + setDeletingAppointment(true); + toast.setError(''); + try { + await appointmentsApi.remove(activeEditingAppointment.id); + setBookingOpen(false); + setEditingAppointmentId(null); + toast.showSuccess(t('successRemoved')); + await loadSchedule(); + } catch (err: unknown) { + toast.showError(getUserFacingError(err, tErrors, t('errorDelete'))); + } finally { + setDeletingAppointment(false); + } + } + + return ( +
+
+

{t('title')}

+

{t('subtitle')}

+
+ + + +
+
+ { + if (!canEditPatients) { + return; + } + setPatientForm(EMPTY_PATIENT_FORM); + setIsCreateOpen(true); + }} + /> + +
+ +
+ + +
+ setScheduleDate(startOfLocalDay(d))} + /> + {loadingSchedule && ( +

{t('loadingSchedule')}

+ )} +
+ + handleSlotClick(startMinute, uid, name)} + onAppointmentClick={(apt) => handleAppointmentClick(apt)} + onAppointmentOutsideHours={(apt) => handleAppointmentOutsideHours(apt)} + /> +
+
+ + { + setBookingOpen(false); + setEditingAppointmentId(null); + }} + onSubmit={handleSaveAppointment} + loading={savingAppointment} + canDelete={canManageAppointments && !isViewingPastDay && !!activeEditingAppointment} + onDelete={() => void handleDeleteEditingAppointment()} + deleting={deletingAppointment} + /> + + setPatientForm((prev) => ({ ...prev, ...patch }))} + onSubmit={() => void handleCreatePatient()} + onClose={() => { + setIsCreateOpen(false); + setPatientForm(EMPTY_PATIENT_FORM); + }} + loading={savingPatient} + /> + +
+ ); +} diff --git a/frontend/src/components/ui/billing/BillingPage.tsx b/frontend/src/components/ui/billing/BillingPage.tsx new file mode 100644 index 0000000..729da84 --- /dev/null +++ b/frontend/src/components/ui/billing/BillingPage.tsx @@ -0,0 +1,296 @@ +// src/app/(dashboard)/billing/page.tsx +'use client'; + +import { useMemo, useState } from 'react'; +import { Pencil } from 'lucide-react'; +import { Button } from '@/components/ui/shared/Button'; +import { Badge } from '@/components/ui/shared/Badge'; +import { Card } from '@/components/ui/shared/Card'; +import { Table } from '@/components/ui/shared/Table'; +import { SearchBar } from '@/components/ui/shared/SearchBar'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { hasPermission } from '@/components/shared/permissions'; + +type InvoiceStatus = 'paid' | 'unpaid' | 'overdue'; + +type Invoice = { + id: string; + patient: string; + date: string; + service: string; + amount: number; + paid: number; + status: InvoiceStatus; +}; + +const invoices: Invoice[] = [ + { id: '#123456', patient: 'Ali Rahmani', date: '24/9/2026', service: 'Hygiene', amount: 300, paid: 0, status: 'unpaid' }, + { id: '#123457', patient: 'Neda Akbari', date: '01/10/2026', service: 'Filling', amount: 700, paid: 400, status: 'overdue' }, + { id: '#123458', patient: 'Nima Haghi', date: '09/12/2026', service: 'Extraction', amount: 450, paid: 450, status: 'paid' }, +]; + +const statusColors = { + paid: 'success', + unpaid: 'warning', + overdue: 'danger', +} as const; + +const statusFilters = ['all', 'paid', 'unpaid', 'overdue'] as const; + +type StatCardColor = 'blue' | 'yellow' | 'green' | 'red'; + +interface StatCardProps { + title: string; + count: number; + amount: number; + color: StatCardColor; +} + +export function BillingPage() { + const { currentOrganization } = useAuth(); + const [search, setSearch] = useState(''); + const [statusFilter, setStatusFilter] = useState<(typeof statusFilters)[number]>('all'); + const canEditBilling = hasPermission(currentOrganization, 'TAB_BILLING_EDIT'); + + const stats = { + total: { count: 235, amount: 80900 }, + unpaid: { count: 30, amount: 2800 }, + paid: { count: 190, amount: 80900 }, + overdue: { count: 235, amount: 80900 }, + }; + + const filteredInvoices = useMemo(() => { + const query = search.trim().toLowerCase(); + + return invoices.filter((invoice) => { + const matchesStatus = statusFilter === 'all' || invoice.status === statusFilter; + const matchesSearch = + !query || + invoice.patient.toLowerCase().includes(query) || + invoice.id.toLowerCase().includes(query) || + invoice.service.toLowerCase().includes(query); + + return matchesStatus && matchesSearch; + }); + }, [search, statusFilter]); + + return ( +
+
+

Billing

+ +
+ +
+ + + + +
+ + + {statusFilters.map((status) => ( + + ))} + + )} + /> + +
+ {filteredInvoices.length === 0 ? ( +
No invoices match your filters.
+ ) : ( + filteredInvoices.map((invoice) => ( + + )) + )} + +
+ +
+
{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) && ( - - )} - - -
- )} -
+ + + + + + + + + + } + body={ + <> + {filteredInvoices.map((invoice) => ( + + + + + + + + + + + ))} + + } + footer={} + /> + + + ); +} + +function InvoiceMobileCard({ + invoice, + canEditBilling, +}: { + invoice: Invoice; + canEditBilling: boolean; +}) { + const remaining = invoice.amount - invoice.paid; + + return ( + +
+
+

{invoice.patient}

+

{invoice.id}

+
+ + {invoice.status} + +
+ +
+ {invoice.service} + · + {invoice.date} +
+ +
+
+

Total

+

${invoice.amount}

+
+
+

Paid

+

${invoice.paid}

+
+
+

Due

+

${remaining}

+
+
+ +
+ +
+
+ ); +} + +function InvoiceEditButton({ canEditBilling }: { canEditBilling: boolean }) { + return ( + + ); +} + +function InvoicePagination({ className = '' }: { className?: string }) { + return ( +
+ +
Page 1 of 10
+ +
+ ); +} + +function StatCard({ title, count, amount, color }: StatCardProps) { + const colors: Record = { + blue: '!bg-purpose-visit-bg !text-purpose-visit-fg !border-purpose-visit-border', + yellow: '!bg-badge-warning-bg !text-badge-warning-fg !border-badge-warning-border', + green: '!bg-badge-success-bg !text-badge-success-fg !border-badge-success-border', + red: '!bg-badge-danger-bg !text-badge-danger-fg !border-badge-danger-border', + }; + + return ( + +

{title}

+

{count}

+

+ ${amount.toLocaleString()} +

+
+ ); +} diff --git a/frontend/src/components/ui/lab/CaseDetailPanel.tsx b/frontend/src/components/ui/lab/CaseDetailPanel.tsx index f970ee0..99d0ab5 100644 --- a/frontend/src/components/ui/lab/CaseDetailPanel.tsx +++ b/frontend/src/components/ui/lab/CaseDetailPanel.tsx @@ -9,17 +9,17 @@ import { Checkbox } from '@/components/ui/shared/Checkbox'; import { CaseToothChartPanel } from '@/components/ui/lab/CaseToothChartPanel'; import { LabCaseAttachmentPreview } from '@/components/ui/lab/LabCaseAttachmentPreview'; import { LabCaseAttachmentsDialog } from '@/components/ui/lab/LabCaseAttachmentsDialog'; -import { labTaskStatusVariant } from '@/components/ui/lab/labTaskStatusDisplay'; +import { labTaskStatusVariant } from '@/components/lab/labTaskStatusDisplay'; import { formatToothList, prosthesisTypeBadgeStyle, -} from '@/components/ui/treatment/prosthesisTypeDisplay'; +} from '@/components/treatment/prosthesisTypeDisplay'; import { buildCaseProsthesisRows, formatCaseDateTime, formatPatientName, latestCaseAttachment, -} from '@/components/ui/lab/caseDetailUtils'; +} from '@/components/lab/caseDetailUtils'; import type { LabCaseDetail, LabTaskStatus } from '@/types/cases'; function CaseTaskProgressBar({ completed, total }: { completed: number; total: number }) { diff --git a/frontend/src/components/ui/lab/CaseToothChartPanel.tsx b/frontend/src/components/ui/lab/CaseToothChartPanel.tsx index acc4513..ea51b15 100644 --- a/frontend/src/components/ui/lab/CaseToothChartPanel.tsx +++ b/frontend/src/components/ui/lab/CaseToothChartPanel.tsx @@ -2,7 +2,7 @@ import { useMemo } from 'react'; import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart'; -import { prosthesisTypeColor } from '@/components/ui/treatment/prosthesisTypeDisplay'; +import { prosthesisTypeColor } from '@/components/treatment/prosthesisTypeDisplay'; import type { FdiToothId } from '@/types/treatment'; export interface CaseToothChartDetail { diff --git a/frontend/src/components/ui/lab/CasesPage.tsx b/frontend/src/components/ui/lab/CasesPage.tsx new file mode 100644 index 0000000..2ce1136 --- /dev/null +++ b/frontend/src/components/ui/lab/CasesPage.tsx @@ -0,0 +1,472 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { useTranslations } from 'next-intl'; +import { ToastStack } from '@/components/ui/shared/Toast'; +import { getUserFacingError } from '@/components/shared/formatApiError'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { useToast } from '@/lib/hooks/useToast'; +import { canEditCases, canEditTasks } from '@/components/shared/permissions'; +import { CaseDetailPanel, CaseTaskProgressBar } from '@/components/ui/lab/CaseDetailPanel'; +import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel'; +import { + formatCaseDateTime, + formatPatientName, +} from '@/components/lab/caseDetailUtils'; +import { casesApi } from '@/lib/api/cases'; +import { tasksApi } from '@/lib/api/tasks'; +import { treatmentCatalogApi } from '@/lib/api/treatment-catalog'; +import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay'; +import { Button } from '@/components/ui/shared/Button'; +import { MobileDetailBackButton } from '@/components/ui/shared/MobileDetailBackButton'; +import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles'; +import { SearchBar } from '@/components/ui/shared/SearchBar'; +import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; +import type { + CasesFilterOptions, + LabCaseDetail, + LabCaseListItem, + LabTaskStatus, + PaginatedLabCases, +} from '@/types/cases'; + +const PAGE_SIZE = 20; + +export function CasesPage() { + const t = useTranslations('cases'); + const tErrors = useTranslations('errors'); + const tCommon = useTranslations('common'); + const { currentOrganization, user } = useAuth(); + const toast = useToast(); + const searchParams = useSearchParams(); + + const [search, setSearch] = useState(''); + const [clinicId, setClinicId] = useState(''); + const [treatmentType, setTreatmentType] = useState(''); + const [sentFrom, setSentFrom] = useState(''); + const [sentTo, setSentTo] = useState(''); + const [page, setPage] = useState(1); + + const [cases, setCases] = useState([]); + const [pagination, setPagination] = useState({ + page: 1, + limit: PAGE_SIZE, + total: 0, + totalPages: 1, + }); + const [filterOptions, setFilterOptions] = useState({ + clinics: [], + treatmentTypes: [], + }); + const [treatmentCatalog, setTreatmentCatalog] = useState([]); + + const [selectedCaseId, setSelectedCaseId] = useState(null); + const [mobileDetailOpen, setMobileDetailOpen] = useState(false); + const [selectedCase, setSelectedCase] = useState(null); + const [loadingList, setLoadingList] = useState(false); + const [loadingDetail, setLoadingDetail] = useState(false); + const [updatingImportant, setUpdatingImportant] = useState(false); + const [commentCount, setCommentCount] = useState(0); + + const canEdit = canEditCases(currentOrganization); + const canEditComments = canEditTasks(currentOrganization); + const locale = user?.language ?? 'en'; + + const treatmentLabel = useCallback( + (type: string) => treatmentTypeLabelFromCatalog(type, treatmentCatalog), + [treatmentCatalog], + ); + + const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo( + () => [ + { value: 'IN_PROGRESS', label: t('statusInProgress') }, + { value: 'COMPLETED', label: t('statusCompleted') }, + ], + [t], + ); + + const hasActiveFilters = Boolean( + search.trim() || clinicId || treatmentType || sentFrom || sentTo, + ); + + const loadCases = async (params: { + q: string; + clinicOrganizationId: string; + treatmentType: string; + sentFrom: string; + sentTo: string; + page: number; + }) => { + setLoadingList(true); + toast.setError(''); + try { + const response = await casesApi.list({ + q: params.q.trim() || undefined, + clinicOrganizationId: params.clinicOrganizationId || undefined, + treatmentType: params.treatmentType || undefined, + sentFrom: params.sentFrom || undefined, + sentTo: params.sentTo || undefined, + page: params.page, + limit: PAGE_SIZE, + }); + setCases(response.data.items); + setPagination(response.data.pagination); + } catch (error: unknown) { + toast.showError(getUserFacingError(error, tErrors, t('errorLoadList'))); + } finally { + setLoadingList(false); + } + }; + + const loadDetail = async (caseId: string, options?: { silent?: boolean }) => { + if (!options?.silent) { + setLoadingDetail(true); + } + toast.setError(''); + try { + const response = await casesApi.getOne(caseId); + setSelectedCase(response.data); + } catch (error: unknown) { + toast.showError(getUserFacingError(error, tErrors, t('errorLoadDetail'))); + if (!options?.silent) { + setSelectedCase(null); + } + } finally { + if (!options?.silent) { + setLoadingDetail(false); + } + } + }; + + useEffect(() => { + void casesApi.listFilterOptions().then((r) => setFilterOptions(r.data)).catch(() => {}); + void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {}); + // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only initial fetch + }, []); + + useEffect(() => { + const caseIdFromUrl = searchParams.get('caseId'); + if (caseIdFromUrl) { + setSelectedCaseId(caseIdFromUrl); + setMobileDetailOpen(true); + } + }, [searchParams]); + + useEffect(() => { + if (!selectedCaseId) { + setMobileDetailOpen(false); + } + }, [selectedCaseId]); + + useEffect(() => { + const timeout = setTimeout(() => { + void loadCases({ + q: search, + clinicOrganizationId: clinicId, + treatmentType, + sentFrom, + sentTo, + page, + }); + }, search ? 300 : 0); + return () => clearTimeout(timeout); + // eslint-disable-next-line react-hooks/exhaustive-deps -- debounced search + filter reload + }, [search, clinicId, treatmentType, sentFrom, sentTo, page]); + + useEffect(() => { + if (selectedCaseId) { + void loadDetail(selectedCaseId); + void tasksApi + .listComments(selectedCaseId) + .then((r) => setCommentCount(r.data.length)) + .catch(() => setCommentCount(0)); + } else { + setSelectedCase(null); + setCommentCount(0); + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- reload when selection changes + }, [selectedCaseId]); + + function scrollToComments() { + document.getElementById('case-comments')?.scrollIntoView({ behavior: 'smooth' }); + } + + const loadCaseAttachmentBlob = useCallback( + (caseId: string, attachmentId: string) => casesApi.getAttachmentFileBlob(caseId, attachmentId), + [], + ); + + function clearFilters() { + setSearch(''); + setClinicId(''); + setTreatmentType(''); + setSentFrom(''); + setSentTo(''); + setPage(1); + } + + async function handleCaseImportantToggle(isImportant: boolean) { + if (!selectedCaseId || !canEdit || !selectedCase) return; + + const previousCase = selectedCase; + setSelectedCase({ ...selectedCase, isImportant }); + + setUpdatingImportant(true); + toast.setError(''); + try { + const response = await casesApi.setCaseImportant(selectedCaseId, isImportant); + setSelectedCase(response.data); + } catch (error: unknown) { + setSelectedCase(previousCase); + toast.showError(getUserFacingError(error, tErrors, t('errorUpdateTask'))); + } finally { + setUpdatingImportant(false); + } + } + + const filterSelectClass = `${FORM_SELECT_CLASS} w-full rounded-md px-3 py-2`; + + return ( +
+
+

{t('title')}

+

{t('subtitle')}

+
+ +
+
+ { + setSearch(value); + setPage(1); + }} + placeholder={t('searchPlaceholder')} + /> + +
+ + + + + + + +
+ + {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 + } + /> + )} +
+
+ + +
+ ); +} diff --git a/frontend/src/components/ui/lab/TasksPage.tsx b/frontend/src/components/ui/lab/TasksPage.tsx new file mode 100644 index 0000000..d154d18 --- /dev/null +++ b/frontend/src/components/ui/lab/TasksPage.tsx @@ -0,0 +1,403 @@ +'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/shared/formSelectStyles'; +import { SearchBar } from '@/components/ui/shared/SearchBar'; +import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel'; +import { + labTaskStatusSelectStyle, + labTaskStatusVariant, +} from '@/components/lab/labTaskStatusDisplay'; +import { + formatToothList, + prosthesisTypeBadgeStyle, +} from '@/components/treatment/prosthesisTypeDisplay'; +import { getUserFacingError } 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'; + +const PAGE_SIZE = 50; + +function formatPatientName(patient: { firstName: string; lastName: string }) { + return `${patient.firstName} ${patient.lastName}`.trim(); +} + +export function TasksPage() { + const t = useTranslations('tasks'); + const tErrors = useTranslations('errors'); + 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(getUserFacingError(error, tErrors, 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(getUserFacingError(error, tErrors, 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, + })} +

+
+ + +
+
+ )} + + +
+ ); +} diff --git a/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx b/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx index fa057cf..16f4832 100644 --- a/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx +++ b/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx @@ -8,7 +8,7 @@ import { useAuth } from '@/lib/hooks/useAuth'; import { useToast } from '@/lib/hooks/useToast'; import { organizationApi } from '@/lib/api/organization'; import { treatmentCatalogApi } from '@/lib/api/treatment-catalog'; -import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay'; +import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay'; import { Button } from '@/components/ui/shared/Button'; import { MobileDetailBackButton } from '@/components/ui/shared/MobileDetailBackButton'; import { SearchBar } from '@/components/ui/shared/SearchBar'; @@ -18,7 +18,7 @@ import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel'; import { formatCaseDateTime, formatPatientName, -} from '@/components/ui/lab/caseDetailUtils'; +} from '@/components/lab/caseDetailUtils'; import { treatmentsApi } from '@/lib/api/treatments'; import { casesApi } from '@/lib/api/cases'; import type { CounterpartItemDto } from '@/lib/api/organization'; diff --git a/frontend/src/components/ui/organizations/InvitationHistoryDialog.tsx b/frontend/src/components/ui/organizations/InvitationHistoryDialog.tsx index 3563226..fb1b244 100644 --- a/frontend/src/components/ui/organizations/InvitationHistoryDialog.tsx +++ b/frontend/src/components/ui/organizations/InvitationHistoryDialog.tsx @@ -9,7 +9,8 @@ import { } from '@/components/ui/shared/ResponsiveDialog'; import { ToastStack, type ToastMessages } from '@/components/ui/shared/Toast'; import type { OrganizationInvitationHistoryItemDto } from '@/lib/api/organization'; -import { Badge, organizationConnectionStatusVariant } from '@/components/ui/shared/Badge'; +import { Badge } from '@/components/ui/shared/Badge'; +import { organizationConnectionStatusVariant } from '@/components/organizations/connectionStatusVariant'; import { Table } from '@/components/ui/shared/Table'; import { CopyInvitationLinkButton } from '@/components/ui/organizations/CopyInvitationLinkButton'; diff --git a/frontend/src/components/ui/organizations/OrganizationConnectionsMobileList.tsx b/frontend/src/components/ui/organizations/OrganizationConnectionsMobileList.tsx index 95e1f21..16ed0e3 100644 --- a/frontend/src/components/ui/organizations/OrganizationConnectionsMobileList.tsx +++ b/frontend/src/components/ui/organizations/OrganizationConnectionsMobileList.tsx @@ -5,7 +5,8 @@ import type { CounterpartItemDto, CounterpartSearchResultDto, } from '@/lib/api/organization'; -import { Badge, organizationConnectionStatusVariant } from '@/components/ui/shared/Badge'; +import { Badge } from '@/components/ui/shared/Badge'; +import { organizationConnectionStatusVariant } from '@/components/organizations/connectionStatusVariant'; import { Button } from '@/components/ui/shared/Button'; import { Card } from '@/components/ui/shared/Card'; import { Input } from '@/components/ui/shared/Input'; diff --git a/frontend/src/components/ui/organizations/OrganizationsPage.tsx b/frontend/src/components/ui/organizations/OrganizationsPage.tsx new file mode 100644 index 0000000..6272ccc --- /dev/null +++ b/frontend/src/components/ui/organizations/OrganizationsPage.tsx @@ -0,0 +1,603 @@ +'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 } from '@/components/ui/shared/Badge'; +import { organizationConnectionStatusVariant } from '@/components/organizations/connectionStatusVariant'; +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 { getUserFacingError } from '@/components/shared/formatApiError'; + +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 function OrganizationsPage() { + const t = useTranslations('organizations'); + const tErrors = useTranslations('errors'); + const tNav = useTranslations('nav'); + const tCommon = useTranslations('common'); + const { currentOrganization } = useAuth(); + const [loading, setLoading] = useState(true); + const toast = useToast(); + const { showError, setError: setToastError } = toast; + + const formatApiMessage = useCallback( + (err: unknown): string => getUserFacingError(err, tErrors, tCommon('errorGeneric')), + [tCommon, tErrors], + ); + + 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(); + }, []); + + useEffect(() => { + let cancelled = false; + const q = query.trim(); + + if (!q) { + setMode('existing'); + setSearchResults([]); + setShowInviteForm(false); + setSearching(false); + return; + } + + setMode('search'); + setShowInviteForm(false); + setSearching(true); + + const timeout = setTimeout(() => { + void (async () => { + setToastError(''); + try { + const res = await organizationApi.search(q); + if (cancelled) return; + setSearchResults(res.data); + } catch (e) { + if (cancelled) return; + showError(formatApiMessage(e)); + setSearchResults([]); + } finally { + if (!cancelled) setSearching(false); + } + })(); + }, 300); + + return () => { + cancelled = true; + clearTimeout(timeout); + }; + }, [query, formatApiMessage, showError, setToastError]); + + 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 && } + + + {t('backToList')} + + ) : undefined + } + /> + + 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'), + }} + /> + +
+
+ Invoice ID + + Patient name + + Date + + Service + + Total amount + + Paid + + Status + + Action +
{invoice.id}{invoice.patient}{invoice.date}{invoice.service}${invoice.amount}${invoice.paid} + + {invoice.status} + + + +
+ + + + + + + } + body={ + <> + {loading || (mode === 'search' && searching) ? ( + + + + ) : 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} + /> + + ); +} diff --git a/frontend/src/components/ui/patient/PatientAppointmentHistory.tsx b/frontend/src/components/ui/patient/PatientAppointmentHistory.tsx index 93d86c1..b1825ee 100644 --- a/frontend/src/components/ui/patient/PatientAppointmentHistory.tsx +++ b/frontend/src/components/ui/patient/PatientAppointmentHistory.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react'; import { useTranslations } from 'next-intl'; import { formatTimeForInput } from '@/components/appointments/appointmentTime'; -import { purposeLabel } from '@/components/ui/appointments/appointmentPurposeStyles'; +import { purposeLabel } from '@/components/appointments/appointmentPurposeStyles'; import { getUserFacingError } from '@/components/shared/formatApiError'; import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge'; import { patientsApi } from '@/lib/api/patients'; diff --git a/frontend/src/components/ui/patient/PatientsPage.tsx b/frontend/src/components/ui/patient/PatientsPage.tsx new file mode 100644 index 0000000..132e425 --- /dev/null +++ b/frontend/src/components/ui/patient/PatientsPage.tsx @@ -0,0 +1,165 @@ +'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 { getUserFacingError } 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 { PatientAppointmentHistory } from '@/components/ui/patient/PatientAppointmentHistory'; + +const EMPTY_PATIENT_FORM: CreatePatientInput = { + firstName: '', + lastName: '', + mobile: '', + email: '', +}; + +export function PatientsPage() { + const t = useTranslations('patients'); + const tErrors = useTranslations('errors'); + 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(getUserFacingError(error, tErrors, 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(getUserFacingError(error, tErrors, 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} + /> + )} + +
+
+ +
+ +
+ + {selectedPatient ? ( + + ) : null} +
+
+
+ ); +} diff --git a/frontend/src/components/ui/settings/AccountSettingsPage.tsx b/frontend/src/components/ui/settings/AccountSettingsPage.tsx new file mode 100644 index 0000000..244392e --- /dev/null +++ b/frontend/src/components/ui/settings/AccountSettingsPage.tsx @@ -0,0 +1,452 @@ +'use client'; + +import { useCallback, 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 { ChevronDown, Lock } from 'lucide-react'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { authApi } from '@/lib/api/auth'; +import { accountApi } from '@/lib/api/account'; +import { Button } from '@/components/ui/shared/Button'; +import { Input } from '@/components/ui/shared/Input'; +import { Toast } from '@/components/ui/shared/Toast'; +import { Checkbox } from '@/components/ui/shared/Checkbox'; +import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; +import { getUserFacingError } from '@/components/shared/formatApiError'; +import { OwnerWorkingHoursDialog } from '@/components/ui/settings/OwnerWorkingHoursDialog'; + +type PasswordForm = { + currentPassword: string; + newPassword: string; + confirmPassword: string; +}; + +export function AccountSettingsPage() { + const t = useTranslations('settings'); + const tErrors = useTranslations('errors'); + const tAuth = useTranslations('auth'); + const tCommon = useTranslations('common'); + const tValidation = useTranslations('validation'); + const { user, currentOrganization, isAuthReady, refreshSession } = useAuth(); + const router = useRouter(); + const searchParams = useSearchParams(); + const isResetFlow = searchParams.get('reset') === '1'; + + const isOwner = Boolean(currentOrganization?.isOwner); + const orgType = currentOrganization?.type; + const showClinicParticipation = isOwner && orgType === 'CLINIC'; + const showLabParticipation = isOwner && orgType === 'LAB'; + + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [passwordExpanded, setPasswordExpanded] = useState(isResetFlow); + + const [participationLoading, setParticipationLoading] = useState(false); + const [participatesInTreatments, setParticipatesInTreatments] = useState(false); + const [participatesInTasks, setParticipatesInTasks] = useState(false); + const [workingHoursOpen, setWorkingHoursOpen] = useState(false); + const [revokeConfirmOpen, setRevokeConfirmOpen] = useState(false); + const [pendingRevokeType, setPendingRevokeType] = useState<'CLINIC' | 'LAB' | null>(null); + + 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: '', + }, + }); + + const loadParticipation = useCallback(async () => { + if (!isOwner) return; + try { + const res = await accountApi.getParticipation(); + setParticipatesInTreatments(res.data.participatesInTreatments); + setParticipatesInTasks(res.data.participatesInTasks); + } catch { + /* non-owners or missing org context */ + } + }, [isOwner]); + + useEffect(() => { + if (isAuthReady && !user) { + router.replace('/login'); + } + }, [isAuthReady, user, router]); + + useEffect(() => { + if (isResetFlow) { + setPasswordExpanded(true); + } + }, [isResetFlow]); + + useEffect(() => { + void loadParticipation(); + }, [loadParticipation, currentOrganization?.id]); + + const syncSessionAfterParticipationChange = useCallback(async () => { + await refreshSession(); + }, [refreshSession]); + + const enableClinicParticipation = useCallback( + async (options: { + skipHours: boolean; + hoursPayload?: { + autoRepeatWeekly: boolean; + blocks: { dayOfWeek: number; startMinute: number; endMinute: number; sortOrder?: number }[]; + }; + }) => { + await accountApi.updateParticipation(true); + if (!options.skipHours && options.hoursPayload) { + await accountApi.upsertMyWorkingHours(options.hoursPayload); + } + setParticipatesInTreatments(true); + await syncSessionAfterParticipationChange(); + setSuccessMessage(t('participateEnabledTreatments')); + }, + [syncSessionAfterParticipationChange, t], + ); + + const enableLabParticipation = async () => { + setParticipationLoading(true); + setError(null); + try { + await accountApi.updateParticipation(true); + setParticipatesInTasks(true); + await syncSessionAfterParticipationChange(); + setSuccessMessage(t('participateEnabledTasks')); + } catch (err: unknown) { + setError(getUserFacingError(err, tErrors, t('participateUpdateFailed'))); + setParticipatesInTasks(false); + } finally { + setParticipationLoading(false); + } + }; + + const confirmRevokeParticipation = async () => { + if (!pendingRevokeType) return; + setParticipationLoading(true); + setError(null); + try { + await accountApi.updateParticipation(false); + if (pendingRevokeType === 'CLINIC') { + setParticipatesInTreatments(false); + } else { + setParticipatesInTasks(false); + } + await syncSessionAfterParticipationChange(); + setSuccessMessage( + pendingRevokeType === 'CLINIC' + ? t('participateDisabledTreatments') + : t('participateDisabledTasks'), + ); + setRevokeConfirmOpen(false); + setPendingRevokeType(null); + } catch (err: unknown) { + setError(getUserFacingError(err, tErrors, t('participateUpdateFailed'))); + } finally { + setParticipationLoading(false); + } + }; + + const handleClinicParticipationChange = (checked: boolean) => { + setError(null); + if (checked) { + setWorkingHoursOpen(true); + return; + } + setPendingRevokeType('CLINIC'); + setRevokeConfirmOpen(true); + }; + + const handleWorkingHoursClose = useCallback(() => { + setWorkingHoursOpen(false); + }, []); + + const handleWorkingHoursComplete = useCallback( + async (options: { + skipHours: boolean; + hoursPayload?: { + autoRepeatWeekly: boolean; + blocks: { dayOfWeek: number; startMinute: number; endMinute: number; sortOrder?: number }[]; + }; + }) => { + setParticipationLoading(true); + setError(null); + try { + await enableClinicParticipation(options); + } catch (err: unknown) { + setError(getUserFacingError(err, tErrors, t('participateUpdateFailed'))); + throw err; + } finally { + setParticipationLoading(false); + } + }, + [enableClinicParticipation, t], + ); + + const handleLabParticipationChange = (checked: boolean) => { + setError(null); + if (checked) { + void enableLabParticipation(); + return; + } + setPendingRevokeType('LAB'); + setRevokeConfirmOpen(true); + }; + + 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) { + setError(getUserFacingError(err, tErrors, t('passwordChangeFailed'))); + } finally { + setIsSubmitting(false); + } + }; + + const passwordToggleLabels = { + show: tAuth('showPassword'), + hide: tAuth('hidePassword'), + }; + + if (!isAuthReady || !user) { + return ( +

{tCommon('loadingEllipsis')}

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

{t('accountTitle')}

+

{t('accountSubtitle')}

+
+ + {(showClinicParticipation || showLabParticipation) && ( +
+
+

{t('participationSectionTitle')}

+

{t('participationSectionSubtitle')}

+
+ + {showClinicParticipation && ( + + )} + + {showLabParticipation && ( + + )} +
+ )} + +
+ + + {passwordExpanded && ( +
+

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

+ {isResetFlow && ( +

{t('resetPasswordSubtitle')}

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

{error}

+
+ )} + + + +
+ )} +
+ + {error && !passwordExpanded && ( +
+

{error}

+
+ )} + + + + {revokeConfirmOpen && ( +
+
+
+

+ {t('participateConfirmRevokeTitle')} +

+ { + if (participationLoading) return; + setRevokeConfirmOpen(false); + setPendingRevokeType(null); + }} + /> +
+

+ {pendingRevokeType === 'CLINIC' + ? t('participateConfirmRevokeBodyTreatments') + : t('participateConfirmRevokeBodyTasks')} +

+
+ + +
+
+
+ )} + + {successMessage && ( + {successMessage} + )} +
+ ); +} diff --git a/frontend/src/components/settings/OwnerWorkingHoursDialog.tsx b/frontend/src/components/ui/settings/OwnerWorkingHoursDialog.tsx similarity index 98% rename from frontend/src/components/settings/OwnerWorkingHoursDialog.tsx rename to frontend/src/components/ui/settings/OwnerWorkingHoursDialog.tsx index 537076b..6105d1b 100644 --- a/frontend/src/components/settings/OwnerWorkingHoursDialog.tsx +++ b/frontend/src/components/ui/settings/OwnerWorkingHoursDialog.tsx @@ -10,7 +10,7 @@ import { useWorkingHoursForm, workingHoursPayloadFromState, workingHoursStateFromApi, -} from '@/components/staff/StaffWorkingHoursStep'; +} from '@/components/ui/staff/StaffWorkingHoursStep'; import { accountApi } from '@/lib/api/account'; type OwnerWorkingHoursDialogProps = { diff --git a/frontend/src/components/ui/settings/SubscriptionsPage.tsx b/frontend/src/components/ui/settings/SubscriptionsPage.tsx new file mode 100644 index 0000000..a6f9cb1 --- /dev/null +++ b/frontend/src/components/ui/settings/SubscriptionsPage.tsx @@ -0,0 +1,204 @@ +'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'; + +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 function SubscriptionsPage() { + 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} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/ui/shared/Badge.tsx b/frontend/src/components/ui/shared/Badge.tsx index 23077f9..04268f3 100644 --- a/frontend/src/components/ui/shared/Badge.tsx +++ b/frontend/src/components/ui/shared/Badge.tsx @@ -57,18 +57,3 @@ export function Badge({ ); } - -/** Map organization connection / invitation row status to badge variant. */ -export function organizationConnectionStatusVariant(status: string): BadgeVariant { - switch (status) { - case 'ACTIVE': - return 'success'; - case 'PENDING': - return 'warning'; - case 'REJECTED': - case 'EXPIRED': - return 'danger'; - default: - return 'default'; - } -} diff --git a/frontend/src/components/staff/StaffMembersMobileList.tsx b/frontend/src/components/ui/staff/StaffMembersMobileList.tsx similarity index 100% rename from frontend/src/components/staff/StaffMembersMobileList.tsx rename to frontend/src/components/ui/staff/StaffMembersMobileList.tsx diff --git a/frontend/src/components/ui/staff/StaffPage.tsx b/frontend/src/components/ui/staff/StaffPage.tsx new file mode 100644 index 0000000..b8b92d2 --- /dev/null +++ b/frontend/src/components/ui/staff/StaffPage.tsx @@ -0,0 +1,1122 @@ +'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/ui/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 { getUserFacingError } from '@/components/shared/formatApiError'; +import { StaffMembersMobileList } from '@/components/ui/staff/StaffMembersMobileList'; +import { useToast } from '@/lib/hooks/useToast'; + +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 function StaffPage() { + const router = useRouter(); + const t = useTranslations('staff'); + const tErrors = useTranslations('errors'); + 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(getUserFacingError(e, tErrors, 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(getUserFacingError(e, tErrors, 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(getUserFacingError(e, tErrors, 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(getUserFacingError(e, tErrors, 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(getUserFacingError(e, tErrors, 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(getUserFacingError(e, tErrors, 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(getUserFacingError(e, tErrors, 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 ? ( + + ) : ( + + ) + ) : ( + + )} +
+
+
+ )} + + ); +} diff --git a/frontend/src/components/staff/StaffWorkingHoursStep.tsx b/frontend/src/components/ui/staff/StaffWorkingHoursStep.tsx similarity index 97% rename from frontend/src/components/staff/StaffWorkingHoursStep.tsx rename to frontend/src/components/ui/staff/StaffWorkingHoursStep.tsx index 524176e..297119b 100644 --- a/frontend/src/components/staff/StaffWorkingHoursStep.tsx +++ b/frontend/src/components/ui/staff/StaffWorkingHoursStep.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react'; import { useTranslations } from 'next-intl'; -import { WorkingHoursEditor } from '@/components/staff/WorkingHoursEditor'; +import { WorkingHoursEditor } from '@/components/ui/staff/WorkingHoursEditor'; import { blocksFromEditorDays, editorDaysFromBlocks, diff --git a/frontend/src/components/staff/WorkingHoursEditor.tsx b/frontend/src/components/ui/staff/WorkingHoursEditor.tsx similarity index 100% rename from frontend/src/components/staff/WorkingHoursEditor.tsx rename to frontend/src/components/ui/staff/WorkingHoursEditor.tsx diff --git a/frontend/src/components/today/ChartCard.tsx b/frontend/src/components/ui/today/ChartCard.tsx similarity index 97% rename from frontend/src/components/today/ChartCard.tsx rename to frontend/src/components/ui/today/ChartCard.tsx index aa7d6e1..ba7dd83 100644 --- a/frontend/src/components/today/ChartCard.tsx +++ b/frontend/src/components/ui/today/ChartCard.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from 'react'; import { Card } from '@/components/ui/shared/Card'; -import { ChartCardSkeleton } from '@/components/today/TodaySkeleton'; +import { ChartCardSkeleton } from '@/components/ui/today/TodaySkeleton'; interface ChartCardProps { title: string; diff --git a/frontend/src/components/today/KpiCard.tsx b/frontend/src/components/ui/today/KpiCard.tsx similarity index 100% rename from frontend/src/components/today/KpiCard.tsx rename to frontend/src/components/ui/today/KpiCard.tsx diff --git a/frontend/src/components/today/TodayAreaChart.tsx b/frontend/src/components/ui/today/TodayAreaChart.tsx similarity index 96% rename from frontend/src/components/today/TodayAreaChart.tsx rename to frontend/src/components/ui/today/TodayAreaChart.tsx index f3d7822..bf8d448 100644 --- a/frontend/src/components/today/TodayAreaChart.tsx +++ b/frontend/src/components/ui/today/TodayAreaChart.tsx @@ -9,7 +9,7 @@ import { XAxis, YAxis, } from 'recharts'; -import { TodayChartFrame } from '@/components/today/TodayChartFrame'; +import { TodayChartFrame } from '@/components/ui/today/TodayChartFrame'; import type { TodayChartBucket } from '@/types/today'; import { TODAY_CHART_AXIS_COLOR, diff --git a/frontend/src/components/today/TodayBarChart.tsx b/frontend/src/components/ui/today/TodayBarChart.tsx similarity index 97% rename from frontend/src/components/today/TodayBarChart.tsx rename to frontend/src/components/ui/today/TodayBarChart.tsx index db701e6..d7dbf7b 100644 --- a/frontend/src/components/today/TodayBarChart.tsx +++ b/frontend/src/components/ui/today/TodayBarChart.tsx @@ -11,7 +11,7 @@ import { YAxis, } from 'recharts'; import type { TodayChartBucket } from '@/types/today'; -import { TodayChartFrame } from '@/components/today/TodayChartFrame'; +import { TodayChartFrame } from '@/components/ui/today/TodayChartFrame'; import { TODAY_CHART_AXIS_COLOR, TODAY_CHART_COLORS, diff --git a/frontend/src/components/today/TodayChartFrame.tsx b/frontend/src/components/ui/today/TodayChartFrame.tsx similarity index 100% rename from frontend/src/components/today/TodayChartFrame.tsx rename to frontend/src/components/ui/today/TodayChartFrame.tsx diff --git a/frontend/src/components/today/TodayCompletionGaugeKpiCard.tsx b/frontend/src/components/ui/today/TodayCompletionGaugeKpiCard.tsx similarity index 95% rename from frontend/src/components/today/TodayCompletionGaugeKpiCard.tsx rename to frontend/src/components/ui/today/TodayCompletionGaugeKpiCard.tsx index 243f696..8f425a6 100644 --- a/frontend/src/components/today/TodayCompletionGaugeKpiCard.tsx +++ b/frontend/src/components/ui/today/TodayCompletionGaugeKpiCard.tsx @@ -4,7 +4,7 @@ import type { LucideIcon } from 'lucide-react'; import { Link } from '@/i18n/navigation'; import { Card } from '@/components/ui/shared/Card'; import { TODAY_CHART_COMPLETED_COLOR } from '@/components/today/chart-theme'; -import { TodayRadialGaugeChart } from '@/components/today/TodayRadialGaugeChart'; +import { TodayRadialGaugeChart } from '@/components/ui/today/TodayRadialGaugeChart'; import type { TodayCompletionGauge } from '@/types/today'; export interface TodayCompletionGaugeKpiCardProps extends TodayCompletionGauge { diff --git a/frontend/src/components/today/TodayDashboard.tsx b/frontend/src/components/ui/today/TodayDashboard.tsx similarity index 94% rename from frontend/src/components/today/TodayDashboard.tsx rename to frontend/src/components/ui/today/TodayDashboard.tsx index 52423b8..c953b34 100644 --- a/frontend/src/components/today/TodayDashboard.tsx +++ b/frontend/src/components/ui/today/TodayDashboard.tsx @@ -13,38 +13,38 @@ import { canViewTasks, canViewTreatment, } from '@/components/shared/permissions'; -import { KpiCard } from '@/components/today/KpiCard'; -import { ChartCard } from '@/components/today/ChartCard'; -import { TodayAreaChart } from '@/components/today/TodayAreaChart'; -import { TodayBarChart } from '@/components/today/TodayBarChart'; +import { KpiCard } from '@/components/ui/today/KpiCard'; +import { ChartCard } from '@/components/ui/today/ChartCard'; +import { TodayAreaChart } from '@/components/ui/today/TodayAreaChart'; +import { TodayBarChart } from '@/components/ui/today/TodayBarChart'; import { mapWeekChartBuckets, useTodayDayLabelFormatter, } from '@/components/today/chart-day-labels'; -import { TodayDashboardGrid } from '@/components/today/TodayDashboardGrid'; -import { TodayDonutChart, TodayDonutChartLegend } from '@/components/today/TodayDonutChart'; -import { TodayHorizontalBarChart } from '@/components/today/TodayHorizontalBarChart'; -import { TodayPartnerCasesStackedBarChart } from '@/components/today/TodayPartnerCasesStackedBarChart'; +import { TodayDashboardGrid } from '@/components/ui/today/TodayDashboardGrid'; +import { TodayDonutChart, TodayDonutChartLegend } from '@/components/ui/today/TodayDonutChart'; +import { TodayHorizontalBarChart } from '@/components/ui/today/TodayHorizontalBarChart'; +import { TodayPartnerCasesStackedBarChart } from '@/components/ui/today/TodayPartnerCasesStackedBarChart'; import { Package, Stethoscope, type LucideIcon } from 'lucide-react'; -import { TodayCompletionGaugeKpiCard } from '@/components/today/TodayCompletionGaugeKpiCard'; +import { TodayCompletionGaugeKpiCard } from '@/components/ui/today/TodayCompletionGaugeKpiCard'; import { mapLabTaskActivityChartData, TodayLabTaskActivityChart, -} from '@/components/today/TodayLabTaskActivityChart'; -import { TodaySubscriptionKpiCard } from '@/components/today/TodaySubscriptionKpiCard'; -import { TodayUpcomingAppointments } from '@/components/today/TodayUpcomingAppointments'; +} from '@/components/ui/today/TodayLabTaskActivityChart'; +import { TodaySubscriptionKpiCard } from '@/components/ui/today/TodaySubscriptionKpiCard'; +import { TodayUpcomingAppointments } from '@/components/ui/today/TodayUpcomingAppointments'; import { ChartCardSkeleton, KpiCardSkeleton, ListRowSkeleton, -} from '@/components/today/TodaySkeleton'; +} from '@/components/ui/today/TodaySkeleton'; import { TODAY_DASHBOARD_LAYOUT, type TodayDashboardCell, } from '@/components/today/today-dashboard-layout'; import { getEligibleTodayKpis, getVisibleTodayKpis } from '@/components/today/widget-registry'; -import { prosthesisTypeColor } from '@/components/ui/treatment/prosthesisTypeDisplay'; -import { treatmentTypeColor } from '@/components/ui/treatment/treatmentTypeDisplay'; +import { prosthesisTypeColor } from '@/components/treatment/prosthesisTypeDisplay'; +import { treatmentTypeColor } from '@/components/shared/treatmentTypeDisplay'; import type { TodayCompletionGauge, TodaySubscriptionSnapshot, diff --git a/frontend/src/components/today/TodayDashboardGrid.tsx b/frontend/src/components/ui/today/TodayDashboardGrid.tsx similarity index 100% rename from frontend/src/components/today/TodayDashboardGrid.tsx rename to frontend/src/components/ui/today/TodayDashboardGrid.tsx diff --git a/frontend/src/components/today/TodayDonutChart.tsx b/frontend/src/components/ui/today/TodayDonutChart.tsx similarity index 98% rename from frontend/src/components/today/TodayDonutChart.tsx rename to frontend/src/components/ui/today/TodayDonutChart.tsx index 302cbf3..4b67315 100644 --- a/frontend/src/components/today/TodayDonutChart.tsx +++ b/frontend/src/components/ui/today/TodayDonutChart.tsx @@ -3,7 +3,7 @@ import type { CSSProperties } from 'react'; import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts'; import type { TodayChartBucket } from '@/types/today'; -import { TodayChartFrame } from '@/components/today/TodayChartFrame'; +import { TodayChartFrame } from '@/components/ui/today/TodayChartFrame'; import { chartRankColor, TODAY_CHART_TOOLTIP_STYLE, diff --git a/frontend/src/components/today/TodayHorizontalBarChart.tsx b/frontend/src/components/ui/today/TodayHorizontalBarChart.tsx similarity index 96% rename from frontend/src/components/today/TodayHorizontalBarChart.tsx rename to frontend/src/components/ui/today/TodayHorizontalBarChart.tsx index 8e70812..a8334fd 100644 --- a/frontend/src/components/today/TodayHorizontalBarChart.tsx +++ b/frontend/src/components/ui/today/TodayHorizontalBarChart.tsx @@ -10,7 +10,7 @@ import { XAxis, YAxis, } from 'recharts'; -import { TodayChartFrame } from '@/components/today/TodayChartFrame'; +import { TodayChartFrame } from '@/components/ui/today/TodayChartFrame'; import type { TodayChartBucket } from '@/types/today'; import { chartRankColor, diff --git a/frontend/src/components/today/TodayLabTaskActivityChart.tsx b/frontend/src/components/ui/today/TodayLabTaskActivityChart.tsx similarity index 98% rename from frontend/src/components/today/TodayLabTaskActivityChart.tsx rename to frontend/src/components/ui/today/TodayLabTaskActivityChart.tsx index b76f9d7..2c5d9d2 100644 --- a/frontend/src/components/today/TodayLabTaskActivityChart.tsx +++ b/frontend/src/components/ui/today/TodayLabTaskActivityChart.tsx @@ -9,7 +9,7 @@ import { XAxis, YAxis, } from 'recharts'; -import { TodayChartFrame } from '@/components/today/TodayChartFrame'; +import { TodayChartFrame } from '@/components/ui/today/TodayChartFrame'; import { TODAY_CHART_AXIS_COLOR, TODAY_CHART_COMPLETED_COLOR, diff --git a/frontend/src/components/today/TodayLoadErrorBanner.tsx b/frontend/src/components/ui/today/TodayLoadErrorBanner.tsx similarity index 100% rename from frontend/src/components/today/TodayLoadErrorBanner.tsx rename to frontend/src/components/ui/today/TodayLoadErrorBanner.tsx diff --git a/frontend/src/components/ui/today/TodayPage.tsx b/frontend/src/components/ui/today/TodayPage.tsx new file mode 100644 index 0000000..da0d6c2 --- /dev/null +++ b/frontend/src/components/ui/today/TodayPage.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { useMemo } from 'react'; +import { useTranslations } from 'next-intl'; +import { Link } from '@/i18n/navigation'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { getUserFacingError } from '@/components/shared/formatApiError'; +import { TodayDashboard } from '@/components/ui/today/TodayDashboard'; +import { TodayLoadErrorBanner } from '@/components/ui/today/TodayLoadErrorBanner'; +import { TodaySectionErrorFallback } from '@/components/ui/today/TodaySectionErrorFallback'; +import { TodayWidgetErrorBoundary } from '@/components/ui/today/TodayWidgetErrorBoundary'; +import { useTodaySummary } from '@/lib/hooks/useTodaySummary'; + +export function TodayPage() { + const t = useTranslations('today'); + const tErrors = useTranslations('errors'); + 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} + + } + > + + +
+ ); +} diff --git a/frontend/src/components/today/TodayPartnerCasesStackedBarChart.tsx b/frontend/src/components/ui/today/TodayPartnerCasesStackedBarChart.tsx similarity index 97% rename from frontend/src/components/today/TodayPartnerCasesStackedBarChart.tsx rename to frontend/src/components/ui/today/TodayPartnerCasesStackedBarChart.tsx index 230e010..59ff12c 100644 --- a/frontend/src/components/today/TodayPartnerCasesStackedBarChart.tsx +++ b/frontend/src/components/ui/today/TodayPartnerCasesStackedBarChart.tsx @@ -9,7 +9,7 @@ import { XAxis, YAxis, } from 'recharts'; -import { TodayChartFrame } from '@/components/today/TodayChartFrame'; +import { TodayChartFrame } from '@/components/ui/today/TodayChartFrame'; import { TODAY_CHART_AXIS_COLOR, TODAY_CHART_COMPLETED_COLOR, diff --git a/frontend/src/components/today/TodayRadialGaugeChart.tsx b/frontend/src/components/ui/today/TodayRadialGaugeChart.tsx similarity index 100% rename from frontend/src/components/today/TodayRadialGaugeChart.tsx rename to frontend/src/components/ui/today/TodayRadialGaugeChart.tsx diff --git a/frontend/src/components/today/TodaySectionErrorFallback.tsx b/frontend/src/components/ui/today/TodaySectionErrorFallback.tsx similarity index 100% rename from frontend/src/components/today/TodaySectionErrorFallback.tsx rename to frontend/src/components/ui/today/TodaySectionErrorFallback.tsx diff --git a/frontend/src/components/today/TodaySkeleton.tsx b/frontend/src/components/ui/today/TodaySkeleton.tsx similarity index 100% rename from frontend/src/components/today/TodaySkeleton.tsx rename to frontend/src/components/ui/today/TodaySkeleton.tsx diff --git a/frontend/src/components/today/TodaySubscriptionKpiCard.tsx b/frontend/src/components/ui/today/TodaySubscriptionKpiCard.tsx similarity index 97% rename from frontend/src/components/today/TodaySubscriptionKpiCard.tsx rename to frontend/src/components/ui/today/TodaySubscriptionKpiCard.tsx index ca0fce1..d60a330 100644 --- a/frontend/src/components/today/TodaySubscriptionKpiCard.tsx +++ b/frontend/src/components/ui/today/TodaySubscriptionKpiCard.tsx @@ -4,7 +4,7 @@ import { useTranslations } from 'next-intl'; import { CreditCard } from 'lucide-react'; import { Link } from '@/i18n/navigation'; import { Card } from '@/components/ui/shared/Card'; -import { TodayRadialGaugeChart } from '@/components/today/TodayRadialGaugeChart'; +import { TodayRadialGaugeChart } from '@/components/ui/today/TodayRadialGaugeChart'; import type { TodaySubscriptionSnapshot } from '@/types/today'; interface TodaySubscriptionKpiCardProps { diff --git a/frontend/src/components/today/TodayUpcomingAppointments.tsx b/frontend/src/components/ui/today/TodayUpcomingAppointments.tsx similarity index 95% rename from frontend/src/components/today/TodayUpcomingAppointments.tsx rename to frontend/src/components/ui/today/TodayUpcomingAppointments.tsx index 0f92981..42c228e 100644 --- a/frontend/src/components/today/TodayUpcomingAppointments.tsx +++ b/frontend/src/components/ui/today/TodayUpcomingAppointments.tsx @@ -6,13 +6,13 @@ import { ChevronRight } from 'lucide-react'; import { Link } from '@/i18n/navigation'; import { Card } from '@/components/ui/shared/Card'; import { formatTimeForInput } from '@/components/appointments/appointmentTime'; -import { purposeLabel } from '@/components/ui/appointments/appointmentPurposeStyles'; +import { purposeLabel } from '@/components/appointments/appointmentPurposeStyles'; import { treatmentAppointmentHref } from '@/components/shared/treatmentSelection'; -import { treatmentTypeColor } from '@/components/ui/treatment/treatmentTypeDisplay'; +import { treatmentTypeColor } from '@/components/shared/treatmentTypeDisplay'; import { canViewMyAppointmentsWeekChart } from '@/components/shared/permissions'; import { useAuth } from '@/lib/hooks/useAuth'; import { treatmentCatalogApi } from '@/lib/api/treatment-catalog'; -import { ListRowSkeleton } from '@/components/today/TodaySkeleton'; +import { ListRowSkeleton } from '@/components/ui/today/TodaySkeleton'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; import type { TodaySummaryActions } from '@/types/today'; diff --git a/frontend/src/components/today/TodayWidgetErrorBoundary.tsx b/frontend/src/components/ui/today/TodayWidgetErrorBoundary.tsx similarity index 100% rename from frontend/src/components/today/TodayWidgetErrorBoundary.tsx rename to frontend/src/components/ui/today/TodayWidgetErrorBoundary.tsx diff --git a/frontend/src/components/ui/treatment/AppointmentsStrip.tsx b/frontend/src/components/ui/treatment/AppointmentsStrip.tsx index 6473585..0faec15 100644 --- a/frontend/src/components/ui/treatment/AppointmentsStrip.tsx +++ b/frontend/src/components/ui/treatment/AppointmentsStrip.tsx @@ -8,7 +8,7 @@ import { startOfLocalDay } from '@/components/appointments/appointmentTime'; import { treatmentTypeBannerStyle, treatmentTypeLabelFromCatalog, -} from '@/components/ui/treatment/treatmentTypeDisplay'; +} from '@/components/shared/treatmentTypeDisplay'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; import type { TreatmentAppointment } from '@/types/treatment'; diff --git a/frontend/src/components/ui/treatment/DetailLabSendBadge.tsx b/frontend/src/components/ui/treatment/DetailLabSendBadge.tsx index ddff044..4d4e8c2 100644 --- a/frontend/src/components/ui/treatment/DetailLabSendBadge.tsx +++ b/frontend/src/components/ui/treatment/DetailLabSendBadge.tsx @@ -2,7 +2,7 @@ import { useTranslations } from 'next-intl'; import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel'; -import { labNotSentBadgeClass, labSentBadgeClass } from '@/components/ui/treatment/treatmentStatusStyles'; +import { labNotSentBadgeClass, labSentBadgeClass } from '@/components/treatment/treatmentStatusStyles'; import type { LinkedOrganizationOption, PastTreatmentDetail } from '@/types/treatment'; interface DetailLabSendBadgeProps { diff --git a/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx index 215ac78..2595104 100644 --- a/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx +++ b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx @@ -5,11 +5,11 @@ import { useTranslations } from 'next-intl'; import { Button } from '@/components/ui/shared/Button'; import { Checkbox } from '@/components/ui/shared/Checkbox'; import { Dropdown } from '@/components/ui/shared/Dropdown'; -import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles'; +import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles'; import { SearchBar } from '@/components/ui/shared/SearchBar'; import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel'; import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel'; -import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay'; +import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay'; import { treatmentsApi } from '@/lib/api/treatments'; import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog'; import type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/treatment-catalog'; diff --git a/frontend/src/components/ui/treatment/ToothGlyph.tsx b/frontend/src/components/ui/treatment/ToothGlyph.tsx index bf8ae17..2e0f7be 100644 --- a/frontend/src/components/ui/treatment/ToothGlyph.tsx +++ b/frontend/src/components/ui/treatment/ToothGlyph.tsx @@ -3,7 +3,7 @@ import { memo, type ReactNode } from 'react'; import type { FdiToothId } from '@/types/treatment'; import type { ToothShapeKind } from '@/components/treatment/fdiToothMeta'; -import { getToothPathModel, type ToothPathModel } from '@/components/ui/treatment/toothPathModel'; +import { getToothPathModel, type ToothPathModel } from '@/components/treatment/toothPathModel'; interface ToothGlyphProps { fdi: FdiToothId; diff --git a/frontend/src/components/ui/treatment/TreatmentDetailSummaryRow.tsx b/frontend/src/components/ui/treatment/TreatmentDetailSummaryRow.tsx index 1b617ac..e016801 100644 --- a/frontend/src/components/ui/treatment/TreatmentDetailSummaryRow.tsx +++ b/frontend/src/components/ui/treatment/TreatmentDetailSummaryRow.tsx @@ -3,7 +3,7 @@ import { useTranslations } from 'next-intl'; import { DetailLabSendBadge } from '@/components/ui/treatment/DetailLabSendBadge'; import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge'; -import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay'; +import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; import type { LinkedOrganizationOption, PastTreatmentDetail } from '@/types/treatment'; diff --git a/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx b/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx index 085e565..f7a33bb 100644 --- a/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx +++ b/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx @@ -8,10 +8,10 @@ import { autosaveStatusClass, labPendingBannerClass, labSentBannerClass, -} from '@/components/ui/treatment/treatmentStatusStyles'; +} from '@/components/treatment/treatmentStatusStyles'; import type { TreatmentDetailDraft } from '@/types/treatment'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; -import { treatmentTypeColor } from '@/components/ui/treatment/treatmentTypeDisplay'; +import { treatmentTypeColor } from '@/components/shared/treatmentTypeDisplay'; interface TreatmentDetailsEditorProps { details: TreatmentDetailDraft[]; diff --git a/frontend/src/components/ui/treatment/TreatmentHistoryDetailLine.tsx b/frontend/src/components/ui/treatment/TreatmentHistoryDetailLine.tsx index 606190b..540c7ad 100644 --- a/frontend/src/components/ui/treatment/TreatmentHistoryDetailLine.tsx +++ b/frontend/src/components/ui/treatment/TreatmentHistoryDetailLine.tsx @@ -2,7 +2,7 @@ import { useTranslations } from 'next-intl'; import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge'; -import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay'; +import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; import type { PastTreatmentDetail } from '@/types/treatment'; diff --git a/frontend/src/components/ui/treatment/TreatmentTypeBadge.tsx b/frontend/src/components/ui/treatment/TreatmentTypeBadge.tsx index 61b5b2a..213dcc5 100644 --- a/frontend/src/components/ui/treatment/TreatmentTypeBadge.tsx +++ b/frontend/src/components/ui/treatment/TreatmentTypeBadge.tsx @@ -3,7 +3,7 @@ import { formatCodeAsLabel, treatmentTypeBannerStyle, -} from '@/components/ui/treatment/treatmentTypeDisplay'; +} from '@/components/shared/treatmentTypeDisplay'; interface TreatmentTypeBadgeProps { type: string; diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx index f8fc2c1..7ccdc3b 100644 --- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx +++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx @@ -10,7 +10,7 @@ import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPan import { TreatmentDetailsEditor } from '@/components/ui/treatment/TreatmentDetailsEditor'; import { TreatmentPreviewCard } from '@/components/ui/treatment/TreatmentPreviewCard'; import { ToastStack } from '@/components/ui/shared/Toast'; -import { treatmentTypeLabelFromCatalog, treatmentTypeColor } from '@/components/ui/treatment/treatmentTypeDisplay'; +import { treatmentTypeLabelFromCatalog, treatmentTypeColor } from '@/components/shared/treatmentTypeDisplay'; import { addCalendarDays, compareLocalDayStart,
{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) && ( + + )} + + +
+ )} +