229 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|