metis-desk/modules/resources/src/web/catalog-page.tsx
2026-10-05 13:13:51 +02:00

304 lines
9.4 KiB
TypeScript

import { Button } from "@repo/shadcn-ui/components/button";
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { Input } from "@repo/shadcn-ui/components/input";
import { Label } from "@repo/shadcn-ui/components/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@repo/shadcn-ui/components/table";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useId, useState } from "react";
import { useTranslation } from "react-i18next";
import type { CatalogItemDTO } from "../common";
import { catalogApi, catalogKeys } from "./catalog-api";
import { CatalogDefinitionDialog } from "./catalog-definition-dialog";
import { CatalogError, CatalogFieldForm } from "./catalog-form";
import { CatalogTemplateForm } from "./catalog-template-form";
type Editor = {
kind: "field" | "template";
item?: CatalogItemDTO;
copy?: CatalogItemDTO;
};
export function ResourceCatalogPage({ scope, csrfToken }: { scope: string; csrfToken: string }) {
const { t } = useTranslation("resources");
const searchId = useId();
const client = useQueryClient();
const [tab, setTab] = useState<"field" | "template">("template");
const [search, setSearch] = useState("");
const [view, setView] = useState<CatalogItemDTO>();
const [editor, setEditor] = useState<Editor>();
const query = useQuery({
queryKey: catalogKeys.scope(scope),
queryFn: () => catalogApi.get(scope),
});
const mutation = useMutation({
mutationFn: ({ item, active }: { item: CatalogItemDTO; active: boolean | null }) =>
catalogApi.status(scope, item.id, active, csrfToken),
onSuccess: async (response) => {
await client.invalidateQueries({ queryKey: ["resource-catalog"] });
client.setQueryData(catalogKeys.scope(scope), response);
},
});
const items = query.data?.items ?? [];
const visible = items.filter(
(item) =>
item.values.kind === tab &&
item.values.name.toLocaleLowerCase().includes(search.toLocaleLowerCase())
);
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 className="text-2xl font-semibold">
{t(scope === "global" ? "title" : "companyTitle")}
</h2>
<p className="mt-1 text-sm text-muted-foreground">
{t(scope === "global" ? "introduction" : "companyIntroduction")}
</p>
</div>
<Button disabled={!query.data} onClick={() => setEditor({ kind: tab })}>
{t({ field: "newField", template: "newTemplate" }[tab])}
</Button>
</div>
<nav aria-label={t("title")} className="flex gap-2">
{(["template", "field"] as const).map((kind) => (
<Button
aria-pressed={tab === kind}
key={kind}
onClick={() => {
setTab(kind);
setSearch("");
}}
variant={tab === kind ? "secondary" : "outline"}
>
{t({ field: "fields", template: "templates" }[kind])}
</Button>
))}
</nav>
<p className="text-sm text-muted-foreground">{t("protected")}</p>
{query.isPending && <p role="status">{t("loading")}</p>}
<CatalogError error={query.error ?? mutation.error} />
{query.isError && (
<Button
onClick={() => {
query.refetch().catch(() => undefined);
}}
variant="outline"
>
{t("retry")}
</Button>
)}
<Card>
<CardContent className="space-y-4 pt-6">
<div className="space-y-2">
<Label htmlFor={searchId}>{t("search")}</Label>
<Input
id={searchId}
onChange={(event) => setSearch(event.target.value)}
value={search}
/>
</div>
{query.data &&
(visible.length ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("name")}</TableHead>
<TableHead>{t("origin")}</TableHead>
<TableHead>{t({ field: "type", template: "fields" }[tab])}</TableHead>
<TableHead>{t("active")}</TableHead>
<TableHead>{t("actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visible.map((item) => (
<CatalogRow
item={item}
items={items}
key={item.id}
onCopy={() => setEditor({ kind: "template", copy: item })}
onEdit={() => setEditor({ kind: tab, item })}
onStatus={(active) => mutation.mutate({ item, active })}
onView={() => setView(item)}
pending={mutation.isPending}
scope={scope}
/>
))}
</TableBody>
</Table>
) : (
<p className="text-muted-foreground">{t("empty")}</p>
))}
</CardContent>
</Card>
<CatalogDefinitionDialog item={view} items={items} onClose={() => setView(undefined)} />
{editor && (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
setEditor(undefined);
}
}}
open
title={t(editorTitle(editor))}
wide={editor.kind === "template"}
>
{editor.kind === "field" ? (
<CatalogFieldForm
csrfToken={csrfToken}
item={editor.item}
onSaved={() => setEditor(undefined)}
scope={scope}
/>
) : (
<CatalogTemplateForm
copy={editor.copy}
csrfToken={csrfToken}
item={editor.item}
items={items}
onSaved={() => setEditor(undefined)}
scope={scope}
/>
)}
</FormDialog>
)}
</div>
);
}
function CatalogRow({
item,
scope,
items,
pending,
onEdit,
onCopy,
onView,
onStatus,
}: {
item: CatalogItemDTO;
scope: string;
items: readonly CatalogItemDTO[];
pending: boolean;
onEdit: () => void;
onCopy: () => void;
onView: () => void;
onStatus: (active: boolean | null) => void;
}) {
const { t } = useTranslation("resources");
const editable = !item.builtin && item.companyId === (scope === "global" ? null : scope);
const values = item.values;
const inactiveFields =
values.kind === "template" &&
values.fields.some(
(field) => !items.find((entry) => entry.id === field.fieldId)?.effectiveActive
);
return (
<TableRow>
<TableCell>
<p className="font-medium">{values.name}</p>
{values.description && (
<p className="text-xs text-muted-foreground">{values.description}</p>
)}
{inactiveFields && (
<p className="text-xs text-muted-foreground">{t("disabledFieldsNotice")}</p>
)}
</TableCell>
<TableCell>
{t(item.builtin ? "builtin" : "custom")}
<p className="text-xs text-muted-foreground">{t(item.companyId ? "own" : "global")}</p>
</TableCell>
<TableCell>
{values.kind === "field"
? t(values.type)
: t("fieldCount", { count: values.fields.length })}
</TableCell>
<TableCell>
{t(item.effectiveActive ? "active" : "inactive")}
{scope !== "global" && !item.companyId && (
<p className="text-xs text-muted-foreground">
{t(item.overridden ? "overridden" : "inherited")}
</p>
)}
</TableCell>
<TableCell>
<CatalogRowActions
editable={editable}
item={item}
onCopy={onCopy}
onEdit={onEdit}
onStatus={onStatus}
onView={onView}
pending={pending}
/>
</TableCell>
</TableRow>
);
}
function editorTitle(editor: Editor) {
if (editor.kind === "field") {
return editor.item ? "editField" : "newField";
}
return editor.item ? "editTemplate" : "newTemplate";
}
function CatalogRowActions({
item,
editable,
pending,
onEdit,
onCopy,
onView,
onStatus,
}: {
item: CatalogItemDTO;
editable: boolean;
pending: boolean;
onEdit: () => void;
onCopy: () => void;
onView: () => void;
onStatus: (active: boolean | null) => void;
}) {
const { t } = useTranslation("resources");
return (
<div className="flex flex-wrap gap-2">
<Button onClick={onView} size="sm" variant="outline">
{t("view")}
</Button>
{editable && (
<Button onClick={onEdit} size="sm" variant="outline">
{t("edit")}
</Button>
)}
{item.values.kind === "template" && (
<Button onClick={onCopy} size="sm" variant="outline">
{t("copy")}
</Button>
)}
<Button
disabled={pending}
onClick={() => onStatus(!item.effectiveActive)}
size="sm"
variant="outline"
>
{t(item.effectiveActive ? "deactivate" : "activate")}
</Button>
{item.overridden && (
<Button disabled={pending} onClick={() => onStatus(null)} size="sm" variant="ghost">
{t("reset")}
</Button>
)}
</div>
);
}