170 lines
8.4 KiB
TypeScript
170 lines
8.4 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import { Form, useActionData, useLoaderData, useNavigate, useNavigation } from "react-router";
|
|
import { useTranslation } from "react-i18next";
|
|
import { FeatherChevronRight, FeatherInfo, FeatherPlus } from "@subframe/core";
|
|
import * as SubframeCore from "@subframe/core";
|
|
import { useRootData } from "~/utils/data/useRootData";
|
|
import { Avatar } from "~/ui/components/Avatar";
|
|
import { BadgeFeedback } from "~/ui/components/BadgeFeedback";
|
|
import { ErrorState } from "~/ui/components/ErrorState";
|
|
import { IconButton } from "~/ui/components/IconButton";
|
|
import { LinkButton } from "~/ui/components/LinkButton";
|
|
import { TextInput } from "~/ui/components/TextInput";
|
|
import { Tooltip } from "~/ui/components/Tooltip";
|
|
import { AuthShell } from "~/components/auth/AuthShell";
|
|
import type { RoleTone } from "@hivegrid/view-models";
|
|
import type { ActionData, LoaderData } from "~/routeModules/auth/select-tenant.impl";
|
|
import { useRedirectToast } from "~/hooks/use-save-toast";
|
|
|
|
// D-D0a / WF-P2 — Flow "Workspace Auswahl". 1:1 an Davids Subframe-Page "S01 Workspace Auswahl":
|
|
// AuthShell (Davids echte Card, large) + Titel mit Info-Tooltip + Untertitel + Such-Feld (TextInput
|
|
// type=search) + "ZULETZT GENUTZT"-Sektion + Workspace-Zeilen (Avatar, Name + Rollen-Badge, Untertitel,
|
|
// Chevron, Hover) + Divider + "Neuen Workspace anlegen"-LinkButton. An die echten Tenants des Users
|
|
// (Loader-VM) verdrahtet: Zeile = Form-Submit -> Action (CSRF + Membership-Check serverseitig) ->
|
|
// Tenant-Space. TextInput filtert client-seitig. data-ui-id-Marker bleiben fuer den Subframe-Swap erhalten.
|
|
// Davids Demo-Metadaten (Branche / Mitgliederzahl / "zuletzt aktiv" / "Aktiv"/"Gesperrt"-Status, gesperrte
|
|
// Zeile) haben kein Backend im VM und werden NICHT erfunden -> nur reale VM-Daten gerendert.
|
|
|
|
const ROLE_BADGE_FARBE: Record<RoleTone, "subtle-negative" | "subtle-blue" | "subtle-positive" | "subtle-neutral"> = {
|
|
"red-subtle": "subtle-negative",
|
|
"blue-subtle": "subtle-blue",
|
|
"green-subtle": "subtle-positive",
|
|
"neutral-subtle": "subtle-neutral",
|
|
};
|
|
|
|
export default function SelectTenantRoute() {
|
|
useRedirectToast();
|
|
const { t } = useTranslation();
|
|
const { csrf } = useRootData();
|
|
const { vm, copy, showCreateAction = true } = useLoaderData<LoaderData>();
|
|
const actionData = useActionData<ActionData>();
|
|
const navigate = useNavigate();
|
|
const navigation = useNavigation();
|
|
const isSubmitting = navigation.state !== "idle";
|
|
const [query, setQuery] = useState("");
|
|
|
|
const tenants = vm.status === "ready" ? vm.data.tenants : [];
|
|
const visibleTenants = useMemo(() => {
|
|
const q = query.trim().toLowerCase();
|
|
if (!q) return tenants;
|
|
return tenants.filter((tenant) => tenant.name.toLowerCase().includes(q));
|
|
}, [tenants, query]);
|
|
|
|
const titleNode = (
|
|
<span className="inline-flex items-center gap-2">
|
|
{t(copy?.titleKey ?? "account.selectTenant.title")}
|
|
<SubframeCore.Tooltip.Provider>
|
|
<SubframeCore.Tooltip.Root>
|
|
<SubframeCore.Tooltip.Trigger asChild>
|
|
<IconButton
|
|
variant="ghost"
|
|
size="small"
|
|
icon={<FeatherInfo />}
|
|
aria-label={t(copy?.infoTooltipKey ?? "account.selectTenant.infoTooltip")}
|
|
data-ui-id="ui.auth.selectTenant.info"
|
|
/>
|
|
</SubframeCore.Tooltip.Trigger>
|
|
<SubframeCore.Tooltip.Portal>
|
|
<SubframeCore.Tooltip.Content side="top" align="center" sideOffset={6} asChild>
|
|
<Tooltip>{t(copy?.infoTooltipKey ?? "account.selectTenant.infoTooltip")}</Tooltip>
|
|
</SubframeCore.Tooltip.Content>
|
|
</SubframeCore.Tooltip.Portal>
|
|
</SubframeCore.Tooltip.Root>
|
|
</SubframeCore.Tooltip.Provider>
|
|
</span>
|
|
);
|
|
|
|
return (
|
|
<AuthShell
|
|
title={titleNode}
|
|
subtitle={t(copy?.subtitleKey ?? "account.selectTenant.subtitle")}
|
|
titleAlign="center"
|
|
headerGap="gap-2"
|
|
subtitleVariant="regular-150"
|
|
cardSize="large"
|
|
cardClassName="max-w-[480px]"
|
|
showLogo={true}
|
|
dataUiId="ui.auth.selectTenant.page"
|
|
>
|
|
{actionData?.error ? <ErrorState variant="full-width" hideCancel title={t(actionData.error)} data-ui-id="ui.auth.selectTenant.error" /> : null}
|
|
|
|
{vm.status === "ready" ? (
|
|
<TextInput className="h-auto w-full flex-none" variant="filled" fillContainer type="search" data-ui-id="ui.auth.selectTenant.search">
|
|
<TextInput.Input
|
|
type="search"
|
|
placeholder={t(copy?.searchPlaceholderKey ?? "account.selectTenant.searchPlaceholder")}
|
|
value={query}
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
aria-label={t(copy?.searchPlaceholderKey ?? "account.selectTenant.searchPlaceholder")}
|
|
/>
|
|
</TextInput>
|
|
) : null}
|
|
|
|
{vm.status === "empty" ? (
|
|
<div className="flex w-full flex-col items-center gap-4 py-4 text-center" data-ui-id="ui.auth.selectTenant.empty">
|
|
<p className="text-body-default-regular-0 font-body-default-regular-0 text-text-neutral-secondary">
|
|
{t(copy?.emptyKey ?? "account.selectTenant.empty")}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="flex w-full flex-col items-center gap-2" data-ui-id="ui.auth.selectTenant.list">
|
|
<span className="text-body-extra-small-semibold-0 font-body-extra-small-semibold-0 text-text-neutral-tertiary tracking-wide">
|
|
{t(copy?.sectionLabelKey ?? "account.selectTenant.recentlyUsed")}
|
|
</span>
|
|
<div className="flex w-full flex-col items-start">
|
|
{visibleTenants.length === 0 ? (
|
|
<p className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary w-full py-4 text-center">
|
|
{t(copy?.noResultsKey ?? "account.selectTenant.noResults")}
|
|
</p>
|
|
) : (
|
|
visibleTenants.map((tenant) => (
|
|
<Form method="post" key={tenant.id} className="w-full">
|
|
<input type="hidden" name="csrf" value={csrf ?? ""} readOnly />
|
|
<input type="hidden" name="tenantId" value={tenant.id} readOnly />
|
|
<input type="hidden" name="slug" value={tenant.slug} readOnly />
|
|
<button
|
|
type="submit"
|
|
disabled={isSubmitting}
|
|
className="group/row hover:bg-surface-neutral-tertiary flex w-full cursor-pointer items-center gap-3 rounded-md px-3 py-3 text-left transition disabled:cursor-default disabled:opacity-60"
|
|
data-ui-id="ui.auth.selectTenant.card"
|
|
>
|
|
<Avatar size="medium" color={tenant.avatarColor} image={tenant.avatarImage}>
|
|
{tenant.avatarInitials}
|
|
</Avatar>
|
|
<div className="flex shrink-0 grow basis-0 flex-col items-start gap-0.5">
|
|
<div className="flex w-full items-center gap-2">
|
|
<span className="text-body-default-medium-0 font-body-default-medium-0 text-text-neutral-primary">{tenant.name}</span>
|
|
{tenant.role && tenant.roleTone ? (
|
|
<BadgeFeedback farbe={ROLE_BADGE_FARBE[tenant.roleTone]} size="small">
|
|
{t(tenant.roleLabelKey ?? "")}
|
|
</BadgeFeedback>
|
|
) : null}
|
|
</div>
|
|
<span className="text-body-extra-small-regular-150 font-body-extra-small-regular-150 text-text-neutral-tertiary w-full">
|
|
{[tenant.subtitle, tenant.isCurrent ? t("account.selectTenant.lastUsed") : null].filter(Boolean).join(" · ")}
|
|
</span>
|
|
</div>
|
|
<FeatherChevronRight className="text-body-default-regular-0 font-body-default-regular-0 text-icon-neutral-tertiary group-hover/row:text-icon-neutral-primary" />
|
|
</button>
|
|
</Form>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{showCreateAction ? (
|
|
<>
|
|
<div className="bg-border-default flex h-px w-full flex-none flex-col items-center" />
|
|
|
|
<div className="flex w-full flex-col items-center justify-center gap-6">
|
|
<LinkButton icon={<FeatherPlus />} iconRight={null} onClick={() => navigate("/new-account")} data-ui-id="ui.auth.selectTenant.addNew">
|
|
{t("account.selectTenant.addNew")}
|
|
</LinkButton>
|
|
</div>
|
|
</>
|
|
) : null}
|
|
</AuthShell>
|
|
);
|
|
}
|