304 lines
9.4 KiB
TypeScript
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>
|
|
);
|
|
}
|