Files
medassist-ng/frontend/src/hooks/useIntakeJournal.ts
T
Daniel Volz c78fc43083 feat(frontend): add intake journal and shared note flows (#648)
* feat(backend): add intake journal APIs and share note support

* feat(frontend): add intake journal and shared note flows
2026-05-24 14:00:30 +02:00

340 lines
10 KiB
TypeScript

import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAuth } from "../components/Auth";
import { useModalHistory } from "./useModalHistory";
export type IntakeJournalEntry = {
doseTrackingId: number;
doseId: string;
medicationId: number;
medicationName: string;
scheduledFor: string;
takenAt: string | null;
dismissed: boolean;
takenSource: "manual" | "automatic";
markedBy: string | null;
note: string | null;
updatedAt: string | null;
createdAt: string | null;
};
export type IntakeJournalHistoryFilters = {
medicationId: number | null;
from: string;
to: string;
limit: number;
};
export interface UseIntakeJournalReturn {
journalEditorOpen: boolean;
journalHistoryOpen: boolean;
journalTargetDoseId: string | null;
journalEvent: IntakeJournalEntry | null;
journalEventLoading: boolean;
journalEventSaving: boolean;
journalEventDeleting: boolean;
journalEventError: string | null;
journalHistoryEntries: IntakeJournalEntry[];
journalHistoryFilters: IntakeJournalHistoryFilters;
journalHistoryLoading: boolean;
journalHistoryError: string | null;
resetJournalState: () => void;
openJournalEditor: (doseId: string) => Promise<void>;
closeJournalEditor: () => void;
saveJournalNote: (note: string) => Promise<boolean>;
deleteJournalNote: () => Promise<boolean>;
openJournalHistory: () => void;
closeJournalHistory: () => void;
setJournalHistoryFilters: (patch: Partial<IntakeJournalHistoryFilters>) => void;
reloadJournalHistory: () => Promise<void>;
reopenJournalHistoryEntry: (doseId: string) => Promise<void>;
}
const DEFAULT_HISTORY_FILTERS: IntakeJournalHistoryFilters = {
medicationId: null,
from: "",
to: "",
limit: 100,
};
async function readErrorMessage(response: Response, fallbackMessage: string): Promise<string> {
try {
const data = (await response.json()) as { error?: string; code?: string };
if (typeof data.error === "string" && data.error.trim().length > 0) {
return data.error;
}
if (typeof data.code === "string" && data.code.trim().length > 0) {
return data.code;
}
} catch {
// Fall back to the supplied message when the response body is not JSON.
}
return fallbackMessage;
}
function buildHistoryQuery(filters: IntakeJournalHistoryFilters): string {
const params = new URLSearchParams();
if (typeof filters.medicationId === "number") {
params.set("medicationId", String(filters.medicationId));
}
if (filters.from.trim().length > 0) {
params.set("from", filters.from.trim());
}
if (filters.to.trim().length > 0) {
params.set("to", filters.to.trim());
}
params.set("limit", String(filters.limit));
const query = params.toString();
return query.length > 0 ? `?${query}` : "";
}
export function useIntakeJournal(): UseIntakeJournalReturn {
const { authFetch } = useAuth();
const { t } = useTranslation();
const [journalEditorOpen, setJournalEditorOpen] = useState(false);
const [journalHistoryOpen, setJournalHistoryOpen] = useState(false);
const [journalTargetDoseId, setJournalTargetDoseId] = useState<string | null>(null);
const [journalEvent, setJournalEvent] = useState<IntakeJournalEntry | null>(null);
const [journalEventLoading, setJournalEventLoading] = useState(false);
const [journalEventSaving, setJournalEventSaving] = useState(false);
const [journalEventDeleting, setJournalEventDeleting] = useState(false);
const [journalEventError, setJournalEventError] = useState<string | null>(null);
const [journalHistoryEntries, setJournalHistoryEntries] = useState<IntakeJournalEntry[]>([]);
const [journalHistoryFilters, setJournalHistoryFiltersState] =
useState<IntakeJournalHistoryFilters>(DEFAULT_HISTORY_FILTERS);
const [journalHistoryLoading, setJournalHistoryLoading] = useState(false);
const [journalHistoryError, setJournalHistoryError] = useState<string | null>(null);
const resetJournalState = useCallback(() => {
setJournalEditorOpen(false);
setJournalHistoryOpen(false);
setJournalTargetDoseId(null);
setJournalEvent(null);
setJournalEventLoading(false);
setJournalEventSaving(false);
setJournalEventDeleting(false);
setJournalEventError(null);
setJournalHistoryEntries([]);
setJournalHistoryFiltersState(DEFAULT_HISTORY_FILTERS);
setJournalHistoryLoading(false);
setJournalHistoryError(null);
}, []);
const loadJournalEvent = useCallback(
async (doseId: string) => {
setJournalEventLoading(true);
setJournalEventError(null);
try {
const response = await authFetch(`/api/intake-journal/event/${encodeURIComponent(doseId)}`);
if (!response.ok) {
const message = await readErrorMessage(response, t("journal.errors.loadFailed"));
setJournalEvent(null);
setJournalEventError(message);
return;
}
const data = (await response.json()) as { entry: IntakeJournalEntry };
setJournalEvent(data.entry);
} catch {
setJournalEvent(null);
setJournalEventError(t("journal.errors.loadFailed"));
} finally {
setJournalEventLoading(false);
}
},
[authFetch, t]
);
const loadJournalHistory = useCallback(
async (filters: IntakeJournalHistoryFilters) => {
setJournalHistoryLoading(true);
setJournalHistoryError(null);
try {
const response = await authFetch(`/api/intake-journal${buildHistoryQuery(filters)}`);
if (!response.ok) {
const message = await readErrorMessage(response, t("journal.errors.historyFailed"));
setJournalHistoryEntries([]);
setJournalHistoryError(message);
return;
}
const data = (await response.json()) as { entries: IntakeJournalEntry[] };
setJournalHistoryEntries(Array.isArray(data.entries) ? data.entries : []);
} catch {
setJournalHistoryEntries([]);
setJournalHistoryError(t("journal.errors.historyFailed"));
} finally {
setJournalHistoryLoading(false);
}
},
[authFetch, t]
);
useEffect(() => {
if (!journalHistoryOpen) {
return;
}
void loadJournalHistory(journalHistoryFilters);
}, [journalHistoryFilters, journalHistoryOpen, loadJournalHistory]);
const openJournalEditor = useCallback(
async (doseId: string) => {
setJournalHistoryOpen(false);
setJournalEditorOpen(true);
setJournalTargetDoseId(doseId);
setJournalEvent(null);
await loadJournalEvent(doseId);
},
[loadJournalEvent]
);
const closeJournalEditor = useCallback(() => {
setJournalEditorOpen(false);
setJournalTargetDoseId(null);
setJournalEvent(null);
setJournalEventError(null);
setJournalEventLoading(false);
setJournalEventSaving(false);
setJournalEventDeleting(false);
}, []);
const saveJournalNote = useCallback(
async (note: string) => {
if (!journalTargetDoseId) {
setJournalEventError(t("journal.errors.noEventSelected"));
return false;
}
setJournalEventSaving(true);
setJournalEventError(null);
try {
const response = await authFetch(`/api/intake-journal/event/${encodeURIComponent(journalTargetDoseId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ note }),
});
if (!response.ok) {
const message = await readErrorMessage(response, t("journal.errors.saveFailed"));
setJournalEventError(message);
return false;
}
const data = (await response.json()) as { entry: IntakeJournalEntry };
setJournalEvent(data.entry);
if (journalHistoryOpen) {
void loadJournalHistory(journalHistoryFilters);
}
return true;
} catch {
setJournalEventError(t("journal.errors.saveFailed"));
return false;
} finally {
setJournalEventSaving(false);
}
},
[authFetch, journalHistoryFilters, journalHistoryOpen, journalTargetDoseId, loadJournalHistory, t]
);
const deleteJournalNote = useCallback(async () => {
if (!journalTargetDoseId) {
setJournalEventError(t("journal.errors.noEventSelected"));
return false;
}
setJournalEventDeleting(true);
setJournalEventError(null);
try {
const response = await authFetch(`/api/intake-journal/event/${encodeURIComponent(journalTargetDoseId)}`, {
method: "DELETE",
});
if (!response.ok) {
const message = await readErrorMessage(response, t("journal.errors.deleteFailed"));
setJournalEventError(message);
return false;
}
setJournalEvent((previous) =>
previous ? { ...previous, note: null, updatedAt: null, createdAt: null } : previous
);
if (journalHistoryOpen) {
void loadJournalHistory(journalHistoryFilters);
}
return true;
} catch {
setJournalEventError(t("journal.errors.deleteFailed"));
return false;
} finally {
setJournalEventDeleting(false);
}
}, [authFetch, journalHistoryFilters, journalHistoryOpen, journalTargetDoseId, loadJournalHistory, t]);
const openJournalHistory = useCallback(() => {
setJournalEditorOpen(false);
setJournalHistoryOpen(true);
setJournalHistoryError(null);
}, []);
const closeJournalHistory = useCallback(() => {
setJournalHistoryOpen(false);
setJournalHistoryError(null);
}, []);
useModalHistory(journalEditorOpen, "intake-journal-editor", closeJournalEditor);
useModalHistory(journalHistoryOpen, "intake-journal-history", closeJournalHistory);
const updateJournalHistoryFilters = useCallback((patch: Partial<IntakeJournalHistoryFilters>) => {
setJournalHistoryFiltersState((previous) => ({
...previous,
...patch,
}));
}, []);
const reloadJournalHistory = useCallback(async () => {
await loadJournalHistory(journalHistoryFilters);
}, [journalHistoryFilters, loadJournalHistory]);
const reopenJournalHistoryEntry = useCallback(
async (doseId: string) => {
setJournalHistoryOpen(false);
await openJournalEditor(doseId);
},
[openJournalEditor]
);
return {
journalEditorOpen,
journalHistoryOpen,
journalTargetDoseId,
journalEvent,
journalEventLoading,
journalEventSaving,
journalEventDeleting,
journalEventError,
journalHistoryEntries,
journalHistoryFilters,
journalHistoryLoading,
journalHistoryError,
resetJournalState,
openJournalEditor,
closeJournalEditor,
saveJournalNote,
deleteJournalNote,
openJournalHistory,
closeJournalHistory,
setJournalHistoryFilters: updateJournalHistoryFilters,
reloadJournalHistory,
reopenJournalHistoryEntry,
};
}