mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
A date-only value is stored at 12:00 UTC. The lab list and history, the document cards and pickers, the document date suggestion, extracted fact dates, source-picker rows and the compliance and mood heatmaps formatted that instant in the reader's zone, which is the next day from UTC+12 to UTC+14. `useCalendarDate` formats such a value through the UTC-pinned calendar formatter the vaccination list already uses, with the same field order and locale as instant dates. Lab readings hold both kinds, so `useLabDate` reads a noon-UTC anchor as its calendar date and a reading typed with its time of day in the reader's zone. A document without a filing date shows its upload day, an instant, in the reader's zone.
202 lines
7.6 KiB
TypeScript
202 lines
7.6 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* The "Dokumente" section on an illness-episode detail page — the
|
|
* condition side of the condition ⇄ documents linking (the document side
|
|
* links back via the detail sheet's condition chips). Lists the episode's
|
|
* most recent linked documents as compact rows deep-linking straight into
|
|
* the vault (`?episode=…&doc=…` opens the detail sheet), plus the three
|
|
* affordances: link an existing document, upload pre-linked (deep link
|
|
* with the episode filter active — the vault threads it into the upload),
|
|
* and the filtered "show all" view.
|
|
*
|
|
* Renders nothing when the documents module is off for this account —
|
|
* module-gated surfaces never leak. An episode without documents shows a
|
|
* quiet one-line affordance, not a teaching empty state.
|
|
*/
|
|
import { useDocumentDate } from "@/components/documents/use-document-date";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { FolderOpen, Link2, Upload } from "lucide-react";
|
|
import Link from "next/link";
|
|
import { useState } from "react";
|
|
|
|
import { TileHeader } from "@/components/insights/tile-header";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { ListRow } from "@/components/ui/list-row";
|
|
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { useAuth } from "@/hooks/use-auth";
|
|
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
|
import { apiGet } from "@/lib/api/api-fetch";
|
|
import { useTranslations } from "@/lib/i18n/context";
|
|
import { queryKeys } from "@/lib/query-keys";
|
|
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
|
|
import { DocumentLinkPicker } from "./document-link-picker";
|
|
import { ImportFromSourceButton } from "./sources/import-from-source-button";
|
|
import { DOCUMENT_KIND_ICONS } from "./document-kind-meta";
|
|
|
|
/** Compact preview size — "Alle anzeigen" carries the long tail. */
|
|
const PREVIEW_LIMIT = 5;
|
|
|
|
interface ListPage {
|
|
documents: InboundDocumentDto[];
|
|
nextCursor: string | null;
|
|
}
|
|
|
|
export function EpisodeDocumentsCard({ episodeId }: { episodeId: string }) {
|
|
const { t } = useTranslations();
|
|
const documentDate = useDocumentDate();
|
|
const { user } = useAuth();
|
|
// Linking a document to an episode writes through
|
|
// `POST /api/documents/inbound/bulk`, the same endpoint the vault gates on
|
|
// `canManageDocuments` throughout. The episode side of the same link had no gate at
|
|
// all, so the vault refused what this card offered. Reading the linked
|
|
// documents stays: the list rows and "show all" are pure navigation.
|
|
const { canManageDomain } = useRecordCapabilities();
|
|
const canManageDocuments = canManageDomain("documents");
|
|
const [pickerOpen, setPickerOpen] = useState(false);
|
|
|
|
const moduleEnabled = user?.modules?.inboundDocuments === true;
|
|
|
|
const list = useQuery({
|
|
queryKey: queryKeys.inboundDocumentEpisodePreview(episodeId),
|
|
enabled: moduleEnabled,
|
|
queryFn: () => {
|
|
const sp = new URLSearchParams();
|
|
sp.set("episodeId", episodeId);
|
|
sp.set("sort", "documentDate");
|
|
sp.set("order", "desc");
|
|
sp.set("limit", String(PREVIEW_LIMIT + 1));
|
|
return apiGet<ListPage>(`/api/documents/inbound?${sp.toString()}`);
|
|
},
|
|
});
|
|
|
|
if (!moduleEnabled) return null;
|
|
|
|
if (list.isError) {
|
|
return (
|
|
<QueryErrorCard
|
|
title={t("documents.episodeCard.loadError")}
|
|
onRetry={() => void list.refetch()}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const documents = (list.data?.documents ?? []).slice(0, PREVIEW_LIMIT);
|
|
const hasMore =
|
|
(list.data?.documents.length ?? 0) > PREVIEW_LIMIT ||
|
|
list.data?.nextCursor != null;
|
|
const vaultHref = `/documents?episode=${encodeURIComponent(episodeId)}`;
|
|
|
|
return (
|
|
<>
|
|
<Card data-slot="episode-documents-card">
|
|
<CardContent className="space-y-4">
|
|
<TileHeader
|
|
icon={FolderOpen}
|
|
title={t("documents.episodeCard.title")}
|
|
right={
|
|
documents.length > 0 ? (
|
|
<span className="text-muted-foreground text-xs tabular-nums">
|
|
{documents.length}
|
|
{hasMore ? "+" : ""}
|
|
</span>
|
|
) : undefined
|
|
}
|
|
/>
|
|
|
|
{list.isPending ? (
|
|
<div className="space-y-2">
|
|
{Array.from({ length: 2 }, (_, i) => (
|
|
<Skeleton key={i} className="h-12 w-full rounded-lg" />
|
|
))}
|
|
</div>
|
|
) : documents.length === 0 ? (
|
|
<p className="text-muted-foreground text-sm">
|
|
{t("documents.episodeCard.emptyLine")}
|
|
</p>
|
|
) : (
|
|
<ul className="space-y-1.5">
|
|
{documents.map((doc) => {
|
|
const title =
|
|
doc.title ?? doc.filename ?? t("documents.card.untitled");
|
|
const Icon = DOCUMENT_KIND_ICONS[doc.kind];
|
|
return (
|
|
<li key={doc.id}>
|
|
<ListRow asChild>
|
|
<Link
|
|
href={`${vaultHref}&doc=${encodeURIComponent(doc.id)}`}
|
|
className="hover:bg-muted/50 focus-visible:ring-ring/50 flex items-center gap-3 focus-visible:ring-[3px] focus-visible:outline-none"
|
|
>
|
|
<Icon
|
|
className="text-foreground size-5 shrink-0"
|
|
aria-hidden
|
|
/>
|
|
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
|
{title}
|
|
</span>
|
|
<span className="text-muted-foreground shrink-0 text-xs">
|
|
{documentDate(doc)}
|
|
</span>
|
|
</Link>
|
|
</ListRow>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
{canManageDocuments ? (
|
|
<>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setPickerOpen(true)}
|
|
>
|
|
<Link2 className="size-4" aria-hidden />
|
|
{t("documents.episodeCard.link")}
|
|
</Button>
|
|
{/* The upload affordance is a deep link into the vault with
|
|
this episode pre-filtered. The vault's own upload control
|
|
is `canManageDocuments`-gated, so an ungated link here would send a
|
|
delegate to a page with nothing to press. */}
|
|
<Button asChild variant="outline" size="sm">
|
|
<Link href={vaultHref}>
|
|
<Upload className="size-4" aria-hidden />
|
|
{t("documents.episodeCard.upload")}
|
|
</Link>
|
|
</Button>
|
|
<ImportFromSourceButton
|
|
link={{ kind: "conditionEpisode", id: episodeId }}
|
|
/>
|
|
</>
|
|
) : null}
|
|
{hasMore ? (
|
|
<Button
|
|
asChild
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-muted-foreground ml-auto"
|
|
>
|
|
<Link href={vaultHref}>
|
|
{t("documents.episodeCard.viewAll")}
|
|
</Link>
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{canManageDocuments ? (
|
|
<DocumentLinkPicker
|
|
episodeId={episodeId}
|
|
open={pickerOpen}
|
|
onOpenChange={setPickerOpen}
|
|
/>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|