Files
homarr/packages/widgets/src/paperless-ngx/component.tsx
T

217 lines
6.6 KiB
TypeScript

"use client";
import { Fragment } from "react";
import type { CSSProperties } from "react";
import { RingProgress, Text } from "@mantine/core";
import { IconFileDescription, IconFileText, IconInbox, IconTag, IconUsers } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.css";
const statVisibilityByOption = {
showDocumentsTotal: "documentsTotal",
showDocumentsInbox: "documentsInbox",
showCorrespondents: "correspondents",
showTags: "tags",
showDocumentTypes: "documentTypes",
} as const;
const statIcons = {
documentsTotal: IconFileText,
documentsInbox: IconInbox,
correspondents: IconUsers,
tags: IconTag,
documentTypes: IconFileDescription,
} as const;
const gridHiddenWhenHeroShown = new Set(["documentsTotal", "documentsInbox"]);
const gridColsByWidth = [
{ minWidth: 380, cols: 3 },
{ minWidth: 220, cols: 2 },
{ minWidth: 0, cols: 1 },
] as const;
const ringSizeByWidth = [
{ minWidth: 400, size: 88 },
{ minWidth: 320, size: 72 },
{ minWidth: 220, size: 56 },
{ minWidth: 0, size: 44 },
] as const;
const iconSizeByWidth = [
{ minWidth: 320, size: 22 },
{ minWidth: 200, size: 18 },
{ minWidth: 0, size: 16 },
] as const;
const ringLabelSizeByRingSize = [
{ minSize: 72, size: "sm" },
{ minSize: 56, size: "xs" },
{ minSize: 0, size: "xs" },
] as const;
const heroLayoutBySecondaryStats = {
false: classes.heroExpanded,
true: "",
} as const;
const heroVariantByRing = {
true: "",
false: classes.heroTextOnly,
} as const;
const heroPartVisibility = {
ratio: "showInboxRatio",
ring: "showInboxRing",
} as const;
export default function PaperlessNgxWidget({
integrationIds,
options,
width,
height,
displayMode = "compact",
}: WidgetComponentProps<"paperlessNgx">) {
const t = useScopedI18n("widget.paperlessNgx");
const { data: stats } = clientApi.widget.paperlessNgx.getStats.useQuery({
integrationId: integrationIds[0] ?? "",
});
if (!stats) return <WidgetEmptyState />;
const canShowInboxHero = options.showDocumentsInbox && options.showDocumentsTotal;
const showHero = options.showInboxRatio && canShowInboxHero;
const inboxProgress = computeInboxProgress(stats.documentsTotal, stats.documentsInbox);
const statValues = {
documentsTotal: stats.documentsTotal,
documentsInbox: stats.documentsInbox,
correspondents: stats.correspondentsCount,
tags: stats.tagsCount,
documentTypes: stats.documentTypesCount,
} as const;
const visibleStatKeys = Object.entries(statVisibilityByOption)
.filter(([optionKey]) => options[optionKey as keyof typeof options])
.map(([, statKey]) => statKey)
.filter((statKey) => !(showHero && gridHiddenWhenHeroShown.has(statKey)));
const advanced = displayMode === "advanced";
const gridCols = advanced ? Math.max(1, Math.min(5, visibleStatKeys.length)) : getGridCols(width);
const ringSize = advanced ? 112 : getRingSize(width);
const iconSize = getIconSize(width);
const ringLabelSize = getRingLabelSize(ringSize);
const hasContent = showHero || visibleStatKeys.length > 0;
const heroLayoutClass =
heroLayoutBySecondaryStats[String(visibleStatKeys.length > 0) as keyof typeof heroLayoutBySecondaryStats];
const showInboxRing = options.showInboxRing && (advanced || height >= 120);
const heroRingClass = heroVariantByRing[String(showInboxRing) as keyof typeof heroVariantByRing];
const visibleHeroParts = Object.entries(heroPartVisibility).filter(
([partKey, optionKey]) => options[optionKey as keyof typeof options] && (partKey !== "ring" || showInboxRing),
);
const heroPartRenderers = {
ratio: (
<div className={classes.heroText}>
<span className={classes.heroLabel}>{t("inboxRatio")}</span>
<div className={classes.heroCounts}>
<span className={classes.heroInbox}>
{stats.documentsInbox} {t("inbox")}
</span>
<span className={classes.heroTotal}>
/ {stats.documentsTotal} {t("total")}
</span>
</div>
</div>
),
ring: (
<RingProgress
className={classes.ring}
size={ringSize}
thickness={Math.max(4, Math.round(ringSize / 10))}
roundCaps
sections={[{ value: inboxProgress, color: "orange" }]}
label={
<Text ta="center" size={ringLabelSize} fw={700}>
{inboxProgress}%
</Text>
}
/>
),
} as const;
return (
<div
className={classes.root}
data-display-mode={displayMode}
style={advanced ? { padding: 16, gap: 16 } : undefined}
>
{showHero && (
<div className={`${classes.hero} ${heroLayoutClass} ${heroRingClass}`}>
{visibleHeroParts.map(([partKey]) => (
<Fragment key={partKey}>{heroPartRenderers[partKey as keyof typeof heroPartRenderers]}</Fragment>
))}
</div>
)}
{!hasContent && (
<div className={classes.emptyState}>
<Text size="sm" c="dimmed">
—
</Text>
</div>
)}
{visibleStatKeys.length > 0 && (
<div className={classes.grid} style={{ "--stat-cols": gridCols } as CSSProperties}>
{visibleStatKeys.map((statKey) => {
const Icon = statIcons[statKey];
return (
<div key={statKey} className={classes.statTile}>
<Icon className={classes.statIcon} size={iconSize} stroke={1.5} />
<span className={classes.statValue}>{statValues[statKey]}</span>
<span className={classes.statLabel}>{t(statKey)}</span>
</div>
);
})}
</div>
)}
</div>
);
}
function computeInboxProgress(total: number, inbox: number): number {
if (total <= 0) {
return 0;
}
return Math.round((inbox / total) * 100);
}
function getGridCols(width: number): number {
const match = gridColsByWidth.find(({ minWidth }) => width >= minWidth);
return match?.cols ?? 1;
}
function getRingSize(width: number): number {
const match = ringSizeByWidth.find(({ minWidth }) => width >= minWidth);
return match?.size ?? 44;
}
function getIconSize(width: number): number {
const match = iconSizeByWidth.find(({ minWidth }) => width >= minWidth);
return match?.size ?? 16;
}
function getRingLabelSize(ringSize: number): "xs" | "sm" {
const match = ringLabelSizeByRingSize.find(({ minSize }) => ringSize >= minSize);
return match?.size ?? "xs";
}