diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index f10327b7..1af327a1 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -43,6 +43,9 @@ jobs: - name: Checkout repository uses: actions/checkout@v7 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Log in to GitHub Container Registry uses: docker/login-action@v4 with: @@ -66,3 +69,7 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + # shared with the e2e-proxy job (tests.yml), which rebuilds the same + # commit from this cache and runs the compose stack on the result + cache-from: type=gha,scope=${{ matrix.component }} + cache-to: type=gha,mode=max,scope=${{ matrix.component }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ef4dc7c4..5fb604b1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -285,9 +285,114 @@ jobs: /tmp/frontend.log retention-days: 7 + # Full stack e2e through the nginx proxy: builds the docker images for this + # commit (sharing the layer cache with the Docker Build workflow), starts the + # production-like compose stack (proxy -> web / backend / keycloak) and runs + # the proxied tests against http://localhost. This catches routing issues + # (e.g. /graphql, /keycloak or /auth/callback misrouted) that the + # dev-server-based e2e job cannot see. + e2e-proxy: + name: E2E (docker stack via nginx proxy) + needs: [backend-lint, frontend] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build backend image + uses: docker/build-push-action@v7 + with: + context: backend + push: false + load: true + tags: local/tasks-backend:e2e + cache-from: type=gha,scope=backend + cache-to: type=gha,mode=max,scope=backend + + - name: Build web image + uses: docker/build-push-action@v7 + with: + context: web + push: false + load: true + tags: local/tasks-web:e2e + cache-from: type=gha,scope=web + cache-to: type=gha,mode=max,scope=web + + - name: Build proxy image + uses: docker/build-push-action@v7 + with: + context: proxy + push: false + load: true + tags: local/tasks-proxy:e2e + cache-from: type=gha,scope=proxy + cache-to: type=gha,mode=max,scope=proxy + + - name: Start the stack + env: + BACKEND_IMAGE: local/tasks-backend:e2e + WEB_IMAGE: local/tasks-web:e2e + PROXY_IMAGE: local/tasks-proxy:e2e + run: docker compose -f docker-compose.e2e.yml up -d + + - name: Wait for the stack behind the proxy + run: | + echo "Waiting for keycloak (realm import)..." + timeout 180 bash -c 'until curl -fs http://localhost/keycloak/realms/tasks/.well-known/openid-configuration > /dev/null; do sleep 3; done' + echo "Waiting for the backend (graphql must answer, 5xx means not routed/up yet)..." + timeout 180 bash -c 'until [ "$(curl -s -o /dev/null -w "%{http_code}" -X POST -H "content-type: application/json" -d "{\"query\":\"{ __typename }\"}" http://localhost/graphql)" -lt 500 ]; do sleep 3; done' + echo "Waiting for the frontend..." + timeout 180 bash -c 'until curl -fs http://localhost/ > /dev/null; do sleep 3; done' + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: tests/package-lock.json + + - name: Install E2E test dependencies + working-directory: tests + run: npm ci + + - name: Install Playwright browsers + working-directory: tests + run: npx playwright install --with-deps chromium + + - name: Run proxied E2E tests + working-directory: tests + env: + CI: true + E2E_PROXY_TARGET: "1" + E2E_BASE_URL: http://localhost + run: npx playwright test e2e/proxy-fullstack.spec.ts + + - name: Dump stack logs + if: failure() + run: docker compose -f docker-compose.e2e.yml logs --no-color > /tmp/stack.log 2>&1 || true + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@v7 + with: + name: playwright-report-proxy + path: tests/playwright-report/ + retention-days: 30 + + - name: Upload stack logs + if: failure() + uses: actions/upload-artifact@v7 + with: + name: proxy-stack-logs + path: /tmp/stack.log + retention-days: 7 + ci: name: CI - needs: [backend-tests, simulator-lint, frontend, e2e-tests] + needs: [backend-tests, simulator-lint, frontend, e2e-tests, e2e-proxy] if: always() runs-on: ubuntu-latest steps: @@ -296,7 +401,8 @@ jobs: if [[ "${{ needs.backend-tests.result }}" != "success" ]] \ || [[ "${{ needs.simulator-lint.result }}" != "success" ]] \ || [[ "${{ needs.frontend.result }}" != "success" ]] \ - || [[ "${{ needs.e2e-tests.result }}" != "success" ]]; then + || [[ "${{ needs.e2e-tests.result }}" != "success" ]] \ + || [[ "${{ needs.e2e-proxy.result }}" != "success" ]]; then echo "One or more required jobs failed or were skipped." exit 1 fi diff --git a/E2E_TESTING.md b/E2E_TESTING.md index 6938a89c..292e3bd8 100644 --- a/E2E_TESTING.md +++ b/E2E_TESTING.md @@ -1,5 +1,50 @@ # E2E Testing Guide +There are two e2e setups: + +1. **Mock-based tests** (most specs under `tests/e2e/`): run the real Next.js + frontend and stub the GraphQL/OIDC network boundary. Fast, deterministic, + no backend required. +2. **Proxied full-stack tests** (`tests/e2e/proxy-fullstack.spec.ts`): run the + production-like docker-compose stack — nginx proxy in front of web, backend + and Keycloak — built from the current commit's Docker images. These catch + routing issues (`/graphql`, `/keycloak/...`, `/auth/callback`) and verify + filtering/sorting against the real query engine. + +## Running the Proxied Full-Stack Tests + +1. **Build the images for your commit** (or use published ones): + ```bash + docker build -t local/tasks-backend:e2e backend + docker build -t local/tasks-web:e2e web + docker build -t local/tasks-proxy:e2e proxy + ``` + +2. **Start the stack** (ephemeral, no persistent volumes): + ```bash + BACKEND_IMAGE=local/tasks-backend:e2e \ + WEB_IMAGE=local/tasks-web:e2e \ + PROXY_IMAGE=local/tasks-proxy:e2e \ + docker compose -f docker-compose.e2e.yml up -d + ``` + +3. **Wait for readiness** (Keycloak realm import takes a while): + ```bash + curl -fs http://localhost/keycloak/realms/tasks/.well-known/openid-configuration + curl -fs http://localhost/ + ``` + +4. **Run the tests against the proxy**: + ```bash + cd tests + E2E_PROXY_TARGET=1 E2E_BASE_URL=http://localhost npx playwright test e2e/proxy-fullstack.spec.ts + ``` + +The `E2E_PROXY_TARGET=1` gate keeps these tests skipped during the mock-based +runs. In CI the `e2e-proxy` job (`.github/workflows/tests.yml`) performs these +steps automatically, rebuilding the commit's images from the shared Buildx +cache of the Docker Build workflow. + ## Running E2E Tests Locally ### Prerequisites diff --git a/backend/api/export/__init__.py b/backend/api/export/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/export/cells.py b/backend/api/export/cells.py new file mode 100644 index 00000000..b870b0d6 --- /dev/null +++ b/backend/api/export/cells.py @@ -0,0 +1,300 @@ +import re +from dataclasses import dataclass, field +from datetime import date, datetime, timezone +from typing import Any +from zoneinfo import ZoneInfo + +from api.inputs import FieldType, PatientState +from database import models + +# Date-only due dates are stored as an UTC 23:59:59.999 sentinel; the wall +# clock date must be shown as-is instead of being converted to the target +# timezone (see web/utils/dueDate.ts). +_DATE_ONLY_SENTINEL_MICROSECOND = 999000 + +CLINIC_KINDS = ("CLINIC", "PRACTICE") + +LOCATION_KIND_COLUMNS: dict[str, tuple[str, ...]] = { + "location-CLINIC": CLINIC_KINDS, + "location-WARD": ("WARD",), + "location-ROOM": ("ROOM",), + "location-BED": ("BED",), +} + + +@dataclass +class ExportCell: + value: Any + kind: str = "text" # text | number | bool | date | datetime + + +@dataclass +class LocationInfo: + title: str + kind: str | None + parent_id: str | None + + +@dataclass +class ExportContext: + labels: dict[str, str] + formats: dict[str, str] + tz: ZoneInfo + now: datetime + exported_by: str | None = None + locations: dict[str, LocationInfo] = field(default_factory=dict) + users: dict[str, models.User] = field(default_factory=dict) + property_definitions: dict[str, models.PropertyDefinition] = field( + default_factory=dict, + ) + properties_by_entity: dict[str, dict[str, models.PropertyValue]] = field( + default_factory=dict, + ) + + +def is_date_only_due_date(value: datetime) -> bool: + return ( + value.hour == 23 + and value.minute == 59 + and value.second == 59 + and value.microsecond >= _DATE_ONLY_SENTINEL_MICROSECOND + ) + + +def to_zoned(value: datetime, tz: ZoneInfo) -> datetime: + aware = value if value.tzinfo else value.replace(tzinfo=timezone.utc) + return aware.astimezone(tz).replace(tzinfo=None) + + +def user_display_name(user: models.User | None) -> str | None: + if user is None: + return None + if user.firstname and user.lastname: + return f"{user.firstname} {user.lastname}" + return user.username + + +def location_path_nodes( + location_id: str | None, + locations: dict[str, LocationInfo], +) -> list[tuple[str, LocationInfo]]: + path: list[tuple[str, LocationInfo]] = [] + seen: set[str] = set() + current_id = location_id + while current_id and current_id not in seen: + seen.add(current_id) + node = locations.get(current_id) + if node is None: + break + path.append((current_id, node)) + current_id = node.parent_id + path.reverse() + return path + + +def location_title_by_kind( + location_id: str | None, + kinds: tuple[str, ...], + locations: dict[str, LocationInfo], +) -> str | None: + for _, node in location_path_nodes(location_id, locations): + if node.kind and node.kind.upper() in kinds: + return node.title + return None + + +def _datetime_cell(value: datetime | None, ctx: ExportContext) -> ExportCell: + if value is None: + return ExportCell(None) + return ExportCell(to_zoned(value, ctx.tz), kind="datetime") + + +def _due_date_cell(value: datetime | None, ctx: ExportContext) -> ExportCell: + if value is None: + return ExportCell(None) + if is_date_only_due_date(value): + return ExportCell(value.date(), kind="date") + return _datetime_cell(value, ctx) + + +# Select values are stored as "-opt-" keys; the label is +# looked up in the definition's comma-separated options, mirroring the UI +# (web/components/properties/PropertyCell.tsx). +_SELECT_OPTION_KEY = re.compile(r"-opt-(\d+)$") + + +def select_option_label( + raw_value: str, + definition: models.PropertyDefinition, +) -> str: + match = _SELECT_OPTION_KEY.search(raw_value) + if match and definition.options: + options = definition.options.split(",") + index = int(match.group(1)) + if 0 <= index < len(options): + return options[index] + return raw_value + + +def _property_cell( + entity_id: str, + definition_id: str, + ctx: ExportContext, +) -> ExportCell: + definition = ctx.property_definitions.get(definition_id) + prop = ctx.properties_by_entity.get(entity_id, {}).get(definition_id) + if definition is None or prop is None: + return ExportCell(None) + field_type = definition.field_type + if field_type == FieldType.FIELD_TYPE_NUMBER.value: + return ExportCell(prop.number_value, kind="number") + if field_type == FieldType.FIELD_TYPE_CHECKBOX.value: + if prop.boolean_value is None: + return ExportCell(None) + return ExportCell(prop.boolean_value, kind="bool") + if field_type == FieldType.FIELD_TYPE_DATE.value: + return ExportCell(prop.date_value, kind="date") + if field_type == FieldType.FIELD_TYPE_DATE_TIME.value: + return _datetime_cell(prop.date_time_value, ctx) + if field_type == FieldType.FIELD_TYPE_SELECT.value: + if not prop.select_value: + return ExportCell(None) + return ExportCell(select_option_label(prop.select_value, definition)) + if field_type == FieldType.FIELD_TYPE_MULTI_SELECT.value: + if not prop.multi_select_values: + return ExportCell(None) + return ExportCell( + ", ".join( + select_option_label(v, definition) + for v in prop.multi_select_values.split(",") + if v + ), + ) + if field_type == FieldType.FIELD_TYPE_USER.value: + raw = prop.user_value + if not raw: + return ExportCell(None) + if raw.startswith("team:"): + team = ctx.locations.get(raw.removeprefix("team:")) + return ExportCell(team.title if team else None) + return ExportCell(user_display_name(ctx.users.get(raw))) + return ExportCell(prop.text_value) + + +def task_cell(task: models.Task, key: str, ctx: ExportContext) -> ExportCell: + if key.startswith("property_"): + return _property_cell(task.id, key.removeprefix("property_"), ctx) + if key == "done": + return ExportCell(task.done, kind="bool") + if key == "title": + return ExportCell(task.title) + if key == "description": + return ExportCell(task.description) + if key == "dueDate": + return _due_date_cell(task.due_date, ctx) + if key == "creationDate": + return _datetime_cell(task.creation_date, ctx) + if key == "updateDate": + return _datetime_cell(task.update_date or task.creation_date, ctx) + if key == "priority": + return ExportCell(task.priority) + if key == "estimatedTime": + return ExportCell(task.estimated_time, kind="number") + if key == "patient": + if task.patient is None: + return ExportCell(ctx.labels["no_patient"]) + return ExportCell( + f"{task.patient.firstname} {task.patient.lastname}", + ) + if key == "assignee": + names = [ + name + for name in ( + user_display_name(assignee) for assignee in task.assignees + ) + if name + ] + if names: + return ExportCell(", ".join(names)) + if task.assignee_team_id: + team = ctx.locations.get(task.assignee_team_id) + return ExportCell(team.title if team else None) + return ExportCell(None) + return ExportCell(None) + + +def _patient_age_years(birthdate: date, today: date) -> int: + return ( + today.year + - birthdate.year + - ((today.month, today.day) < (birthdate.month, birthdate.day)) + ) + + +def _patient_update_date(patient: models.Patient) -> datetime | None: + task_dates = [ + task.update_date for task in patient.tasks if task.update_date + ] + task_max = max(task_dates) if task_dates else None + if task_max is not None and patient.updated_at is not None: + return max(task_max, patient.updated_at) + return task_max or patient.updated_at + + +def patient_cell( + patient: models.Patient, + key: str, + ctx: ExportContext, +) -> ExportCell: + if key.startswith("property_"): + return _property_cell(patient.id, key.removeprefix("property_"), ctx) + if key == "name": + return ExportCell(f"{patient.firstname} {patient.lastname}") + if key == "firstname": + return ExportCell(patient.firstname) + if key == "lastname": + return ExportCell(patient.lastname) + if key == "state": + return ExportCell( + ctx.labels.get(f"state_{patient.state}", patient.state), + ) + if key == "sex": + return ExportCell(ctx.labels.get(f"sex_{patient.sex}", patient.sex)) + if key == "description": + return ExportCell(patient.description) + if key == "clinic": + clinic = ctx.locations.get(patient.clinic_id or "") + return ExportCell(clinic.title if clinic else None) + if key == "position": + position = ctx.locations.get(patient.position_id or "") + return ExportCell(position.title if position else None) + if key in LOCATION_KIND_COLUMNS: + return ExportCell( + location_title_by_kind( + patient.position_id, + LOCATION_KIND_COLUMNS[key], + ctx.locations, + ), + ) + if key == "birthdate": + today = ctx.now.date() + age = _patient_age_years(patient.birthdate, today) + formatted = patient.birthdate.strftime(ctx.formats["date"]) + return ExportCell(f"{formatted} ({age} {ctx.labels['years']})") + if key == "tasks": + counts_for_aggregate = patient.state in ( + PatientState.ADMITTED.value, + PatientState.WAIT.value, + ) + tasks = patient.tasks if counts_for_aggregate else [] + closed = sum(1 for task in tasks if task.done) + return ExportCell(f"{closed}/{len(tasks)}") + if key == "updateDate": + return _datetime_cell(_patient_update_date(patient), ctx) + if key == "stateUpdateDate": + return _datetime_cell(patient.state_updated_at, ctx) + if key == "clinicUpdateDate": + return _datetime_cell(patient.clinic_updated_at, ctx) + if key == "positionUpdateDate": + return _datetime_cell(patient.position_updated_at, ctx) + return ExportCell(None) diff --git a/backend/api/export/labels.py b/backend/api/export/labels.py new file mode 100644 index 00000000..422fc3a7 --- /dev/null +++ b/backend/api/export/labels.py @@ -0,0 +1,69 @@ +DE_LABELS = { + "tasks_title": "Aufgaben", + "patients_title": "Patienten", + "generated_at": "Erstellt am", + "generated_by": "von", + "entries": "Einträge", + "page": "Seite", + "page_of": "von", + "yes": "Ja", + "no": "Nein", + "no_patient": "Kein Patient", + "years": "Jahre", + "state_WAIT": "Wartend", + "state_ADMITTED": "Aufgenommen", + "state_DISCHARGED": "Entlassen", + "state_DEAD": "Verstorben", + "sex_MALE": "Männlich", + "sex_FEMALE": "Weiblich", + "sex_UNKNOWN": "Divers", +} + +EN_LABELS = { + "tasks_title": "Tasks", + "patients_title": "Patients", + "generated_at": "Generated at", + "generated_by": "by", + "entries": "entries", + "page": "Page", + "page_of": "of", + "yes": "Yes", + "no": "No", + "no_patient": "No patient", + "years": "years", + "state_WAIT": "Waiting", + "state_ADMITTED": "Admitted", + "state_DISCHARGED": "Discharged", + "state_DEAD": "Deceased", + "sex_MALE": "Male", + "sex_FEMALE": "Female", + "sex_UNKNOWN": "Diverse", +} + +DE_FORMATS = { + "date": "%d.%m.%Y", + "datetime": "%d.%m.%Y %H:%M", + "xlsx_date": "DD.MM.YYYY", + "xlsx_datetime": "DD.MM.YYYY HH:MM", + "decimal_separator": ",", +} + +EN_FORMATS = { + "date": "%Y-%m-%d", + "datetime": "%Y-%m-%d %H:%M", + "xlsx_date": "YYYY-MM-DD", + "xlsx_datetime": "YYYY-MM-DD HH:MM", + "decimal_separator": ".", +} + + +def is_german_locale(locale: str | None) -> bool: + return bool(locale) and locale.strip().lower().startswith("de") + + +def get_labels(locale: str | None) -> dict[str, str]: + return DE_LABELS if is_german_locale(locale) else EN_LABELS + + +def get_formats(locale: str | None) -> dict[str, str]: + return DE_FORMATS if is_german_locale(locale) else EN_FORMATS diff --git a/backend/api/export/render.py b/backend/api/export/render.py new file mode 100644 index 00000000..ea7c1eba --- /dev/null +++ b/backend/api/export/render.py @@ -0,0 +1,230 @@ +import codecs +import csv +import io +from datetime import date, datetime + +from openpyxl import Workbook +from openpyxl.styles import Alignment, Border, Font, PatternFill, Side +from openpyxl.utils import get_column_letter +from openpyxl.worksheet.page import PageMargins +from openpyxl.worksheet.properties import PageSetupProperties + +from api.export.cells import ExportCell, ExportContext + +# Values starting with these characters would be interpreted as formulas by +# spreadsheet applications; they get an apostrophe prefix so patient data can +# never execute as a formula (CSV/formula injection). +_FORMULA_PREFIXES = ("=", "+", "-", "@", "\t", "\r") + +_HEADER_FILL = PatternFill( + start_color="FF334155", end_color="FF334155", fill_type="solid", +) +_ZEBRA_FILL = PatternFill( + start_color="FFF1F5F9", end_color="FFF1F5F9", fill_type="solid", +) +_THIN_BORDER = Border( + left=Side(style="thin", color="FFCBD5E1"), + right=Side(style="thin", color="FFCBD5E1"), + top=Side(style="thin", color="FFCBD5E1"), + bottom=Side(style="thin", color="FFCBD5E1"), +) + +_MIN_COLUMN_WIDTH = 10 +_MAX_COLUMN_WIDTH = 45 +_HEADER_ROW_HEIGHT = 26 +_BASE_ROW_HEIGHT = 22 +_LINE_HEIGHT = 13 +_MAX_WRAP_LINES = 4 + + +def neutralize_formula(text: str) -> str: + if text.startswith(_FORMULA_PREFIXES): + return f"'{text}" + return text + + +def format_number_text(value: float | int, ctx: ExportContext) -> str: + if isinstance(value, bool): + return format_cell_text(ExportCell(value, kind="bool"), ctx) + if isinstance(value, int) or float(value).is_integer(): + return str(int(value)) + return str(value).replace(".", ctx.formats["decimal_separator"]) + + +def format_cell_text(cell: ExportCell, ctx: ExportContext) -> str: + value = cell.value + if value is None: + return "" + if cell.kind == "bool": + return ctx.labels["yes"] if value else ctx.labels["no"] + if cell.kind == "datetime" and isinstance(value, datetime): + return value.strftime(ctx.formats["datetime"]) + if cell.kind == "date": + if isinstance(value, datetime): + value = value.date() + if isinstance(value, date): + return value.strftime(ctx.formats["date"]) + if cell.kind == "number" and isinstance(value, (int, float)): + return format_number_text(value, ctx) + return neutralize_formula(str(value)) + + +def render_csv( + headers: list[str], + rows: list[list[ExportCell]], + ctx: ExportContext, +) -> bytes: + buffer = io.StringIO() + writer = csv.writer(buffer, delimiter=";", lineterminator="\r\n") + writer.writerow([neutralize_formula(header) for header in headers]) + for row in rows: + writer.writerow([format_cell_text(cell, ctx) for cell in row]) + return codecs.BOM_UTF8 + buffer.getvalue().encode("utf-8") + + +def _sheet_title(title: str) -> str: + cleaned = "".join(c for c in title if c not in "[]:*?/\\") + return cleaned.strip()[:31] or "Export" + + +def _write_cell(worksheet, row: int, column: int, cell: ExportCell, ctx: ExportContext): + target = worksheet.cell(row=row, column=column) + value = cell.value + if value is None: + target.value = None + elif cell.kind == "datetime" and isinstance(value, datetime): + target.value = value + target.number_format = ctx.formats["xlsx_datetime"] + elif cell.kind == "date" and isinstance(value, (date, datetime)): + target.value = value + target.number_format = ctx.formats["xlsx_date"] + elif cell.kind == "number" and isinstance(value, (int, float)): + target.value = value + elif cell.kind == "bool": + target.value = ctx.labels["yes"] if value else ctx.labels["no"] + else: + target.value = neutralize_formula(str(value)) + return target + + +def _estimate_width(values: list[str]) -> float: + longest = max((len(v) for v in values), default=0) + return min(max(longest + 3, _MIN_COLUMN_WIDTH), _MAX_COLUMN_WIDTH) + + +def _estimate_row_height(texts: list[str], widths: list[float]) -> float: + max_lines = 1 + for text, width in zip(texts, widths): + if not text: + continue + usable_chars = max(int(width) - 2, 1) + lines = min(-(-len(text) // usable_chars), _MAX_WRAP_LINES) + max_lines = max(max_lines, lines) + return max(_BASE_ROW_HEIGHT, max_lines * _LINE_HEIGHT + 9.0) + + +def render_xlsx( + headers: list[str], + rows: list[list[ExportCell]], + ctx: ExportContext, + title: str, +) -> bytes: + workbook = Workbook() + worksheet = workbook.active + worksheet.title = _sheet_title(title) + + column_count = max(len(headers), 1) + generated_at = ctx.now.strftime(ctx.formats["datetime"]) + generated_by = ( + f" {ctx.labels['generated_by']} {ctx.exported_by}" + if ctx.exported_by + else "" + ) + subtitle = ( + f"{ctx.labels['generated_at']} {generated_at} ({ctx.tz.key})" + f"{generated_by} — {len(rows)} {ctx.labels['entries']}" + ) + + title_cell = worksheet.cell(row=1, column=1, value=title) + title_cell.font = Font(size=14, bold=True) + worksheet.merge_cells( + start_row=1, start_column=1, end_row=1, end_column=column_count, + ) + subtitle_cell = worksheet.cell(row=2, column=1, value=subtitle) + subtitle_cell.font = Font(size=9, color="FF64748B") + worksheet.merge_cells( + start_row=2, start_column=1, end_row=2, end_column=column_count, + ) + + text_rows = [ + [format_cell_text(cell, ctx) for cell in row] for row in rows + ] + widths = [ + _estimate_width( + [headers[column_index]] + + [ + text_row[column_index] + for text_row in text_rows + if column_index < len(text_row) + ], + ) + for column_index in range(column_count) + ] + for column_index, width in enumerate(widths, start=1): + worksheet.column_dimensions[ + get_column_letter(column_index) + ].width = width + + header_row = 4 + worksheet.row_dimensions[header_row].height = _HEADER_ROW_HEIGHT + for index, header in enumerate(headers, start=1): + cell = worksheet.cell(row=header_row, column=index, value=header) + cell.font = Font(bold=True, color="FFFFFFFF") + cell.fill = _HEADER_FILL + cell.border = _THIN_BORDER + cell.alignment = Alignment( + vertical="center", wrap_text=True, + ) + + for row_offset, row in enumerate(rows): + row_index = header_row + 1 + row_offset + for column_index, cell in enumerate(row, start=1): + written = _write_cell(worksheet, row_index, column_index, cell, ctx) + written.border = _THIN_BORDER + written.alignment = Alignment(vertical="center", wrap_text=True) + if row_offset % 2 == 1: + written.fill = _ZEBRA_FILL + worksheet.row_dimensions[row_index].height = _estimate_row_height( + text_rows[row_offset], + widths, + ) + + last_row = header_row + len(rows) + worksheet.freeze_panes = f"A{header_row + 1}" + if rows: + worksheet.auto_filter.ref = ( + f"A{header_row}:{get_column_letter(column_count)}{last_row}" + ) + + worksheet.print_title_rows = f"{header_row}:{header_row}" + worksheet.print_area = ( + f"A1:{get_column_letter(column_count)}{max(last_row, header_row)}" + ) + worksheet.page_setup.orientation = "landscape" + worksheet.page_setup.paperSize = worksheet.PAPERSIZE_A4 + worksheet.page_setup.fitToWidth = 1 + worksheet.page_setup.fitToHeight = 0 + worksheet.sheet_properties.pageSetUpPr = PageSetupProperties(fitToPage=True) + worksheet.page_margins = PageMargins( + left=0.4, right=0.4, top=0.6, bottom=0.6, header=0.3, footer=0.3, + ) + worksheet.oddFooter.left.text = f"{title} — {subtitle}" + worksheet.oddFooter.left.size = 8 + worksheet.oddFooter.right.text = ( + f"{ctx.labels['page']} &P {ctx.labels['page_of']} &N" + ) + worksheet.oddFooter.right.size = 8 + + output = io.BytesIO() + workbook.save(output) + return output.getvalue() diff --git a/backend/api/export/schemas.py b/backend/api/export/schemas.py new file mode 100644 index 00000000..48f89ad7 --- /dev/null +++ b/backend/api/export/schemas.py @@ -0,0 +1,109 @@ +from datetime import date, datetime +from typing import Literal + +from api.inputs import PatientState, SortDirection +from api.query.enums import QueryOperator +from api.query.inputs import ( + QueryFilterClauseInput, + QueryFilterValueInput, + QuerySearchInput, + QuerySortClauseInput, +) +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel + +ExportEntity = Literal["tasks", "patients"] +ExportFormat = Literal["csv", "xlsx"] + + +class CamelModel(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class ExportColumnInput(CamelModel): + key: str + label: str + + +class ExportFilterValueInput(CamelModel): + string_value: str | None = None + string_values: list[str] | None = None + float_value: float | None = None + float_min: float | None = None + float_max: float | None = None + bool_value: bool | None = None + date_value: datetime | None = None + date_min: date | None = None + date_max: date | None = None + uuid_value: str | None = None + uuid_values: list[str] | None = None + + +class ExportFilterClauseInput(CamelModel): + field_key: str + operator: QueryOperator + value: ExportFilterValueInput | None = None + + +class ExportSortClauseInput(CamelModel): + field_key: str + direction: SortDirection + + +class ExportSearchInput(CamelModel): + search_text: str | None = None + include_properties: bool = False + + +class TableExportRequest(CamelModel): + format: ExportFormat + columns: list[ExportColumnInput] = Field(min_length=1) + filters: list[ExportFilterClauseInput] = Field(default_factory=list) + sorts: list[ExportSortClauseInput] = Field(default_factory=list) + search: ExportSearchInput | None = None + locale: str = "de-DE" + timezone: str = "Europe/Berlin" + title: str | None = None + root_location_ids: list[str] | None = None + assignee_id: str | None = None + assignee_team_id: str | None = None + patient_id: str | None = None + location_node_id: str | None = None + states: list[PatientState] | None = None + + def query_filters(self) -> list[QueryFilterClauseInput] | None: + if not self.filters: + return None + return [ + QueryFilterClauseInput( + field_key=clause.field_key, + operator=clause.operator, + value=( + QueryFilterValueInput( + **clause.value.model_dump() + ) + if clause.value is not None + else None + ), + ) + for clause in self.filters + ] + + def query_sorts(self) -> list[QuerySortClauseInput] | None: + if not self.sorts: + return None + return [ + QuerySortClauseInput( + field_key=clause.field_key, + direction=clause.direction, + ) + for clause in self.sorts + ] + + def query_search(self) -> QuerySearchInput | None: + if self.search is None or not (self.search.search_text or "").strip(): + return None + return QuerySearchInput( + search_text=self.search.search_text, + include_properties=self.search.include_properties, + ) diff --git a/backend/api/export/service.py b/backend/api/export/service.py new file mode 100644 index 00000000..7529f7e2 --- /dev/null +++ b/backend/api/export/service.py @@ -0,0 +1,210 @@ +import re +import unicodedata +from dataclasses import dataclass +from datetime import datetime +from types import SimpleNamespace +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from sqlalchemy import select + +from api.context import Context +from api.export.cells import ( + ExportCell, + ExportContext, + LocationInfo, + patient_cell, + task_cell, + user_display_name, +) +from api.export.labels import get_formats, get_labels +from api.export.render import render_csv, render_xlsx +from api.export.schemas import TableExportRequest +from api.inputs import PaginationInput +from config import EXPORT_MAX_ROWS +from database import models + +_TRANSLITERATIONS = str.maketrans( + {"ä": "ae", "ö": "oe", "ü": "ue", "Ä": "Ae", "Ö": "Oe", "Ü": "Ue", "ß": "ss"} +) + +CSV_MEDIA_TYPE = "text/csv; charset=utf-8" +XLSX_MEDIA_TYPE = ( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" +) + + +@dataclass +class ExportResult: + content: bytes + media_type: str + filename: str + + +def _resolve_timezone(name: str) -> ZoneInfo: + for candidate in (name, "Europe/Berlin", "UTC"): + try: + return ZoneInfo(candidate) + except (ZoneInfoNotFoundError, ValueError): + continue + raise RuntimeError("No usable timezone database available") + + +def _slugify_filename(title: str) -> str: + transliterated = title.translate(_TRANSLITERATIONS) + normalized = unicodedata.normalize("NFKD", transliterated) + ascii_text = normalized.encode("ascii", "ignore").decode("ascii") + slug = re.sub(r"[^A-Za-z0-9._-]+", "-", ascii_text).strip("-._") + return slug.lower() or "export" + + +async def _load_locations(db) -> dict[str, LocationInfo]: + result = await db.execute( + select( + models.LocationNode.id, + models.LocationNode.title, + models.LocationNode.kind, + models.LocationNode.parent_id, + ), + ) + return { + row.id: LocationInfo( + title=row.title, + kind=row.kind, + parent_id=row.parent_id, + ) + for row in result.all() + } + + +async def _load_property_data( + db, + ctx: ExportContext, + entity_column, + entity_ids: list[str], +) -> None: + definitions_result = await db.execute(select(models.PropertyDefinition)) + ctx.property_definitions = { + definition.id: definition + for definition in definitions_result.scalars().all() + } + + if not entity_ids: + return + + values_result = await db.execute( + select(models.PropertyValue).where(entity_column.in_(entity_ids)), + ) + property_user_ids: set[str] = set() + for value in values_result.scalars().all(): + entity_id = value.task_id or value.patient_id + if entity_id is None: + continue + ctx.properties_by_entity.setdefault(entity_id, {})[ + value.definition_id + ] = value + if value.user_value and not value.user_value.startswith("team:"): + property_user_ids.add(value.user_value) + + if property_user_ids: + users_result = await db.execute( + select(models.User).where(models.User.id.in_(property_user_ids)), + ) + ctx.users = {user.id: user for user in users_result.scalars().all()} + + +def _export_pagination() -> PaginationInput: + return PaginationInput(page_index=0, page_size=EXPORT_MAX_ROWS) + + +async def _fetch_tasks(info, request: TableExportRequest) -> list[models.Task]: + from api.resolvers.task import TaskQuery + + return await TaskQuery().tasks( + info, + patient_id=request.patient_id, + assignee_id=request.assignee_id, + assignee_team_id=request.assignee_team_id, + root_location_ids=request.root_location_ids, + filters=request.query_filters(), + sorts=request.query_sorts(), + pagination=_export_pagination(), + search=request.query_search(), + ) + + +async def _fetch_patients( + info, + request: TableExportRequest, +) -> list[models.Patient]: + from api.resolvers.patient import PatientQuery + + return await PatientQuery().patients( + info, + location_node_id=request.location_node_id, + root_location_ids=request.root_location_ids, + states=request.states, + filters=request.query_filters(), + sorts=request.query_sorts(), + pagination=_export_pagination(), + search=request.query_search(), + ) + + +async def run_table_export( + context: Context, + entity: str, + request: TableExportRequest, +) -> ExportResult: + info = SimpleNamespace(context=context) + db = context.db + tz = _resolve_timezone(request.timezone) + ctx = ExportContext( + labels=get_labels(request.locale), + formats=get_formats(request.locale), + tz=tz, + now=datetime.now(tz).replace(tzinfo=None), + exported_by=user_display_name(context.user), + ) + ctx.locations = await _load_locations(db) + + if entity == "tasks": + records = await _fetch_tasks(info, request) + await _load_property_data( + db, + ctx, + models.PropertyValue.task_id, + [task.id for task in records], + ) + resolve_cell = task_cell + default_title = ctx.labels["tasks_title"] + else: + records = await _fetch_patients(info, request) + await _load_property_data( + db, + ctx, + models.PropertyValue.patient_id, + [patient.id for patient in records], + ) + resolve_cell = patient_cell + default_title = ctx.labels["patients_title"] + + title = (request.title or "").strip() or default_title + headers = [column.label for column in request.columns] + rows: list[list[ExportCell]] = [ + [resolve_cell(record, column.key, ctx) for column in request.columns] + for record in records + ] + + timestamp = ctx.now.strftime("%Y-%m-%d_%H-%M") + base_filename = f"{_slugify_filename(title)}_{timestamp}" + if request.format == "csv": + return ExportResult( + content=render_csv(headers, rows, ctx), + media_type=CSV_MEDIA_TYPE, + filename=f"{base_filename}.csv", + ) + return ExportResult( + content=render_xlsx(headers, rows, ctx, title), + media_type=XLSX_MEDIA_TYPE, + filename=f"{base_filename}.xlsx", + ) diff --git a/backend/api/query/adapters/patient.py b/backend/api/query/adapters/patient.py index c1385574..9d42f050 100644 --- a/backend/api/query/adapters/patient.py +++ b/backend/api/query/adapters/patient.py @@ -100,6 +100,20 @@ def _parse_property_key(field_key: str) -> str | None: return field_key.removeprefix("property_") +FIELD_UPDATE_DATE_COLUMNS: dict[str, Any] = { + "stateUpdateDate": models.Patient.state_updated_at, + "clinicUpdateDate": models.Patient.clinic_updated_at, + "positionUpdateDate": models.Patient.position_updated_at, +} + + +FIELD_UPDATE_DATE_LABELS: dict[str, str] = { + "stateUpdateDate": "State updated", + "clinicUpdateDate": "Clinic updated", + "positionUpdateDate": "Location updated", +} + + LOCATION_SORT_KEY_KINDS: dict[str, tuple[str, ...]] = { "location-WARD": ("WARD",), "location-ROOM": ("ROOM",), @@ -222,6 +236,13 @@ def apply_patient_filter_clause( if c is not None: query = query.where(c) return query + if key in FIELD_UPDATE_DATE_COLUMNS: + c = apply_ops_to_column( + FIELD_UPDATE_DATE_COLUMNS[key], op, val, as_datetime=True + ) + if c is not None: + query = query.where(c) + return query if key in LOCATION_SORT_KEY_KINDS: query, lineage_nodes = _ensure_position_lineage_joins(query, ctx) expr = _location_title_for_kind( @@ -270,6 +291,7 @@ def apply_patient_filter_clause( expr = location_title_expr(ln) if op in ( QueryOperator.CONTAINS, + QueryOperator.NOT_CONTAINS, QueryOperator.STARTS_WITH, QueryOperator.ENDS_WITH, ): @@ -368,6 +390,11 @@ def apply_patient_sorts( order_parts.append( col.desc().nulls_last() if desc_order else col.asc().nulls_first() ) + elif key in FIELD_UPDATE_DATE_COLUMNS: + col = FIELD_UPDATE_DATE_COLUMNS[key] + order_parts.append( + col.desc().nulls_last() if desc_order else col.asc().nulls_first() + ) elif key == "position": query, ln = _ensure_position_join(query, ctx) t = location_title_expr(ln) @@ -427,6 +454,7 @@ def build_patient_queryable_fields_static() -> list[QueryableField]: QueryOperator.EQ, QueryOperator.NEQ, QueryOperator.CONTAINS, + QueryOperator.NOT_CONTAINS, QueryOperator.STARTS_WITH, QueryOperator.ENDS_WITH, QueryOperator.IN, @@ -442,6 +470,7 @@ def build_patient_queryable_fields_static() -> list[QueryableField]: QueryOperator.LT, QueryOperator.LTE, QueryOperator.BETWEEN, + QueryOperator.NOT_BETWEEN, QueryOperator.IS_NULL, QueryOperator.IS_NOT_NULL, ] @@ -545,6 +574,19 @@ def build_patient_queryable_fields_static() -> list[QueryableField]: sort_directions=sort_directions_for(True), searchable=False, ), + *[ + QueryableField( + key=key, + label=label, + kind=QueryableFieldKind.SCALAR, + value_type=QueryableValueType.DATETIME, + allowed_operators=dt_ops, + sortable=True, + sort_directions=sort_directions_for(True), + searchable=False, + ) + for key, label in FIELD_UPDATE_DATE_LABELS.items() + ], QueryableField( key="description", label="Description", @@ -574,6 +616,7 @@ def build_patient_queryable_fields_static() -> list[QueryableField]: QueryOperator.EQ, QueryOperator.IN, QueryOperator.CONTAINS, + QueryOperator.NOT_CONTAINS, QueryOperator.STARTS_WITH, QueryOperator.ENDS_WITH, QueryOperator.IS_NULL, diff --git a/backend/api/query/adapters/task.py b/backend/api/query/adapters/task.py index 43e95195..cba7490f 100644 --- a/backend/api/query/adapters/task.py +++ b/backend/api/query/adapters/task.py @@ -247,6 +247,11 @@ def apply_task_filter_clause( c = _assignee_label_exists(op, val) if c is not None: query = query.where(c) + elif op == QueryOperator.NOT_CONTAINS: + # keep tasks without a matching assignee (including unassigned) + c = _assignee_label_exists(QueryOperator.CONTAINS, val) + if c is not None: + query = query.where(~c) elif op in (QueryOperator.IS_NULL, QueryOperator.IS_NOT_NULL): has_assignees = exists( select(1).where(models.task_assignees.c.task_id == models.Task.id) @@ -264,6 +269,7 @@ def apply_task_filter_clause( query = query.where(models.Task.assignee_team_id == val.uuid_value) elif op in ( QueryOperator.CONTAINS, + QueryOperator.NOT_CONTAINS, QueryOperator.STARTS_WITH, QueryOperator.ENDS_WITH, ): @@ -296,6 +302,11 @@ def apply_task_filter_clause( c = _patient_label_exists(op, val) if c is not None: query = query.where(c) + elif op == QueryOperator.NOT_CONTAINS: + # keep tasks whose patient does not match (including no patient) + c = _patient_label_exists(QueryOperator.CONTAINS, val) + if c is not None: + query = query.where(~c) return query return query @@ -435,6 +446,7 @@ def build_task_queryable_fields_static() -> list[QueryableField]: QueryOperator.EQ, QueryOperator.IN, QueryOperator.CONTAINS, + QueryOperator.NOT_CONTAINS, QueryOperator.STARTS_WITH, QueryOperator.ENDS_WITH, QueryOperator.IS_NULL, @@ -444,6 +456,7 @@ def build_task_queryable_fields_static() -> list[QueryableField]: QueryOperator.EQ, QueryOperator.NEQ, QueryOperator.CONTAINS, + QueryOperator.NOT_CONTAINS, QueryOperator.STARTS_WITH, QueryOperator.ENDS_WITH, QueryOperator.IN, @@ -459,6 +472,7 @@ def build_task_queryable_fields_static() -> list[QueryableField]: QueryOperator.LT, QueryOperator.LTE, QueryOperator.BETWEEN, + QueryOperator.NOT_BETWEEN, QueryOperator.IS_NULL, QueryOperator.IS_NOT_NULL, ] @@ -470,6 +484,7 @@ def build_task_queryable_fields_static() -> list[QueryableField]: QueryOperator.LT, QueryOperator.LTE, QueryOperator.BETWEEN, + QueryOperator.NOT_BETWEEN, QueryOperator.IS_NULL, QueryOperator.IS_NOT_NULL, ] diff --git a/backend/api/query/engine.py b/backend/api/query/engine.py index e8b81b47..355e7978 100644 --- a/backend/api/query/engine.py +++ b/backend/api/query/engine.py @@ -66,8 +66,17 @@ async def apply_unified_query( if text: stmt = handler["apply_search"](stmt, search, ctx) - if ctx.get("needs_distinct"): + # Rewrap DISTINCT statements (both from filter/search joins and from the + # resolvers' base queries) into a plain SELECT over deduplicated ids. + # PostgreSQL rejects `SELECT DISTINCT ... ORDER BY ` when the + # expression is not part of the select list, which breaks every sort that + # orders by a joined or correlated expression (e.g. tasks by patient name, + # patients by update date or ward/room/bed). + if ctx.get("needs_distinct") or bool(getattr(stmt, "_distinct", False)): stmt = dedupe_orm_select_by_root_id(stmt, handler["root_model"]) + # join memos recorded while filtering refer to the old statement; the + # sort adapters must re-join against the rewrapped one + ctx.clear() ctx["needs_distinct"] = False if not for_count: diff --git a/backend/api/query/enums.py b/backend/api/query/enums.py index cb099161..fd5680ef 100644 --- a/backend/api/query/enums.py +++ b/backend/api/query/enums.py @@ -12,9 +12,11 @@ class QueryOperator(Enum): LT = "LT" LTE = "LTE" BETWEEN = "BETWEEN" + NOT_BETWEEN = "NOT_BETWEEN" IN = "IN" NOT_IN = "NOT_IN" CONTAINS = "CONTAINS" + NOT_CONTAINS = "NOT_CONTAINS" STARTS_WITH = "STARTS_WITH" ENDS_WITH = "ENDS_WITH" IS_NULL = "IS_NULL" diff --git a/backend/api/query/field_ops.py b/backend/api/query/field_ops.py index e6a63d18..87231e04 100644 --- a/backend/api/query/field_ops.py +++ b/backend/api/query/field_ops.py @@ -88,6 +88,23 @@ def apply_ops_to_column( return column.between(value.float_min, value.float_max) return None + if operator == QueryOperator.NOT_BETWEEN: + # "not between" keeps rows outside the range, including rows without a + # value (SQL NOT BETWEEN would drop NULLs). + if value is None: + return None + if value.date_min is not None and value.date_max is not None: + return or_( + column.is_(None), + not_(func.date(column).between(value.date_min, value.date_max)), + ) + if value.float_min is not None and value.float_max is not None: + return or_( + column.is_(None), + not_(column.between(value.float_min, value.float_max)), + ) + return None + if operator == QueryOperator.IN: if value and value.string_values: return column.in_(value.string_values) @@ -107,6 +124,13 @@ def apply_ops_to_column( if s is None: return None return column.ilike(f"%{s}%") + if operator == QueryOperator.NOT_CONTAINS: + # "does not contain" keeps rows whose value lacks the needle, including + # rows without a value (SQL NOT ILIKE would drop NULLs). + s = _str_norm(value) if value else None + if s is None: + return None + return or_(column.is_(None), not_(column.ilike(f"%{s}%"))) if operator == QueryOperator.STARTS_WITH: s = _str_norm(value) if value else None if s is None: @@ -151,6 +175,15 @@ def _apply_date_ops( and value.date_max is not None ): return dc.between(value.date_min, value.date_max) + if ( + operator == QueryOperator.NOT_BETWEEN + and value.date_min is not None + and value.date_max is not None + ): + return or_( + column.is_(None), + not_(dc.between(value.date_min, value.date_max)), + ) if operator == QueryOperator.IN and value.string_values: return dc.in_(value.string_values) if value.date_value is not None: @@ -193,6 +226,15 @@ def _apply_datetime_ops( and value.date_max is not None ): return func.date(column).between(value.date_min, value.date_max) + if ( + operator == QueryOperator.NOT_BETWEEN + and value.date_min is not None + and value.date_max is not None + ): + return or_( + column.is_(None), + not_(func.date(column).between(value.date_min, value.date_max)), + ) return None diff --git a/backend/api/query/metadata_service.py b/backend/api/query/metadata_service.py index 14375f33..ae9121fb 100644 --- a/backend/api/query/metadata_service.py +++ b/backend/api/query/metadata_service.py @@ -26,6 +26,7 @@ def _str_ops() -> list[QueryOperator]: QueryOperator.EQ, QueryOperator.NEQ, QueryOperator.CONTAINS, + QueryOperator.NOT_CONTAINS, QueryOperator.STARTS_WITH, QueryOperator.ENDS_WITH, QueryOperator.IN, @@ -44,6 +45,7 @@ def _num_ops() -> list[QueryOperator]: QueryOperator.LT, QueryOperator.LTE, QueryOperator.BETWEEN, + QueryOperator.NOT_BETWEEN, QueryOperator.IS_NULL, QueryOperator.IS_NOT_NULL, ] @@ -58,6 +60,7 @@ def _date_ops() -> list[QueryOperator]: QueryOperator.LT, QueryOperator.LTE, QueryOperator.BETWEEN, + QueryOperator.NOT_BETWEEN, QueryOperator.IS_NULL, QueryOperator.IS_NOT_NULL, ] diff --git a/backend/api/resolvers/patient.py b/backend/api/resolvers/patient.py index 8a566b44..12b7d6c9 100644 --- a/backend/api/resolvers/patient.py +++ b/backend/api/resolvers/patient.py @@ -527,7 +527,12 @@ async def create_patient( new_patient, data.properties, "patient" ) - new_patient.updated_at = datetime.now() + now = datetime.now() + new_patient.updated_at = now + new_patient.state_updated_at = now + new_patient.clinic_updated_at = now + if new_patient.position_id is not None: + new_patient.position_updated_at = now repo = BaseMutationResolver.get_repository(db, models.Patient) await repo.create(new_patient) @@ -589,10 +594,14 @@ async def update_patient( if data.clinic_id not in accessible_location_ids: raise_forbidden() await location_service.validate_and_get_clinic(data.clinic_id) + if patient.clinic_id != data.clinic_id: + patient.clinic_updated_at = datetime.now() patient.clinic_id = data.clinic_id if data.position_id is not strawberry.UNSET: if data.position_id is None: + if patient.position_id is not None: + patient.position_updated_at = datetime.now() patient.position_id = None else: if data.position_id not in accessible_location_ids: @@ -600,6 +609,8 @@ async def update_patient( await location_service.validate_and_get_position( data.position_id ) + if patient.position_id != data.position_id: + patient.position_updated_at = datetime.now() patient.position_id = data.position_id if data.team_ids is not strawberry.UNSET: @@ -702,8 +713,11 @@ async def _update_patient_state( ): raise_forbidden() + now = datetime.now() + if patient.state != state.value: + patient.state_updated_at = now patient.state = state.value - patient.updated_at = datetime.now() + patient.updated_at = now await BaseMutationResolver.update_and_notify( info, patient, models.Patient, "patient" ) diff --git a/backend/api/types/patient.py b/backend/api/types/patient.py index 192c540f..74fc6dce 100644 --- a/backend/api/types/patient.py +++ b/backend/api/types/patient.py @@ -168,6 +168,18 @@ async def update_date(self, info: Info) -> datetime | None: return max(task_max, patient_updated) return task_max or patient_updated + @strawberry.field + def state_update_date(self) -> datetime | None: + return self.state_updated_at + + @strawberry.field + def clinic_update_date(self) -> datetime | None: + return self.clinic_updated_at + + @strawberry.field + def position_update_date(self) -> datetime | None: + return self.position_updated_at + @strawberry.field async def tasks( self, diff --git a/backend/config.py b/backend/config.py index e2ed44ed..d1cafd10 100644 --- a/backend/config.py +++ b/backend/config.py @@ -66,6 +66,8 @@ class ScaffoldStrategy(str, Enum): except ValueError: SCAFFOLD_STRATEGY = ScaffoldStrategy.CHECK +EXPORT_MAX_ROWS = int(os.getenv("EXPORT_MAX_ROWS", "10000")) + INFLUXDB_URL = os.getenv("INFLUXDB_URL", "http://localhost:8086") INFLUXDB_TOKEN = os.getenv("INFLUXDB_TOKEN", None) INFLUXDB_ORG = os.getenv("INFLUXDB_ORG", "tasks") diff --git a/backend/database/migrations/versions/add_patient_field_update_timestamps.py b/backend/database/migrations/versions/add_patient_field_update_timestamps.py new file mode 100644 index 00000000..4b1a0aef --- /dev/null +++ b/backend/database/migrations/versions/add_patient_field_update_timestamps.py @@ -0,0 +1,38 @@ +"""Add per-field update timestamps to patients. + +Revision ID: add_patient_field_update_ts +Revises: add_patient_updated_at +Create Date: 2026-07-05 00:00:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "add_patient_field_update_ts" +down_revision: Union[str, Sequence[str], None] = "add_patient_updated_at" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "patients", + sa.Column("state_updated_at", sa.DateTime(), nullable=True), + ) + op.add_column( + "patients", + sa.Column("clinic_updated_at", sa.DateTime(), nullable=True), + ) + op.add_column( + "patients", + sa.Column("position_updated_at", sa.DateTime(), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("patients", "position_updated_at") + op.drop_column("patients", "clinic_updated_at") + op.drop_column("patients", "state_updated_at") diff --git a/backend/database/models/patient.py b/backend/database/models/patient.py index 511ba969..49a13d56 100644 --- a/backend/database/models/patient.py +++ b/backend/database/models/patient.py @@ -61,6 +61,18 @@ class Patient(Base): default=datetime.now, onupdate=datetime.now, ) + state_updated_at: Mapped[datetime | None] = mapped_column( + DateTime, + nullable=True, + ) + clinic_updated_at: Mapped[datetime | None] = mapped_column( + DateTime, + nullable=True, + ) + position_updated_at: Mapped[datetime | None] = mapped_column( + DateTime, + nullable=True, + ) assigned_locations: Mapped[list[LocationNode]] = relationship( "LocationNode", diff --git a/backend/main.py b/backend/main.py index 6b755093..7ade4a6e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -10,7 +10,7 @@ from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse -from routers import auth +from routers import auth, export from scaffold import load_scaffold_data from starlette.requests import ClientDisconnect from strawberry import Schema @@ -61,6 +61,7 @@ async def client_disconnect_handler(request: Request, exc: ClientDisconnect): ) app.include_router(auth.router) +app.include_router(export.router) app.include_router(graphql_app, prefix="/graphql") @@ -75,4 +76,5 @@ async def health_check(): allow_credentials=True, allow_methods=["*"], allow_headers=["*"], + expose_headers=["Content-Disposition"], ) diff --git a/backend/requirements.txt b/backend/requirements.txt index 05c157dd..b636162b 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -9,6 +9,8 @@ sqlalchemy==2.0.51 strawberry-graphql[fastapi]==0.320.0 uvicorn[standard]==0.49.0 influxdb_client==1.50.0 +openpyxl==3.1.5 +tzdata==2025.2 pytest==9.1.1 pytest-asyncio==1.4.0 pytest-cov==7.1.0 diff --git a/backend/routers/export.py b/backend/routers/export.py new file mode 100644 index 00000000..96849327 --- /dev/null +++ b/backend/routers/export.py @@ -0,0 +1,45 @@ +from urllib.parse import quote + +from api.context import Context, get_context +from api.export.schemas import ExportEntity, TableExportRequest +from api.export.service import run_table_export +from fastapi import APIRouter, Depends, HTTPException, Response +from graphql import GraphQLError + +router = APIRouter(prefix="/export", tags=["export"]) + +_GRAPHQL_ERROR_STATUS = { + "FORBIDDEN": 403, + "UNAUTHENTICATED": 401, + "BAD_REQUEST": 400, + "NOT_FOUND": 404, +} + + +@router.post("/{entity}") +async def export_table( + entity: ExportEntity, + request: TableExportRequest, + context: Context = Depends(get_context), +) -> Response: + if context.user is None: + raise HTTPException(status_code=401, detail="Not authenticated") + + try: + result = await run_table_export(context, entity, request) + except GraphQLError as error: + code = (error.extensions or {}).get("code") + raise HTTPException( + status_code=_GRAPHQL_ERROR_STATUS.get(code, 400), + detail=error.message, + ) from error + + return Response( + content=result.content, + media_type=result.media_type, + headers={ + "Content-Disposition": ( + f"attachment; filename*=UTF-8''{quote(result.filename)}" + ), + }, + ) diff --git a/backend/schema.graphql b/backend/schema.graphql index c4c05b46..957c5291 100644 --- a/backend/schema.graphql +++ b/backend/schema.graphql @@ -193,6 +193,9 @@ type PatientType { tasks(done: Boolean = null): [TaskType!]! properties: [PropertyValueType!]! updateDate: DateTime + stateUpdateDate: DateTime + clinicUpdateDate: DateTime + positionUpdateDate: DateTime checksum: String! } @@ -294,9 +297,11 @@ enum QueryOperator { LT LTE BETWEEN + NOT_BETWEEN IN NOT_IN CONTAINS + NOT_CONTAINS STARTS_WITH ENDS_WITH IS_NULL diff --git a/backend/tests/integration/test_patient_resolver.py b/backend/tests/integration/test_patient_resolver.py index 736415ee..0be1bc8c 100644 --- a/backend/tests/integration/test_patient_resolver.py +++ b/backend/tests/integration/test_patient_resolver.py @@ -120,6 +120,93 @@ async def test_patient_mutation_discharge_patient(db_session, sample_patient, sa assert result.state == PatientState.DISCHARGED.value +@pytest.mark.asyncio +async def test_patient_field_update_timestamps_on_create( + db_session, sample_location, sample_user_with_location_access +): + from api.inputs import CreatePatientInput + from datetime import date + + info = MockInfo(db_session, sample_user_with_location_access) + mutation = PatientMutation() + input_data = CreatePatientInput( + firstname="Jane", + lastname="Doe", + birthdate=date(1990, 1, 1), + sex=Sex.FEMALE, + clinic_id=sample_location.id, + ) + result = await mutation.create_patient(info, input_data) + assert result.state_updated_at is not None + assert result.clinic_updated_at is not None + assert result.position_updated_at is None + + +@pytest.mark.asyncio +async def test_patient_state_update_timestamp_on_state_change( + db_session, sample_patient, sample_user_with_location_access +): + info = MockInfo(db_session, sample_user_with_location_access) + mutation = PatientMutation() + assert sample_patient.state_updated_at is None + + result = await mutation.discharge_patient(info, sample_patient.id) + assert result.state_updated_at is not None + stamped = result.state_updated_at + + result = await mutation.discharge_patient(info, sample_patient.id) + assert result.state_updated_at == stamped + + +@pytest.mark.asyncio +async def test_patient_field_update_timestamps_on_update( + db_session, sample_patient, sample_location, sample_user_with_location_access +): + from api.inputs import UpdatePatientInput + from database.models.location import LocationNode + + bed = LocationNode( + id="bed-1", + title="Bed 1", + kind="BED", + parent_id=sample_location.id, + ) + db_session.add(bed) + await db_session.commit() + + info = MockInfo(db_session, sample_user_with_location_access) + mutation = PatientMutation() + + result = await mutation.update_patient( + info, sample_patient.id, UpdatePatientInput(firstname="Unchanged") + ) + assert result.clinic_updated_at is None + assert result.position_updated_at is None + + result = await mutation.update_patient( + info, sample_patient.id, UpdatePatientInput(position_id=bed.id) + ) + assert result.position_updated_at is not None + stamped = result.position_updated_at + + result = await mutation.update_patient( + info, sample_patient.id, UpdatePatientInput(position_id=bed.id) + ) + assert result.position_updated_at == stamped + + result = await mutation.update_patient( + info, sample_patient.id, UpdatePatientInput(clinic_id=sample_location.id) + ) + assert result.clinic_updated_at is None + + result = await mutation.update_patient( + info, sample_patient.id, UpdatePatientInput(position_id=None) + ) + assert result.position_id is None + assert result.position_updated_at is not None + assert result.position_updated_at != stamped + + @pytest.mark.asyncio async def test_patient_mutation_clear_patient_property( db_session, sample_patient, sample_user_with_location_access diff --git a/backend/tests/unit/test_export.py b/backend/tests/unit/test_export.py new file mode 100644 index 00000000..9f94072c --- /dev/null +++ b/backend/tests/unit/test_export.py @@ -0,0 +1,356 @@ +import io +from datetime import date, datetime +from zoneinfo import ZoneInfo + +import pytest +from openpyxl import load_workbook + +from api.export.cells import ( + ExportCell, + ExportContext, + LocationInfo, + is_date_only_due_date, + location_title_by_kind, + patient_cell, + select_option_label, + task_cell, + to_zoned, +) +from api.export.labels import get_formats, get_labels +from api.export.render import ( + format_cell_text, + neutralize_formula, + render_csv, + render_xlsx, +) +from api.export.service import _slugify_filename +from api.inputs import FieldType, PatientState, Sex +from database.models.patient import Patient +from database.models.property import PropertyDefinition, PropertyValue +from database.models.task import Task +from database.models.user import User + +BERLIN = ZoneInfo("Europe/Berlin") + + +def make_context(locale: str = "de-DE") -> ExportContext: + return ExportContext( + labels=get_labels(locale), + formats=get_formats(locale), + tz=BERLIN, + now=datetime(2026, 7, 5, 12, 0), + exported_by="Erika Musterfrau", + ) + + +def make_patient(**overrides) -> Patient: + values = { + "id": "patient-1", + "firstname": "Erika", + "lastname": "Musterfrau", + "birthdate": date(1960, 8, 1), + "sex": Sex.FEMALE.value, + "state": PatientState.ADMITTED.value, + "clinic_id": "clinic-1", + "position_id": "bed-1", + } + values.update(overrides) + patient = Patient(**values) + patient.tasks = overrides.get("tasks", []) + return patient + + +def location_tree() -> dict[str, LocationInfo]: + return { + "clinic-1": LocationInfo("Innere Medizin", "CLINIC", None), + "ward-1": LocationInfo("Station 3", "WARD", "clinic-1"), + "room-1": LocationInfo("Zimmer 12", "ROOM", "ward-1"), + "bed-1": LocationInfo("Bett A", "BED", "room-1"), + } + + +def test_datetime_converted_to_berlin_and_german_format(): + ctx = make_context() + cell = ExportCell(to_zoned(datetime(2026, 1, 5, 23, 30), ctx.tz), "datetime") + assert format_cell_text(cell, ctx) == "06.01.2026 00:30" + + +def test_date_only_due_date_sentinel_keeps_utc_wall_date(): + assert is_date_only_due_date(datetime(2026, 3, 1, 23, 59, 59, 999000)) + assert not is_date_only_due_date(datetime(2026, 3, 1, 23, 59, 59)) + + ctx = make_context() + task = Task(id="t", title="x", done=False) + task.due_date = datetime(2026, 3, 1, 23, 59, 59, 999000) + task.assignees = [] + cell = task_cell(task, "dueDate", ctx) + assert cell.kind == "date" + assert format_cell_text(cell, ctx) == "01.03.2026" + + +def test_regular_due_date_converted_to_timezone(): + ctx = make_context() + task = Task(id="t", title="x", done=False) + task.due_date = datetime(2026, 7, 1, 10, 0) + task.assignees = [] + cell = task_cell(task, "dueDate", ctx) + assert cell.kind == "datetime" + assert format_cell_text(cell, ctx) == "01.07.2026 12:00" + + +def test_task_assignee_names_and_team_fallback(): + ctx = make_context() + ctx.locations = {"team-1": LocationInfo("Pflege Team", "TEAM", None)} + task = Task(id="t", title="x", done=False) + task.assignees = [ + User(id="u1", username="anna", firstname="Anna", lastname="Muster"), + User(id="u2", username="jonas"), + ] + assert task_cell(task, "assignee", ctx).value == "Anna Muster, jonas" + + task.assignees = [] + task.assignee_team_id = "team-1" + assert task_cell(task, "assignee", ctx).value == "Pflege Team" + + +def test_task_without_patient_uses_localized_label(): + ctx = make_context() + task = Task(id="t", title="x", done=False) + task.assignees = [] + task.patient = None + assert task_cell(task, "patient", ctx).value == "Kein Patient" + + +def test_patient_cells_localized(): + ctx = make_context() + ctx.locations = location_tree() + done_task = Task(id="t1", title="a", done=True) + open_task = Task(id="t2", title="b", done=False) + patient = make_patient(tasks=[done_task, open_task]) + + assert patient_cell(patient, "name", ctx).value == "Erika Musterfrau" + assert patient_cell(patient, "state", ctx).value == "Aufgenommen" + assert patient_cell(patient, "sex", ctx).value == "Weiblich" + assert patient_cell(patient, "clinic", ctx).value == "Innere Medizin" + assert patient_cell(patient, "position", ctx).value == "Bett A" + assert patient_cell(patient, "location-WARD", ctx).value == "Station 3" + assert patient_cell(patient, "location-ROOM", ctx).value == "Zimmer 12" + assert patient_cell(patient, "location-BED", ctx).value == "Bett A" + assert patient_cell(patient, "tasks", ctx).value == "1/2" + assert ( + patient_cell(patient, "birthdate", ctx).value + == "01.08.1960 (65 Jahre)" + ) + + +def test_discharged_patient_task_progress_is_zero(): + ctx = make_context() + patient = make_patient( + state=PatientState.DISCHARGED.value, + tasks=[Task(id="t1", title="a", done=True)], + ) + assert patient_cell(patient, "tasks", ctx).value == "0/0" + + +def _select_definition(definition_id: str, field_type: FieldType) -> PropertyDefinition: + return PropertyDefinition( + id=definition_id, + name="Diet", + field_type=field_type.value, + options="Vegetarisch,Vegan,Normal", + ) + + +def test_select_option_label_resolves_option_keys(): + definition = _select_definition("def-1", FieldType.FIELD_TYPE_SELECT) + assert select_option_label("def-1-opt-0", definition) == "Vegetarisch" + assert select_option_label("def-1-opt-2", definition) == "Normal" + assert select_option_label("def-1-opt-9", definition) == "def-1-opt-9" + assert select_option_label("free text", definition) == "free text" + + +def test_select_property_cell_shows_option_label(): + ctx = make_context() + definition = _select_definition("def-1", FieldType.FIELD_TYPE_SELECT) + ctx.property_definitions = {"def-1": definition} + ctx.properties_by_entity = { + "patient-1": { + "def-1": PropertyValue( + id="pv-1", + definition_id="def-1", + patient_id="patient-1", + select_value="def-1-opt-1", + ), + }, + } + patient = make_patient() + assert patient_cell(patient, "property_def-1", ctx).value == "Vegan" + + +def test_multi_select_property_cell_joins_option_labels_inline(): + ctx = make_context() + definition = _select_definition("def-1", FieldType.FIELD_TYPE_MULTI_SELECT) + ctx.property_definitions = {"def-1": definition} + ctx.properties_by_entity = { + "task-1": { + "def-1": PropertyValue( + id="pv-1", + definition_id="def-1", + task_id="task-1", + multi_select_values="def-1-opt-0,def-1-opt-2", + ), + }, + } + task = Task(id="task-1", title="x", done=False) + task.assignees = [] + cell = task_cell(task, "property_def-1", ctx) + assert cell.value == "Vegetarisch, Normal" + + +def test_location_title_by_kind_missing_levels(): + locations = { + "ward-1": LocationInfo("Station 3", "WARD", None), + "room-1": LocationInfo("Zimmer 12", "ROOM", "ward-1"), + } + assert ( + location_title_by_kind("room-1", ("WARD",), locations) == "Station 3" + ) + assert location_title_by_kind("room-1", ("BED",), locations) is None + assert location_title_by_kind(None, ("BED",), locations) is None + + +def test_number_formatting_uses_german_decimal_separator(): + ctx = make_context() + assert format_cell_text(ExportCell(3.5, "number"), ctx) == "3,5" + assert format_cell_text(ExportCell(42.0, "number"), ctx) == "42" + english = make_context("en-US") + assert format_cell_text(ExportCell(3.5, "number"), english) == "3.5" + + +def test_bool_formatting_is_localized(): + ctx = make_context() + assert format_cell_text(ExportCell(True, "bool"), ctx) == "Ja" + assert format_cell_text(ExportCell(False, "bool"), ctx) == "Nein" + + +def test_formula_injection_is_neutralized(): + assert neutralize_formula("=SUM(A1:A2)") == "'=SUM(A1:A2)" + assert neutralize_formula("+49 123") == "'+49 123" + assert neutralize_formula("@foo") == "'@foo" + assert neutralize_formula("normal") == "normal" + + +def test_render_csv_uses_semicolon_bom_and_crlf(): + ctx = make_context() + rows = [ + [ExportCell("Erika; Musterfrau"), ExportCell(True, "bool")], + [ExportCell("=cmd"), ExportCell(None)], + ] + payload = render_csv(["Name", "Erledigt"], rows, ctx) + assert payload.startswith(b"\xef\xbb\xbf") + text = payload.decode("utf-8-sig") + lines = text.split("\r\n") + assert lines[0] == "Name;Erledigt" + assert lines[1] == '"Erika; Musterfrau";Ja' + assert lines[2] == "'=cmd;" + + +def test_render_xlsx_printable_layout(): + ctx = make_context() + rows = [ + [ + ExportCell("Erika Musterfrau"), + ExportCell(datetime(2026, 7, 1, 12, 0), "datetime"), + ], + [ExportCell("Max Mustermann"), ExportCell(None)], + ] + payload = render_xlsx(["Name", "Fällig"], rows, ctx, "Station 3") + workbook = load_workbook(io.BytesIO(payload)) + sheet = workbook.active + + assert sheet.title == "Station 3" + assert sheet.cell(row=1, column=1).value == "Station 3" + subtitle = sheet.cell(row=2, column=1).value + assert "von Erika Musterfrau" in subtitle + assert sheet.cell(row=4, column=1).value == "Name" + assert sheet.cell(row=5, column=1).value == "Erika Musterfrau" + assert sheet.cell(row=5, column=2).value == datetime(2026, 7, 1, 12, 0) + assert sheet.cell(row=5, column=2).number_format == "DD.MM.YYYY HH:MM" + assert sheet.freeze_panes == "A5" + assert sheet.print_title_rows == "$4:$4" + assert sheet.page_setup.orientation == "landscape" + assert sheet.page_setup.fitToWidth == 1 + assert sheet.row_dimensions[4].height == 26 + assert sheet.row_dimensions[5].height >= 22 + + +def test_slugify_filename_handles_umlauts(): + assert _slugify_filename("Meine Aufgaben – Station 3ä") == ( + "meine-aufgaben-station-3ae" + ) + assert _slugify_filename("///") == "export" + + +@pytest.mark.asyncio +async def test_run_table_export_patients_end_to_end( + db_session, + sample_patient, + sample_user_with_location_access, +): + from api.context import Context + from api.export.schemas import TableExportRequest + from api.export.service import run_table_export + + context = Context(db=db_session, user=sample_user_with_location_access) + request = TableExportRequest.model_validate({ + "format": "csv", + "columns": [ + {"key": "name", "label": "Name"}, + {"key": "state", "label": "Status"}, + {"key": "clinic", "label": "Klinik"}, + ], + "states": ["ADMITTED"], + "locale": "de-DE", + "timezone": "Europe/Berlin", + }) + + result = await run_table_export(context, "patients", request) + + assert result.filename.endswith(".csv") + text = result.content.decode("utf-8-sig") + lines = text.strip().split("\r\n") + assert lines[0] == "Name;Status;Klinik" + assert lines[1] == "John Doe;Aufgenommen;Test Clinic" + + +@pytest.mark.asyncio +async def test_run_table_export_tasks_xlsx_end_to_end( + db_session, + sample_task, + sample_user_with_location_access, +): + from api.context import Context + from api.export.schemas import TableExportRequest + from api.export.service import run_table_export + + context = Context(db=db_session, user=sample_user_with_location_access) + request = TableExportRequest.model_validate({ + "format": "xlsx", + "columns": [ + {"key": "title", "label": "Titel"}, + {"key": "patient", "label": "Patient"}, + {"key": "done", "label": "Erledigt"}, + ], + "title": "Meine Aufgaben", + }) + + result = await run_table_export(context, "tasks", request) + + workbook = load_workbook(io.BytesIO(result.content)) + sheet = workbook.active + assert sheet.cell(row=1, column=1).value == "Meine Aufgaben" + assert "Test User" in sheet.cell(row=2, column=1).value + assert sheet.cell(row=4, column=1).value == "Titel" + assert sheet.cell(row=5, column=1).value == "Test Task" + assert sheet.cell(row=5, column=2).value == "John Doe" + assert sheet.cell(row=5, column=3).value == "Nein" diff --git a/backend/tests/unit/test_export_router.py b/backend/tests/unit/test_export_router.py new file mode 100644 index 00000000..afec2818 --- /dev/null +++ b/backend/tests/unit/test_export_router.py @@ -0,0 +1,51 @@ +from database.session import get_db_session +from fastapi.testclient import TestClient +from main import app + +VALID_BODY = { + "format": "csv", + "columns": [{"key": "title", "label": "Titel"}], +} + + +async def _no_db_session(): + yield None + + +def make_client() -> TestClient: + app.dependency_overrides[get_db_session] = _no_db_session + return TestClient(app) + + +def teardown_function(): + app.dependency_overrides.clear() + + +def test_export_requires_authentication(): + client = make_client() + response = client.post("/export/tasks", json=VALID_BODY) + assert response.status_code == 401 + + +def test_export_rejects_unknown_entity(): + client = make_client() + response = client.post("/export/rooms", json=VALID_BODY) + assert response.status_code == 422 + + +def test_export_rejects_unknown_format(): + client = make_client() + response = client.post( + "/export/tasks", + json={"format": "pdf", "columns": [{"key": "title", "label": "T"}]}, + ) + assert response.status_code == 422 + + +def test_export_requires_columns(): + client = make_client() + response = client.post( + "/export/patients", + json={"format": "csv", "columns": []}, + ) + assert response.status_code == 422 diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml new file mode 100644 index 00000000..83c8f9eb --- /dev/null +++ b/docker-compose.e2e.yml @@ -0,0 +1,122 @@ +# Full stack for proxied e2e tests. +# +# Mirrors the production docker-compose.yml (nginx proxy in front of web, +# backend and Keycloak) but with parameterized image references so CI can run +# the images built for the current commit: +# +# BACKEND_IMAGE=local/tasks-backend:e2e \ +# WEB_IMAGE=local/tasks-web:e2e \ +# PROXY_IMAGE=local/tasks-proxy:e2e \ +# docker compose -f docker-compose.e2e.yml up -d +# +# Then run the proxied tests against http://localhost: +# +# cd tests && E2E_PROXY_TARGET=1 E2E_BASE_URL=http://localhost npx playwright test e2e/proxy-fullstack.spec.ts +# +# No named volumes: the stack is ephemeral by design (fresh database per run). +services: + keycloak-postgres: + image: postgres:15.15 + environment: + POSTGRES_DB: "keycloak" + POSTGRES_USER: "keycloak" + POSTGRES_PASSWORD: "password" + + keycloak: + image: quay.io/keycloak/keycloak:26.4 + environment: + KC_DB: "postgres" + KC_DB_URL: "jdbc:postgresql://keycloak-postgres:5432/keycloak" + KC_DB_USERNAME: "keycloak" + KC_DB_PASSWORD: "password" + + KC_HOSTNAME_STRICT: false + KC_PROXY: edge + KC_HTTP_RELATIVE_PATH: /keycloak + KC_HTTP_ENABLED: true + + KEYCLOAK_ADMIN: "admin" + KEYCLOAK_ADMIN_PASSWORD: "admin" + command: start --import-realm + depends_on: + - keycloak-postgres + volumes: + - "./keycloak:/opt/keycloak/data/import:ro" + + postgres: + image: postgres:15.15 + environment: + POSTGRES_DB: "postgres" + POSTGRES_USER: "postgres" + POSTGRES_PASSWORD: "password" + + redis: + image: redis:8.4 + command: redis-server --requirepass password + + influxdb: + image: influxdb:2.7 + environment: + DOCKER_INFLUXDB_INIT_MODE: "setup" + DOCKER_INFLUXDB_INIT_USERNAME: "admin" + DOCKER_INFLUXDB_INIT_PASSWORD: "password" + DOCKER_INFLUXDB_INIT_ORG: "tasks" + DOCKER_INFLUXDB_INIT_BUCKET: "audit" + DOCKER_INFLUXDB_INIT_ADMIN_TOKEN: "tasks-token-secret" + + backend: + image: ${BACKEND_IMAGE:-ghcr.io/helpwave/tasks-backend:latest} + # the backend runs migrations on boot and exits if postgres is not + # accepting connections yet — retry until the database is up + restart: on-failure + environment: + DATABASE_HOSTNAME: "postgres" + DATABASE_NAME: "postgres" + DATABASE_USERNAME: "postgres" + DATABASE_PASSWORD: "password" + + REDIS_HOSTNAME: "redis" + REDIS_PASSWORD: "password" + + ISSUER_URI: "http://keycloak:8080/keycloak/realms/tasks" + PUBLIC_ISSUER_URI: "http://localhost/keycloak/realms/tasks" + CLIENT_ID: "tasks-backend" + CLIENT_SECRET: "tasks-secret" + + ENV: "development" + SCAFFOLD_DIRECTORY: "/scaffold" + + INFLUXDB_URL: "http://influxdb:8086" + INFLUXDB_TOKEN: "tasks-token-secret" + INFLUXDB_ORG: "tasks" + INFLUXDB_BUCKET: "audit" + volumes: + - "./scaffold:/scaffold:ro" + depends_on: + - postgres + - redis + - influxdb + + web: + image: ${WEB_IMAGE:-ghcr.io/helpwave/tasks-web:latest} + environment: + RUNTIME_GRAPHQL_ENDPOINT: "http://localhost/graphql" + RUNTIME_ISSUER_URI: "http://localhost/keycloak/realms/tasks" + RUNTIME_REDIRECT_URI: "http://localhost/auth/callback" + RUNTIME_POST_LOGOUT_REDIRECT_URI: "http://localhost/" + RUNTIME_CLIENT_ID: "tasks-web" + depends_on: + - backend + + proxy: + image: ${PROXY_IMAGE:-ghcr.io/helpwave/tasks-proxy:latest} + environment: + KEYCLOAK_HOST: keycloak:8080 + BACKEND_HOST: backend:80 + FRONTEND_HOST: web:80 + ports: + - "80:80" + depends_on: + - keycloak + - backend + - web diff --git a/docs/TABLE_EXPORTS.md b/docs/TABLE_EXPORTS.md new file mode 100644 index 00000000..3b5b69f2 --- /dev/null +++ b/docs/TABLE_EXPORTS.md @@ -0,0 +1,101 @@ +# Table exports (CSV / XLSX) + +## Why + +List screens use virtualized infinite scrolling, so the browser only ever +renders a window of rows. Printing the DOM therefore breaks down for large +lists. Instead, every table view can be exported server-side as **CSV** (raw +data, development friendly) or **XLSX** (a styled, print-ready ward list) that +always contains the *complete* result set of the current view — not just the +rows that happen to be rendered. + +## Architecture + +``` +TaskList / PatientList (web) + └─ TableExportMenu ── POST {backend}/export/{tasks|patients} + └─ routers/export.py (auth via get_context) + └─ api/export/service.py + ├─ reuses TaskQuery.tasks / PatientQuery.patients + │ (same visibility, filters, sorts, search as the UI) + ├─ api/export/cells.py (value formatting) + └─ api/export/render.py (CSV / XLSX) +``` + +The frontend sends exactly what the table currently shows: + +- **columns** – the visible columns in their user-defined order, each as + `{ key, label }`. Labels are the already-localized column headers, so the + backend never needs UI translations for headers. +- **filters / sorts / search** – the same wire format the GraphQL list + queries use (`QueryFilterClauseInput`, `QuerySortClauseInput`, + `QuerySearchInput`), produced by `web/utils/tableStateToApi.ts`. +- **scope** – the list-specific query variables (`rootLocationIds`, + `assigneeId`, `locationNodeId`, `states`, …), so exports honour the same + location scoping and saved-view parameters as the list itself. +- **locale / timezone** – from the active UI locale and the configured app + timezone (default `Europe/Berlin`). + +The backend authenticates the request with the same Keycloak bearer token +used for GraphQL (`get_context`), rebuilds the identical list query through +the existing resolvers (including the authorization CTEs and the unified +filter/sort/search engine) and runs it **without UI pagination**, capped at +`EXPORT_MAX_ROWS` (env var, default `10000`). + +## Formatting rules + +Implemented in `backend/api/export/cells.py` / `render.py`: + +- Timestamps are stored UTC-naive; they are converted to the requested + timezone and formatted per locale (`de-*` → `DD.MM.YYYY HH:MM`, otherwise + ISO). In XLSX they are written as real date cells with a number format, so + sorting/filtering keeps working in Excel. +- Date-only due dates (stored as the UTC `23:59:59.999` sentinel, see + `web/utils/dueDate.ts`) keep their calendar date and are rendered without a + time component. +- Enums (patient state, sex) and booleans are rendered as localized labels + (`de` and `en` label sets, `en` fallback for other locales). +- Aggregated cells replicate the UI: patient name, assignee names (team + fallback), clinic/position titles, ward/room/bed columns derived from the + position's ancestor chain, task progress (`closed/total`), birthdate with + age, patient update date (max of patient and task updates), and dynamic + `property_*` columns for all field types. +- Select and multi-select property values are stored as + `-opt-` keys; the export resolves them to the + definition's option labels exactly like `PropertyCell.tsx`, joining + multi-select values inline with `, `. +- CSV is `;`-separated, CRLF, UTF-8 with BOM — opens correctly in German + Excel. Decimal numbers use a comma for `de-*` locales. +- Text cells starting with `=`, `+`, `-`, `@` are apostrophe-prefixed to + prevent CSV/formula injection. + +## XLSX print template + +The XLSX renderer produces a ward list that prints well without any manual +setup: title + generation block (timestamp, timezone, **exporting user** and +row count — repeated in the print footer), styled header row, zebra striping, +column widths estimated from content, row heights scaled to wrapped content +(minimum 22pt), freeze pane below the header, auto-filter, A4 **landscape by +default**, fit-to-width scaling, the header row repeated on every printed +page, and a footer with generation info and page numbers. + +## Frontend integration + +- `web/utils/tableExport.ts` – request building, column collection + (visibility + order + localized labels) and the download trigger. +- `web/components/tables/TableExportMenu.tsx` – the export dropdown (Excel / + CSV) shown next to the column switcher. +- `PatientList` builds its export request itself (it owns its query state); + `TaskList` receives the page-level scope via the `exportScope` / + `exportTitle` props (used by `/tasks` and `/view/[uid]`). +- The patient detail's tasks tab (`PatientTasksView`) exports the patient's + todo list with a fixed column set, scoped via `patientId`. + +## Testing + +- `backend/tests/unit/test_export.py` – formatting, localization, timezone + and sentinel handling, CSV/XLSX rendering, end-to-end service runs against + the in-memory DB fixtures. +- `backend/tests/unit/test_export_router.py` – auth and payload validation. +- `web/utils/tableExport.test.ts` – column collection and endpoint + derivation. diff --git a/proxy/nginx.conf b/proxy/nginx.conf index ec8595c2..271d3c4c 100644 --- a/proxy/nginx.conf +++ b/proxy/nginx.conf @@ -25,7 +25,7 @@ http { listen 80; server_name localhost; - location ~ ^/(graphql|callback)$ { + location ~ ^/(graphql|callback|export(/.*)?)$ { proxy_pass http://backend_upstream; proxy_set_header Host $host; diff --git a/tests/e2e/filter-sort-patients.spec.ts b/tests/e2e/filter-sort-patients.spec.ts new file mode 100644 index 00000000..60175353 --- /dev/null +++ b/tests/e2e/filter-sort-patients.spec.ts @@ -0,0 +1,204 @@ +import { test, expect } from '@playwright/test' +import { mockBackend, seedAuth, type PatientFixture } from './support/mockBackend' +import { + ROW_SELECTOR, + addSorting, + addTagFilter, + addTextFilter, + beginAddFilter, + commitFilterPopup, + openFilterPanel, + openSortingPanel, + seedStoredSelection, + setSortDirection, + visibleRowFirstCells, +} from './support/filterSortUi' + +const BASE = process.env.E2E_BASE_URL || 'http://localhost:3000' + +const ROOT_LOCATIONS = [ + { id: 'root-1', title: 'General Hospital', kind: 'CLINIC' }, +] + +const ALLERGY_DEF = { id: 'def-allergy', name: 'Allergy', fieldType: 'FIELD_TYPE_TEXT', options: [] } + +/** + * Small deterministic fixture. Lastnames are unique and alphabetically ordered + * (Adams < Baker < ... < Hodge) so expected sort orders are easy to state. + * The patient list renders the name as "lastname, firstname". + */ +const PATIENTS: PatientFixture[] = [ + { id: 'p-01', firstname: 'Ivy', lastname: 'Adams', state: 'ADMITTED', sex: 'FEMALE', birthdate: '1955-03-10', updateDate: '2026-06-05T10:00:00Z', properties: [{ id: 'v-01', definitionId: ALLERGY_DEF.id, textValue: 'Peanuts' }] }, + { id: 'p-02', firstname: 'Hank', lastname: 'Baker', state: 'WAIT', sex: 'MALE', birthdate: '1980-01-01', updateDate: '2026-06-01T10:00:00Z', properties: [{ id: 'v-02', definitionId: ALLERGY_DEF.id, textValue: 'Latex' }] }, + { id: 'p-03', firstname: 'Gwen', lastname: 'Clark', state: 'ADMITTED', sex: 'FEMALE', birthdate: '1990-07-20', updateDate: '2026-06-03T10:00:00Z', properties: [{ id: 'v-03', definitionId: ALLERGY_DEF.id, textValue: 'Aspirin' }] }, + { id: 'p-04', firstname: 'Finn', lastname: 'Davis', state: 'DISCHARGED', sex: 'MALE', birthdate: '1970-11-30', updateDate: null, properties: [] }, + { id: 'p-05', firstname: 'Eve', lastname: 'Evans', state: 'ADMITTED', sex: 'FEMALE', birthdate: '2000-05-15', updateDate: '2026-06-04T10:00:00Z', properties: [{ id: 'v-05', definitionId: ALLERGY_DEF.id, textValue: 'Pollen' }] }, + { id: 'p-06', firstname: 'Dan', lastname: 'Fischer', state: 'DEAD', sex: 'MALE', birthdate: '1940-02-02', updateDate: '2026-05-20T10:00:00Z', properties: [] }, + { id: 'p-07', firstname: 'Cara', lastname: 'Gray', state: 'WAIT', sex: 'UNKNOWN', birthdate: '1985-09-09', updateDate: '2026-06-02T10:00:00Z', properties: [{ id: 'v-07', definitionId: ALLERGY_DEF.id, textValue: 'Iodine' }] }, + { id: 'p-08', firstname: 'Ben', lastname: 'Hodge', state: 'ADMITTED', sex: 'MALE', birthdate: '1965-12-24', updateDate: '2026-06-06T10:00:00Z', properties: [{ id: 'v-08', definitionId: ALLERGY_DEF.id, textValue: 'None' }] }, +] + +const displayName = (p: PatientFixture) => `${p.lastname}, ${p.firstname}` +const byLastname = [...PATIENTS].sort((a, b) => a.lastname.localeCompare(b.lastname)) + +async function gotoPatients(page: import('@playwright/test').Page) { + await page.goto(`${BASE}/patients`) + await expect(page.locator(ROW_SELECTOR).first()).toBeVisible({ timeout: 20000 }) +} + +async function expectRowNames(page: import('@playwright/test').Page, expected: string[]) { + await expect.poll(() => visibleRowFirstCells(page), { timeout: 15000 }).toEqual(expected) +} + +test.describe('patient list filtering and sorting', () => { + test.beforeEach(async ({ page }) => { + await seedAuth(page) + await seedStoredSelection(page, ['root-1']) + }) + + test('sorts by name ascending and descending', async ({ page }) => { + const handle = await mockBackend(page, { + patients: PATIENTS, + propertyDefinitions: [ALLERGY_DEF], + rootLocations: ROOT_LOCATIONS, + }) + await gotoPatients(page) + + await openSortingPanel(page) + await addSorting(page, 'Name') + await expectRowNames(page, byLastname.map(displayName)) + + // the request must carry a server-side sort clause for `name` + const lastSorted = [...handle.operations].reverse().find(o => o.name === 'GetPatients') + expect(lastSorted?.variables['sorts']).toEqual([{ fieldKey: 'name', direction: 'ASC' }]) + + await setSortDirection(page, 'Name', 'DESC') + await expectRowNames(page, [...byLastname].reverse().map(displayName)) + }) + + test('sorts by birthdate', async ({ page }) => { + await mockBackend(page, { + patients: PATIENTS, + propertyDefinitions: [ALLERGY_DEF], + rootLocations: ROOT_LOCATIONS, + }) + await gotoPatients(page) + + await openSortingPanel(page) + await addSorting(page, 'Birthdate') + const byBirthdate = [...PATIENTS].sort((a, b) => a.birthdate!.localeCompare(b.birthdate!)) + await expectRowNames(page, byBirthdate.map(displayName)) + + await setSortDirection(page, 'Birthdate', 'DESC') + await expectRowNames(page, [...byBirthdate].reverse().map(displayName)) + }) + + test('sorts by a text property column (Allergy)', async ({ page }) => { + await mockBackend(page, { + patients: PATIENTS, + propertyDefinitions: [ALLERGY_DEF], + rootLocations: ROOT_LOCATIONS, + }) + await gotoPatients(page) + + await openSortingPanel(page) + await addSorting(page, 'Allergy') + // ascending with nulls first (backend: asc().nulls_first()) + const withValue = PATIENTS.filter(p => p.properties?.length) + .sort((a, b) => a.properties![0]!.textValue!.localeCompare(b.properties![0]!.textValue!)) + const withoutValue = PATIENTS.filter(p => !p.properties?.length) + .sort((a, b) => a.id.localeCompare(b.id)) + await expectRowNames(page, [...withoutValue, ...withValue].map(displayName)) + }) + + test('filters by name text (contains)', async ({ page }) => { + const handle = await mockBackend(page, { + patients: PATIENTS, + propertyDefinitions: [ALLERGY_DEF], + rootLocations: ROOT_LOCATIONS, + }) + await gotoPatients(page) + + await openFilterPanel(page) + await addTextFilter(page, 'Name', 'Baker') + await expectRowNames(page, ['Baker, Hank']) + + const lastFiltered = [...handle.operations].reverse().find(o => o.name === 'GetPatients') + const filters = lastFiltered?.variables['filters'] as Array<{ fieldKey: string, operator: string }> | undefined + expect(filters).toBeTruthy() + expect(filters![0]).toMatchObject({ fieldKey: 'name', operator: 'CONTAINS' }) + }) + + test('filters by name text (does not contain)', async ({ page }) => { + const handle = await mockBackend(page, { + patients: PATIENTS, + propertyDefinitions: [ALLERGY_DEF], + rootLocations: ROOT_LOCATIONS, + }) + await gotoPatients(page) + + await openFilterPanel(page) + await beginAddFilter(page, 'Name') + await page.locator('[data-name="filter-operator-select"]').click() + await page.getByRole('option', { name: 'Not contains', exact: true }).click() + await page.getByPlaceholder('Value').fill('Baker') + await commitFilterPopup(page) + + // everyone except Baker (a "does not contain" filter must exclude + // matching rows, not behave like "not equals") + await expectRowNames(page, byLastname.filter(p => p.lastname !== 'Baker').map(displayName)) + + const last = [...handle.operations].reverse().find(o => o.name === 'GetPatients') + const filters = last?.variables['filters'] as Array<{ fieldKey: string, operator: string }> | undefined + expect(filters?.[0]).toMatchObject({ fieldKey: 'name', operator: 'NOT_CONTAINS' }) + }) + + test('filters by patient state (tag filter)', async ({ page }) => { + await mockBackend(page, { + patients: PATIENTS, + propertyDefinitions: [ALLERGY_DEF], + rootLocations: ROOT_LOCATIONS, + }) + await gotoPatients(page) + + await openFilterPanel(page) + await addTagFilter(page, 'Status', ['Admitted']) + + const admitted = byLastname.filter(p => p.state === 'ADMITTED') + await expectRowNames(page, admitted.map(displayName)) + }) + + test('filters by sex (tag filter) and combines with sorting', async ({ page }) => { + await mockBackend(page, { + patients: PATIENTS, + propertyDefinitions: [ALLERGY_DEF], + rootLocations: ROOT_LOCATIONS, + }) + await gotoPatients(page) + + await openFilterPanel(page) + await addTagFilter(page, 'Sex', ['Male']) + const males = byLastname.filter(p => p.sex === 'MALE') + await expectRowNames(page, males.map(displayName)) + + await openSortingPanel(page) + await addSorting(page, 'Birthdate') + const malesByBirthdate = [...males].sort((a, b) => a.birthdate!.localeCompare(b.birthdate!)) + await expectRowNames(page, malesByBirthdate.map(displayName)) + }) + + test('search narrows the list server-side', async ({ page }) => { + const handle = await mockBackend(page, { + patients: PATIENTS, + propertyDefinitions: [ALLERGY_DEF], + rootLocations: ROOT_LOCATIONS, + }) + await gotoPatients(page) + + await page.getByPlaceholder('Search').fill('Gray') + await expectRowNames(page, ['Gray, Cara']) + + const lastSearch = [...handle.operations].reverse().find(o => o.name === 'GetPatients') + expect(lastSearch?.variables['search']).toMatchObject({ searchText: 'Gray' }) + }) +}) diff --git a/tests/e2e/filter-sort-tasks.spec.ts b/tests/e2e/filter-sort-tasks.spec.ts new file mode 100644 index 00000000..37da4f36 --- /dev/null +++ b/tests/e2e/filter-sort-tasks.spec.ts @@ -0,0 +1,213 @@ +import { test, expect } from '@playwright/test' +import { mockBackend, seedAuth, type PatientFixture, type TaskFixture } from './support/mockBackend' +import { + ROW_SELECTOR, + addSorting, + addTagFilter, + addTextFilter, + openFilterPanel, + openSortingPanel, + removeSorting, + seedStoredSelection, + setSortDirection, +} from './support/filterSortUi' + +const BASE = process.env.E2E_BASE_URL || 'http://localhost:3000' + +const ROOT_LOCATIONS = [ + { id: 'root-1', title: 'General Hospital', kind: 'CLINIC' }, +] + +const PATIENTS: PatientFixture[] = [ + { id: 'p-01', firstname: 'Ivy', lastname: 'Adams', state: 'ADMITTED', sex: 'FEMALE', birthdate: '1955-03-10' }, + { id: 'p-02', firstname: 'Hank', lastname: 'Baker', state: 'ADMITTED', sex: 'MALE', birthdate: '1980-01-01' }, + { id: 'p-03', firstname: 'Gwen', lastname: 'Clark', state: 'WAIT', sex: 'FEMALE', birthdate: '1990-07-20' }, +] + +/** + * All tasks are open (not done) unless stated otherwise, assigned to the + * mock session user `user-1` (the tasks page shows "my tasks"). Titles are + * unique so rows are identifiable. Patient query names ("firstname lastname"): + * p-01 = "Ivy Adams", p-02 = "Hank Baker", p-03 = "Gwen Clark". + */ +const TASKS: TaskFixture[] = [ + { id: 't-01', title: 'Draw blood', dueDate: '2026-07-08T09:00:00Z', priority: 'P2', patientId: 'p-02', assigneeIds: ['user-1'] }, + { id: 't-02', title: 'Administer meds', dueDate: '2026-07-06T09:00:00Z', priority: 'P1', patientId: 'p-01', assigneeIds: ['user-1'] }, + { id: 't-03', title: 'Check vitals', dueDate: '2026-07-07T09:00:00Z', priority: 'P4', patientId: 'p-03', assigneeIds: ['user-1'] }, + { id: 't-04', title: 'Update chart', dueDate: null, priority: null, patientId: null, assigneeIds: ['user-1'] }, + { id: 't-05', title: 'Book MRI', dueDate: '2026-07-05T09:00:00Z', priority: 'P3', patientId: 'p-01', assigneeIds: ['user-1'], done: true }, +] + +async function gotoTasks(page: import('@playwright/test').Page) { + await page.goto(`${BASE}/tasks`) + await expect(page.locator(ROW_SELECTOR).first()).toBeVisible({ timeout: 20000 }) +} + +/** Task title column: cell 0 is the done checkbox, cell 1 the title. */ +async function rowTitles(page: import('@playwright/test').Page): Promise { + return page.$$eval(ROW_SELECTOR, (rows) => + rows.map((row) => row.querySelectorAll('td')[1]?.textContent?.trim() ?? '')) +} + +async function expectRowTitles(page: import('@playwright/test').Page, expected: string[]) { + await expect.poll(() => rowTitles(page), { timeout: 15000 }).toEqual(expected) +} + +test.describe('task list filtering and sorting', () => { + test.beforeEach(async ({ page }) => { + await seedAuth(page) + await seedStoredSelection(page, ['root-1']) + }) + + test('default order: open tasks by due date first, done tasks last', async ({ page }) => { + const handle = await mockBackend(page, { + patients: PATIENTS, + tasks: TASKS, + rootLocations: ROOT_LOCATIONS, + }) + await gotoTasks(page) + + // default sorting is done asc (open first), then dueDate asc (nulls first) + await expectRowTitles(page, ['Update chart', 'Administer meds', 'Check vitals', 'Draw blood', 'Book MRI']) + const last = [...handle.operations].reverse().find(o => o.name === 'GetTasks') + expect(last?.variables['sorts']).toEqual([ + { fieldKey: 'done', direction: 'ASC' }, + { fieldKey: 'dueDate', direction: 'ASC' }, + ]) + }) + + test('sorts by patient name ascending and descending', async ({ page }) => { + const handle = await mockBackend(page, { + patients: PATIENTS, + tasks: TASKS, + rootLocations: ROOT_LOCATIONS, + }) + await gotoTasks(page) + + await openSortingPanel(page) + // replace the default sorting with patient so the assertion is unambiguous + await removeSorting(page, 'Done') + await removeSorting(page, 'Due Date') + await addSorting(page, 'Patient') + + // ascending by patient display name ("firstname lastname": + // Gwen Clark < Hank Baker < Ivy Adams), tasks without patient first + await expectRowTitles(page, [ + 'Update chart', // no patient (nulls first) + 'Check vitals', // Gwen Clark + 'Draw blood', // Hank Baker + 'Administer meds', // Ivy Adams (id tiebreak) + 'Book MRI', // Ivy Adams + ]) + + const lastSorted = [...handle.operations].reverse().find(o => o.name === 'GetTasks') + expect(lastSorted?.variables['sorts']).toEqual([{ fieldKey: 'patient', direction: 'ASC' }]) + + await setSortDirection(page, 'Patient', 'DESC') + await expectRowTitles(page, [ + 'Administer meds', // Ivy Adams (id tiebreak) + 'Book MRI', // Ivy Adams + 'Draw blood', // Hank Baker + 'Check vitals', // Gwen Clark + 'Update chart', // no patient (nulls last) + ]) + }) + + test('sorts by title', async ({ page }) => { + await mockBackend(page, { + patients: PATIENTS, + tasks: TASKS, + rootLocations: ROOT_LOCATIONS, + }) + await gotoTasks(page) + + await openSortingPanel(page) + await removeSorting(page, 'Done') + await removeSorting(page, 'Due Date') + await addSorting(page, 'Title') + await expectRowTitles(page, ['Administer meds', 'Book MRI', 'Check vitals', 'Draw blood', 'Update chart']) + + await setSortDirection(page, 'Title', 'DESC') + await expectRowTitles(page, ['Update chart', 'Draw blood', 'Check vitals', 'Book MRI', 'Administer meds']) + }) + + test('sorts by priority', async ({ page }) => { + await mockBackend(page, { + patients: PATIENTS, + tasks: TASKS, + rootLocations: ROOT_LOCATIONS, + }) + await gotoTasks(page) + + await openSortingPanel(page) + await removeSorting(page, 'Done') + await removeSorting(page, 'Due Date') + await addSorting(page, 'Priority') + + // P1 < P2 < P3 < P4 < none (backend priority case ordering) + await expectRowTitles(page, ['Administer meds', 'Draw blood', 'Book MRI', 'Check vitals', 'Update chart']) + }) + + test('filters by title text (contains)', async ({ page }) => { + const handle = await mockBackend(page, { + patients: PATIENTS, + tasks: TASKS, + rootLocations: ROOT_LOCATIONS, + }) + await gotoTasks(page) + + await openFilterPanel(page) + await addTextFilter(page, 'Title', 'blood') + await expectRowTitles(page, ['Draw blood']) + + const last = [...handle.operations].reverse().find(o => o.name === 'GetTasks') + const filters = last?.variables['filters'] as Array<{ fieldKey: string, operator: string }> | undefined + expect(filters?.[0]).toMatchObject({ fieldKey: 'title', operator: 'CONTAINS' }) + }) + + test('filters by priority (tag filter)', async ({ page }) => { + await mockBackend(page, { + patients: PATIENTS, + tasks: TASKS, + rootLocations: ROOT_LOCATIONS, + }) + await gotoTasks(page) + + await openFilterPanel(page) + // app labels: P1 Normal, P2 Medium, P3 High, P4 Critical + await addTagFilter(page, 'Priority', ['Normal', 'Medium']) + await expectRowTitles(page, ['Administer meds', 'Draw blood']) + }) + + test('filters by patient name text', async ({ page }) => { + await mockBackend(page, { + patients: PATIENTS, + tasks: TASKS, + rootLocations: ROOT_LOCATIONS, + }) + await gotoTasks(page) + + await openFilterPanel(page) + await addTextFilter(page, 'Patient', 'Adams') + // both tasks of Ivy Adams, in default order (done last) + await expectRowTitles(page, ['Administer meds', 'Book MRI']) + }) + + test('search matches title and patient name server-side', async ({ page }) => { + const handle = await mockBackend(page, { + patients: PATIENTS, + tasks: TASKS, + rootLocations: ROOT_LOCATIONS, + }) + await gotoTasks(page) + + await page.getByPlaceholder('Search').fill('vitals') + await expectRowTitles(page, ['Check vitals']) + + const last = [...handle.operations].reverse().find(o => o.name === 'GetTasks') + expect(last?.variables['search']).toMatchObject({ searchText: 'vitals' }) + + await page.getByPlaceholder('Search').fill('Baker') + await expectRowTitles(page, ['Draw blood']) + }) +}) diff --git a/tests/e2e/filter-sort-views.spec.ts b/tests/e2e/filter-sort-views.spec.ts new file mode 100644 index 00000000..65677d6e --- /dev/null +++ b/tests/e2e/filter-sort-views.spec.ts @@ -0,0 +1,281 @@ +import { test, expect, type Page } from '@playwright/test' +import { mockBackend, seedAuth, type PatientFixture, type SavedViewFixture, type TaskFixture } from './support/mockBackend' +import { + ROW_SELECTOR, + addSorting, + addSingleTagEqualsFilter, + beginAddFilter, + commitFilterPopup, + openFilterPanel, + openSortingPanel, + seedStoredSelection, + visibleRowFirstCells, +} from './support/filterSortUi' + +const BASE = process.env.E2E_BASE_URL || 'http://localhost:3000' + +const ROOT_LOCATIONS = [ + { id: 'root-1', title: 'General Hospital', kind: 'CLINIC' }, +] + +const PATIENTS: PatientFixture[] = [ + { id: 'p-01', firstname: 'Ivy', lastname: 'Adams', state: 'ADMITTED', sex: 'FEMALE', birthdate: '1955-03-10' }, + { id: 'p-02', firstname: 'Hank', lastname: 'Baker', state: 'WAIT', sex: 'MALE', birthdate: '1980-01-01' }, + { id: 'p-03', firstname: 'Gwen', lastname: 'Clark', state: 'ADMITTED', sex: 'FEMALE', birthdate: '1990-07-20' }, + { id: 'p-04', firstname: 'Finn', lastname: 'Davis', state: 'DISCHARGED', sex: 'MALE', birthdate: '1970-11-30' }, +] + +/** + * Related tasks: the latest task update per patient determines the patient's + * "Updated" value in a task view's patients panel. Deliberately ordered so + * update-date order (Clark < Baker < Adams) differs from name order. + */ +const TASKS: TaskFixture[] = [ + { id: 't-01', title: 'Draw blood', priority: 'P2', patientId: 'p-01', assigneeIds: ['user-1'], dueDate: '2026-07-08T09:00:00Z', updateDate: '2026-06-03T10:00:00Z' }, + { id: 't-02', title: 'Administer meds', priority: 'P1', patientId: 'p-02', assigneeIds: ['user-1'], dueDate: '2026-07-06T09:00:00Z', updateDate: '2026-06-05T10:00:00Z' }, + { id: 't-03', title: 'Check vitals', priority: 'P4', patientId: 'p-03', assigneeIds: ['user-1'], dueDate: '2026-07-07T09:00:00Z', updateDate: '2026-06-01T10:00:00Z' }, + { id: 't-04', title: 'Update chart', priority: null, patientId: 'p-01', assigneeIds: ['user-1'], dueDate: null, updateDate: '2026-06-06T10:00:00Z' }, +] + +const stateFilterDefinition = JSON.stringify([ + { + id: 'state', + value: { dataType: 'singleTag', operator: 'contains', parameter: { uuidValues: ['ADMITTED'] } }, + }, +]) + +// legacy serialization: tag selection stored under `searchTags` +const legacyStateFilterDefinition = JSON.stringify([ + { + id: 'state', + value: { dataType: 'singleTag', operator: 'contains', parameter: { searchTags: ['ADMITTED'] } }, + }, +]) + +const priorityFilterDefinition = JSON.stringify([ + { + id: 'priority', + value: { dataType: 'singleTag', operator: 'contains', parameter: { uuidValues: ['P1', 'P2'] } }, + }, +]) + +function patientView(overrides: Partial): SavedViewFixture { + return { + id: 'view-patient', + name: 'Admitted patients', + baseEntityType: 'PATIENT', + filterDefinition: stateFilterDefinition, + sortDefinition: JSON.stringify([{ id: 'name', desc: true }]), + parameters: JSON.stringify({ rootLocationIds: ['root-1'] }), + ...overrides, + } +} + +function taskView(overrides: Partial): SavedViewFixture { + return { + id: 'view-task', + name: 'Important tasks', + baseEntityType: 'TASK', + filterDefinition: priorityFilterDefinition, + sortDefinition: JSON.stringify([{ id: 'patient', desc: false }]), + parameters: JSON.stringify({ rootLocationIds: ['root-1'], assigneeId: 'user-1' }), + ...overrides, + } +} + +async function gotoView(page: Page, id: string) { + await page.goto(`${BASE}/view/${id}`) + await expect(page.locator(ROW_SELECTOR).first()).toBeVisible({ timeout: 20000 }) +} + +async function expectFirstCells(page: Page, expected: string[]) { + await expect.poll(() => visibleRowFirstCells(page), { timeout: 15000 }).toEqual(expected) +} + +/** Task rows: cell 0 is the done checkbox, cell 1 the title. */ +async function taskRowTitles(page: Page): Promise { + return page.$$eval(ROW_SELECTOR, (rows) => + rows.map((row) => row.querySelectorAll('td')[1]?.textContent?.trim() ?? '')) +} + +test.describe('custom views (saved views) filtering and sorting', () => { + test.beforeEach(async ({ page }) => { + await seedAuth(page) + await seedStoredSelection(page, ['root-1']) + }) + + test('patient view applies its stored filter and sorting', async ({ page }) => { + const handle = await mockBackend(page, { + patients: PATIENTS, + tasks: TASKS, + savedViews: [patientView({})], + rootLocations: ROOT_LOCATIONS, + }) + await gotoView(page, 'view-patient') + + // only admitted patients, name descending (Clark > Adams) + await expectFirstCells(page, ['Clark, Gwen', 'Adams, Ivy']) + await expect(page.getByRole('button', { name: 'Filter (1)' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Sorting (1)' })).toBeVisible() + + const last = [...handle.operations].reverse().find(o => o.name === 'GetPatients') + expect(last?.variables['sorts']).toEqual([{ fieldKey: 'name', direction: 'DESC' }]) + const filters = last?.variables['filters'] as Array<{ fieldKey: string, operator: string, value: { uuidValues?: string[] } }> + expect(filters?.[0]).toMatchObject({ fieldKey: 'state', operator: 'IN' }) + }) + + test('legacy patient view (searchTags serialization) still filters', async ({ page }) => { + await mockBackend(page, { + patients: PATIENTS, + tasks: TASKS, + savedViews: [patientView({ filterDefinition: legacyStateFilterDefinition, sortDefinition: '[]' })], + rootLocations: ROOT_LOCATIONS, + }) + await gotoView(page, 'view-patient') + + await expectFirstCells(page, ['Adams, Ivy', 'Clark, Gwen']) + }) + + test('task view applies its stored filter and sorting (sort by patient)', async ({ page }) => { + const handle = await mockBackend(page, { + patients: PATIENTS, + tasks: TASKS, + savedViews: [taskView({})], + rootLocations: ROOT_LOCATIONS, + }) + await gotoView(page, 'view-task') + + // P1/P2 tasks only, ascending by patient query name + // (Hank Baker < Ivy Adams): Administer meds (Baker), Draw blood (Adams) + await expect.poll(() => taskRowTitles(page), { timeout: 15000 }) + .toEqual(['Administer meds', 'Draw blood']) + + const last = [...handle.operations].reverse().find(o => o.name === 'GetTasks') + expect(last?.variables['sorts']).toEqual([{ fieldKey: 'patient', direction: 'ASC' }]) + }) + + test('saving a new view from the patients page stores filters and sorting', async ({ page }) => { + const handle = await mockBackend(page, { + patients: PATIENTS, + tasks: [], + rootLocations: ROOT_LOCATIONS, + }) + await page.goto(`${BASE}/patients`) + await expect(page.locator(ROW_SELECTOR).first()).toBeVisible({ timeout: 20000 }) + + await openFilterPanel(page) + await beginAddFilter(page, 'Name') + await page.getByPlaceholder('Value').fill('a') + await commitFilterPopup(page) + await openSortingPanel(page) + await addSorting(page, 'Birthdate') + + await page.getByRole('button', { name: 'Save as new quick access' }).click() + const dialog = page.getByRole('dialog').last() + await dialog.locator('input').fill('My saved patients') + await dialog.getByRole('button', { name: 'Add', exact: true }).click() + + await expect.poll(() => handle.mutations.filter(m => m.name === 'CreateSavedView').length).toBe(1) + const created = handle.mutations.find(m => m.name === 'CreateSavedView')! + const data = created.variables['data'] as Record + expect(data['name']).toBe('My saved patients') + expect(data['baseEntityType']).toBe('PATIENT') + expect(JSON.parse(data['sortDefinition'])).toEqual([{ id: 'birthdate', desc: false }]) + const storedFilters = JSON.parse(data['filterDefinition']) as Array<{ id: string, value: { operator: string } }> + expect(storedFilters[0]).toMatchObject({ id: 'name', value: { operator: 'contains' } }) + + // the stored view is immediately usable + await gotoView(page, 'view-1') + // names containing 'a': all four patients; sorted by birthdate asc + await expectFirstCells(page, ['Adams, Ivy', 'Davis, Finn', 'Baker, Hank', 'Clark, Gwen']) + }) + + test('overwriting a view persists the modified sorting', async ({ page }) => { + const handle = await mockBackend(page, { + patients: PATIENTS, + tasks: TASKS, + savedViews: [patientView({ sortDefinition: '[]' })], + rootLocations: ROOT_LOCATIONS, + }) + await gotoView(page, 'view-patient') + await expectFirstCells(page, ['Adams, Ivy', 'Clark, Gwen']) + + await openSortingPanel(page) + await addSorting(page, 'Name') + await page.getByRole('button', { name: 'Name', exact: true }).click() + await page.getByRole('button', { name: 'DESC', exact: true }).click() + await page.keyboard.press('Escape') + await expectFirstCells(page, ['Clark, Gwen', 'Adams, Ivy']) + + await page.getByRole('button', { name: 'Save quick access' }).click() + await page.getByText('Overwrite current quick access').click() + + await expect.poll(() => handle.mutations.filter(m => m.name === 'UpdateSavedView').length).toBe(1) + const update = handle.mutations.find(m => m.name === 'UpdateSavedView')! + const data = update.variables['data'] as Record + expect(JSON.parse(data['sortDefinition'])).toEqual([{ id: 'name', desc: true }]) + }) + + test('discarding view changes restores the stored filter and sorting', async ({ page }) => { + await mockBackend(page, { + patients: PATIENTS, + tasks: TASKS, + savedViews: [patientView({ sortDefinition: JSON.stringify([{ id: 'name', desc: false }]) })], + rootLocations: ROOT_LOCATIONS, + }) + await gotoView(page, 'view-patient') + await expectFirstCells(page, ['Adams, Ivy', 'Clark, Gwen']) + + // deviate from the stored view: drop the state filter + await openFilterPanel(page) + await page.getByRole('button', { name: /Status/ }).click() + await page.getByRole('button', { name: 'Remove filter' }).click() + await expectFirstCells(page, ['Adams, Ivy', 'Baker, Hank', 'Clark, Gwen', 'Davis, Finn']) + + await page.getByRole('button', { name: 'Discard changes' }).click() + await expectFirstCells(page, ['Adams, Ivy', 'Clark, Gwen']) + }) + + test('task view: related patients panel sorts by Updated (latest task update)', async ({ page }) => { + await mockBackend(page, { + patients: PATIENTS, + tasks: TASKS, + savedViews: [taskView({ filterDefinition: '[]', sortDefinition: '[]' })], + rootLocations: ROOT_LOCATIONS, + }) + await gotoView(page, 'view-task') + + await page.getByRole('tab', { name: 'Patients' }).click() + // derived from tasks: Adams (t-01,t-04), Baker (t-02), Clark (t-03), + // default order by name + await expectFirstCells(page, ['Adams, Ivy', 'Baker, Hank', 'Clark, Gwen']) + + await openSortingPanel(page) + await addSorting(page, 'Updated') + // ascending by latest related-task update: + // Clark (06-01) < Baker (06-05) < Adams (06-06) + await expectFirstCells(page, ['Clark, Gwen', 'Baker, Hank', 'Adams, Ivy']) + }) + + test('patient view: related tasks panel filters by priority with the equals operator', async ({ page }) => { + await mockBackend(page, { + patients: PATIENTS, + tasks: TASKS, + savedViews: [patientView({ filterDefinition: '[]', sortDefinition: '[]' })], + rootLocations: ROOT_LOCATIONS, + }) + await gotoView(page, 'view-patient') + + await page.getByRole('tab', { name: 'Tasks' }).click() + // tasks of admitted/waiting patients: all four fixtures + await expect.poll(() => taskRowTitles(page), { timeout: 15000 }) + .toEqual(['Update chart', 'Administer meds', 'Check vitals', 'Draw blood']) + + await openFilterPanel(page) + await addSingleTagEqualsFilter(page, 'Priority', 'Normal') + + await expect(page.locator('button:visible', { hasText: 'Filter (1)' })).toBeVisible() + await expect.poll(() => taskRowTitles(page), { timeout: 15000 }) + .toEqual(['Administer meds']) + }) +}) diff --git a/tests/e2e/patient-table.spec.ts b/tests/e2e/patient-table.spec.ts index 3962df5a..4b09a11d 100644 --- a/tests/e2e/patient-table.spec.ts +++ b/tests/e2e/patient-table.spec.ts @@ -308,6 +308,55 @@ test.describe('patient table (patient list)', () => { const table = page.locator('table[data-name="table"]') await expect(table).toHaveAttribute('data-column-sizing', 'natural') expect(await table.evaluate((el) => (el as HTMLElement).style.width)).toBe('') - expect(await table.evaluate((el) => getComputedStyle(el).tableLayout)).toBe('auto') + await expect(table).toHaveAttribute('data-natural-locked', '') + expect(await table.evaluate((el) => getComputedStyle(el).tableLayout)).toBe('fixed') + }) + + test('column resize drags clamp to the minimum width but allow widening', async ({ page }) => { + await page.setViewportSize({ width: 1600, height: 900 }) + await seedAuth(page) + await seedStoredSelection(page, ['root-1']) + await mockBackend(page, { + patients: PATIENTS, + propertyDefinitions: [ALLERGY_DEF], + rootLocations: ROOT_LOCATIONS, + }) + + await page.goto(`${BASE}/patients`) + await expect(page.locator(ROW_SELECTOR).first()).toBeVisible({ timeout: 20000 }) + await page.waitForTimeout(500) + + const nameHeader = page.locator('th[data-name="table-header-cell"]').first() + const handle = nameHeader.locator('[data-name="table-resize-indicator"]') + const before = (await nameHeader.boundingBox())! + + await nameHeader.hover() + let handleBox = (await handle.boundingBox())! + await page.mouse.move(handleBox.x + handleBox.width / 2, handleBox.y + handleBox.height / 2) + await page.mouse.down() + for (let x = handleBox.x; x > handleBox.x - 400; x -= 40) { + await page.mouse.move(x, handleBox.y) + await page.waitForTimeout(20) + } + await page.mouse.up() + await page.waitForTimeout(300) + + const shrunk = (await nameHeader.boundingBox())! + expect(shrunk.width).toBeGreaterThanOrEqual(199) + expect(shrunk.width).toBeLessThanOrEqual(before.width + 1) + + await nameHeader.hover() + handleBox = (await handle.boundingBox())! + await page.mouse.move(handleBox.x + handleBox.width / 2, handleBox.y + handleBox.height / 2) + await page.mouse.down() + for (let x = handleBox.x; x < handleBox.x + 300; x += 40) { + await page.mouse.move(x, handleBox.y) + await page.waitForTimeout(20) + } + await page.mouse.up() + await page.waitForTimeout(300) + + const widened = (await nameHeader.boundingBox())! + expect(widened.width).toBeGreaterThan(shrunk.width + 200) }) }) diff --git a/tests/e2e/proxy-fullstack.spec.ts b/tests/e2e/proxy-fullstack.spec.ts new file mode 100644 index 00000000..42840ed1 --- /dev/null +++ b/tests/e2e/proxy-fullstack.spec.ts @@ -0,0 +1,300 @@ +import { test, expect, type APIRequestContext, type Page } from '@playwright/test' +import { + ROW_SELECTOR, + addSorting, + openSortingPanel, + setSortDirection, + visibleRowFirstCells, +} from './support/filterSortUi' + +/** + * Full-stack e2e against the real backend behind the nginx proxy. + * + * These tests do NOT mock the network: they exercise the docker-compose stack + * (proxy -> web / backend / Keycloak) started from the images built for this + * commit (see docker-compose.e2e.yml and the `e2e-proxy` CI job). They verify + * proxy routing (/, /graphql, /keycloak/...) and that filtering/sorting works + * end-to-end through the real query engine. + * + * Gated behind E2E_PROXY_TARGET=1 so the mock-based suite can keep running + * without the docker stack. + */ + +const PROXY_TARGET = process.env.E2E_PROXY_TARGET === '1' +const BASE = (process.env.E2E_BASE_URL || 'http://localhost').replace(/\/$/, '') + +// unique per run so tests never depend on (or collide with) leftover data +const RUN_ID = `e2e${Date.now().toString(36)}` + +const USERNAME = process.env.E2E_USERNAME || 'test' +const PASSWORD = process.env.E2E_PASSWORD || 'test' +const CLIENT_ID = 'tasks-web' +const TOKEN_URL = `${BASE}/keycloak/realms/tasks/protocol/openid-connect/token` + +async function getAccessToken(request: APIRequestContext): Promise { + const response = await request.post(TOKEN_URL, { + form: { + grant_type: 'password', + client_id: CLIENT_ID, + username: USERNAME, + password: PASSWORD, + scope: 'openid profile email organization', + }, + }) + expect(response.ok(), `token endpoint through the proxy: ${response.status()}`).toBeTruthy() + const body = await response.json() as { access_token?: string } + expect(body.access_token, 'access token from the proxied keycloak').toBeTruthy() + return body.access_token! +} + +async function gql>( + request: APIRequestContext, + token: string, + query: string, + variables?: Record +): Promise { + const response = await request.post(`${BASE}/graphql`, { + headers: { authorization: `Bearer ${token}` }, + data: { query, variables: variables ?? {} }, + }) + expect(response.ok(), `graphql through the proxy: ${response.status()}`).toBeTruthy() + const body = await response.json() as { data?: T, errors?: Array<{ message: string }> } + expect(body.errors, `graphql errors: ${JSON.stringify(body.errors)}`).toBeUndefined() + return body.data as T +} + +type Me = { id: string, username: string, rootLocations: Array<{ id: string, title: string, kind: string }> } + +async function fetchMe(request: APIRequestContext, token: string): Promise { + const data = await gql<{ me: Me }>(request, token, ` + query { me { id username rootLocations { id title kind } } } + `) + expect(data.me, 'me through the proxy').toBeTruthy() + return data.me +} + +type SeededContext = { + token: string, + me: Me, + rootLocationIds: string[], + // lastnames carry the sortable suffixes: Alpha < Mid < Zeta + patients: Array<{ id: string, name: string, lastname: string }>, +} + +/** + * Seed three patients (and one task each, assigned to the current user) + * through the proxied GraphQL API. Lastnames are `-alpha|mid|zeta`, so + * name-based ordering among the seeded rows is deterministic. + */ +async function seedData(request: APIRequestContext): Promise { + const token = await getAccessToken(request) + const me = await fetchMe(request, token) + expect(me.rootLocations.length, 'user has root locations (scaffold import)').toBeGreaterThan(0) + const rootLocationIds = me.rootLocations.map(l => l.id) + + const { locationNodes } = await gql<{ locationNodes: Array<{ id: string, title: string, kind: string }> }>( + request, + token, + `query { locationNodes { id title kind } }` + ) + const clinic = locationNodes.find(l => l.kind === 'CLINIC') + expect(clinic, 'a clinic exists in the scaffold data').toBeTruthy() + + const fixtures = [ + { firstname: 'Zoe', lastname: `${RUN_ID}-alpha`, birthdate: '1990-01-10' }, + { firstname: 'Max', lastname: `${RUN_ID}-mid`, birthdate: '1970-05-20' }, + { firstname: 'Amy', lastname: `${RUN_ID}-zeta`, birthdate: '1980-09-30' }, + ] + const patients: SeededContext['patients'] = [] + for (const f of fixtures) { + const { createPatient } = await gql<{ createPatient: { id: string, name: string, lastname: string } }>( + request, + token, + `mutation Create($data: CreatePatientInput!) { + createPatient(data: $data) { id name lastname } + }`, + { + data: { + firstname: f.firstname, + lastname: f.lastname, + birthdate: f.birthdate, + sex: 'FEMALE', + state: 'ADMITTED', + clinicId: clinic!.id, + }, + } + ) + patients.push(createPatient) + + await gql(request, token, ` + mutation CreateTask($data: CreateTaskInput!) { + createTask(data: $data) { id title } + }`, + { + data: { + title: `Task ${f.lastname}`, + patientId: createPatient.id, + assigneeIds: [me.id], + }, + }) + } + + return { token, me, rootLocationIds, patients } +} + +test.describe('full stack behind the nginx proxy', () => { + test.skip(!PROXY_TARGET, 'requires the docker proxy stack (E2E_PROXY_TARGET=1)') + test.describe.configure({ mode: 'serial' }) + + let seeded: SeededContext + + test('proxy routes /, /keycloak and /graphql to the right services', async ({ request }) => { + // frontend + const home = await request.get(`${BASE}/`) + expect(home.status()).toBe(200) + expect(home.headers()['content-type'] ?? '').toContain('text/html') + + // keycloak, served under the /keycloak subpath + const oidc = await request.get(`${BASE}/keycloak/realms/tasks/.well-known/openid-configuration`) + expect(oidc.status()).toBe(200) + const oidcBody = await oidc.json() as { issuer?: string, token_endpoint?: string } + expect(oidcBody.issuer ?? '').toContain('/keycloak/realms/tasks') + + // graphql must reach the backend, not the frontend catch-all: the backend + // answers JSON (even for unauthenticated requests), never an HTML page + const graphql = await request.post(`${BASE}/graphql`, { data: { query: '{ __typename }' } }) + expect(graphql.status()).toBeLessThan(500) + expect(graphql.headers()['content-type'] ?? '').not.toContain('text/html') + }) + + test('authenticates through the proxied keycloak and reads data', async ({ request }) => { + seeded = await seedData(request) + expect(seeded.me.username).toBe(USERNAME) + expect(seeded.patients).toHaveLength(3) + }) + + test('backend filters and sorts patients through the proxy', async ({ request }) => { + const { token, rootLocationIds } = seeded + const query = ` + query Patients($rootLocationIds: [ID!], $filters: [QueryFilterClauseInput!], $sorts: [QuerySortClauseInput!]) { + patients(rootLocationIds: $rootLocationIds, filters: $filters, sorts: $sorts) { id lastname } + patientsTotal(rootLocationIds: $rootLocationIds, filters: $filters, sorts: $sorts) + }` + const containsRun = [{ + fieldKey: 'name', + operator: 'CONTAINS', + value: { stringValue: RUN_ID }, + }] + + const asc = await gql<{ patients: Array<{ lastname: string }>, patientsTotal: number }>( + request, token, query, + { rootLocationIds, filters: containsRun, sorts: [{ fieldKey: 'name', direction: 'ASC' }] } + ) + expect(asc.patientsTotal).toBe(3) + expect(asc.patients.map(p => p.lastname)).toEqual([ + `${RUN_ID}-alpha`, `${RUN_ID}-mid`, `${RUN_ID}-zeta`, + ]) + + const desc = await gql<{ patients: Array<{ lastname: string }> }>( + request, token, query, + { rootLocationIds, filters: containsRun, sorts: [{ fieldKey: 'name', direction: 'DESC' }] } + ) + expect(desc.patients.map(p => p.lastname)).toEqual([ + `${RUN_ID}-zeta`, `${RUN_ID}-mid`, `${RUN_ID}-alpha`, + ]) + + // birthdate sort: Max (1970) < Amy (1980) < Zoe (1990) + const byBirthdate = await gql<{ patients: Array<{ lastname: string }> }>( + request, token, query, + { rootLocationIds, filters: containsRun, sorts: [{ fieldKey: 'birthdate', direction: 'ASC' }] } + ) + expect(byBirthdate.patients.map(p => p.lastname)).toEqual([ + `${RUN_ID}-mid`, `${RUN_ID}-zeta`, `${RUN_ID}-alpha`, + ]) + + // ordering by a correlated/joined expression must not fail on Postgres: + // the base query is SELECT DISTINCT, which rejects ORDER BY expressions + // outside the select list unless the engine rewraps the statement + const byUpdated = await gql<{ patients: Array<{ lastname: string }> }>( + request, token, query, + { rootLocationIds, filters: containsRun, sorts: [{ fieldKey: 'updateDate', direction: 'DESC' }] } + ) + expect(byUpdated.patients).toHaveLength(3) + + // "does not contain" excludes all seeded rows (NOT_CONTAINS operator) + const excluded = await gql<{ patients: Array<{ lastname: string }>, patientsTotal: number }>( + request, token, query, + { + rootLocationIds, + filters: [{ fieldKey: 'name', operator: 'NOT_CONTAINS', value: { stringValue: RUN_ID } }], + sorts: [], + } + ) + expect(excluded.patients.every(p => !p.lastname.includes(RUN_ID))).toBe(true) + }) + + test('backend sorts tasks by patient through the proxy (issue #213)', async ({ request }) => { + const { token, me, rootLocationIds } = seeded + const query = ` + query Tasks($rootLocationIds: [ID!], $assigneeId: ID, $filters: [QueryFilterClauseInput!], $sorts: [QuerySortClauseInput!]) { + tasks(rootLocationIds: $rootLocationIds, assigneeId: $assigneeId, filters: $filters, sorts: $sorts) { + id title patient { lastname } + } + }` + const filters = [{ fieldKey: 'title', operator: 'CONTAINS', value: { stringValue: RUN_ID } }] + + const asc = await gql<{ tasks: Array<{ patient: { lastname: string } | null }> }>( + request, token, query, + { rootLocationIds, assigneeId: me.id, filters, sorts: [{ fieldKey: 'patient', direction: 'ASC' }] } + ) + // patient display name is "firstname lastname": Amy ... < Max ... < Zoe ... + expect(asc.tasks.map(t => t.patient?.lastname)).toEqual([ + `${RUN_ID}-zeta`, `${RUN_ID}-mid`, `${RUN_ID}-alpha`, + ]) + + const desc = await gql<{ tasks: Array<{ patient: { lastname: string } | null }> }>( + request, token, query, + { rootLocationIds, assigneeId: me.id, filters, sorts: [{ fieldKey: 'patient', direction: 'DESC' }] } + ) + expect(desc.tasks.map(t => t.patient?.lastname)).toEqual([ + `${RUN_ID}-alpha`, `${RUN_ID}-mid`, `${RUN_ID}-zeta`, + ]) + }) + + test('UI: login via the proxied keycloak and sort the patient list', async ({ page, request }) => { + // pre-select the user's root location so the mandatory picker does not block + await page.addInitScript((ids) => { + window.localStorage.setItem('selected-root-location-ids', JSON.stringify(ids)) + }, seeded.rootLocationIds) + + await page.goto(`${BASE}/`) + // unauthenticated: the app redirects to the Keycloak login page through the proxy + await page.waitForURL('**/keycloak/realms/tasks/**', { timeout: 30000 }) + await page.locator('#username').fill(USERNAME) + await page.locator('#password').fill(PASSWORD) + await page.locator('#kc-login').click() + // back through the proxy to the app: /auth/callback exchanges the code + // and lands on the start page once the session is stored + await page.waitForURL((url) => url.pathname === '/', { timeout: 30000 }) + + await page.goto(`${BASE}/patients`) + await expect(page.locator(ROW_SELECTOR).first()).toBeVisible({ timeout: 30000 }) + + // narrow to the seeded rows and sort by name through the real backend + await page.getByPlaceholder('Search').fill(RUN_ID) + await expect.poll(() => page.locator(ROW_SELECTOR).count(), { timeout: 20000 }).toBe(3) + + await openSortingPanel(page) + await addSorting(page, 'Name') + await expect.poll(async () => { + const names = await visibleRowFirstCells(page) + return names.map(n => n.includes('-alpha') ? 'alpha' : n.includes('-mid') ? 'mid' : n.includes('-zeta') ? 'zeta' : n) + }, { timeout: 20000 }).toEqual(['alpha', 'mid', 'zeta']) + + await setSortDirection(page, 'Name', 'DESC') + await expect.poll(async () => { + const names = await visibleRowFirstCells(page) + return names.map(n => n.includes('-alpha') ? 'alpha' : n.includes('-mid') ? 'mid' : n.includes('-zeta') ? 'zeta' : n) + }, { timeout: 20000 }).toEqual(['zeta', 'mid', 'alpha']) + }) +}) diff --git a/tests/e2e/support/filterSortUi.ts b/tests/e2e/support/filterSortUi.ts new file mode 100644 index 00000000..3f78aa1b --- /dev/null +++ b/tests/e2e/support/filterSortUi.ts @@ -0,0 +1,124 @@ +import { expect, type Page } from '@playwright/test' + +export const ROW_SELECTOR = 'tr[data-name="table-body-row"]' + +/** Text of the first visible cell of every rendered data row, in DOM order. */ +export async function visibleRowFirstCells(page: Page): Promise { + return page.$$eval(ROW_SELECTOR, (rows) => + rows.map((row) => row.querySelector('td')?.textContent?.trim() ?? '')) +} + +/** Text of the cell at `cellIndex` of every rendered data row, in DOM order. */ +export async function visibleRowCells(page: Page, cellIndex: number): Promise { + return page.$$eval( + `${ROW_SELECTOR}`, + (rows, idx) => rows.map((row) => row.querySelectorAll('td')[idx as number]?.textContent?.trim() ?? ''), + cellIndex + ) +} + +export async function seedStoredSelection(page: Page, ids: string[]): Promise { + await page.addInitScript((selected) => { + window.localStorage.setItem('selected-root-location-ids', JSON.stringify(selected)) + }, ids) +} + +// --------------------------------------------------------------------------- +// toolbar panels +// --------------------------------------------------------------------------- + +export async function openFilterPanel(page: Page): Promise { + await page.locator('button:visible', { hasText: /^Filter \(\d+\)/ }).first().click() + await expect(page.getByRole('button', { name: 'Add filter' })).toBeVisible() +} + +export async function openSortingPanel(page: Page): Promise { + await page.locator('button:visible', { hasText: /^Sorting \(\d+\)/ }).first().click() + await expect(page.getByRole('button', { name: 'Add sorting' })).toBeVisible() +} + +/** + * Add a sort entry via the "Add sorting" combobox. The panel must be open. + * The new entry starts ascending. + */ +export async function addSorting(page: Page, label: string): Promise { + await page.getByRole('button', { name: 'Add sorting' }).click() + await page.getByRole('option', { name: label, exact: true }).click() +} + +/** + * Toggle an existing sort chip (identified by its label) to the given + * direction using the ASC/DESC buttons in its popup. + */ +export async function setSortDirection(page: Page, label: string, direction: 'ASC' | 'DESC'): Promise { + await page.getByRole('button', { name: label, exact: true }).click() + await page.getByRole('button', { name: direction, exact: true }).click() + await page.keyboard.press('Escape') +} + +/** Remove a sort chip via the trash icon in its popup. */ +export async function removeSorting(page: Page, label: string): Promise { + await page.getByRole('button', { name: label, exact: true }).click() + await page.getByRole('button', { name: 'Remove filter' }).click() +} + +function activeFilterPopup(page: Page) { + return page.locator('[data-name="pop-up"]:visible, [role="dialog"]:visible').last() +} + +/** Open the "Add filter" combobox and pick a field; leaves the filter popup open. */ +export async function beginAddFilter(page: Page, label: string): Promise { + await page.getByRole('button', { name: 'Add filter' }).click() + await page.getByRole('option', { name: label, exact: true }).click() +} + +/** Commit the currently-open filter popup via its Done (check) button. */ +export async function commitFilterPopup(page: Page): Promise { + await page.getByRole('button', { name: 'Done', exact: true }).click() +} + +/** + * Add a text filter (`contains` by default) for the given field. + * The filter panel must be open. + */ +export async function addTextFilter(page: Page, label: string, text: string): Promise { + await beginAddFilter(page, label) + await page.getByPlaceholder('Value').fill(text) + await commitFilterPopup(page) +} + +/** + * Add a singleTag filter with the "equals" operator and pick one option. + * The filter panel must be open. + */ +export async function addSingleTagEqualsFilter(page: Page, label: string, optionLabel: string): Promise { + await beginAddFilter(page, label) + const dialog = page.getByRole('dialog').filter({ hasText: label }) + await dialog.locator('[data-name="filter-operator-select"]').click() + await page.getByRole('option', { name: 'Equals', exact: true }).click() + await dialog.getByRole('button', { name: /click to select/i }).click() + const option = page.getByRole('option', { name: optionLabel, exact: true }) + if (await option.count() > 0) { + await option.click() + } else { + await page.getByRole('option').first().click() + } + await commitFilterPopup(page) +} + +/** + * Add a tag filter (singleTag `contains` = multi select) and pick the given + * options. The filter panel must be open. + */ +export async function addTagFilter(page: Page, label: string, optionLabels: string[]): Promise { + await beginAddFilter(page, label) + const popup = activeFilterPopup(page) + await popup.getByRole('button', { name: /Select/i }).first().click().catch(async () => { + await popup.locator('button').first().click() + }) + for (const option of optionLabels) { + await page.getByRole('option', { name: option, exact: true }).click() + } + await page.keyboard.press('Escape') + await commitFilterPopup(page) +} diff --git a/tests/e2e/support/mockBackend.ts b/tests/e2e/support/mockBackend.ts index b9512766..9b1137be 100644 --- a/tests/e2e/support/mockBackend.ts +++ b/tests/e2e/support/mockBackend.ts @@ -1,4 +1,6 @@ import type { Page, Route } from '@playwright/test' +import type { FilterClause, SearchInput, SortClause } from './queryEngine' +import { applyOp, orderBy, paginate } from './queryEngine' /** * Deterministic mock of the GraphQL backend + OIDC session for the web app. @@ -39,8 +41,39 @@ export type PatientFixture = { properties?: Array<{ id: string, definitionId: string, textValue?: string | null }>, } +export type TaskFixture = { + id: string, + title: string, + description?: string | null, + done?: boolean, + dueDate?: string | null, + priority?: string | null, + estimatedTime?: number | null, + creationDate?: string, + updateDate?: string | null, + // reference into MockOptions.patients (or absent for "no patient") + patientId?: string | null, + assigneeIds?: string[], +} + +export type SavedViewFixture = { + id: string, + name: string, + baseEntityType: 'PATIENT' | 'TASK', + filterDefinition: string, + sortDefinition: string, + parameters: string, + relatedFilterDefinition?: string, + relatedSortDefinition?: string, + relatedParameters?: string, + isOwner?: boolean, +} + export type MockOptions = { patients: PatientFixture[], + tasks?: TaskFixture[], + savedViews?: SavedViewFixture[], + users?: Array<{ id: string, name: string }>, propertyDefinitions?: PropertyDefinition[], // root locations the user has access to (drives the location picker prompt) rootLocations?: Array<{ id: string, title: string, kind: string }>, @@ -133,6 +166,9 @@ function fullPatient(p: PatientFixture, defs: PropertyDefinition[]) { sex: p.sex ?? 'FEMALE', state: p.state ?? 'ADMITTED', updateDate: p.updateDate ?? null, + stateUpdateDate: null, + clinicUpdateDate: null, + positionUpdateDate: null, description: '', checksum: 'chk-1', assignedLocation: null, @@ -145,6 +181,197 @@ function fullPatient(p: PatientFixture, defs: PropertyDefinition[]) { } } +// --------------------------------------------------------------------------- +// queryable-field metadata (mirrors backend/api/query/adapters + metadata_service) +// --------------------------------------------------------------------------- + +const STR_OPS = ['EQ', 'NEQ', 'CONTAINS', 'NOT_CONTAINS', 'STARTS_WITH', 'ENDS_WITH', 'IN', 'NOT_IN', 'IS_NULL', 'IS_NOT_NULL'] +const NUM_OPS = ['EQ', 'NEQ', 'GT', 'GTE', 'LT', 'LTE', 'BETWEEN', 'NOT_BETWEEN', 'IS_NULL', 'IS_NOT_NULL'] +const DATE_OPS = NUM_OPS +const BOOL_OPS = ['EQ', 'IS_NULL', 'IS_NOT_NULL'] +const CHOICE_OPS = ['EQ', 'NEQ', 'IN', 'NOT_IN', 'IS_NULL', 'IS_NOT_NULL'] +const REF_OPS = ['EQ', 'IN', 'CONTAINS', 'NOT_CONTAINS', 'STARTS_WITH', 'ENDS_WITH', 'IS_NULL', 'IS_NOT_NULL'] + +type QueryableFieldShape = { + key: string, + label: string, + kind: string, + valueType: string, + allowedOperators: string[], + sortable: boolean, + searchable: boolean, + propertyDefinitionId?: string | null, + relation?: { targetEntity: string, idFieldKey: string, labelFieldKey: string, allowedFilterModes: string[] } | null, + choice?: { optionKeys: string[], optionLabels: string[] } | null, +} + +function queryableField(f: QueryableFieldShape) { + return { + __typename: 'QueryableField', + ...f, + propertyDefinitionId: f.propertyDefinitionId ?? null, + relation: f.relation ? { __typename: 'QueryableRelationMeta', ...f.relation } : null, + choice: f.choice ? { __typename: 'QueryableChoiceMeta', ...f.choice } : null, + sortDirections: f.sortable ? ['ASC', 'DESC'] : [], + filterable: f.allowedOperators.length > 0, + } +} + +function propertyQueryableFields(defs: PropertyDefinition[]) { + return defs.map((d) => queryableField({ + key: `property_${d.id}`, + label: d.name, + kind: d.fieldType === 'FIELD_TYPE_SELECT' ? 'CHOICE' : 'PROPERTY', + valueType: d.fieldType === 'FIELD_TYPE_NUMBER' ? 'NUMBER' : 'STRING', + allowedOperators: d.fieldType === 'FIELD_TYPE_SELECT' ? CHOICE_OPS : STR_OPS, + sortable: true, + searchable: d.fieldType === 'FIELD_TYPE_TEXT', + propertyDefinitionId: d.id, + choice: d.fieldType === 'FIELD_TYPE_SELECT' + ? { optionKeys: d.options, optionLabels: d.options } + : null, + })) +} + +function patientQueryableFields(defs: PropertyDefinition[]) { + return [ + queryableField({ key: 'name', label: 'Name', kind: 'SCALAR', valueType: 'STRING', allowedOperators: STR_OPS, sortable: true, searchable: true }), + queryableField({ key: 'firstname', label: 'First name', kind: 'SCALAR', valueType: 'STRING', allowedOperators: STR_OPS, sortable: true, searchable: true }), + queryableField({ key: 'lastname', label: 'Last name', kind: 'SCALAR', valueType: 'STRING', allowedOperators: STR_OPS, sortable: true, searchable: true }), + queryableField({ + key: 'state', label: 'State', kind: 'CHOICE', valueType: 'STRING', allowedOperators: CHOICE_OPS, sortable: true, searchable: false, + choice: { optionKeys: ['WAIT', 'ADMITTED', 'DISCHARGED', 'DEAD'], optionLabels: ['WAIT', 'ADMITTED', 'DISCHARGED', 'DEAD'] }, + }), + queryableField({ + key: 'sex', label: 'Sex', kind: 'CHOICE', valueType: 'STRING', allowedOperators: CHOICE_OPS, sortable: true, searchable: false, + choice: { optionKeys: ['MALE', 'FEMALE', 'UNKNOWN'], optionLabels: ['Male', 'Female', 'Unknown'] }, + }), + queryableField({ key: 'birthdate', label: 'Birthdate', kind: 'SCALAR', valueType: 'DATE', allowedOperators: DATE_OPS, sortable: true, searchable: false }), + queryableField({ key: 'updateDate', label: 'Update date', kind: 'SCALAR', valueType: 'DATETIME', allowedOperators: DATE_OPS, sortable: true, searchable: false }), + queryableField({ key: 'description', label: 'Description', kind: 'SCALAR', valueType: 'STRING', allowedOperators: STR_OPS, sortable: true, searchable: true }), + queryableField({ key: 'clinic', label: 'Clinic', kind: 'SCALAR', valueType: 'STRING', allowedOperators: STR_OPS, sortable: true, searchable: false }), + queryableField({ + key: 'position', label: 'Location', kind: 'REFERENCE', valueType: 'UUID', allowedOperators: REF_OPS, sortable: true, searchable: false, + relation: { targetEntity: 'LocationNode', idFieldKey: 'id', labelFieldKey: 'title', allowedFilterModes: ['ID', 'LABEL'] }, + }), + queryableField({ key: 'location-WARD', label: 'Ward', kind: 'SCALAR', valueType: 'STRING', allowedOperators: STR_OPS, sortable: true, searchable: false }), + queryableField({ key: 'location-ROOM', label: 'Room', kind: 'SCALAR', valueType: 'STRING', allowedOperators: STR_OPS, sortable: true, searchable: false }), + queryableField({ key: 'location-BED', label: 'Bed', kind: 'SCALAR', valueType: 'STRING', allowedOperators: STR_OPS, sortable: true, searchable: false }), + ...propertyQueryableFields(defs), + ] +} + +function taskQueryableFields() { + return [ + queryableField({ key: 'title', label: 'Title', kind: 'SCALAR', valueType: 'STRING', allowedOperators: STR_OPS, sortable: true, searchable: true }), + queryableField({ key: 'description', label: 'Description', kind: 'SCALAR', valueType: 'STRING', allowedOperators: STR_OPS, sortable: true, searchable: true }), + queryableField({ key: 'done', label: 'Done', kind: 'SCALAR', valueType: 'BOOLEAN', allowedOperators: BOOL_OPS, sortable: true, searchable: false }), + queryableField({ key: 'dueDate', label: 'Due date', kind: 'SCALAR', valueType: 'DATETIME', allowedOperators: DATE_OPS, sortable: true, searchable: false }), + queryableField({ + key: 'priority', label: 'Priority', kind: 'CHOICE', valueType: 'STRING', + allowedOperators: CHOICE_OPS, sortable: true, searchable: false, + choice: { optionKeys: ['P1', 'P2', 'P3', 'P4'], optionLabels: ['P1', 'P2', 'P3', 'P4'] }, + }), + queryableField({ key: 'estimatedTime', label: 'Estimated time', kind: 'SCALAR', valueType: 'NUMBER', allowedOperators: NUM_OPS, sortable: true, searchable: false }), + queryableField({ key: 'creationDate', label: 'Creation date', kind: 'SCALAR', valueType: 'DATETIME', allowedOperators: DATE_OPS, sortable: true, searchable: false }), + queryableField({ key: 'updateDate', label: 'Update date', kind: 'SCALAR', valueType: 'DATETIME', allowedOperators: DATE_OPS, sortable: true, searchable: false }), + queryableField({ + key: 'patient', label: 'Patient', kind: 'REFERENCE', valueType: 'UUID', allowedOperators: REF_OPS, sortable: true, searchable: true, + relation: { targetEntity: 'Patient', idFieldKey: 'id', labelFieldKey: 'name', allowedFilterModes: ['ID', 'LABEL'] }, + }), + queryableField({ + key: 'assignee', label: 'Assignee', kind: 'REFERENCE', valueType: 'UUID', allowedOperators: REF_OPS, sortable: true, searchable: true, + relation: { targetEntity: 'User', idFieldKey: 'id', labelFieldKey: 'name', allowedFilterModes: ['ID', 'LABEL'] }, + }), + queryableField({ + key: 'assigneeTeam', label: 'Assignee team', kind: 'REFERENCE', valueType: 'UUID', allowedOperators: REF_OPS, sortable: true, searchable: false, + relation: { targetEntity: 'LocationNode', idFieldKey: 'id', labelFieldKey: 'title', allowedFilterModes: ['ID', 'LABEL'] }, + }), + ] +} + +// --------------------------------------------------------------------------- +// entity query execution (filters / search / sorts), mirroring the adapters +// --------------------------------------------------------------------------- + +type FullPatient = ReturnType + +/** Backend display name used for query semantics (`firstname lastname`). */ +function patientQueryName(p: { firstname: string, lastname: string }): string { + return `${p.firstname} ${p.lastname}`.trim() +} + +function patientPropertyTextValue(p: FullPatient, defId: string): string | null { + const prop = (p.properties as Array<{ definition: { id: string }, textValue?: string | null }>) + .find(v => v.definition.id === defId) + return prop?.textValue ?? null +} + +function patientPassesFilter(p: FullPatient, clause: FilterClause): boolean { + const { fieldKey: key, operator: op, value: val } = clause + if (key.startsWith('property_')) { + return applyOp(patientPropertyTextValue(p, key.replace('property_', '')), op, val) + } + switch (key) { + case 'firstname': return applyOp(p.firstname, op, val) + case 'lastname': return applyOp(p.lastname, op, val) + case 'name': return applyOp(patientQueryName(p), op, val) + case 'state': return applyOp(p.state, op, val) + case 'sex': return applyOp(p.sex, op, val) + case 'birthdate': return applyOp(p.birthdate, op, val, 'date') + case 'description': return applyOp(p.description, op, val) + case 'updateDate': return applyOp(p.updateDate, op, val, 'datetime') + case 'clinic': return applyOp((p.clinic as { title?: string } | null)?.title ?? null, op, val) + case 'position': return applyOp((p.position as { title?: string } | null)?.title ?? null, op, val) + default: return true + } +} + +const PATIENT_STATE_ORDER: Record = { WAIT: 0, ADMITTED: 1, DISCHARGED: 2, DEAD: 3 } +const TASK_PRIORITY_ORDER: Record = { P1: 1, P2: 2, P3: 3, P4: 4 } + +function patientSortAccessors(): Record string | number | null | undefined> { + const base: Record string | number | null | undefined> = { + 'firstname': p => p.firstname, + 'lastname': p => p.lastname, + // backend sorts `name` by lastname, then firstname + 'name': p => `${p.lastname}${p.firstname}`, + 'state': p => PATIENT_STATE_ORDER[p.state] ?? 4, + 'sex': p => p.sex, + 'birthdate': p => p.birthdate, + 'description': p => p.description || null, + 'updateDate': p => p.updateDate ?? null, + 'clinic': p => (p.clinic as { title?: string } | null)?.title ?? null, + 'position': p => (p.position as { title?: string } | null)?.title ?? null, + } + return new Proxy(base, { + get(target, prop: string) { + if (prop in target) return target[prop] + if (typeof prop === 'string' && prop.startsWith('property_')) { + const defId = prop.replace('property_', '') + return (p: FullPatient) => patientPropertyTextValue(p, defId) + } + return undefined + }, + has(target, prop: string) { + return prop in target || (typeof prop === 'string' && prop.startsWith('property_')) + }, + }) +} + +function patientMatchesSearch(p: FullPatient, search: SearchInput | null | undefined): boolean { + const text = search?.searchText?.trim() + if (!text) return true + const q = text.toLowerCase() + const parts = [p.firstname, p.lastname, patientQueryName(p), p.description] + if (search?.includeProperties) { + for (const prop of p.properties as Array<{ textValue?: string | null }>) { + if (prop.textValue) parts.push(prop.textValue) + } + } + return parts.some(v => (v ?? '').toLowerCase().includes(q)) +} + export async function mockBackend(page: Page, options: MockOptions): Promise { const defs = options.propertyDefinitions ?? [] const rootLocations = options.rootLocations ?? [ @@ -155,6 +382,125 @@ export async function mockBackend(page: Page, options: MockOptions): Promise [p.id, fullPatient(p, defs)])) + const users = options.users ?? [{ id: 'user-1', name: 'Test User' }] + const userById = new Map(users.map(u => [u.id, u])) + const fullTask = (t: TaskFixture) => ({ + __typename: 'TaskType', + id: t.id, + title: t.title, + description: t.description ?? '', + done: t.done ?? false, + dueDate: t.dueDate ?? null, + priority: t.priority ?? null, + estimatedTime: t.estimatedTime ?? null, + creationDate: t.creationDate ?? '2026-01-01T08:00:00Z', + updateDate: t.updateDate ?? '2026-01-02T08:00:00Z', + sourceTaskPresetId: null, + patient: t.patientId ? (patients.get(t.patientId) ?? null) : null, + assignees: (t.assigneeIds ?? []).map(id => ({ + __typename: 'UserType', + id, + name: userById.get(id)?.name ?? id, + avatarUrl: null, + lastOnline: null, + isOnline: true, + })), + assigneeTeam: null, + properties: [], + }) + const tasks = new Map((options.tasks ?? []).map(t => [t.id, fullTask(t)])) + + // tasks embedded into a patient (GetPatients selection) — without the + // back-reference to the patient, which would make the JSON cyclic + const tasksOfPatient = (patientId: string) => + Array.from(tasks.values()) + .filter(t => t.patient?.id === patientId) + .map(({ patient: _patient, ...rest }) => rest) + + type FullTask = ReturnType + const taskPassesFilter = (t: FullTask, clause: FilterClause): boolean => { + const { fieldKey: key, operator: op, value: val } = clause + switch (key) { + case 'title': return applyOp(t.title, op, val) + case 'description': return applyOp(t.description, op, val) + case 'done': { + if (op === 'EQ' && val?.boolValue != null) return t.done === val.boolValue + if (op === 'IS_NULL') return t.done == null + if (op === 'IS_NOT_NULL') return t.done != null + return true + } + case 'dueDate': return applyOp(t.dueDate, op, val, 'datetime') + case 'creationDate': return applyOp(t.creationDate, op, val, 'datetime') + case 'updateDate': return applyOp(t.updateDate, op, val, 'datetime') + case 'priority': return applyOp(t.priority, op, val) + case 'estimatedTime': return applyOp(t.estimatedTime, op, val, 'number') + case 'patient': { + if (op === 'IS_NULL') return t.patient == null + if (op === 'IS_NOT_NULL') return t.patient != null + if ((op === 'EQ' || op === 'IN') && (val?.uuidValue || val?.uuidValues?.length)) { + return applyOp(t.patient?.id ?? null, op, val) + } + if (op === 'CONTAINS' || op === 'STARTS_WITH' || op === 'ENDS_WITH') { + if (!t.patient) return false + return applyOp(patientQueryName(t.patient), op, val) + } + return true + } + case 'assignee': { + if (op === 'IS_NULL') return t.assignees.length === 0 + if (op === 'IS_NOT_NULL') return t.assignees.length > 0 + if ((op === 'EQ' || op === 'IN') && (val?.uuidValue || val?.uuidValues?.length)) { + const wanted = val?.uuidValue ? [val.uuidValue] : (val?.uuidValues ?? []) + return t.assignees.some(a => wanted.includes(a.id)) + } + if (op === 'CONTAINS' || op === 'STARTS_WITH' || op === 'ENDS_WITH') { + return t.assignees.some(a => applyOp(a.name, op, val)) + } + return true + } + default: return true + } + } + + const taskSortAccessors: Record string | number | null | undefined> = { + 'title': t => t.title, + 'description': t => t.description, + 'done': t => (t.done ? 1 : 0), + 'dueDate': t => t.dueDate, + 'priority': t => (t.priority != null ? TASK_PRIORITY_ORDER[t.priority] ?? 99 : 99), + 'estimatedTime': t => t.estimatedTime, + 'creationDate': t => t.creationDate, + 'updateDate': t => t.updateDate, + 'patient': t => (t.patient ? patientQueryName(t.patient) : null), + 'assignee': t => (t.assignees.length ? [...t.assignees].map(a => a.name).sort()[0] : null), + 'assigneeTeam': () => null, + } + + const taskMatchesSearch = (t: FullTask, search: SearchInput | null | undefined): boolean => { + const text = search?.searchText?.trim() + if (!text) return true + const q = text.toLowerCase() + const parts = [t.title, t.description ?? '', t.patient ? patientQueryName(t.patient) : '', ...t.assignees.map(a => a.name)] + return parts.some(v => (v ?? '').toLowerCase().includes(q)) + } + + // saved views store (custom views) + let savedViewSeq = 0 + const savedViews = new Map( + (options.savedViews ?? []).map(v => [v.id, { + __typename: 'SavedView', + relatedFilterDefinition: '', + relatedSortDefinition: '', + relatedParameters: '', + ownerUserId: 'user-1', + visibility: 'PRIVATE', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + isOwner: true, + ...v, + }]) + ) + const respond = (route: Route, body: unknown) => route.fulfill({ status: 200, @@ -227,18 +573,25 @@ export async function mockBackend(page: Page, options: MockOptions): Promise setTimeout(r, options.patientsDelayMs)) } - // Honour the `pagination` argument so infinite scroll behaves like the real - // backend: each page returns only its own slice while `patientsTotal` - // reports the full count. Omitting pagination (or a non-positive pageSize) - // returns the whole list, preserving the previous single-page behaviour. + // Honour the `states`, `filters`, `search`, `sorts` and `pagination` + // arguments so filtering/sorting behaves like the real backend: + // `patientsTotal` reports the filtered count while each page returns only + // its own slice. + const states = variables['states'] as string[] | undefined + const filters = (variables['filters'] as FilterClause[] | undefined) ?? [] + const search = variables['search'] as SearchInput | undefined + const sorts = variables['sorts'] as SortClause[] | undefined const pagination = variables['pagination'] as { pageIndex?: number, pageSize?: number } | undefined - const total = patientList.length - let pageItems = patientList - if (pagination && typeof pagination.pageSize === 'number' && pagination.pageSize > 0) { - const pageIndex = typeof pagination.pageIndex === 'number' ? pagination.pageIndex : 0 - const start = pageIndex * pagination.pageSize - pageItems = patientList.slice(start, start + pagination.pageSize) + + let rows = patientList + if (states && states.length > 0) { + rows = rows.filter(p => states.includes(p.state)) } + rows = rows.filter(p => filters.every(clause => patientPassesFilter(p, clause))) + rows = rows.filter(p => patientMatchesSearch(p, search)) + rows = orderBy(rows, sorts, patientSortAccessors()) + const total = rows.length + const pageItems = paginate(rows, pagination).map(p => ({ ...p, tasks: tasksOfPatient(p.id) })) return respond(route, { data: { patients: pageItems, patientsTotal: total }, }) @@ -246,7 +599,8 @@ export async function mockBackend(page: Page, options: MockOptions): Promise + const id = `view-${++savedViewSeq}` + const view = { + __typename: 'SavedView', + id, + name: String(data['name'] ?? 'view'), + baseEntityType: String(data['baseEntityType'] ?? 'PATIENT'), + filterDefinition: String(data['filterDefinition'] ?? '[]'), + sortDefinition: String(data['sortDefinition'] ?? '[]'), + parameters: String(data['parameters'] ?? '{}'), + relatedFilterDefinition: String(data['relatedFilterDefinition'] ?? ''), + relatedSortDefinition: String(data['relatedSortDefinition'] ?? ''), + relatedParameters: String(data['relatedParameters'] ?? ''), + ownerUserId: 'user-1', + visibility: 'PRIVATE', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + isOwner: true, + } + savedViews.set(id, view as never) + return respond(route, { data: { createSavedView: view } }) + } + + case 'UpdateSavedView': { + handle.mutations.push({ name, variables }) + const id = variables['id'] as string + const data = (variables['data'] ?? {}) as Record + const current = savedViews.get(id) + if (!current) return respond(route, { data: { updateSavedView: null } }) + for (const key of [ + 'name', 'filterDefinition', 'sortDefinition', 'parameters', + 'relatedFilterDefinition', 'relatedSortDefinition', 'relatedParameters', + ]) { + if (data[key] != null) (current as Record)[key] = data[key] + } + ;(current as Record)['updatedAt'] = '2026-01-02T00:00:00Z' + return respond(route, { data: { updateSavedView: current } }) + } + + case 'GetTasks': { + const assigneeId = variables['assigneeId'] as string | undefined + const filters = (variables['filters'] as FilterClause[] | undefined) ?? [] + const search = variables['search'] as SearchInput | undefined + const sorts = variables['sorts'] as SortClause[] | undefined + const pagination = variables['pagination'] as { pageIndex?: number, pageSize?: number } | undefined - case 'GetTasks': - return respond(route, { data: { tasks: [], tasksTotal: 0 } }) + let rows = Array.from(tasks.values()) + if (assigneeId) { + rows = rows.filter(t => t.assignees.some(a => a.id === assigneeId)) + } + rows = rows.filter(t => filters.every(clause => taskPassesFilter(t, clause))) + rows = rows.filter(t => taskMatchesSearch(t, search)) + rows = orderBy(rows, sorts, taskSortAccessors) + const total = rows.length + const pageItems = paginate(rows, pagination) + return respond(route, { data: { tasks: pageItems, tasksTotal: total } }) + } case 'GetUsers': return respond(route, { data: { users: [] } }) diff --git a/tests/e2e/support/queryEngine.ts b/tests/e2e/support/queryEngine.ts new file mode 100644 index 00000000..f16164b2 --- /dev/null +++ b/tests/e2e/support/queryEngine.ts @@ -0,0 +1,220 @@ +/** + * In-memory re-implementation of the backend query engine semantics + * (backend/api/query/*) so the e2e mock backend can honour the `filters`, + * `sorts` and `search` GraphQL arguments the web app sends. + * + * The semantics here intentionally mirror the SQLAlchemy adapters: + * - `apply_ops_to_column` (field_ops.py) for operator behaviour + * - `apply_patient_*` / `apply_task_*` (adapters/patient.py, adapters/task.py) + * for which field keys resolve to which values + * so that a test passing against this mock describes the behaviour the real + * backend implements. + */ + +export type FilterValue = { + stringValue?: string | null, + stringValues?: string[] | null, + floatValue?: number | null, + floatMin?: number | null, + floatMax?: number | null, + boolValue?: boolean | null, + dateValue?: string | null, + dateMin?: string | null, + dateMax?: string | null, + uuidValue?: string | null, + uuidValues?: string[] | null, +} + +export type FilterClause = { + fieldKey: string, + operator: string, + value?: FilterValue | null, +} + +export type SortClause = { + fieldKey: string, + direction: 'ASC' | 'DESC', +} + +export type SearchInput = { + searchText?: string | null, + includeProperties?: boolean | null, +} + +// --------------------------------------------------------------------------- +// operator application (mirrors field_ops.apply_ops_to_column) +// --------------------------------------------------------------------------- + +type Comparable = string | number | boolean | null | undefined + +function ilike(haystack: Comparable, needle: string, mode: 'contains' | 'starts' | 'ends'): boolean { + if (haystack == null) return false + const h = String(haystack).toLowerCase() + const n = needle.toLowerCase() + if (mode === 'contains') return h.includes(n) + if (mode === 'starts') return h.startsWith(n) + return h.endsWith(n) +} + +function dateOnly(iso: string): string { + return iso.slice(0, 10) +} + +/** + * Apply a query operator to a scalar value. + * `kind` mirrors the `as_date` / `as_datetime` flags of the backend. + */ +export function applyOp( + raw: Comparable, + operator: string, + value: FilterValue | null | undefined, + kind: 'string' | 'number' | 'boolean' | 'date' | 'datetime' = 'string' +): boolean { + if (operator === 'IS_NULL') return raw == null + if (operator === 'IS_NOT_NULL') return raw != null + if (value == null) return true // no condition -> row passes (backend adds no WHERE) + + if (kind === 'date' || kind === 'datetime') { + // "not between" keeps rows without a value (mirrors the backend) + if (raw == null) return operator === 'NOT_BETWEEN' + const rawIso = String(raw) + const rawCmp = kind === 'date' ? dateOnly(rawIso) : rawIso + if (operator === 'BETWEEN' && value.dateMin && value.dateMax) { + const d = dateOnly(rawIso) + return d >= dateOnly(value.dateMin) && d <= dateOnly(value.dateMax) + } + if (operator === 'NOT_BETWEEN' && value.dateMin && value.dateMax) { + const d = dateOnly(rawIso) + return d < dateOnly(value.dateMin) || d > dateOnly(value.dateMax) + } + if (!value.dateValue) return true + const cmp = kind === 'date' ? dateOnly(value.dateValue) : value.dateValue + // datetime comparisons compare instants, date comparisons calendar days + const a = kind === 'date' ? rawCmp : new Date(rawIso).getTime() + const b = kind === 'date' ? cmp : new Date(cmp).getTime() + switch (operator) { + case 'EQ': return a === b + case 'NEQ': return a !== b + case 'GT': return a > b + case 'GTE': return a >= b + case 'LT': return a < b + case 'LTE': return a <= b + default: return true + } + } + + switch (operator) { + case 'EQ': { + if (value.uuidValue != null) return raw === value.uuidValue + if (value.stringValue != null) return raw === value.stringValue + if (value.floatValue != null) return raw === value.floatValue + if (value.boolValue != null) return raw === value.boolValue + return true + } + case 'NEQ': { + if (value.uuidValue != null) return raw !== value.uuidValue + if (value.stringValue != null) return raw !== value.stringValue + if (value.floatValue != null) return raw !== value.floatValue + if (value.boolValue != null) return raw !== value.boolValue + return true + } + case 'GT': return cmpNumeric(raw, value, (a, b) => a > b) + case 'GTE': return cmpNumeric(raw, value, (a, b) => a >= b) + case 'LT': return cmpNumeric(raw, value, (a, b) => a < b) + case 'LTE': return cmpNumeric(raw, value, (a, b) => a <= b) + case 'BETWEEN': { + if (value.floatMin != null && value.floatMax != null && typeof raw === 'number') { + return raw >= value.floatMin && raw <= value.floatMax + } + return true + } + case 'NOT_BETWEEN': { + if (value.floatMin != null && value.floatMax != null) { + if (typeof raw !== 'number') return true // no value -> kept (backend parity) + return raw < value.floatMin || raw > value.floatMax + } + return true + } + case 'IN': { + const list = value.stringValues?.length ? value.stringValues : value.uuidValues + if (!list?.length) return true + return raw != null && list.includes(String(raw)) + } + case 'NOT_IN': { + const list = value.stringValues?.length ? value.stringValues : value.uuidValues + if (!list?.length) return true + return raw == null || !list.includes(String(raw)) + } + case 'CONTAINS': { + if (value.stringValue == null) return true + return ilike(raw, value.stringValue, 'contains') + } + case 'NOT_CONTAINS': { + // keeps rows without a value (mirrors the backend) + if (value.stringValue == null) return true + return raw == null || !ilike(raw, value.stringValue, 'contains') + } + case 'STARTS_WITH': { + if (value.stringValue == null) return true + return ilike(raw, value.stringValue, 'starts') + } + case 'ENDS_WITH': { + if (value.stringValue == null) return true + return ilike(raw, value.stringValue, 'ends') + } + default: + return true + } +} + +function cmpNumeric(raw: Comparable, value: FilterValue, pred: (a: number, b: number) => boolean): boolean { + if (value.floatValue != null) { + if (typeof raw !== 'number') return false + return pred(raw, value.floatValue) + } + return true +} + +// --------------------------------------------------------------------------- +// generic ordering helpers +// --------------------------------------------------------------------------- + +export type SortAccessor = (row: T) => string | number | null | undefined + +/** + * Order rows like the backend: per sort clause `asc().nulls_first()` / + * `desc().nulls_last()`, with a stable `id asc` tiebreak. + */ +export function orderBy( + rows: T[], + sorts: SortClause[] | null | undefined, + accessors: Record> +): T[] { + const applicable = (sorts ?? []).filter(s => accessors[s.fieldKey]) + const copy = [...rows] + copy.sort((a, b) => { + for (const s of applicable) { + const acc = accessors[s.fieldKey]! + const av = acc(a) + const bv = acc(b) + const desc = s.direction === 'DESC' + if (av == null && bv == null) continue + // asc: nulls first, desc: nulls last (mirrors the SQLAlchemy adapters) + if (av == null) return desc ? 1 : -1 + if (bv == null) return desc ? -1 : 1 + let c = 0 + if (typeof av === 'number' && typeof bv === 'number') c = av - bv + else c = String(av).localeCompare(String(bv), 'en') + if (c !== 0) return desc ? -c : c + } + return a.id.localeCompare(b.id, 'en') + }) + return copy +} + +export function paginate(rows: T[], pagination?: { pageIndex?: number, pageSize?: number } | null): T[] { + if (!pagination || typeof pagination.pageSize !== 'number' || pagination.pageSize <= 0) return rows + const pageIndex = typeof pagination.pageIndex === 'number' ? pagination.pageIndex : 0 + const start = pageIndex * pagination.pageSize + return rows.slice(start, start + pagination.pageSize) +} diff --git a/tests/e2e/playwright.config.ts b/tests/playwright.config.ts similarity index 74% rename from tests/e2e/playwright.config.ts rename to tests/playwright.config.ts index 7c23228e..e85c13d9 100644 --- a/tests/e2e/playwright.config.ts +++ b/tests/playwright.config.ts @@ -1,13 +1,13 @@ -import { defineConfig, devices } from '@playwright/test'; +import { defineConfig, devices } from '@playwright/test' -const baseURL = process.env.E2E_BASE_URL || 'http://localhost:3000'; +const baseURL = process.env.E2E_BASE_URL || 'http://localhost:3000' if (!baseURL || baseURL.trim() === '') { - throw new Error('E2E_BASE_URL must be set to a valid URL'); + throw new Error('E2E_BASE_URL must be set to a valid URL') } export default defineConfig({ - testDir: './tests/e2e', + testDir: './e2e', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, @@ -21,17 +21,15 @@ export default defineConfig({ projects: [ { name: 'chromium', - use: { + use: { ...devices['Desktop Chrome'], baseURL: baseURL.trim(), }, }, ], webServer: process.env.CI ? undefined : { - command: 'cd web && npm run dev', + command: 'cd ../web && npm run dev', url: baseURL.trim(), reuseExistingServer: true, }, -}); - - +}) diff --git a/web/api/gql/generated.ts b/web/api/gql/generated.ts index 6e9f6da3..0dee2f40 100644 --- a/web/api/gql/generated.ts +++ b/web/api/gql/generated.ts @@ -369,6 +369,7 @@ export type PatientType = { checksum: Scalars['String']['output']; clinic: LocationNodeType; clinicId: Scalars['ID']['output']; + clinicUpdateDate?: Maybe; description?: Maybe; firstname: Scalars['String']['output']; id: Scalars['ID']['output']; @@ -376,9 +377,11 @@ export type PatientType = { name: Scalars['String']['output']; position?: Maybe; positionId?: Maybe; + positionUpdateDate?: Maybe; properties: Array; sex: Sex; state: PatientState; + stateUpdateDate?: Maybe; tasks: Array; teams: Array; updateDate?: Maybe; @@ -637,6 +640,8 @@ export enum QueryOperator { AnyIn = 'ANY_IN', Between = 'BETWEEN', Contains = 'CONTAINS', + NotBetween = 'NOT_BETWEEN', + NotContains = 'NOT_CONTAINS', EndsWith = 'ENDS_WITH', Eq = 'EQ', Gt = 'GT', @@ -1055,7 +1060,7 @@ export type GetPatientsQueryVariables = Exact<{ }>; -export type GetPatientsQuery = { __typename?: 'Query', patientsTotal: number, patients: Array<{ __typename?: 'PatientType', id: string, name: string, firstname: string, lastname: string, birthdate: any, sex: Sex, state: PatientState, updateDate?: any | null, assignedLocation?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null, assignedLocations: Array<{ __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null } | null }>, clinic: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null } | null } | null }, position?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null } | null } | null } | null } | null, teams: Array<{ __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null } | null } | null }>, tasks: Array<{ __typename?: 'TaskType', id: string, title: string, description?: string | null, done: boolean, dueDate?: any | null, priority?: string | null, estimatedTime?: number | null, creationDate: any, updateDate?: any | null, sourceTaskPresetId?: string | null, assignees: Array<{ __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean }>, assigneeTeam?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null }>, properties: Array<{ __typename?: 'PropertyValueType', id: string, textValue?: string | null, numberValue?: number | null, booleanValue?: boolean | null, dateValue?: any | null, dateTimeValue?: any | null, selectValue?: string | null, multiSelectValues?: Array | null, userValue?: string | null, definition: { __typename?: 'PropertyDefinitionType', id: string, name: string, description?: string | null, fieldType: FieldType, isActive: boolean, allowedEntities: Array, options: Array }, user?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean } | null, team?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null }> }> }; +export type GetPatientsQuery = { __typename?: 'Query', patientsTotal: number, patients: Array<{ __typename?: 'PatientType', id: string, name: string, firstname: string, lastname: string, birthdate: any, sex: Sex, state: PatientState, updateDate?: any | null, stateUpdateDate?: any | null, clinicUpdateDate?: any | null, positionUpdateDate?: any | null, assignedLocation?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null, assignedLocations: Array<{ __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null } | null }>, clinic: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null } | null } | null }, position?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null } | null } | null } | null } | null, teams: Array<{ __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string, parent?: { __typename?: 'LocationNodeType', id: string, title: string } | null } | null } | null } | null }>, tasks: Array<{ __typename?: 'TaskType', id: string, title: string, description?: string | null, done: boolean, dueDate?: any | null, priority?: string | null, estimatedTime?: number | null, creationDate: any, updateDate?: any | null, sourceTaskPresetId?: string | null, assignees: Array<{ __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean }>, assigneeTeam?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null }>, properties: Array<{ __typename?: 'PropertyValueType', id: string, textValue?: string | null, numberValue?: number | null, booleanValue?: boolean | null, dateValue?: any | null, dateTimeValue?: any | null, selectValue?: string | null, multiSelectValues?: Array | null, userValue?: string | null, definition: { __typename?: 'PropertyDefinitionType', id: string, name: string, description?: string | null, fieldType: FieldType, isActive: boolean, allowedEntities: Array, options: Array }, user?: { __typename?: 'UserType', id: string, name: string, avatarUrl?: string | null, lastOnline?: any | null, isOnline: boolean } | null, team?: { __typename?: 'LocationNodeType', id: string, title: string, kind: LocationType } | null }> }> }; export type GetTaskQueryVariables = Exact<{ id: Scalars['ID']['input']; @@ -1430,7 +1435,7 @@ export const GetLocationsDocument = {"kind":"Document","definitions":[{"kind":"O export const GetMyTasksDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetMyTasks"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"me"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"tasks"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"done"}},{"kind":"Field","name":{"kind":"Name","value":"dueDate"}},{"kind":"Field","name":{"kind":"Name","value":"priority"}},{"kind":"Field","name":{"kind":"Name","value":"estimatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"creationDate"}},{"kind":"Field","name":{"kind":"Name","value":"updateDate"}},{"kind":"Field","name":{"kind":"Name","value":"patient"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"assignedLocation"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"assignedLocations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"assignees"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const GetOverviewDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetOverviewData"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"recentPatientsFilters"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"QueryFilterClauseInput"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"recentPatientsSorts"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"QuerySortClauseInput"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"recentPatientsPagination"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"PaginationInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"recentPatientsSearch"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"QuerySearchInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"recentTasksFilters"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"QueryFilterClauseInput"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"recentTasksSorts"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"QuerySortClauseInput"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"recentTasksPagination"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"PaginationInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"recentTasksSearch"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"QuerySearchInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"recentPatients"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"rootLocationIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}}},{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recentPatientsFilters"}}},{"kind":"Argument","name":{"kind":"Name","value":"sorts"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recentPatientsSorts"}}},{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recentPatientsPagination"}}},{"kind":"Argument","name":{"kind":"Name","value":"search"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recentPatientsSearch"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"firstname"}},{"kind":"Field","name":{"kind":"Name","value":"lastname"}},{"kind":"Field","name":{"kind":"Name","value":"sex"}},{"kind":"Field","name":{"kind":"Name","value":"birthdate"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"updateDate"}},{"kind":"Field","name":{"kind":"Name","value":"position"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"tasks"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"done"}},{"kind":"Field","name":{"kind":"Name","value":"updateDate"}}]}},{"kind":"Field","name":{"kind":"Name","value":"properties"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"definition"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"fieldType"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"allowedEntities"}},{"kind":"Field","name":{"kind":"Name","value":"options"}}]}},{"kind":"Field","name":{"kind":"Name","value":"textValue"}},{"kind":"Field","name":{"kind":"Name","value":"numberValue"}},{"kind":"Field","name":{"kind":"Name","value":"booleanValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateTimeValue"}},{"kind":"Field","name":{"kind":"Name","value":"selectValue"}},{"kind":"Field","name":{"kind":"Name","value":"multiSelectValues"}},{"kind":"Field","name":{"kind":"Name","value":"userValue"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentPatientsTotal"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"rootLocationIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}}},{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recentPatientsFilters"}}},{"kind":"Argument","name":{"kind":"Name","value":"sorts"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recentPatientsSorts"}}},{"kind":"Argument","name":{"kind":"Name","value":"search"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recentPatientsSearch"}}}]},{"kind":"Field","name":{"kind":"Name","value":"recentTasks"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"rootLocationIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}}},{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recentTasksFilters"}}},{"kind":"Argument","name":{"kind":"Name","value":"sorts"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recentTasksSorts"}}},{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recentTasksPagination"}}},{"kind":"Argument","name":{"kind":"Name","value":"search"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recentTasksSearch"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"done"}},{"kind":"Field","name":{"kind":"Name","value":"dueDate"}},{"kind":"Field","name":{"kind":"Name","value":"creationDate"}},{"kind":"Field","name":{"kind":"Name","value":"updateDate"}},{"kind":"Field","name":{"kind":"Name","value":"priority"}},{"kind":"Field","name":{"kind":"Name","value":"estimatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"sourceTaskPresetId"}},{"kind":"Field","name":{"kind":"Name","value":"assignees"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"assigneeTeam"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}},{"kind":"Field","name":{"kind":"Name","value":"patient"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"position"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"properties"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"definition"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"fieldType"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"allowedEntities"}},{"kind":"Field","name":{"kind":"Name","value":"options"}}]}},{"kind":"Field","name":{"kind":"Name","value":"textValue"}},{"kind":"Field","name":{"kind":"Name","value":"numberValue"}},{"kind":"Field","name":{"kind":"Name","value":"booleanValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateTimeValue"}},{"kind":"Field","name":{"kind":"Name","value":"selectValue"}},{"kind":"Field","name":{"kind":"Name","value":"multiSelectValues"}},{"kind":"Field","name":{"kind":"Name","value":"userValue"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentTasksTotal"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"rootLocationIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}}},{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recentTasksFilters"}}},{"kind":"Argument","name":{"kind":"Name","value":"sorts"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recentTasksSorts"}}},{"kind":"Argument","name":{"kind":"Name","value":"search"},"value":{"kind":"Variable","name":{"kind":"Name","value":"recentTasksSearch"}}}]}]}}]} as unknown as DocumentNode; export const GetPatientDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetPatient"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"patient"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"firstname"}},{"kind":"Field","name":{"kind":"Name","value":"lastname"}},{"kind":"Field","name":{"kind":"Name","value":"birthdate"}},{"kind":"Field","name":{"kind":"Name","value":"sex"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"updateDate"}},{"kind":"Field","name":{"kind":"Name","value":"checksum"}},{"kind":"Field","name":{"kind":"Name","value":"assignedLocation"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}},{"kind":"Field","name":{"kind":"Name","value":"assignedLocations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}},{"kind":"Field","name":{"kind":"Name","value":"clinic"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"position"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"teams"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"tasks"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"done"}},{"kind":"Field","name":{"kind":"Name","value":"dueDate"}},{"kind":"Field","name":{"kind":"Name","value":"priority"}},{"kind":"Field","name":{"kind":"Name","value":"estimatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"updateDate"}},{"kind":"Field","name":{"kind":"Name","value":"sourceTaskPresetId"}},{"kind":"Field","name":{"kind":"Name","value":"assignees"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"assigneeTeam"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"properties"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"definition"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"fieldType"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"allowedEntities"}},{"kind":"Field","name":{"kind":"Name","value":"options"}}]}},{"kind":"Field","name":{"kind":"Name","value":"textValue"}},{"kind":"Field","name":{"kind":"Name","value":"numberValue"}},{"kind":"Field","name":{"kind":"Name","value":"booleanValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateTimeValue"}},{"kind":"Field","name":{"kind":"Name","value":"selectValue"}},{"kind":"Field","name":{"kind":"Name","value":"multiSelectValues"}},{"kind":"Field","name":{"kind":"Name","value":"userValue"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}}]}}]}}]}}]} as unknown as DocumentNode; -export const GetPatientsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetPatients"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"locationId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"states"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"PatientState"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filters"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"QueryFilterClauseInput"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"sorts"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"QuerySortClauseInput"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"PaginationInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"search"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"QuerySearchInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"patients"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"locationNodeId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locationId"}}},{"kind":"Argument","name":{"kind":"Name","value":"rootLocationIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}}},{"kind":"Argument","name":{"kind":"Name","value":"states"},"value":{"kind":"Variable","name":{"kind":"Name","value":"states"}}},{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filters"}}},{"kind":"Argument","name":{"kind":"Name","value":"sorts"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sorts"}}},{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}}},{"kind":"Argument","name":{"kind":"Name","value":"search"},"value":{"kind":"Variable","name":{"kind":"Name","value":"search"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"firstname"}},{"kind":"Field","name":{"kind":"Name","value":"lastname"}},{"kind":"Field","name":{"kind":"Name","value":"birthdate"}},{"kind":"Field","name":{"kind":"Name","value":"sex"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"updateDate"}},{"kind":"Field","name":{"kind":"Name","value":"assignedLocation"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"assignedLocations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"clinic"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"position"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"teams"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"tasks"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"done"}},{"kind":"Field","name":{"kind":"Name","value":"dueDate"}},{"kind":"Field","name":{"kind":"Name","value":"priority"}},{"kind":"Field","name":{"kind":"Name","value":"estimatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"creationDate"}},{"kind":"Field","name":{"kind":"Name","value":"updateDate"}},{"kind":"Field","name":{"kind":"Name","value":"sourceTaskPresetId"}},{"kind":"Field","name":{"kind":"Name","value":"assignees"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"assigneeTeam"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"properties"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"definition"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"fieldType"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"allowedEntities"}},{"kind":"Field","name":{"kind":"Name","value":"options"}}]}},{"kind":"Field","name":{"kind":"Name","value":"textValue"}},{"kind":"Field","name":{"kind":"Name","value":"numberValue"}},{"kind":"Field","name":{"kind":"Name","value":"booleanValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateTimeValue"}},{"kind":"Field","name":{"kind":"Name","value":"selectValue"}},{"kind":"Field","name":{"kind":"Name","value":"multiSelectValues"}},{"kind":"Field","name":{"kind":"Name","value":"userValue"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"patientsTotal"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"locationNodeId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locationId"}}},{"kind":"Argument","name":{"kind":"Name","value":"rootLocationIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}}},{"kind":"Argument","name":{"kind":"Name","value":"states"},"value":{"kind":"Variable","name":{"kind":"Name","value":"states"}}},{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filters"}}},{"kind":"Argument","name":{"kind":"Name","value":"sorts"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sorts"}}},{"kind":"Argument","name":{"kind":"Name","value":"search"},"value":{"kind":"Variable","name":{"kind":"Name","value":"search"}}}]}]}}]} as unknown as DocumentNode; +export const GetPatientsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetPatients"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"locationId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"states"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"PatientState"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filters"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"QueryFilterClauseInput"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"sorts"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"QuerySortClauseInput"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"PaginationInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"search"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"QuerySearchInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"patients"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"locationNodeId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locationId"}}},{"kind":"Argument","name":{"kind":"Name","value":"rootLocationIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}}},{"kind":"Argument","name":{"kind":"Name","value":"states"},"value":{"kind":"Variable","name":{"kind":"Name","value":"states"}}},{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filters"}}},{"kind":"Argument","name":{"kind":"Name","value":"sorts"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sorts"}}},{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}}},{"kind":"Argument","name":{"kind":"Name","value":"search"},"value":{"kind":"Variable","name":{"kind":"Name","value":"search"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"firstname"}},{"kind":"Field","name":{"kind":"Name","value":"lastname"}},{"kind":"Field","name":{"kind":"Name","value":"birthdate"}},{"kind":"Field","name":{"kind":"Name","value":"sex"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"updateDate"}},{"kind":"Field","name":{"kind":"Name","value":"stateUpdateDate"}},{"kind":"Field","name":{"kind":"Name","value":"clinicUpdateDate"}},{"kind":"Field","name":{"kind":"Name","value":"positionUpdateDate"}},{"kind":"Field","name":{"kind":"Name","value":"assignedLocation"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"assignedLocations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"clinic"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"position"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"teams"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"tasks"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"done"}},{"kind":"Field","name":{"kind":"Name","value":"dueDate"}},{"kind":"Field","name":{"kind":"Name","value":"priority"}},{"kind":"Field","name":{"kind":"Name","value":"estimatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"creationDate"}},{"kind":"Field","name":{"kind":"Name","value":"updateDate"}},{"kind":"Field","name":{"kind":"Name","value":"sourceTaskPresetId"}},{"kind":"Field","name":{"kind":"Name","value":"assignees"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"assigneeTeam"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"properties"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"definition"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"fieldType"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"allowedEntities"}},{"kind":"Field","name":{"kind":"Name","value":"options"}}]}},{"kind":"Field","name":{"kind":"Name","value":"textValue"}},{"kind":"Field","name":{"kind":"Name","value":"numberValue"}},{"kind":"Field","name":{"kind":"Name","value":"booleanValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateTimeValue"}},{"kind":"Field","name":{"kind":"Name","value":"selectValue"}},{"kind":"Field","name":{"kind":"Name","value":"multiSelectValues"}},{"kind":"Field","name":{"kind":"Name","value":"userValue"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"patientsTotal"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"locationNodeId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locationId"}}},{"kind":"Argument","name":{"kind":"Name","value":"rootLocationIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}}},{"kind":"Argument","name":{"kind":"Name","value":"states"},"value":{"kind":"Variable","name":{"kind":"Name","value":"states"}}},{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filters"}}},{"kind":"Argument","name":{"kind":"Name","value":"sorts"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sorts"}}},{"kind":"Argument","name":{"kind":"Name","value":"search"},"value":{"kind":"Variable","name":{"kind":"Name","value":"search"}}}]}]}}]} as unknown as DocumentNode; export const GetTaskDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetTask"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"task"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"done"}},{"kind":"Field","name":{"kind":"Name","value":"dueDate"}},{"kind":"Field","name":{"kind":"Name","value":"priority"}},{"kind":"Field","name":{"kind":"Name","value":"estimatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"checksum"}},{"kind":"Field","name":{"kind":"Name","value":"updateDate"}},{"kind":"Field","name":{"kind":"Name","value":"sourceTaskPresetId"}},{"kind":"Field","name":{"kind":"Name","value":"patient"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"assignees"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"assigneeTeam"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}},{"kind":"Field","name":{"kind":"Name","value":"properties"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"definition"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"fieldType"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"allowedEntities"}},{"kind":"Field","name":{"kind":"Name","value":"options"}}]}},{"kind":"Field","name":{"kind":"Name","value":"textValue"}},{"kind":"Field","name":{"kind":"Name","value":"numberValue"}},{"kind":"Field","name":{"kind":"Name","value":"booleanValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateTimeValue"}},{"kind":"Field","name":{"kind":"Name","value":"selectValue"}},{"kind":"Field","name":{"kind":"Name","value":"multiSelectValues"}},{"kind":"Field","name":{"kind":"Name","value":"userValue"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const GetTasksDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetTasks"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"assigneeId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"assigneeTeamId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filters"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"QueryFilterClauseInput"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"sorts"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"QuerySortClauseInput"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"PaginationInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"search"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"QuerySearchInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"tasks"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"rootLocationIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}}},{"kind":"Argument","name":{"kind":"Name","value":"assigneeId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"assigneeId"}}},{"kind":"Argument","name":{"kind":"Name","value":"assigneeTeamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"assigneeTeamId"}}},{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filters"}}},{"kind":"Argument","name":{"kind":"Name","value":"sorts"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sorts"}}},{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}}},{"kind":"Argument","name":{"kind":"Name","value":"search"},"value":{"kind":"Variable","name":{"kind":"Name","value":"search"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"done"}},{"kind":"Field","name":{"kind":"Name","value":"dueDate"}},{"kind":"Field","name":{"kind":"Name","value":"priority"}},{"kind":"Field","name":{"kind":"Name","value":"estimatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"creationDate"}},{"kind":"Field","name":{"kind":"Name","value":"updateDate"}},{"kind":"Field","name":{"kind":"Name","value":"sourceTaskPresetId"}},{"kind":"Field","name":{"kind":"Name","value":"patient"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"firstname"}},{"kind":"Field","name":{"kind":"Name","value":"lastname"}},{"kind":"Field","name":{"kind":"Name","value":"birthdate"}},{"kind":"Field","name":{"kind":"Name","value":"sex"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"assignedLocation"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"assignedLocations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"clinic"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"position"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"teams"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}}]}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"properties"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"definition"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"fieldType"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"allowedEntities"}},{"kind":"Field","name":{"kind":"Name","value":"options"}}]}},{"kind":"Field","name":{"kind":"Name","value":"textValue"}},{"kind":"Field","name":{"kind":"Name","value":"numberValue"}},{"kind":"Field","name":{"kind":"Name","value":"booleanValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateTimeValue"}},{"kind":"Field","name":{"kind":"Name","value":"selectValue"}},{"kind":"Field","name":{"kind":"Name","value":"multiSelectValues"}},{"kind":"Field","name":{"kind":"Name","value":"userValue"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"assignees"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"assigneeTeam"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}},{"kind":"Field","name":{"kind":"Name","value":"properties"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"definition"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"fieldType"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"allowedEntities"}},{"kind":"Field","name":{"kind":"Name","value":"options"}}]}},{"kind":"Field","name":{"kind":"Name","value":"textValue"}},{"kind":"Field","name":{"kind":"Name","value":"numberValue"}},{"kind":"Field","name":{"kind":"Name","value":"booleanValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateValue"}},{"kind":"Field","name":{"kind":"Name","value":"dateTimeValue"}},{"kind":"Field","name":{"kind":"Name","value":"selectValue"}},{"kind":"Field","name":{"kind":"Name","value":"multiSelectValues"}},{"kind":"Field","name":{"kind":"Name","value":"userValue"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"kind"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"tasksTotal"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"rootLocationIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"rootLocationIds"}}},{"kind":"Argument","name":{"kind":"Name","value":"assigneeId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"assigneeId"}}},{"kind":"Argument","name":{"kind":"Name","value":"assigneeTeamId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"assigneeTeamId"}}},{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filters"}}},{"kind":"Argument","name":{"kind":"Name","value":"sorts"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sorts"}}},{"kind":"Argument","name":{"kind":"Name","value":"search"},"value":{"kind":"Variable","name":{"kind":"Name","value":"search"}}}]}]}}]} as unknown as DocumentNode; export const GetUserDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetUser"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"user"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"firstname"}},{"kind":"Field","name":{"kind":"Name","value":"lastname"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"lastOnline"}},{"kind":"Field","name":{"kind":"Name","value":"isOnline"}}]}}]}}]} as unknown as DocumentNode; diff --git a/web/api/graphql/GetPatients.graphql b/web/api/graphql/GetPatients.graphql index 0c53799c..80b44e3c 100644 --- a/web/api/graphql/GetPatients.graphql +++ b/web/api/graphql/GetPatients.graphql @@ -8,6 +8,9 @@ query GetPatients($locationId: ID, $rootLocationIds: [ID!], $states: [PatientSta sex state updateDate + stateUpdateDate + clinicUpdateDate + positionUpdateDate assignedLocation { id title diff --git a/web/api/mutations/patients/updatePatient.plan.list.integration.test.ts b/web/api/mutations/patients/updatePatient.plan.list.integration.test.ts index 4e3feb6d..afba0f10 100644 --- a/web/api/mutations/patients/updatePatient.plan.list.integration.test.ts +++ b/web/api/mutations/patients/updatePatient.plan.list.integration.test.ts @@ -28,6 +28,9 @@ function seedListPatient(cache: InMemoryCache) { sex: 'FEMALE', state: 'ADMITTED', updateDate: null, + stateUpdateDate: null, + clinicUpdateDate: null, + positionUpdateDate: null, assignedLocation: null, assignedLocations: [], clinic: null, @@ -171,6 +174,9 @@ describe('updatePatientOptimisticPlan list integration', () => { sex: 'MALE' as const, state: 'ADMITTED' as const, updateDate: null, + stateUpdateDate: null, + clinicUpdateDate: null, + positionUpdateDate: null, assignedLocation: null, assignedLocations: [], clinic: null, diff --git a/web/components/common/ScrollToTopButton.tsx b/web/components/common/ScrollToTopButton.tsx index c55e4d61..2b835a61 100644 --- a/web/components/common/ScrollToTopButton.tsx +++ b/web/components/common/ScrollToTopButton.tsx @@ -27,10 +27,21 @@ export const ScrollToTopButton = () => { useEffect(() => { if (!scrollElement) return - const handleScroll = () => setIsVisible(scrollElement.scrollTop > SHOW_THRESHOLD_PX) - handleScroll() + let frame: number | null = null + const update = () => { + frame = null + setIsVisible(scrollElement.scrollTop > SHOW_THRESHOLD_PX) + } + const handleScroll = () => { + if (frame !== null) return + frame = window.requestAnimationFrame(update) + } + update() scrollElement.addEventListener('scroll', handleScroll, { passive: true }) - return () => scrollElement.removeEventListener('scroll', handleScroll) + return () => { + if (frame !== null) window.cancelAnimationFrame(frame) + scrollElement.removeEventListener('scroll', handleScroll) + } }, [scrollElement]) return ( diff --git a/web/components/patients/PatientTasksView.tsx b/web/components/patients/PatientTasksView.tsx index 6ab6ffa2..69cc829f 100644 --- a/web/components/patients/PatientTasksView.tsx +++ b/web/components/patients/PatientTasksView.tsx @@ -1,7 +1,10 @@ import { useState, useMemo, useEffect, useCallback } from 'react' -import { Button, Drawer, ExpandableContent, ExpandableHeader, ExpandableRoot } from '@helpwave/hightide' +import { Button, Drawer, ExpandableContent, ExpandableHeader, ExpandableRoot, useLocale } from '@helpwave/hightide' import { useTasksTranslation } from '@/i18n/useTasksTranslation' import { CheckCircle2, ChevronDown, Circle, Combine, PlusIcon } from 'lucide-react' +import { SortDirection } from '@/api/gql/generated' +import type { TableExportFormat, TableExportRequest } from '@/utils/tableExport' +import { TableExportMenu } from '@/components/tables/TableExportMenu' import { TaskCardView } from '@/components/tasks/TaskCardView' import clsx from 'clsx' import type { GetPatientQuery } from '@/api/gql/generated' @@ -90,6 +93,29 @@ export const PatientTasksView = ({ const openTasks = useMemo(() => sortByDueDate(tasks.filter(t => !t.done)), [tasks]) const closedTasks = useMemo(() => sortByDueDate(tasks.filter(t => t.done)), [tasks]) + const { locale, timeZone } = useLocale() + const buildExportRequest = useCallback((format: TableExportFormat): TableExportRequest => ({ + entity: 'tasks', + format, + columns: [ + { key: 'done', label: translation('done') }, + { key: 'title', label: translation('title') }, + { key: 'description', label: translation('description') }, + { key: 'dueDate', label: translation('dueDate') }, + { key: 'assignee', label: translation('assignedTo') }, + ], + sorts: [ + { fieldKey: 'done', direction: SortDirection.Asc }, + { fieldKey: 'dueDate', direction: SortDirection.Asc }, + ], + locale, + timezone: timeZone, + title: initialPatientName + ? `${translation('tasks')}: ${initialPatientName}` + : translation('tasks'), + scope: { patientId }, + }), [translation, locale, timeZone, initialPatientName, patientId]) + useEffect(() => { setOptimisticTaskUpdates(new Map()) }, [patientData?.patient?.tasks]) @@ -151,6 +177,7 @@ export const PatientTasksView = ({
+
)} -
+
(({ containerProps={{ className: 'print:max-h-none print:overflow-visible', }} - className="print-content overflow-x-auto hw-touch-scroll" + className="print-content" />
{listLayout === 'card' && ( diff --git a/web/components/tables/TableExportMenu.tsx b/web/components/tables/TableExportMenu.tsx new file mode 100644 index 00000000..5ec6a170 --- /dev/null +++ b/web/components/tables/TableExportMenu.tsx @@ -0,0 +1,74 @@ +'use client' + +import { useState } from 'react' +import { IconButton, Menu, MenuItem } from '@helpwave/hightide' +import { FileDown, Loader2 } from 'lucide-react' +import { useTasksTranslation } from '@/i18n/useTasksTranslation' +import type { TableExportFormat, TableExportRequest } from '@/utils/tableExport' +import { downloadTableExport } from '@/utils/tableExport' + +export type TableExportMenuProps = { + buildRequest: (format: TableExportFormat) => TableExportRequest, +} + +export function TableExportMenu({ buildRequest }: TableExportMenuProps) { + const translation = useTasksTranslation() + const [isExporting, setIsExporting] = useState(false) + const [hasError, setHasError] = useState(false) + + const startExport = async (format: TableExportFormat) => { + setIsExporting(true) + setHasError(false) + try { + await downloadTableExport(buildRequest(format)) + } catch (error) { + console.error('Table export failed', error) + setHasError(true) + } finally { + setIsExporting(false) + } + } + + return ( + ( + + {isExporting ? : } + + )} + className="min-w-56 p-2" + > + {({ close }) => ( + <> + { + void startExport('xlsx') + close() + }} + isDisabled={isExporting} + className="rounded-md cursor-pointer" + > + {translation('exportExcel')} + + { + void startExport('csv') + close() + }} + isDisabled={isExporting} + className="rounded-md cursor-pointer" + > + {translation('exportCsv')} + + + )} + + ) +} diff --git a/web/components/tables/TaskList.tsx b/web/components/tables/TaskList.tsx index 1911cc79..5f6442ed 100644 --- a/web/components/tables/TaskList.tsx +++ b/web/components/tables/TaskList.tsx @@ -1,7 +1,7 @@ import { useMemo, useState, forwardRef, useImperativeHandle, useEffect, useRef, useCallback, type ReactNode } from 'react' import { useQueryClient } from '@tanstack/react-query' import type { FilterListItem } from '@helpwave/hightide' -import { Button, Checkbox, ConfirmDialog, FilterList, FillerCell, IconButton, SearchBar, TableColumnSwitcher, TableDisplay, TableProvider, SortingList, ExpansionIcon, VirtualizedCardGrid } from '@helpwave/hightide' +import { Button, Checkbox, ConfirmDialog, FilterList, FillerCell, IconButton, SearchBar, TableColumnSwitcher, TableDisplay, TableProvider, SortingList, ExpansionIcon, VirtualizedCardGrid, overscanRowsForBuffer, useLocale } from '@helpwave/hightide' import clsx from 'clsx' import { Edit2, ExternalLink, LayoutGrid, PlusIcon, Table2, UserCheck } from 'lucide-react' import type { IdentifierFilterValue } from '@helpwave/hightide' @@ -32,7 +32,7 @@ import { queryableFieldsToFilterListItems, queryableFieldsToSortingListItems, ty import { LIST_PAGE_SIZE } from '@/utils/listPaging' import { TaskCardView } from '@/components/tasks/TaskCardView' import { RefreshingTaskIdsContext, TaskRowRefreshingGate } from '@/components/tables/TaskRowRefreshingGate' -import { overscanRowsForBuffer } from '@/utils/virtualGrid' + import { ListLoadingHint } from '@/components/common/ListLoadingHint' import { useIsPrinting } from '@/hooks/useIsPrinting' import { ScrollToTopButton } from '@/components/common/ScrollToTopButton' @@ -44,6 +44,9 @@ import { PropertyColumnHeader } from '@/components/properties/PropertyColumnHead import { ClearPropertyColumnDialog } from '@/components/properties/ClearPropertyColumnDialog' import { useTaskPropertyClearDialog } from '@/hooks/useTaskPropertyClearDialog' import { buildListLayoutStorageKey, resolveListRouteId, useListLayoutPreference } from '@/hooks/useListLayoutPreference' +import { columnFiltersToQueryFilterClauses, sortingStateToQuerySortClauses } from '@/utils/tableStateToApi' +import { collectExportColumns, type TableExportFormat, type TableExportRequest, type TableExportScope } from '@/utils/tableExport' +import { TableExportMenu } from '@/components/tables/TableExportMenu' import { useRouter } from 'next/router' import type { DialogState } from '@/types/DialogState' @@ -121,9 +124,11 @@ type TaskListProps = { embedded?: boolean, virtualDerivedOrder?: boolean, taskInitialCreationData?: TaskCreationInitialData, + exportScope?: TableExportScope, + exportTitle?: string, } -export const TaskList = forwardRef(({ tasks: initialTasks, onRefetch, showAssignee = false, initialTaskId, onInitialTaskOpened, headerActions, saveViewSlot, totalCount, loading = false, tableState: controlledTableState, searchQuery: searchQueryProp, onSearchQueryChange, loadMore: loadMoreProp, hasMore: hasMoreProp, isFetchingMore = false, embedded = false, virtualDerivedOrder = false, taskInitialCreationData }, ref) => { +export const TaskList = forwardRef(({ tasks: initialTasks, onRefetch, showAssignee = false, initialTaskId, onInitialTaskOpened, headerActions, saveViewSlot, totalCount, loading = false, tableState: controlledTableState, searchQuery: searchQueryProp, onSearchQueryChange, loadMore: loadMoreProp, hasMore: hasMoreProp, isFetchingMore = false, embedded = false, virtualDerivedOrder = false, taskInitialCreationData, exportScope, exportTitle }, ref) => { const translation = useTasksTranslation() const isPrinting = useIsPrinting() const { data: propertyDefinitionsData } = usePropertyDefinitions() @@ -298,9 +303,11 @@ export const TaskList = forwardRef(({ tasks: initial return a.done ? 1 : -1 } + // backend parity: ascending due-date order with tasks that have no + // due date first (dueDate.asc().nulls_first()) if (!a.dueDate && !b.dueDate) return 0 - if (!a.dueDate) return 1 - if (!b.dueDate) return -1 + if (!a.dueDate) return -1 + if (!b.dueDate) return 1 return a.dueDate.getTime() - b.dueDate.getTime() }) @@ -881,6 +888,22 @@ export const TaskList = forwardRef(({ tasks: initial [columnOrder, knownColumnIdsOrdered] ) + const { locale, timeZone } = useLocale() + const buildExportRequest = useCallback((format: TableExportFormat): TableExportRequest => ({ + entity: 'tasks', + format, + columns: collectExportColumns(columns, tableColumnVisibility, sanitizedColumnOrder, { + done: translation('done'), + }), + filters: columnFiltersToQueryFilterClauses(filters as ColumnFiltersState), + sorts: sortingStateToQuerySortClauses(sorting), + search: searchQuery ? { searchText: searchQuery, includeProperties: true } : undefined, + locale, + timezone: timeZone, + title: exportTitle, + scope: exportScope, + }), [columns, tableColumnVisibility, sanitizedColumnOrder, translation, filters, sorting, searchQuery, locale, timeZone, exportTitle, exportScope]) + const deferSetColumnOrder = useDeferredColumnOrderChange(setColumnOrder) const embeddedTableStateNoop = useCallback(() => { }, []) const hasOpenDrawer = taskDialogState.isOpen || patientDialogState != null @@ -978,6 +1001,9 @@ export const TaskList = forwardRef(({ tasks: initial buttonProps={{ className: 'min-h-11 min-w-11 shrink-0' }} style={{ zIndex: 120 }} /> + {exportScope && ( + + )}
)} -
+
{!embedded && (