Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/components/screens/admin/AdminTenantOperationsPanel.tsx
T

229 lines
13 KiB
TypeScript

import { useTranslation } from "react-i18next";
import { Form } from "react-router";
import { FeatherCalendarPlus, FeatherPin, FeatherPlug, FeatherStickyNote, FeatherTrash2 } from "@subframe/core";
import type { AdminConnectorListVM, AdminConnectorRow, AdminTenantNoteListVM } from "@hivegrid/view-models";
import { BadgeFeedback } from "~/ui/components/BadgeFeedback";
import { BannerAlert } from "~/ui/components/BannerAlert";
import { Button } from "~/ui/components/Button";
import { Card } from "~/ui/components/Card";
import { Checkbox } from "~/ui/components/Checkbox";
import { EmptyState } from "~/ui/components/EmptyState";
import { NumberInput } from "~/ui/components/NumberInput";
import { Select } from "~/ui/components/Select";
import { Table } from "~/ui/components/Table";
import { TextArea } from "~/ui/components/TextArea";
import { TextInput } from "~/ui/components/TextInput";
import { useLocaleFormatters } from "~/locale/format";
import { subscriptionStatusLabelKey, translateOrHumanize } from "./adminEnumLabels";
export type AdminTenantTrialInfo = {
status: string | null;
trialEndsAtIso: string | null;
};
export type AdminTenantOperationsPanelProps = {
connectors: AdminConnectorListVM;
notes: AdminTenantNoteListVM;
trial: AdminTenantTrialInfo;
noteCategories: readonly string[];
csrf: string;
submitting: boolean;
feedback?: { success?: string; error?: string };
};
/**
* AK-P19 DoD #3 — AD-28 interim panel on the admin tenant detail: trial
* extension, subscription feature connectors (Abo-Baukasten) and operator
* notes. VMs in, action forms out — no data fetching in here.
*/
export function AdminTenantOperationsPanel({ connectors, notes, trial, noteCategories, csrf, submitting, feedback }: AdminTenantOperationsPanelProps): React.JSX.Element {
return (
<div data-ui-id="ui.admin.tenantOps.panel" className="flex w-full flex-col items-start gap-4">
{feedback?.success ? <BannerAlert type="success" fillContainer>{feedback.success}</BannerAlert> : null}
{feedback?.error ? <BannerAlert type="error" fillContainer>{feedback.error}</BannerAlert> : null}
<TrialCard trial={trial} csrf={csrf} submitting={submitting} />
<ConnectorsCard connectors={connectors} csrf={csrf} submitting={submitting} />
<NotesCard notes={notes} noteCategories={noteCategories} csrf={csrf} submitting={submitting} />
</div>
);
}
function TrialCard({ trial, csrf, submitting }: { trial: AdminTenantTrialInfo; csrf: string; submitting: boolean }): React.JSX.Element {
const { t } = useTranslation();
const formatters = useLocaleFormatters();
return (
<Card>
<div className="flex w-full flex-col items-start gap-1">
<h2 className="text-body-large-0 font-body-large-0 text-text-neutral-primary">{t("admin.tenantOps.trial.title")}</h2>
<p className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">{t("admin.tenantOps.trial.description")}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<BadgeFeedback farbe={trial.status === "trial" ? "subtle-information" : "subtle-neutral"} size="small">
{/* Printed the raw `trial` next to a translated "Aktiv" (audit I18N-02). */}
{trial.status ? translateOrHumanize(t, subscriptionStatusLabelKey(trial.status), trial.status) : t("admin.tenantOps.trial.noSubscription")}
</BadgeFeedback>
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-secondary">
{trial.trialEndsAtIso
? t("admin.tenantOps.trial.endsAt", { date: formatters.dateTime(new Date(trial.trialEndsAtIso)) })
: t("admin.tenantOps.trial.noTrial")}
</span>
</div>
{/* NumberInput is a fixed 192px-wide component — the column must match or the stepper gets overlapped. */}
<Form method="post" className="grid w-full grid-cols-[192px_minmax(240px,1fr)_auto] items-end gap-4 mobile:grid-cols-1">
<input type="hidden" name="csrf" value={csrf} />
<input type="hidden" name="action" value="trial-extend" />
<NumberInput showLabel label={t("admin.tenantOps.trial.daysLabel")}>
<NumberInput.Input name="days" min={1} max={365} defaultValue={14} required />
</NumberInput>
<TextInput showLabel fillContainer label={t("admin.tenantOps.reasonLabel")}>
<TextInput.Input name="reason" required minLength={5} placeholder={t("admin.tenantOps.reasonPlaceholder")} />
</TextInput>
<Button type="submit" loading={submitting} icon={<FeatherCalendarPlus />}>{t("admin.tenantOps.trial.extend")}</Button>
</Form>
</Card>
);
}
function ConnectorsCard({ connectors, csrf, submitting }: { connectors: AdminConnectorListVM; csrf: string; submitting: boolean }): React.JSX.Element {
const { t } = useTranslation();
const ready = connectors.status === "ready" ? connectors.data : null;
return (
<Card>
<div className="flex w-full items-start justify-between gap-4">
<div className="flex flex-col items-start gap-1">
<h2 className="text-body-large-0 font-body-large-0 text-text-neutral-primary">{t("admin.connectors.title")}</h2>
<p className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">{t("admin.connectors.description")}</p>
</div>
{ready ? (
<div className="flex flex-wrap items-center gap-2">
<BadgeFeedback farbe="subtle-positive" size="small">{t("admin.connectors.enabledCount", { count: ready.enabledCount })}</BadgeFeedback>
<BadgeFeedback farbe={ready.overriddenCount ? "subtle-warning" : "subtle-neutral"} size="small">
{t("admin.connectors.overriddenCount", { count: ready.overriddenCount })}
</BadgeFeedback>
</div>
) : null}
</div>
{connectors.status === "empty" || !ready ? (
<EmptyState outlined title={t("admin.connectors.empty.headline")} body={t("admin.connectors.empty.body")} />
) : (
<Table
header={
<Table.HeaderRow>
<Table.HeaderCell>{t("admin.connectors.columns.feature")}</Table.HeaderCell>
<Table.HeaderCell>{t("admin.connectors.columns.effective")}</Table.HeaderCell>
<Table.HeaderCell>{t("admin.connectors.columns.override")}</Table.HeaderCell>
</Table.HeaderRow>
}
>
{ready.rows.map((row) => (
<ConnectorRow key={row.featureKey} row={row} csrf={csrf} submitting={submitting} />
))}
</Table>
)}
</Card>
);
}
function ConnectorRow({ row, csrf, submitting }: { row: AdminConnectorRow; csrf: string; submitting: boolean }): React.JSX.Element {
const { t, i18n } = useTranslation();
const label = i18n.exists(row.labelKey) ? t(row.labelKey) : row.featureKey;
return (
<Table.Row>
<Table.Cell>
<div className="flex min-w-0 flex-col items-start">
<span className="text-body-small-medium-0 font-body-small-medium-0 text-text-neutral-primary">{label}</span>
<span className="text-body-extra-small-regular-150 font-body-extra-small-regular-150 text-text-neutral-tertiary">
{t(row.baseEnabled ? "admin.connectors.baseEnabled" : "admin.connectors.baseDisabled")}
</span>
</div>
</Table.Cell>
<Table.Cell>
<div className="flex flex-wrap items-center gap-2">
<BadgeFeedback farbe={row.effectiveEnabled ? "subtle-positive" : "subtle-negative"} size="small">
{t(row.effectiveEnabled ? "admin.connectors.state.on" : "admin.connectors.state.off")}
</BadgeFeedback>
{row.overridden ? <BadgeFeedback farbe="subtle-warning" size="small"><FeatherPlug /> {t("admin.connectors.state.overridden")}</BadgeFeedback> : null}
</div>
</Table.Cell>
<Table.Cell>
{row.toggleable ? (
<Form method="post" className="flex flex-wrap items-center gap-2">
<input type="hidden" name="csrf" value={csrf} />
<input type="hidden" name="action" value="connector-set" />
<input type="hidden" name="featureKey" value={row.featureKey} />
<Select name="state" defaultValue={row.overrideState} placeholder={t("admin.connectors.columns.override")}>
<Select.Item value="inherit">{t("admin.connectors.override.inherit")}</Select.Item>
<Select.Item value="enabled">{t("admin.connectors.override.enabled")}</Select.Item>
<Select.Item value="disabled">{t("admin.connectors.override.disabled")}</Select.Item>
</Select>
<TextInput>
<TextInput.Input name="reason" required minLength={5} placeholder={t("admin.tenantOps.reasonPlaceholder")} />
</TextInput>
<Button type="submit" variant="secondary" size="small" loading={submitting}>{t("shared.save")}</Button>
</Form>
) : (
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">{t("admin.connectors.notToggleable")}</span>
)}
</Table.Cell>
</Table.Row>
);
}
function NotesCard({ notes, noteCategories, csrf, submitting }: { notes: AdminTenantNoteListVM; noteCategories: readonly string[]; csrf: string; submitting: boolean }): React.JSX.Element {
const { t } = useTranslation();
const formatters = useLocaleFormatters();
const ready = notes.status === "ready" ? notes.data : null;
return (
<Card>
<div className="flex w-full flex-col items-start gap-1">
<h2 className="text-body-large-0 font-body-large-0 text-text-neutral-primary">{t("admin.tenantNotes.title")}</h2>
<p className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">{t("admin.tenantNotes.description")}</p>
</div>
<Form method="post" className="flex w-full flex-col items-start gap-4">
<input type="hidden" name="csrf" value={csrf} />
<input type="hidden" name="action" value="note-create" />
<TextArea showLabel className="w-full" label={t("admin.tenantNotes.bodyLabel")}>
<TextArea.Input name="noteText" required placeholder={t("admin.tenantNotes.bodyPlaceholder")} />
</TextArea>
<div className="flex w-full flex-wrap items-end gap-4">
<Select name="category" defaultValue="general" placeholder={t("admin.tenantNotes.categoryLabel")}>
{noteCategories.map((category) => (
<Select.Item key={category} value={category}>{t(`admin.tenantNotes.category.${category}`)}</Select.Item>
))}
</Select>
<Checkbox name="pinned" value="true" label={t("admin.tenantNotes.pinnedLabel")} />
<Button type="submit" loading={submitting} icon={<FeatherStickyNote />}>{t("admin.tenantNotes.create")}</Button>
</div>
</Form>
{notes.status === "empty" || !ready ? (
<EmptyState outlined title={t("admin.tenantNotes.empty.headline")} body={t("admin.tenantNotes.empty.body")} />
) : (
<div className="flex w-full flex-col items-start gap-3">
{ready.rows.map((note) => (
<div key={note.id} className="flex w-full items-start justify-between gap-4 rounded-md border border-solid border-border-default bg-surface-neutral-primary px-4 py-3 mobile:flex-col">
<div className="flex min-w-0 flex-col items-start gap-1">
<div className="flex flex-wrap items-center gap-2">
<BadgeFeedback farbe="subtle-neutral" size="small">{t(note.categoryLabelKey)}</BadgeFeedback>
{note.pinned ? <BadgeFeedback farbe="subtle-information" size="small"><FeatherPin /> {t("admin.tenantNotes.pinnedLabel")}</BadgeFeedback> : null}
</div>
<span className="whitespace-pre-wrap text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-primary">{note.bodyMarkdown}</span>
<span className="text-body-extra-small-regular-150 font-body-extra-small-regular-150 text-text-neutral-tertiary">
{note.authorLabel.startsWith("admin.") ? t(note.authorLabel) : note.authorLabel} · {formatters.dateTime(new Date(note.createdAtIso))}
</span>
</div>
<Form method="post">
<input type="hidden" name="csrf" value={csrf} />
<input type="hidden" name="action" value="note-delete" />
<input type="hidden" name="noteId" value={note.id} />
<Button type="submit" variant="destructive" size="small" loading={submitting} icon={<FeatherTrash2 />}>{t("shared.delete")}</Button>
</Form>
</div>
))}
</div>
)}
</Card>
);
}