From ed1438230c8ee161b8b6c0935a155219c4650b36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 13:21:43 +0200 Subject: [PATCH] feat(encounters): visits and the address book become reachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /checkups gains a second section rather than a nav entry. The page already owns the right sentence — when do I have to do what, where — and is deliberately not module gated, which is the same reason a visit carries no module key of its own: what a visit may point AT follows the modules that own those things, so the gate sits on the three link pickers and blanks the block rather than rendering an empty one. A visit saves with a date and nothing else. No required practitioner, no required kind, no required link, and the only thing that can disable Save is a date that will not parse. The status is derived from the date on a new visit and only becomes a field on an existing one, because somebody booking an appointment should not also have to tell the app that it is in the future. The address book is record content, not configuration, so it sits under /checkups rather than in Settings — and it inherits that page's route-family classification with it. Deleting an entry says in one sentence that the old visits stay, because a person deleting a doctor has every reason to fear otherwise. Two defects closed on the way: - /checkups carried sharedRecord: true and appeared in neither route family, so a scoped grant could not present it at all. It was invisible while a whole-record grant admitted every path; the visits section makes it load bearing, and the page is now listed under both domains whose content it shows. Deduplicated, or a grant holding both would offer the page twice. - the address-book search read the name only, so looking a practice up by the name on the door returned nothing and read as "not in the book". The practitioner search description in the OpenAPI module moved with it. --- messages/de.json | 82 ++++- messages/en.json | 82 ++++- messages/es.json | 82 ++++- messages/fr.json | 82 ++++- messages/it.json | 82 ++++- messages/pl.json | 82 ++++- .../destructive-control-guard.test.ts | 20 ++ .../shared-record-navigation.test.tsx | 36 ++- src/app/api/practitioners/route.ts | 21 +- src/app/checkups/page.tsx | 45 ++- src/app/checkups/practitioners/page.tsx | 28 ++ .../__tests__/encounter-form.test.tsx | 184 ++++++++++++ .../__tests__/visits-section.test.tsx | 127 ++++++++ src/components/encounters/encounter-form.tsx | 283 ++++++++++++++++++ src/components/encounters/encounter-labels.ts | 74 +++++ .../encounters/encounter-link-pickers.tsx | 248 +++++++++++++++ src/components/encounters/encounter-sheet.tsx | 207 +++++++++++++ .../encounters/practitioner-combobox.tsx | 177 +++++++++++ src/components/encounters/visit-card.tsx | 127 ++++++++ src/components/encounters/visits-section.tsx | 147 +++++++++ .../practitioners/practitioner-list.tsx | 228 ++++++++++++++ .../practitioners/practitioner-sheet.tsx | 205 +++++++++++++ src/hooks/use-encounters.ts | 117 ++++++++ src/hooks/use-practitioners.ts | 72 +++++ src/lib/navigation/shared-record.ts | 21 +- src/lib/openapi/routes/encounters.ts | 2 +- src/lib/query-keys/encounters.ts | 9 + 27 files changed, 2851 insertions(+), 19 deletions(-) create mode 100644 src/app/checkups/practitioners/page.tsx create mode 100644 src/components/encounters/__tests__/encounter-form.test.tsx create mode 100644 src/components/encounters/__tests__/visits-section.test.tsx create mode 100644 src/components/encounters/encounter-form.tsx create mode 100644 src/components/encounters/encounter-labels.ts create mode 100644 src/components/encounters/encounter-link-pickers.tsx create mode 100644 src/components/encounters/encounter-sheet.tsx create mode 100644 src/components/encounters/practitioner-combobox.tsx create mode 100644 src/components/encounters/visit-card.tsx create mode 100644 src/components/encounters/visits-section.tsx create mode 100644 src/components/practitioners/practitioner-list.tsx create mode 100644 src/components/practitioners/practitioner-sheet.tsx create mode 100644 src/hooks/use-encounters.ts create mode 100644 src/hooks/use-practitioners.ts diff --git a/messages/de.json b/messages/de.json index 22e0b9015..8d03ea71b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -10073,6 +10073,86 @@ "hospital": "Krankenhaus", "therapy": "Therapietermin", "other": "Arzttermin" - } + }, + "sectionTitle": "Termine", + "sectionDescription": "Arztbesuche und gebuchte Termine an einem Ort.", + "add": "Termin", + "loadError": "Termine konnten nicht geladen werden.", + "emptyTitle": "Noch kein Termin erfasst", + "emptyDescription": "Ein Datum genügt. Alles andere ist freiwillig.", + "upcomingHeading": "Anstehend", + "pastHeading": "Vergangen", + "createTitle": "Termin erfassen", + "editTitle": "Termin bearbeiten", + "formDescription": "Nur das Datum ist Pflicht.", + "created": "Termin gespeichert.", + "updated": "Termin aktualisiert.", + "saveFailed": "Termin konnte nicht gespeichert werden.", + "checkupNotClosed": "Termin gespeichert. Die Vorsorge bleibt offen — dafür reicht deine Freigabe nicht.", + "status": { + "planned": "Geplant", + "done": "Stattgefunden", + "cancelled": "Abgesagt", + "noShow": "Nicht wahrgenommen" + }, + "card": { + "documentCount": "verknüpfte Dokumente", + "labCount": "verknüpfte Laborwerte", + "conditionCount": "verknüpfte Erkrankungen" + }, + "form": { + "date": "Datum und Uhrzeit", + "dateDerivesStatusHint": "Ein Datum in der Zukunft ist ein gebuchter Termin.", + "status": "Status", + "practitioner": "Praxis oder Ärztin", + "practitionerHint": "Optional — du kannst den Namen später nachtragen.", + "practitionerNone": "Keine Angabe", + "practitionerSearch": "Name oder Praxis suchen", + "practitionerNoMatch": "Kein Eintrag gefunden.", + "practitionerAdd": "Neu anlegen", + "practitionerClear": "Auswahl entfernen", + "kind": "Art", + "reason": "Anlass", + "outcome": "Ergebnis", + "outcomeReveal": "Ergebnis eintragen", + "linkDocuments": "Dokumente", + "linkLabResults": "Laborwerte", + "linkConditions": "Erkrankungen", + "linkNothingToOffer": "Nichts zum Verknüpfen vorhanden." + }, + "deleteTitle": "Termin löschen?", + "deleteDescription": "Der Eintrag wird entfernt. Ist der Termin gebucht, entfällt auch die Erinnerung dazu.", + "deleteFailed": "Termin konnte nicht gelöscht werden." + }, + "checkups": { + "viewSwitchLabel": "Vorsorge oder Termine anzeigen", + "tabVorsorge": "Vorsorge", + "tabVisits": "Termine" + }, + "practitioners": { + "title": "Praxen und Ärzte", + "description": "Dein eigenes Adressbuch — einmal tippen, überall auswählen.", + "linkLabel": "Adressbuch", + "add": "Eintrag", + "searchPlaceholder": "Name oder Praxis suchen", + "loadError": "Adressbuch konnte nicht geladen werden.", + "emptyTitle": "Noch kein Eintrag", + "emptyDescription": "Lege eine Praxis an, damit du sie beim Termin auswählen kannst.", + "noMatchTitle": "Kein Treffer", + "noMatchDescription": "Zu dieser Suche gibt es keinen Eintrag.", + "createTitle": "Eintrag anlegen", + "editTitle": "Eintrag bearbeiten", + "formDescription": "Nur der Name ist Pflicht.", + "name": "Name", + "specialty": "Fachrichtung", + "practice": "Praxis", + "location": "Adresse", + "phone": "Telefon", + "note": "Notiz", + "noteHint": "Verschlüsselt gespeichert.", + "saveFailed": "Eintrag konnte nicht gespeichert werden.", + "deleteTitle": "Eintrag löschen?", + "deleteDescription": "Deine bisherigen Termine bleiben erhalten und behalten diesen Namen.", + "deleteFailed": "Eintrag konnte nicht gelöscht werden." } } diff --git a/messages/en.json b/messages/en.json index afacc42d2..c62cff149 100644 --- a/messages/en.json +++ b/messages/en.json @@ -10073,6 +10073,86 @@ "hospital": "Hospital", "therapy": "Therapy session", "other": "Appointment" - } + }, + "sectionTitle": "Visits", + "sectionDescription": "Doctor's visits and booked appointments in one place.", + "add": "Visit", + "loadError": "Visits could not be loaded.", + "emptyTitle": "No visit recorded yet", + "emptyDescription": "A date is enough. Everything else is optional.", + "upcomingHeading": "Upcoming", + "pastHeading": "Past", + "createTitle": "Record a visit", + "editTitle": "Edit visit", + "formDescription": "Only the date is required.", + "created": "Visit saved.", + "updated": "Visit updated.", + "saveFailed": "The visit could not be saved.", + "checkupNotClosed": "Visit saved. The checkup stays open — your access does not cover that.", + "status": { + "planned": "Planned", + "done": "Attended", + "cancelled": "Cancelled", + "noShow": "Missed" + }, + "card": { + "documentCount": "linked documents", + "labCount": "linked lab results", + "conditionCount": "linked conditions" + }, + "form": { + "date": "Date and time", + "dateDerivesStatusHint": "A date in the future is a booked appointment.", + "status": "Status", + "practitioner": "Practice or doctor", + "practitionerHint": "Optional — you can add the name later.", + "practitionerNone": "Not specified", + "practitionerSearch": "Search name or practice", + "practitionerNoMatch": "No entry found.", + "practitionerAdd": "Add new", + "practitionerClear": "Clear selection", + "kind": "Kind", + "reason": "Reason", + "outcome": "Outcome", + "outcomeReveal": "Add an outcome", + "linkDocuments": "Documents", + "linkLabResults": "Lab results", + "linkConditions": "Conditions", + "linkNothingToOffer": "Nothing here to link yet." + }, + "deleteTitle": "Delete this visit?", + "deleteDescription": "The entry is removed. If the visit is booked, its reminder stops with it.", + "deleteFailed": "The visit could not be deleted." + }, + "checkups": { + "viewSwitchLabel": "Show checkups or visits", + "tabVorsorge": "Checkups", + "tabVisits": "Visits" + }, + "practitioners": { + "title": "Practices and doctors", + "description": "Your own address book — type a practice once, pick it everywhere.", + "linkLabel": "Address book", + "add": "Entry", + "searchPlaceholder": "Search name or practice", + "loadError": "The address book could not be loaded.", + "emptyTitle": "No entry yet", + "emptyDescription": "Add a practice so you can pick it when you record a visit.", + "noMatchTitle": "No match", + "noMatchDescription": "No entry matches this search.", + "createTitle": "Add an entry", + "editTitle": "Edit entry", + "formDescription": "Only the name is required.", + "name": "Name", + "specialty": "Specialty", + "practice": "Practice", + "location": "Address", + "phone": "Phone", + "note": "Note", + "noteHint": "Stored encrypted.", + "saveFailed": "The entry could not be saved.", + "deleteTitle": "Delete this entry?", + "deleteDescription": "Your existing visits stay, and they keep this name.", + "deleteFailed": "The entry could not be deleted." } } diff --git a/messages/es.json b/messages/es.json index 7cec1ba45..a7c38c795 100644 --- a/messages/es.json +++ b/messages/es.json @@ -10073,6 +10073,86 @@ "hospital": "Hospital", "therapy": "Sesión de terapia", "other": "Cita médica" - } + }, + "sectionTitle": "Citas", + "sectionDescription": "Visitas médicas y citas reservadas en un solo sitio.", + "add": "Cita", + "loadError": "No se pudieron cargar las citas.", + "emptyTitle": "Todavía no hay ninguna cita", + "emptyDescription": "Basta con una fecha. Todo lo demás es opcional.", + "upcomingHeading": "Próximas", + "pastHeading": "Pasadas", + "createTitle": "Registrar una cita", + "editTitle": "Editar la cita", + "formDescription": "Solo la fecha es obligatoria.", + "created": "Cita guardada.", + "updated": "Cita actualizada.", + "saveFailed": "No se pudo guardar la cita.", + "checkupNotClosed": "Cita guardada. La revisión sigue pendiente: tu acceso no lo permite.", + "status": { + "planned": "Prevista", + "done": "Realizada", + "cancelled": "Cancelada", + "noShow": "No acudida" + }, + "card": { + "documentCount": "documentos vinculados", + "labCount": "resultados de laboratorio vinculados", + "conditionCount": "enfermedades vinculadas" + }, + "form": { + "date": "Fecha y hora", + "dateDerivesStatusHint": "Una fecha futura es una cita reservada.", + "status": "Estado", + "practitioner": "Consulta o médico", + "practitionerHint": "Opcional: puedes añadir el nombre más tarde.", + "practitionerNone": "Sin indicar", + "practitionerSearch": "Buscar nombre o consulta", + "practitionerNoMatch": "No se encontró ninguna entrada.", + "practitionerAdd": "Añadir nueva", + "practitionerClear": "Quitar la selección", + "kind": "Tipo", + "reason": "Motivo", + "outcome": "Resultado", + "outcomeReveal": "Añadir un resultado", + "linkDocuments": "Documentos", + "linkLabResults": "Resultados de laboratorio", + "linkConditions": "Enfermedades", + "linkNothingToOffer": "Todavía no hay nada que vincular." + }, + "deleteTitle": "¿Eliminar esta cita?", + "deleteDescription": "Se elimina la entrada. Si la cita está reservada, su recordatorio también desaparece.", + "deleteFailed": "No se pudo eliminar la cita." + }, + "checkups": { + "viewSwitchLabel": "Mostrar revisiones o citas", + "tabVorsorge": "Revisiones", + "tabVisits": "Citas" + }, + "practitioners": { + "title": "Consultas y médicos", + "description": "Tu propia agenda: escribe una consulta una vez y elígela en todas partes.", + "linkLabel": "Agenda", + "add": "Entrada", + "searchPlaceholder": "Buscar nombre o consulta", + "loadError": "No se pudo cargar la agenda.", + "emptyTitle": "Todavía no hay entradas", + "emptyDescription": "Añade una consulta para poder elegirla al registrar una cita.", + "noMatchTitle": "Sin coincidencias", + "noMatchDescription": "Ninguna entrada coincide con esta búsqueda.", + "createTitle": "Añadir una entrada", + "editTitle": "Editar la entrada", + "formDescription": "Solo el nombre es obligatorio.", + "name": "Nombre", + "specialty": "Especialidad", + "practice": "Consulta", + "location": "Dirección", + "phone": "Teléfono", + "note": "Nota", + "noteHint": "Se guarda cifrada.", + "saveFailed": "No se pudo guardar la entrada.", + "deleteTitle": "¿Eliminar esta entrada?", + "deleteDescription": "Tus citas anteriores se mantienen y conservan este nombre.", + "deleteFailed": "No se pudo eliminar la entrada." } } diff --git a/messages/fr.json b/messages/fr.json index d91f2cf3d..3b69dd66d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -10073,6 +10073,86 @@ "hospital": "Hôpital", "therapy": "Séance de thérapie", "other": "Rendez-vous médical" - } + }, + "sectionTitle": "Rendez-vous", + "sectionDescription": "Consultations et rendez-vous pris, au même endroit.", + "add": "Rendez-vous", + "loadError": "Impossible de charger les rendez-vous.", + "emptyTitle": "Aucun rendez-vous enregistré", + "emptyDescription": "Une date suffit. Le reste est facultatif.", + "upcomingHeading": "À venir", + "pastHeading": "Passés", + "createTitle": "Enregistrer un rendez-vous", + "editTitle": "Modifier le rendez-vous", + "formDescription": "Seule la date est obligatoire.", + "created": "Rendez-vous enregistré.", + "updated": "Rendez-vous mis à jour.", + "saveFailed": "Le rendez-vous n'a pas pu être enregistré.", + "checkupNotClosed": "Rendez-vous enregistré. Le dépistage reste ouvert : votre accès ne le couvre pas.", + "status": { + "planned": "Prévu", + "done": "Effectué", + "cancelled": "Annulé", + "noShow": "Non honoré" + }, + "card": { + "documentCount": "documents liés", + "labCount": "résultats de laboratoire liés", + "conditionCount": "maladies liées" + }, + "form": { + "date": "Date et heure", + "dateDerivesStatusHint": "Une date à venir correspond à un rendez-vous pris.", + "status": "Statut", + "practitioner": "Cabinet ou médecin", + "practitionerHint": "Facultatif : vous pourrez ajouter le nom plus tard.", + "practitionerNone": "Non précisé", + "practitionerSearch": "Rechercher un nom ou un cabinet", + "practitionerNoMatch": "Aucune entrée trouvée.", + "practitionerAdd": "Créer une entrée", + "practitionerClear": "Retirer la sélection", + "kind": "Type", + "reason": "Motif", + "outcome": "Résultat", + "outcomeReveal": "Ajouter un résultat", + "linkDocuments": "Documents", + "linkLabResults": "Résultats de laboratoire", + "linkConditions": "Maladies", + "linkNothingToOffer": "Rien à lier pour le moment." + }, + "deleteTitle": "Supprimer ce rendez-vous ?", + "deleteDescription": "L'entrée est supprimée. Si le rendez-vous est pris, son rappel disparaît avec elle.", + "deleteFailed": "Le rendez-vous n'a pas pu être supprimé." + }, + "checkups": { + "viewSwitchLabel": "Afficher les dépistages ou les rendez-vous", + "tabVorsorge": "Dépistages", + "tabVisits": "Rendez-vous" + }, + "practitioners": { + "title": "Cabinets et médecins", + "description": "Votre carnet d'adresses : saisissez un cabinet une fois, choisissez-le partout.", + "linkLabel": "Carnet d'adresses", + "add": "Entrée", + "searchPlaceholder": "Rechercher un nom ou un cabinet", + "loadError": "Impossible de charger le carnet d'adresses.", + "emptyTitle": "Aucune entrée", + "emptyDescription": "Ajoutez un cabinet pour pouvoir le choisir lors d'un rendez-vous.", + "noMatchTitle": "Aucun résultat", + "noMatchDescription": "Aucune entrée ne correspond à cette recherche.", + "createTitle": "Ajouter une entrée", + "editTitle": "Modifier l'entrée", + "formDescription": "Seul le nom est obligatoire.", + "name": "Nom", + "specialty": "Spécialité", + "practice": "Cabinet", + "location": "Adresse", + "phone": "Téléphone", + "note": "Note", + "noteHint": "Enregistrée chiffrée.", + "saveFailed": "L'entrée n'a pas pu être enregistrée.", + "deleteTitle": "Supprimer cette entrée ?", + "deleteDescription": "Vos rendez-vous existants restent et conservent ce nom.", + "deleteFailed": "L'entrée n'a pas pu être supprimée." } } diff --git a/messages/it.json b/messages/it.json index 60bfd0bf4..c79d33ad0 100644 --- a/messages/it.json +++ b/messages/it.json @@ -10073,6 +10073,86 @@ "hospital": "Ospedale", "therapy": "Seduta di terapia", "other": "Appuntamento medico" - } + }, + "sectionTitle": "Appuntamenti", + "sectionDescription": "Visite mediche e appuntamenti fissati in un unico posto.", + "add": "Appuntamento", + "loadError": "Impossibile caricare gli appuntamenti.", + "emptyTitle": "Nessun appuntamento registrato", + "emptyDescription": "Basta una data. Tutto il resto è facoltativo.", + "upcomingHeading": "In arrivo", + "pastHeading": "Passati", + "createTitle": "Registra un appuntamento", + "editTitle": "Modifica appuntamento", + "formDescription": "Solo la data è obbligatoria.", + "created": "Appuntamento salvato.", + "updated": "Appuntamento aggiornato.", + "saveFailed": "Non è stato possibile salvare l'appuntamento.", + "checkupNotClosed": "Appuntamento salvato. Il controllo resta aperto: il tuo accesso non lo comprende.", + "status": { + "planned": "Previsto", + "done": "Effettuato", + "cancelled": "Annullato", + "noShow": "Non presentato" + }, + "card": { + "documentCount": "documenti collegati", + "labCount": "esami di laboratorio collegati", + "conditionCount": "malattie collegate" + }, + "form": { + "date": "Data e ora", + "dateDerivesStatusHint": "Una data futura è un appuntamento fissato.", + "status": "Stato", + "practitioner": "Studio o medico", + "practitionerHint": "Facoltativo: puoi aggiungere il nome più tardi.", + "practitionerNone": "Non indicato", + "practitionerSearch": "Cerca nome o studio", + "practitionerNoMatch": "Nessuna voce trovata.", + "practitionerAdd": "Aggiungi nuova", + "practitionerClear": "Rimuovi la selezione", + "kind": "Tipo", + "reason": "Motivo", + "outcome": "Esito", + "outcomeReveal": "Aggiungi un esito", + "linkDocuments": "Documenti", + "linkLabResults": "Esami di laboratorio", + "linkConditions": "Malattie", + "linkNothingToOffer": "Per ora non c'è nulla da collegare." + }, + "deleteTitle": "Eliminare questo appuntamento?", + "deleteDescription": "La voce viene rimossa. Se l'appuntamento è fissato, anche il promemoria sparisce.", + "deleteFailed": "Non è stato possibile eliminare l'appuntamento." + }, + "checkups": { + "viewSwitchLabel": "Mostra controlli o appuntamenti", + "tabVorsorge": "Controlli", + "tabVisits": "Appuntamenti" + }, + "practitioners": { + "title": "Studi e medici", + "description": "La tua rubrica: scrivi uno studio una volta e selezionalo ovunque.", + "linkLabel": "Rubrica", + "add": "Voce", + "searchPlaceholder": "Cerca nome o studio", + "loadError": "Impossibile caricare la rubrica.", + "emptyTitle": "Nessuna voce", + "emptyDescription": "Aggiungi uno studio per poterlo scegliere quando registri un appuntamento.", + "noMatchTitle": "Nessuna corrispondenza", + "noMatchDescription": "Nessuna voce corrisponde a questa ricerca.", + "createTitle": "Aggiungi una voce", + "editTitle": "Modifica la voce", + "formDescription": "Solo il nome è obbligatorio.", + "name": "Nome", + "specialty": "Specializzazione", + "practice": "Studio", + "location": "Indirizzo", + "phone": "Telefono", + "note": "Nota", + "noteHint": "Salvata cifrata.", + "saveFailed": "Non è stato possibile salvare la voce.", + "deleteTitle": "Eliminare questa voce?", + "deleteDescription": "I tuoi appuntamenti esistenti restano e conservano questo nome.", + "deleteFailed": "Non è stato possibile eliminare la voce." } } diff --git a/messages/pl.json b/messages/pl.json index cd1f4c71e..6ac56d55c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -10073,6 +10073,86 @@ "hospital": "Szpital", "therapy": "Sesja terapeutyczna", "other": "Wizyta lekarska" - } + }, + "sectionTitle": "Wizyty", + "sectionDescription": "Wizyty u lekarza i umówione terminy w jednym miejscu.", + "add": "Wizyta", + "loadError": "Nie udało się wczytać wizyt.", + "emptyTitle": "Brak zapisanych wizyt", + "emptyDescription": "Wystarczy data. Reszta jest opcjonalna.", + "upcomingHeading": "Nadchodzące", + "pastHeading": "Minione", + "createTitle": "Zapisz wizytę", + "editTitle": "Edytuj wizytę", + "formDescription": "Wymagana jest tylko data.", + "created": "Wizyta zapisana.", + "updated": "Wizyta zaktualizowana.", + "saveFailed": "Nie udało się zapisać wizyty.", + "checkupNotClosed": "Wizyta zapisana. Badanie pozostaje otwarte — twój dostęp tego nie obejmuje.", + "status": { + "planned": "Zaplanowana", + "done": "Odbyta", + "cancelled": "Odwołana", + "noShow": "Niewykorzystana" + }, + "card": { + "documentCount": "powiązane dokumenty", + "labCount": "powiązane wyniki badań", + "conditionCount": "powiązane choroby" + }, + "form": { + "date": "Data i godzina", + "dateDerivesStatusHint": "Data w przyszłości oznacza umówiony termin.", + "status": "Status", + "practitioner": "Gabinet lub lekarz", + "practitionerHint": "Opcjonalne — nazwę możesz dodać później.", + "practitionerNone": "Nie podano", + "practitionerSearch": "Szukaj nazwiska lub gabinetu", + "practitionerNoMatch": "Nie znaleziono wpisu.", + "practitionerAdd": "Dodaj nowy", + "practitionerClear": "Usuń wybór", + "kind": "Rodzaj", + "reason": "Powód", + "outcome": "Wynik", + "outcomeReveal": "Dodaj wynik", + "linkDocuments": "Dokumenty", + "linkLabResults": "Wyniki badań", + "linkConditions": "Choroby", + "linkNothingToOffer": "Na razie nie ma nic do powiązania." + }, + "deleteTitle": "Usunąć tę wizytę?", + "deleteDescription": "Wpis zostanie usunięty. Jeśli wizyta jest umówiona, przypomnienie o niej też zniknie.", + "deleteFailed": "Nie udało się usunąć wizyty." + }, + "checkups": { + "viewSwitchLabel": "Pokaż badania profilaktyczne lub wizyty", + "tabVorsorge": "Profilaktyka", + "tabVisits": "Wizyty" + }, + "practitioners": { + "title": "Gabinety i lekarze", + "description": "Twoja własna książka adresowa — wpisz gabinet raz, wybieraj go wszędzie.", + "linkLabel": "Książka adresowa", + "add": "Wpis", + "searchPlaceholder": "Szukaj nazwiska lub gabinetu", + "loadError": "Nie udało się wczytać książki adresowej.", + "emptyTitle": "Brak wpisów", + "emptyDescription": "Dodaj gabinet, aby móc go wybrać przy zapisywaniu wizyty.", + "noMatchTitle": "Brak wyników", + "noMatchDescription": "Żaden wpis nie pasuje do tego wyszukiwania.", + "createTitle": "Dodaj wpis", + "editTitle": "Edytuj wpis", + "formDescription": "Wymagana jest tylko nazwa.", + "name": "Nazwa", + "specialty": "Specjalizacja", + "practice": "Gabinet", + "location": "Adres", + "phone": "Telefon", + "note": "Notatka", + "noteHint": "Zapisywana w postaci zaszyfrowanej.", + "saveFailed": "Nie udało się zapisać wpisu.", + "deleteTitle": "Usunąć ten wpis?", + "deleteDescription": "Twoje dotychczasowe wizyty pozostają i zachowują tę nazwę.", + "deleteFailed": "Nie udało się usunąć wpisu." } } diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index 8d798ce6a..413863fac 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -388,6 +388,26 @@ const REGISTRY: DestructiveEntry[] = [ confirm: ["AlertDialog"], }, + // ── Visits and the address book ───────────────────────────────────────── + { + file: "hooks/use-encounters.ts", + destroys: + "a visit with its reason, outcome and the links it collected — and, for a booked one, the appointment reminder that would have nudged about it", + triggers: ["components/encounters/encounter-sheet.tsx"], + // The route soft-deletes and a restore route exists, but nothing in the + // product reaches it yet, so from a person's side the visit is gone. This + // becomes "restorable" the day a surface offers the undo, not before. + recovery: "tombstoned-no-restore", + confirm: ["AlertDialog"], + }, + { + file: "hooks/use-practitioners.ts", + destroys: "one address-book entry", + triggers: ["components/practitioners/practitioner-list.tsx"], + recovery: "tombstoned-no-restore", + confirm: ["AlertDialog"], + }, + // ── Cycle ─────────────────────────────────────────────────────────────── { file: "components/cycle/use-cycle.ts", diff --git a/src/__tests__/shared-record-navigation.test.tsx b/src/__tests__/shared-record-navigation.test.tsx index 3413dc1a3..65cf82ca5 100644 --- a/src/__tests__/shared-record-navigation.test.tsx +++ b/src/__tests__/shared-record-navigation.test.tsx @@ -44,12 +44,44 @@ describe("shared-record navigation", () => { it("keeps profile facts outside Settings and makes profile the scoped doorway", () => { const navigation = resolveSharedRecordNavigation(["profile"]); - expect(SHARED_RECORD_DOMAIN_ROUTE_FAMILIES.profile).toEqual(["/profile"]); - expect(navigation.destinationHrefs).toEqual(["/profile"]); + expect(SHARED_RECORD_DOMAIN_ROUTE_FAMILIES.profile).toEqual([ + "/profile", + "/checkups", + ]); + expect(navigation.destinationHrefs).toEqual(["/profile", "/checkups"]); expect(navigation.allowsPath("/profile")).toBe(true); expect(navigation.allowsPath("/settings/anamnesis")).toBe(false); }); + it("presents the checkups page under both domains whose content it shows", () => { + // The page holds two things: the preventive-care list, which is a + // `measurements` read, and the visits section, which is `profile`. A grant + // naming either one must be able to reach it; a grant naming neither must + // not. Before the visits section the `measurements` omission was invisible, + // because a whole-record grant admits every path regardless. + expect( + resolveSharedRecordNavigation(["measurements"]).allowsPath("/checkups"), + ).toBe(true); + expect( + resolveSharedRecordNavigation(["profile"]).allowsPath("/checkups"), + ).toBe(true); + expect(resolveSharedRecordNavigation(null).allowsPath("/checkups")).toBe( + true, + ); + expect( + resolveSharedRecordNavigation(["labs"]).allowsPath("/checkups"), + ).toBe(false); + + // Listed twice, offered once: a grant holding both domains must not put + // two identical entries in the navigation. + expect( + resolveSharedRecordNavigation([ + "measurements", + "profile", + ]).destinationHrefs.filter((href) => href === "/checkups"), + ).toEqual(["/checkups"]); + }); + it("uses the active record scope instead of the actor's module flags", () => { const actorModules = { medications: false, diff --git a/src/app/api/practitioners/route.ts b/src/app/api/practitioners/route.ts index 04a495b5a..bac66da4b 100644 --- a/src/app/api/practitioners/route.ts +++ b/src/app/api/practitioners/route.ts @@ -50,8 +50,27 @@ export const GET = apiHandler(async (request: NextRequest) => { where: { userId: user.id, deletedAt: null, + // Both plaintext columns, because a person looks a practice up by + // whichever of the two they remember: the doctor's name on the referral + // or the practice name on the door. Searching only the name made the + // second attempt return nothing and read as "not in the address book". ...(parsed.data.q - ? { name: { contains: parsed.data.q, mode: "insensitive" as const } } + ? { + OR: [ + { + name: { + contains: parsed.data.q, + mode: "insensitive" as const, + }, + }, + { + practice: { + contains: parsed.data.q, + mode: "insensitive" as const, + }, + }, + ], + } : {}), }, orderBy: { name: "asc" }, diff --git a/src/app/checkups/page.tsx b/src/app/checkups/page.tsx index 2105fb54b..4b2fbf1c5 100644 --- a/src/app/checkups/page.tsx +++ b/src/app/checkups/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback } from "react"; +import { useCallback, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; @@ -8,8 +8,11 @@ import { useMounted } from "@/hooks/use-mounted"; import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator"; import { PageAuthGate } from "@/components/ui/page-auth-gate"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { VorsorgeSection } from "@/components/measurement-reminders/vorsorge-section"; -import { queryKeys } from "@/lib/query-keys"; +import { VisitsSection } from "@/components/encounters/visits-section"; +import { useTranslations } from "@/lib/i18n/context"; +import { invalidateKeys, queryKeys } from "@/lib/query-keys"; /** * v1.17.1 — Checkups (preventive-care) reminders page. The dedicated @@ -19,17 +22,30 @@ import { queryKeys } from "@/lib/query-keys"; * v1.18.1 — pull-to-refresh parity with every peer page (labs, mood, * measurements): a pull invalidates the measurement-reminders read so the * list + server-computed next-due repaint. + * + * The page now holds two things, split by a segmented control rather than by a + * second nav entry: the preventive-care list, and the visits. They answer the + * same question from two directions — what do I still have to arrange, and what + * did I actually attend — and the page's own sentence already covers both. A + * visit carries no module gate for the same reason this page never had one. + * + * The pull invalidates both reads. Refreshing one and leaving the other stale + * is how a page ends up showing a visit that closed a checkup still listed as + * due. */ export default function CheckupsPage() { const { isAuthenticated, isLoading } = useAuth(); const mounted = useMounted(); const queryClient = useQueryClient(); + const { t } = useTranslations(); + const [view, setView] = useState("vorsorge"); const refresh = useCallback( () => - queryClient.invalidateQueries({ - queryKey: queryKeys.measurementReminders(), - }), + invalidateKeys(queryClient, [ + queryKeys.measurementReminders(), + queryKeys.encounters(), + ]), [queryClient], ); const pull = usePullToRefresh({ onRefresh: refresh }); @@ -41,7 +57,24 @@ export default function CheckupsPage() { return (
- + + + + {t("checkups.tabVorsorge")} + + + {t("checkups.tabVisits")} + + + + + + + {/* Mounted only on the active tab, so the visits read does not fire + for a person who never opens it. */} + + +
); } diff --git a/src/app/checkups/practitioners/page.tsx b/src/app/checkups/practitioners/page.tsx new file mode 100644 index 000000000..1a4d27e7f --- /dev/null +++ b/src/app/checkups/practitioners/page.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { PageAuthGate } from "@/components/ui/page-auth-gate"; +import { PractitionerList } from "@/components/practitioners/practitioner-list"; +import { useAuth } from "@/hooks/use-auth"; +import { useMounted } from "@/hooks/use-mounted"; + +/** + * The address book, one level under the checkups page the visits live on. + * + * Deliberately not in Settings. The Settings IA rule is that a settings surface + * configures behaviour; a list of one's own doctors configures nothing and is + * record content like every other clinical list. Sitting under `/checkups` + * also means it inherits that page's route-family classification, so a + * `profile`-scoped delegate reaches it exactly as they reach the visits. + */ +export default function PractitionersPage() { + const { isAuthenticated, isLoading } = useAuth(); + const mounted = useMounted(); + + if (!mounted || isLoading) return ; + + return ( +
+ +
+ ); +} diff --git a/src/components/encounters/__tests__/encounter-form.test.tsx b/src/components/encounters/__tests__/encounter-form.test.tsx new file mode 100644 index 000000000..ee7b9a04b --- /dev/null +++ b/src/components/encounters/__tests__/encounter-form.test.tsx @@ -0,0 +1,184 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +/** + * The one product constraint this whole feature exists to hold: **a visit + * saves with a date and nothing else.** + * + * Three properties, each of which has an obvious way to get broken by a later + * "improvement": + * + * - no field but the date can block Save; + * - the status is DERIVED from the date on a new visit, never asked; + * - a link picker whose module is off is BLANK, not empty — an empty picker + * for a switched-off module advertises a feature that is not there. + */ + +let modules: Record = {}; + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", modules }, + isLoading: false, + isAuthenticated: true, + }), +})); + +vi.mock("@/hooks/use-record-capabilities", () => ({ + useRecordCapabilities: () => ({ canManage: true, canWrite: true }), +})); + +vi.mock("@/lib/api/api-fetch", async () => { + const actual = await vi.importActual( + "@/lib/api/api-fetch", + ); + return { + ...actual, + apiGet: () => new Promise(() => {}), + apiPost: vi.fn(), + apiPatch: vi.fn(), + apiDelete: vi.fn(), + }; +}); + +import { I18nProvider } from "@/lib/i18n/context"; +import { + EncounterForm, + derivedStatus, + draftInstant, + draftToBody, + emptyDraft, +} from "../encounter-form"; + +/** + * The form body, rendered directly rather than through its sheet: the sheet is + * a Radix portal and renders nothing at all under `renderToStaticMarkup`, so a + * test that went through it would assert against an empty string and pass for + * the wrong reason. + */ +function renderForm(over?: { + modules?: Record; + isEdit?: boolean; +}) { + modules = over?.modules ?? {}; + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return renderToStaticMarkup( + + + {}} + isEdit={over?.isEdit ?? false} + /> + + , + ); +} + +describe("nothing but the date is required", () => { + it("admits a draft carrying only a date", () => { + // `draftInstant(...) !== null` is the sheet's ONLY save gate, so a draft + // with no practitioner, no reason, no outcome and no link is saveable. + const draft = emptyDraft({ occurredAt: "2026-08-08T10:00" }); + expect(draft.practitioner).toBeNull(); + expect(draft.reason).toBe(""); + expect(draft.documentIds).toEqual([]); + expect(draftInstant(draft.occurredAt)).not.toBeNull(); + }); + + it("refuses only when the date itself cannot be read", () => { + expect(draftInstant("not a date")).toBeNull(); + }); + + it("marks the date field required and nothing else", () => { + const html = renderForm(); + const required = html.match(/aria-required="true"/g) ?? []; + expect(required).toHaveLength(1); + expect(html).toContain('data-testid="encounter-occurred-at"'); + }); + + it("turns a date-only draft into a complete write body", () => { + const body = draftToBody(emptyDraft({ occurredAt: "2026-08-08T10:00" }), { + isEdit: false, + }); + expect(body).not.toBeNull(); + expect(body?.practitionerId).toBeNull(); + expect(body?.reason).toBeNull(); + expect(body?.outcome).toBeNull(); + expect(body?.documentIds).toEqual([]); + }); + + it("refuses to build a body from a date that does not parse", () => { + expect( + draftToBody(emptyDraft({ occurredAt: "not a date" }), { isEdit: false }), + ).toBeNull(); + }); +}); + +describe("the status follows the date on a new visit", () => { + const now = Date.parse("2026-08-08T12:00:00.000Z"); + + it("reads a future instant as a booked appointment", () => { + expect(derivedStatus("2026-08-09T09:00", now)).toBe("PLANNED"); + }); + + it("reads a past instant as a visit that happened", () => { + expect(derivedStatus("2026-08-07T09:00", now)).toBe("DONE"); + }); + + it("never asks for the status on a new visit", () => { + // The picker is what "asked" would look like; its absence is the property. + expect(renderForm()).not.toContain('id="encounter-status"'); + }); + + it("carries the derived status into the body rather than the draft default", () => { + const body = draftToBody( + emptyDraft({ occurredAt: "2999-01-01T09:00", status: "DONE" }), + { isEdit: false }, + ); + expect(body?.status).toBe("PLANNED"); + }); + + it("uses the draft's own status on an edit, where it is editable", () => { + const body = draftToBody( + emptyDraft({ occurredAt: "2999-01-01T09:00", status: "CANCELLED" }), + { isEdit: true }, + ); + expect(body?.status).toBe("CANCELLED"); + }); +}); + +describe("the link pickers follow the modules that own their targets", () => { + it("renders no link block at all when all three modules are off", () => { + const html = renderForm({ modules: {} }); + expect(html).not.toContain('data-slot="encounter-link-pickers"'); + expect(html).not.toContain('data-slot="encounter-link-documents"'); + expect(html).not.toContain('data-slot="encounter-link-labs"'); + expect(html).not.toContain('data-slot="encounter-link-conditions"'); + }); + + it("blanks the block of a disabled module while keeping its neighbours", () => { + const html = renderForm({ + modules: { inboundDocuments: true, labs: false, illness: false }, + }); + expect(html).toContain('data-slot="encounter-link-documents"'); + // Not "renders an empty list" — absent. + expect(html).not.toContain('data-slot="encounter-link-labs"'); + expect(html).not.toContain('data-slot="encounter-link-conditions"'); + }); + + it("offers all three when all three modules are on", () => { + const html = renderForm({ + modules: { inboundDocuments: true, labs: true, illness: true }, + }); + expect(html).toContain('data-slot="encounter-link-documents"'); + expect(html).toContain('data-slot="encounter-link-labs"'); + expect(html).toContain('data-slot="encounter-link-conditions"'); + // The positive control: without it the three assertions above would pass + // against a component that never renders any block. + expect(html).toContain('data-slot="encounter-link-pickers"'); + }); +}); diff --git a/src/components/encounters/__tests__/visits-section.test.tsx b/src/components/encounters/__tests__/visits-section.test.tsx new file mode 100644 index 000000000..a7da7ed91 --- /dev/null +++ b/src/components/encounters/__tests__/visits-section.test.tsx @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +/** + * The visits surface's designed states, pinned by single-pass static renders. + * + * - an account with no visits paints the empty state and one action, not an + * error and not a teaching wall; + * - upcoming sorts above past, in the two lists the SERVER split — the + * client renders the order it was given rather than re-deriving one; + * - a soft-deleted visit never arrives, so it cannot be rendered. That is a + * server property and the API test owns it; what this file pins is that + * the section renders exactly what the response carried, with nothing + * filtered or re-sorted on this side that could disagree with it. + */ + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", modules: {} }, + isLoading: false, + isAuthenticated: true, + }), +})); + +vi.mock("@/hooks/use-record-capabilities", () => ({ + useRecordCapabilities: () => ({ canManage: true, canWrite: true }), +})); + +vi.mock("@/lib/api/api-fetch", async () => { + const actual = await vi.importActual( + "@/lib/api/api-fetch", + ); + return { + ...actual, + // Never resolves inside a synchronous render, so an unseeded query stays + // pending and cannot accidentally satisfy an assertion. + apiGet: () => new Promise(() => {}), + apiPost: vi.fn(), + apiPatch: vi.fn(), + apiDelete: vi.fn(), + }; +}); + +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import type { EncounterDTO } from "@/lib/encounters/dto"; +import { VisitsSection } from "../visits-section"; + +function visit(over: Partial & { id: string }): EncounterDTO { + return { + occurredAt: "2026-08-01T09:00:00.000Z", + status: "DONE", + kind: "ROUTINE", + practitioner: null, + reason: null, + outcome: null, + reminderNextDueAt: null, + createdAt: "2026-08-01T09:00:00.000Z", + updatedAt: "2026-08-01T09:00:00.000Z", + ...over, + }; +} + +function render(list: { upcoming: EncounterDTO[]; past: EncounterDTO[] }) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + queryClient.setQueryData( + queryKeys.encounterList(null, null, undefined), + list, + ); + return renderToStaticMarkup( + + + + + , + ); +} + +describe("", () => { + it("paints the empty state, with one action, when there is no visit", () => { + const html = render({ upcoming: [], past: [] }); + expect(html).toContain("No visit recorded yet"); + expect(html).toContain("A date is enough"); + expect(html).toContain('data-slot="visits-add"'); + // An empty account is not an error, and the two states never share copy. + expect(html).not.toContain("Visits could not be loaded."); + expect(html).not.toContain('data-slot="visits-upcoming"'); + }); + + it("puts the upcoming list above the past one", () => { + const html = render({ + upcoming: [visit({ id: "later", status: "PLANNED" })], + past: [visit({ id: "earlier" })], + }); + const upcomingAt = html.indexOf('data-slot="visits-upcoming"'); + const pastAt = html.indexOf('data-slot="visits-past"'); + expect(upcomingAt).toBeGreaterThan(-1); + expect(pastAt).toBeGreaterThan(-1); + expect(upcomingAt).toBeLessThan(pastAt); + expect(html.indexOf('data-encounter-id="later"')).toBeLessThan( + html.indexOf('data-encounter-id="earlier"'), + ); + }); + + it("renders the server's order inside a list rather than re-sorting it", () => { + // Two rows deliberately handed over in an order the client has no reason + // to like. Re-sorting here would be the second place the ordering lives. + const html = render({ + upcoming: [], + past: [ + visit({ id: "second", occurredAt: "2026-07-01T09:00:00.000Z" }), + visit({ id: "first", occurredAt: "2026-08-01T09:00:00.000Z" }), + ], + }); + expect(html.indexOf('data-encounter-id="second"')).toBeLessThan( + html.indexOf('data-encounter-id="first"'), + ); + }); + + it("offers the address book beside the add action", () => { + const html = render({ upcoming: [], past: [] }); + expect(html).toContain('href="/checkups/practitioners"'); + }); +}); diff --git a/src/components/encounters/encounter-form.tsx b/src/components/encounters/encounter-form.tsx new file mode 100644 index 000000000..5c611a276 --- /dev/null +++ b/src/components/encounters/encounter-form.tsx @@ -0,0 +1,283 @@ +"use client"; + +/** + * The visit form. + * + * **A visit saves with a date and nothing else.** No required practitioner, no + * required kind, no required link, and no Save button disabled until something + * is picked. That is the whole product constraint this feature exists to hold: + * a required field on a form somebody fills after a doctor's appointment is the + * bureaucracy it was built to avoid. The only thing that can block Save is a + * missing or unparseable date, because a visit with no instant is not a record + * of anything. + * + * Status is DERIVED from the date on a new visit, never asked: a future instant + * means PLANNED and a past one means DONE. A person booking an appointment + * should not have to also tell the app that it is in the future. On an existing + * visit the status becomes editable, because moving a planned visit to done, + * cancelled or a no-show is the transition that closes a checkup. + * + * The three link pickers are gated by the modules that own what they point at, + * and the gate BLANKS the block rather than post-filtering it — an empty + * picker for a module the account turned off advertises a feature that is not + * there. + */ +import { useMemo, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { DateTimeField } from "@/components/ui/date-time-field"; +import { FieldGroup } from "@/components/ui/field-group"; +import { NativeSelect } from "@/components/ui/native-select"; +import { Textarea } from "@/components/ui/textarea"; +import { useAuth } from "@/hooks/use-auth"; +import { useTranslations } from "@/lib/i18n/context"; +import type { Encounter, EncounterWriteBody } from "@/hooks/use-encounters"; +import type { Practitioner } from "@/hooks/use-practitioners"; +import { + ENCOUNTER_KINDS, + ENCOUNTER_STATUSES, + encounterKindText, + encounterStatusText, +} from "./encounter-labels"; +import { PractitionerCombobox } from "./practitioner-combobox"; +import { EncounterLinkPickers } from "./encounter-link-pickers"; + +export interface EncounterDraft { + /** Local `yyyy-MM-ddTHH:mm`, the `DateTimeField` contract. */ + occurredAt: string; + status: "PLANNED" | "DONE" | "CANCELLED" | "NO_SHOW"; + kind: string; + practitioner: Practitioner | null; + reason: string; + outcome: string; + documentIds: string[]; + labResultIds: string[]; + episodeIds: string[]; +} + +function localValue(instant: Date): string { + const offset = instant.getTimezoneOffset(); + return new Date(instant.getTime() - offset * 60_000) + .toISOString() + .slice(0, 16); +} + +export function nowLocalValue(): string { + return localValue(new Date()); +} + +/** A local `yyyy-MM-ddTHH:mm` back to the wire's ISO-8601 instant, or null. */ +export function draftInstant(value: string): string | null { + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); +} + +export function emptyDraft( + overrides?: Partial, +): EncounterDraft { + return { + occurredAt: nowLocalValue(), + status: "DONE", + kind: "OTHER", + practitioner: null, + reason: "", + outcome: "", + documentIds: [], + labResultIds: [], + episodeIds: [], + ...overrides, + }; +} + +export function draftFromEncounter(row: Encounter): EncounterDraft { + return { + occurredAt: localValue(new Date(row.occurredAt)), + status: row.status, + kind: row.kind, + practitioner: row.practitioner, + reason: row.reason ?? "", + outcome: row.outcome ?? "", + documentIds: row.links?.documents.map((link) => link.id) ?? [], + labResultIds: row.links?.labResults.map((link) => link.id) ?? [], + episodeIds: row.links?.conditions.map((link) => link.id) ?? [], + }; +} + +/** + * The status a NEW visit carries, read off its own date. + * + * Exported so the sheet and its tests agree on the derivation rather than each + * spelling out the comparison. + */ +export function derivedStatus( + occurredAt: string, + now = Date.now(), +): "PLANNED" | "DONE" { + const parsed = new Date(occurredAt).getTime(); + return Number.isFinite(parsed) && parsed > now ? "PLANNED" : "DONE"; +} + +/** The body a draft becomes, with the empty strings turned into absences. */ +export function draftToBody( + draft: EncounterDraft, + options: { isEdit: boolean; reminderId?: string | null }, +): EncounterWriteBody | null { + const occurredAt = draftInstant(draft.occurredAt); + if (!occurredAt) return null; + return { + occurredAt, + status: options.isEdit ? draft.status : derivedStatus(draft.occurredAt), + kind: draft.kind, + practitionerId: draft.practitioner?.id ?? null, + reason: draft.reason.trim() || null, + outcome: draft.outcome.trim() || null, + ...(options.reminderId ? { reminderId: options.reminderId } : {}), + documentIds: draft.documentIds, + labResultIds: draft.labResultIds, + episodeIds: draft.episodeIds, + }; +} + +export function EncounterForm({ + draft, + onChange, + isEdit, +}: { + draft: EncounterDraft; + onChange: (next: EncounterDraft) => void; + isEdit: boolean; +}) { + const { t } = useTranslations(); + const { user } = useAuth(); + const patch = (part: Partial) => + onChange({ ...draft, ...part }); + + const planned = useMemo( + () => + isEdit + ? draft.status === "PLANNED" + : derivedStatus(draft.occurredAt) === "PLANNED", + [isEdit, draft.status, draft.occurredAt], + ); + + // The outcome field starts collapsed on a planned visit: there is nothing to + // write about a thing that has not happened. Derived rather than + // synchronised — a visit that already carries an outcome is never collapsed + // over it, and expressing that as an effect would be a second source of + // truth for something the draft already knows. + const [revealed, setRevealed] = useState(false); + const outcomeOpen = revealed || !planned || draft.outcome.length > 0; + + return ( +
+ + patch({ occurredAt: value })} + required + aria-required="true" + data-testid="encounter-occurred-at" + /> + + + {isEdit ? ( + + + patch({ + status: event.target.value as EncounterDraft["status"], + }) + } + > + {ENCOUNTER_STATUSES.map((status) => ( + + ))} + + + ) : null} + + + patch({ practitioner: next })} + /> + + + + patch({ kind: event.target.value })} + > + {ENCOUNTER_KINDS.map((kind) => ( + + ))} + + + + +