feat(t5): ein Tabs-Baustein für Listen, Einstellungen und Statistik (T5a A8)
- ui/tabs.tsx nur noch Variante segment: Leiste auf --muted, aktiver Tab mit Kartenfläche, fetter Schrift und 3-px-Balken in --primary-ink; 44 px bei Touch, kompakt mit Maus; inaktive Schrift ≥ 6,27:1. - ActiveHistoryTabs als Vorlage auf Tabs für 10 Listen (Pfeiltasten schalten direkt, Daten sind geladen), mit toolbar und heading. - Einstellungen (Automatisierung, Benutzer & Rollen) und Statistik mit manueller Aktivierung: Pfeil bewegt den Fokus, Enter/Leertaste wählt; ?tab= über router.replace. TabSwitcher entfernt. - Menüeinträge (Dropdown, Select) mit innerem Fokusring ≥ 3:1. - Aufgaben: der Prioritätsfilter lädt die Liste jetzt neu (queryKey). - Wächter: keine eigenen tablist/tab/tabpanel außerhalb ui/tabs.tsx. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
759d85dc67
commit
fce9d10192
26 files changed
+1137
-606
No files matched your search
@@ -35,7 +35,25 @@ export default function ApiariesPage() {
|
||||
);
|
||||
const activeApiaries = byLocation.filter((apiary) => apiary.status === "ACTIVE");
|
||||
const historyApiaries = byLocation.filter((apiary) => apiary.status !== "ACTIVE");
|
||||
const visibleApiaries = tab === "active" ? activeApiaries : historyApiaries;
|
||||
|
||||
const listOf = (bucket: EntityLifecycleTab) => {
|
||||
const apiaries = bucket === "active" ? activeApiaries : historyApiaries;
|
||||
return (
|
||||
<QueryState query={apiariesQuery} what="Bienenstände">
|
||||
{() =>
|
||||
apiaries.length === 0 ? (
|
||||
<ApiariesEmpty
|
||||
tab={bucket}
|
||||
isFiltered={location !== ""}
|
||||
onResetFilter={() => setLocation("")}
|
||||
/>
|
||||
) : (
|
||||
<ApiaryGrid apiaries={apiaries} />
|
||||
)
|
||||
}
|
||||
</QueryState>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
@@ -63,35 +81,23 @@ export default function ApiariesPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
activeCount={activeApiaries.length}
|
||||
historyCount={historyApiaries.length}
|
||||
/>
|
||||
<Input
|
||||
className="w-48"
|
||||
placeholder="Standort"
|
||||
aria-label="Nach Standort filtern"
|
||||
value={location}
|
||||
onChange={(event) => setLocation(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<QueryState query={apiariesQuery} what="Bienenstände">
|
||||
{() =>
|
||||
visibleApiaries.length === 0 ? (
|
||||
<ApiariesEmpty
|
||||
tab={tab}
|
||||
isFiltered={location !== ""}
|
||||
onResetFilter={() => setLocation("")}
|
||||
/>
|
||||
) : (
|
||||
<ApiaryGrid apiaries={visibleApiaries} />
|
||||
)
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
activeCount={activeApiaries.length}
|
||||
historyCount={historyApiaries.length}
|
||||
toolbar={
|
||||
<Input
|
||||
className="w-48"
|
||||
placeholder="Standort"
|
||||
aria-label="Nach Standort filtern"
|
||||
value={location}
|
||||
onChange={(event) => setLocation(event.target.value)}
|
||||
/>
|
||||
}
|
||||
</QueryState>
|
||||
>
|
||||
{{ active: listOf("active"), history: listOf("history") }}
|
||||
</ActiveHistoryTabs>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,10 +48,30 @@ export default function ColoniesPage() {
|
||||
const colonies = coloniesQuery.data ?? [];
|
||||
const activeColonies = colonies.filter((colony) => colony.deactivatedAt === null);
|
||||
const historyColonies = colonies.filter((colony) => colony.deactivatedAt !== null);
|
||||
const bucketColonies = tab === "active" ? activeColonies : historyColonies;
|
||||
const visibleColonies = status ? bucketColonies.filter((colony) => colony.status === status) : bucketColonies;
|
||||
const statusOptions = tab === "active" ? LIVING_COLONY_STATUSES : ENDED_COLONY_STATUSES;
|
||||
|
||||
const listOf = (bucket: EntityLifecycleTab) => {
|
||||
const bucketColonies = bucket === "active" ? activeColonies : historyColonies;
|
||||
const visibleColonies = status
|
||||
? bucketColonies.filter((colony) => colony.status === status)
|
||||
: bucketColonies;
|
||||
return (
|
||||
<QueryState query={coloniesQuery} what="Völker">
|
||||
{() =>
|
||||
visibleColonies.length === 0 ? (
|
||||
<ColoniesEmpty
|
||||
tab={bucket}
|
||||
isFiltered={status !== "" && bucketColonies.length > 0}
|
||||
onResetFilter={() => setStatus("")}
|
||||
/>
|
||||
) : (
|
||||
<ColonyGrid colonies={visibleColonies} />
|
||||
)
|
||||
}
|
||||
</QueryState>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<PageHeader
|
||||
@@ -82,40 +102,28 @@ export default function ColoniesPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onChange={(next) => {
|
||||
setTab(next);
|
||||
setStatus("");
|
||||
}}
|
||||
activeCount={activeColonies.length}
|
||||
historyCount={historyColonies.length}
|
||||
/>
|
||||
<EnumSelect
|
||||
ariaLabel="Nach Status filtern"
|
||||
className="w-40"
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
values={statusOptions}
|
||||
labels={COLONY_STATUS_LABELS}
|
||||
noneLabel="Alle Status"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<QueryState query={coloniesQuery} what="Völker">
|
||||
{() =>
|
||||
visibleColonies.length === 0 ? (
|
||||
<ColoniesEmpty
|
||||
tab={tab}
|
||||
isFiltered={status !== "" && bucketColonies.length > 0}
|
||||
onResetFilter={() => setStatus("")}
|
||||
/>
|
||||
) : (
|
||||
<ColonyGrid colonies={visibleColonies} />
|
||||
)
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onValueChange={(next) => {
|
||||
setTab(next);
|
||||
setStatus("");
|
||||
}}
|
||||
activeCount={activeColonies.length}
|
||||
historyCount={historyColonies.length}
|
||||
toolbar={
|
||||
<EnumSelect
|
||||
ariaLabel="Nach Status filtern"
|
||||
className="w-40"
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
values={statusOptions}
|
||||
labels={COLONY_STATUS_LABELS}
|
||||
noneLabel="Alle Status"
|
||||
/>
|
||||
}
|
||||
</QueryState>
|
||||
>
|
||||
{{ active: listOf("active"), history: listOf("history") }}
|
||||
</ActiveHistoryTabs>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,7 +42,34 @@ export default function HoneyBatchesPage() {
|
||||
const honeyBatches = honeyBatchesQuery.data ?? [];
|
||||
const activeBatches = honeyBatches.filter((batch) => !HISTORY_STATUSES.includes(batch.status));
|
||||
const historyBatches = honeyBatches.filter((batch) => HISTORY_STATUSES.includes(batch.status));
|
||||
const visibleBatches = tab === "active" ? activeBatches : historyBatches;
|
||||
|
||||
const listOf = (bucket: EntityLifecycleTab) => {
|
||||
const batches = bucket === "active" ? activeBatches : historyBatches;
|
||||
return (
|
||||
<QueryState query={honeyBatchesQuery} what="Honigchargen">
|
||||
{() =>
|
||||
batches.length === 0 ? (
|
||||
bucket === "active" ? (
|
||||
<EmptyState
|
||||
icon={Droplet}
|
||||
title="Noch keine Honigchargen"
|
||||
description="Lege eine Charge an, sobald du geschleudert hast."
|
||||
action={<CreateHoneyBatchDialog />}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={Droplet}
|
||||
title="Keine Historie"
|
||||
description="Verkaufte oder verbrauchte Chargen erscheinen hier."
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<HoneyBatchGrid honeyBatches={batches} />
|
||||
)
|
||||
}
|
||||
</QueryState>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
@@ -74,33 +101,12 @@ export default function HoneyBatchesPage() {
|
||||
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
onValueChange={setTab}
|
||||
activeCount={activeBatches.length}
|
||||
historyCount={historyBatches.length}
|
||||
/>
|
||||
|
||||
<QueryState query={honeyBatchesQuery} what="Honigchargen">
|
||||
{() =>
|
||||
visibleBatches.length === 0 ? (
|
||||
tab === "active" ? (
|
||||
<EmptyState
|
||||
icon={Droplet}
|
||||
title="Noch keine Honigchargen"
|
||||
description="Lege eine Charge an, sobald du geschleudert hast."
|
||||
action={<CreateHoneyBatchDialog />}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={Droplet}
|
||||
title="Keine Historie"
|
||||
description="Verkaufte oder verbrauchte Chargen erscheinen hier."
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<HoneyBatchGrid honeyBatches={visibleBatches} />
|
||||
)
|
||||
}
|
||||
</QueryState>
|
||||
>
|
||||
{{ active: listOf("active"), history: listOf("history") }}
|
||||
</ActiveHistoryTabs>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,6 +35,22 @@ export default function MedicinesPage() {
|
||||
? `${visible.length} ${tab === "active" ? (visible.length === 1 ? "aktives Mittel" : "aktive Mittel") : visible.length === 1 ? "deaktiviertes Mittel" : "deaktivierte Mittel"}`
|
||||
: "";
|
||||
|
||||
const listOf = (bucket: EntityLifecycleTab) => {
|
||||
const shown = bucket === "active" ? active : inactive;
|
||||
if (shown.length > 0) return <MedicineGrid medicines={shown} />;
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Pill}
|
||||
title={bucket === "active" ? "Keine aktiven Mittel" : "Keine deaktivierten Mittel"}
|
||||
description={
|
||||
bucket === "active"
|
||||
? "Alle Mittel sind deaktiviert. Aktiviere eins wieder auf seiner Seite."
|
||||
: "Deaktivierte Mittel erscheinen hier."
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
if (!canRead) return <NoReadAccess />;
|
||||
|
||||
return (
|
||||
@@ -47,35 +63,24 @@ export default function MedicinesPage() {
|
||||
/>
|
||||
{canCreate ? <OnlineOnlyHint id={hintId} /> : null}
|
||||
|
||||
{medicines.length > 0 ? (
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
activeCount={active.length}
|
||||
historyCount={inactive.length}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<p aria-live="polite" className="min-h-5 text-sm text-muted-foreground">
|
||||
{medicines.length > 0 ? countText : ""}
|
||||
</p>
|
||||
|
||||
<QueryState query={medicinesQuery} what="Tierarzneimittel">
|
||||
{() =>
|
||||
medicines.length === 0 ? (
|
||||
<MedicineSuggestions canCreate={canCreate} disabled={isOffline} describedBy={describedBy} />
|
||||
) : visible.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Pill}
|
||||
title={tab === "active" ? "Keine aktiven Mittel" : "Keine deaktivierten Mittel"}
|
||||
description={
|
||||
tab === "active"
|
||||
? "Alle Mittel sind deaktiviert. Aktiviere eins wieder auf seiner Seite."
|
||||
: "Deaktivierte Mittel erscheinen hier."
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<MedicineGrid medicines={visible} />
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
activeCount={active.length}
|
||||
historyCount={inactive.length}
|
||||
toolbar={
|
||||
<p aria-live="polite" className="text-sm text-muted-foreground">
|
||||
{countText}
|
||||
</p>
|
||||
}
|
||||
>
|
||||
{{ active: listOf("active"), history: listOf("history") }}
|
||||
</ActiveHistoryTabs>
|
||||
)
|
||||
}
|
||||
</QueryState>
|
||||
|
||||
@@ -27,32 +27,14 @@ export default function ProductsPage() {
|
||||
const products = productsQuery.data ?? [];
|
||||
const activeProducts = products.filter((product) => product.status === "ACTIVE");
|
||||
const historyProducts = products.filter((product) => product.status !== "ACTIVE");
|
||||
const visibleProducts = tab === "active" ? activeProducts : historyProducts;
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<PageHeader
|
||||
area="products"
|
||||
title="Produkte"
|
||||
description="Hier legst du deine verkaufsfertigen Produkte an und pflegst sie."
|
||||
actions={
|
||||
<Button size="touch" nativeButton={false} render={<Link href="/products/new" />}>
|
||||
+ Neues Produkt
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
activeCount={activeProducts.length}
|
||||
historyCount={historyProducts.length}
|
||||
/>
|
||||
|
||||
const listOf = (bucket: EntityLifecycleTab) => {
|
||||
const visibleProducts = bucket === "active" ? activeProducts : historyProducts;
|
||||
return (
|
||||
<QueryState query={productsQuery} what="Produkte">
|
||||
{() =>
|
||||
visibleProducts.length === 0 ? (
|
||||
tab === "active" ? (
|
||||
bucket === "active" ? (
|
||||
<EmptyState
|
||||
icon={ShoppingBag}
|
||||
title="Noch keine Produkte"
|
||||
@@ -75,6 +57,30 @@ export default function ProductsPage() {
|
||||
)
|
||||
}
|
||||
</QueryState>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<PageHeader
|
||||
area="products"
|
||||
title="Produkte"
|
||||
description="Hier legst du deine verkaufsfertigen Produkte an und pflegst sie."
|
||||
actions={
|
||||
<Button size="touch" nativeButton={false} render={<Link href="/products/new" />}>
|
||||
+ Neues Produkt
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
activeCount={activeProducts.length}
|
||||
historyCount={historyProducts.length}
|
||||
>
|
||||
{{ active: listOf("active"), history: listOf("history") }}
|
||||
</ActiveHistoryTabs>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,8 +28,26 @@ export default function QueensPage() {
|
||||
const queens = queensQuery.data ?? [];
|
||||
const activeQueens = queens.filter((queen) => queen.deactivatedAt === null);
|
||||
const historyQueens = queens.filter((queen) => queen.deactivatedAt !== null);
|
||||
const bucketQueens = tab === "active" ? activeQueens : historyQueens;
|
||||
const visibleQueens = status ? bucketQueens.filter((queen) => queen.status === status) : bucketQueens;
|
||||
|
||||
const listOf = (bucket: EntityLifecycleTab) => {
|
||||
const bucketQueens = bucket === "active" ? activeQueens : historyQueens;
|
||||
const visibleQueens = status ? bucketQueens.filter((queen) => queen.status === status) : bucketQueens;
|
||||
return (
|
||||
<QueryState query={queensQuery} what="Königinnen">
|
||||
{() =>
|
||||
visibleQueens.length === 0 ? (
|
||||
<QueensEmpty
|
||||
tab={bucket}
|
||||
isFiltered={status !== "" && bucketQueens.length > 0}
|
||||
onResetFilter={() => setStatus("")}
|
||||
/>
|
||||
) : (
|
||||
<QueenGrid queens={visibleQueens} />
|
||||
)
|
||||
}
|
||||
</QueryState>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
@@ -44,40 +62,28 @@ export default function QueensPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onChange={(next) => {
|
||||
setTab(next);
|
||||
setStatus("");
|
||||
}}
|
||||
activeCount={activeQueens.length}
|
||||
historyCount={historyQueens.length}
|
||||
/>
|
||||
<EnumSelect
|
||||
ariaLabel="Nach Status filtern"
|
||||
className="w-40"
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
values={QUEEN_STATUSES}
|
||||
labels={QUEEN_STATUS_LABELS}
|
||||
noneLabel="Alle Status"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<QueryState query={queensQuery} what="Königinnen">
|
||||
{() =>
|
||||
visibleQueens.length === 0 ? (
|
||||
<QueensEmpty
|
||||
tab={tab}
|
||||
isFiltered={status !== "" && bucketQueens.length > 0}
|
||||
onResetFilter={() => setStatus("")}
|
||||
/>
|
||||
) : (
|
||||
<QueenGrid queens={visibleQueens} />
|
||||
)
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onValueChange={(next) => {
|
||||
setTab(next);
|
||||
setStatus("");
|
||||
}}
|
||||
activeCount={activeQueens.length}
|
||||
historyCount={historyQueens.length}
|
||||
toolbar={
|
||||
<EnumSelect
|
||||
ariaLabel="Nach Status filtern"
|
||||
className="w-40"
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
values={QUEEN_STATUSES}
|
||||
labels={QUEEN_STATUS_LABELS}
|
||||
noneLabel="Alle Status"
|
||||
/>
|
||||
}
|
||||
</QueryState>
|
||||
>
|
||||
{{ active: listOf("active"), history: listOf("history") }}
|
||||
</ActiveHistoryTabs>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense } from "react";
|
||||
import { Suspense, type ComponentType } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { TabSwitcher } from "@/components/tab-switcher";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { WebhooksTab } from "./webhooks-tab";
|
||||
import { TaskTemplatesTab } from "./task-templates-tab";
|
||||
import { LabelTemplatesTab } from "./label-templates-tab";
|
||||
@@ -22,17 +22,17 @@ type Tab =
|
||||
| "api-keys";
|
||||
|
||||
// Everyday settings first, connections to other systems last.
|
||||
const TABS: { value: Tab; label: string }[] = [
|
||||
{ value: "rules", label: "Vorschläge" },
|
||||
{ value: "task-templates", label: "Wiederkehrende Aufgaben" },
|
||||
{ value: "label-templates", label: "Etikettenvorlagen" },
|
||||
{ value: "social-media", label: "Stammdaten & Web" },
|
||||
{ value: "calendar-sync", label: "Kalender-Sync" },
|
||||
{ value: "api-keys", label: "API-Schlüssel" },
|
||||
{ value: "webhooks", label: "Webhooks" },
|
||||
const TABS: { value: Tab; label: string; Content: ComponentType }[] = [
|
||||
{ value: "rules", label: "Vorschläge", Content: AutomationRulesTab },
|
||||
{ value: "task-templates", label: "Wiederkehrende Aufgaben", Content: TaskTemplatesTab },
|
||||
{ value: "label-templates", label: "Etikettenvorlagen", Content: LabelTemplatesTab },
|
||||
{ value: "social-media", label: "Stammdaten & Web", Content: SocialMediaTab },
|
||||
{ value: "calendar-sync", label: "Kalender-Sync", Content: CalendarSyncTab },
|
||||
{ value: "api-keys", label: "API-Schlüssel", Content: ApiKeysTab },
|
||||
{ value: "webhooks", label: "Webhooks", Content: WebhooksTab },
|
||||
];
|
||||
|
||||
function isTab(value: string | null): value is Tab {
|
||||
function isTab(value: unknown): value is Tab {
|
||||
return TABS.some((tab) => tab.value === value);
|
||||
}
|
||||
|
||||
@@ -54,15 +54,25 @@ function AutomationPageContent() {
|
||||
description="Was ApiCore dir abnimmt – und wie es mit anderen Systemen verbunden ist."
|
||||
/>
|
||||
|
||||
<TabSwitcher tabs={TABS} value={tab} onChange={setTab} />
|
||||
|
||||
{tab === "webhooks" ? <WebhooksTab /> : null}
|
||||
{tab === "task-templates" ? <TaskTemplatesTab /> : null}
|
||||
{tab === "label-templates" ? <LabelTemplatesTab /> : null}
|
||||
{tab === "social-media" ? <SocialMediaTab /> : null}
|
||||
{tab === "rules" ? <AutomationRulesTab /> : null}
|
||||
{tab === "calendar-sync" ? <CalendarSyncTab /> : null}
|
||||
{tab === "api-keys" ? <ApiKeysTab /> : null}
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(next) => {
|
||||
if (isTab(next)) setTab(next);
|
||||
}}
|
||||
>
|
||||
<TabsList aria-label="Bereiche der Automatisierung">
|
||||
{TABS.map((entry) => (
|
||||
<TabsTrigger key={entry.value} value={entry.value}>
|
||||
{entry.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{TABS.map(({ value, Content }) => (
|
||||
<TabsContent key={value} value={value}>
|
||||
<Content />
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,74 +48,81 @@ export function TaskTemplatesTab() {
|
||||
|
||||
const activeTemplates = (templates ?? []).filter((template) => template.active);
|
||||
const historyTemplates = (templates ?? []).filter((template) => !template.active);
|
||||
const visibleTemplates = tab === "active" ? activeTemplates : historyTemplates;
|
||||
|
||||
const listOf = (bucket: EntityLifecycleTab) => {
|
||||
const visibleTemplates = bucket === "active" ? activeTemplates : historyTemplates;
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||
{isError ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Vorlagen konnten nicht geladen werden.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{templates && visibleTemplates.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Repeat}
|
||||
title={bucket === "active" ? "Noch keine Vorlagen angelegt" : "Keine Historie"}
|
||||
description={
|
||||
bucket === "active"
|
||||
? "Lege eine Vorlage an, um wiederkehrende Aufgaben zu automatisieren."
|
||||
: "Deaktivierte Vorlagen erscheinen hier."
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{visibleTemplates.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{visibleTemplates.map((template) => (
|
||||
<EntityCard
|
||||
key={template.id}
|
||||
area="tasks"
|
||||
title={template.title}
|
||||
badge={
|
||||
<Badge variant={template.active ? "default" : "outline"}>
|
||||
{template.active ? "Aktiv" : "Inaktiv"}
|
||||
</Badge>
|
||||
}
|
||||
fields={[
|
||||
{ label: "Wiederholung", value: describeRecurrence(template) },
|
||||
{ label: "Nächste Fälligkeit", value: formatDate(template.nextDueDate) },
|
||||
]}
|
||||
actions={
|
||||
<>
|
||||
<EditTaskTemplateDialog template={template} />
|
||||
<ConfirmDialog
|
||||
triggerLabel="Deaktivieren"
|
||||
triggerVariant="ghost"
|
||||
triggerDisabled={!template.active || deactivateMutation.isPending}
|
||||
title="Wiederkehrende Aufgabe beenden?"
|
||||
description={`Für „${template.title}“ werden keine neuen Aufgaben mehr angelegt. Bereits angelegte Aufgaben bleiben in der Liste.`}
|
||||
confirmLabel="Beenden"
|
||||
isPending={deactivateMutation.isPending}
|
||||
onConfirm={() => deactivateMutation.mutate(template.id)}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
activeCount={activeTemplates.length}
|
||||
historyCount={historyTemplates.length}
|
||||
/>
|
||||
<CreateTaskTemplateDialog />
|
||||
</div>
|
||||
|
||||
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||
{isError ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Vorlagen konnten nicht geladen werden.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{templates && visibleTemplates.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Repeat}
|
||||
title={tab === "active" ? "Noch keine Vorlagen angelegt" : "Keine Historie"}
|
||||
description={
|
||||
tab === "active"
|
||||
? "Lege eine Vorlage an, um wiederkehrende Aufgaben zu automatisieren."
|
||||
: "Deaktivierte Vorlagen erscheinen hier."
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{visibleTemplates.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{visibleTemplates.map((template) => (
|
||||
<EntityCard
|
||||
key={template.id}
|
||||
area="tasks"
|
||||
title={template.title}
|
||||
badge={
|
||||
<Badge variant={template.active ? "default" : "outline"}>
|
||||
{template.active ? "Aktiv" : "Inaktiv"}
|
||||
</Badge>
|
||||
}
|
||||
fields={[
|
||||
{ label: "Wiederholung", value: describeRecurrence(template) },
|
||||
{ label: "Nächste Fälligkeit", value: formatDate(template.nextDueDate) },
|
||||
]}
|
||||
actions={
|
||||
<>
|
||||
<EditTaskTemplateDialog template={template} />
|
||||
<ConfirmDialog
|
||||
triggerLabel="Deaktivieren"
|
||||
triggerVariant="ghost"
|
||||
triggerDisabled={!template.active || deactivateMutation.isPending}
|
||||
title="Wiederkehrende Aufgabe beenden?"
|
||||
description={`Für „${template.title}“ werden keine neuen Aufgaben mehr angelegt. Bereits angelegte Aufgaben bleiben in der Liste.`}
|
||||
confirmLabel="Beenden"
|
||||
isPending={deactivateMutation.isPending}
|
||||
onConfirm={() => deactivateMutation.mutate(template.id)}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
activeCount={activeTemplates.length}
|
||||
historyCount={historyTemplates.length}
|
||||
toolbar={<CreateTaskTemplateDialog />}
|
||||
toolbarAlign="end"
|
||||
>
|
||||
{{ active: listOf("active"), history: listOf("history") }}
|
||||
</ActiveHistoryTabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import AutomationPage from "./automation/page";
|
||||
import UsersRolesPage from "./users-roles/page";
|
||||
|
||||
const replace = vi.fn();
|
||||
let search = "";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ replace }),
|
||||
useSearchParams: () => new URLSearchParams(search),
|
||||
}));
|
||||
|
||||
// The tab contents fetch their own data; here only the switch between them matters.
|
||||
vi.mock("./automation/webhooks-tab", () => ({ WebhooksTab: () => <p>Inhalt Webhooks</p> }));
|
||||
vi.mock("./automation/task-templates-tab", () => ({ TaskTemplatesTab: () => <p>Inhalt Vorlagen</p> }));
|
||||
vi.mock("./automation/label-templates-tab", () => ({ LabelTemplatesTab: () => <p>Inhalt Etiketten</p> }));
|
||||
vi.mock("./automation/social-media-tab", () => ({ SocialMediaTab: () => <p>Inhalt Stammdaten</p> }));
|
||||
vi.mock("./automation/automation-rules-tab", () => ({ AutomationRulesTab: () => <p>Inhalt Vorschläge</p> }));
|
||||
vi.mock("./automation/calendar-sync-tab", () => ({ CalendarSyncTab: () => <p>Inhalt Kalender</p> }));
|
||||
vi.mock("./automation/api-keys-tab", () => ({ ApiKeysTab: () => <p>Inhalt API</p> }));
|
||||
vi.mock("./users-roles/users-tab", () => ({ UsersTab: () => <p>Inhalt Benutzer</p> }));
|
||||
vi.mock("./users-roles/roles-tab", () => ({ RolesTab: () => <p>Inhalt Rollen</p> }));
|
||||
|
||||
describe("both settings pages use Tabs directly", () => {
|
||||
beforeEach(() => {
|
||||
replace.mockReset();
|
||||
search = "";
|
||||
});
|
||||
|
||||
it("Automatisierung: ?tab= picks the tab, the panel is labelled by it and a switch keeps the URL form", async () => {
|
||||
search = "tab=api-keys";
|
||||
const user = userEvent.setup();
|
||||
render(<AutomationPage />);
|
||||
|
||||
const selected = screen.getByRole("tab", { name: "API-Schlüssel" });
|
||||
expect(selected).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("tabpanel", { name: "API-Schlüssel" })).toHaveTextContent("Inhalt API");
|
||||
expect(screen.getByRole("tablist")).toHaveAttribute("data-variant", "segment");
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Webhooks" }));
|
||||
expect(replace).toHaveBeenLastCalledWith("/settings/automation?tab=webhooks");
|
||||
});
|
||||
|
||||
it("Automatisierung: an unknown ?tab= falls back to Vorschläge", () => {
|
||||
search = "tab=unknown";
|
||||
render(<AutomationPage />);
|
||||
expect(screen.getByRole("tab", { name: "Vorschläge" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("tabpanel")).toHaveTextContent("Inhalt Vorschläge");
|
||||
});
|
||||
|
||||
it("Benutzer & Rollen: an arrow only moves the focus, Enter switches the tab through the URL", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<UsersRolesPage />);
|
||||
|
||||
expect(screen.getByRole("tabpanel", { name: "Benutzer" })).toHaveTextContent("Inhalt Benutzer");
|
||||
await user.click(screen.getByRole("tab", { name: "Benutzer" }));
|
||||
replace.mockReset();
|
||||
await user.keyboard("{ArrowRight}");
|
||||
expect(screen.getByRole("tab", { name: "Rollen & Rechte" })).toHaveFocus();
|
||||
expect(replace).not.toHaveBeenCalled();
|
||||
|
||||
await user.keyboard("{Enter}");
|
||||
expect(replace).toHaveBeenLastCalledWith("/settings/users-roles?tab=roles");
|
||||
});
|
||||
|
||||
it("Benutzer & Rollen: ?tab=roles shows the roles", () => {
|
||||
search = "tab=roles";
|
||||
render(<UsersRolesPage />);
|
||||
expect(screen.getByRole("tab", { name: "Rollen & Rechte" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("tabpanel")).toHaveTextContent("Inhalt Rollen");
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Suspense } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { TabSwitcher } from "@/components/tab-switcher";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { UsersTab } from "./users-tab";
|
||||
import { RolesTab } from "./roles-tab";
|
||||
|
||||
@@ -14,7 +14,7 @@ const TABS: { value: Tab; label: string }[] = [
|
||||
{ value: "roles", label: "Rollen & Rechte" },
|
||||
];
|
||||
|
||||
function isTab(value: string | null): value is Tab {
|
||||
function isTab(value: unknown): value is Tab {
|
||||
return value === "users" || value === "roles";
|
||||
}
|
||||
|
||||
@@ -36,9 +36,26 @@ function UsersRolesPageContent() {
|
||||
description="Zugänge, Rollen und Berechtigungen verwalten"
|
||||
/>
|
||||
|
||||
<TabSwitcher tabs={TABS} value={tab} onChange={setTab} />
|
||||
|
||||
{tab === "users" ? <UsersTab /> : <RolesTab />}
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(next) => {
|
||||
if (isTab(next)) setTab(next);
|
||||
}}
|
||||
>
|
||||
<TabsList aria-label="Benutzer oder Rollen">
|
||||
{TABS.map((entry) => (
|
||||
<TabsTrigger key={entry.value} value={entry.value}>
|
||||
{entry.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
<TabsContent value="users">
|
||||
<UsersTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="roles">
|
||||
<RolesTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,112 +54,119 @@ export function RolesTab() {
|
||||
|
||||
const activeRoles = (roles ?? []).filter((role) => role.status === "ACTIVE");
|
||||
const historyRoles = (roles ?? []).filter((role) => role.status === "INACTIVE");
|
||||
const visibleRoles = tab === "active" ? activeRoles : historyRoles;
|
||||
const catalogLength = catalog?.length ?? 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
activeCount={activeRoles.length}
|
||||
historyCount={historyRoles.length}
|
||||
/>
|
||||
<CreateRoleDialog />
|
||||
</div>
|
||||
const listOf = (bucket: EntityLifecycleTab) => {
|
||||
const visibleRoles = bucket === "active" ? activeRoles : historyRoles;
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||
{isError ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Rollen konnten nicht geladen werden.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||
{isError ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Rollen konnten nicht geladen werden.
|
||||
</p>
|
||||
) : null}
|
||||
{roles && visibleRoles.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Shield}
|
||||
title={bucket === "active" ? "Noch keine Rollen" : "Keine Historie"}
|
||||
description={
|
||||
bucket === "active"
|
||||
? "Lege eine Rolle an, um Zugriffe granular zu steuern."
|
||||
: "Deaktivierte Rollen erscheinen hier."
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{roles && visibleRoles.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Shield}
|
||||
title={tab === "active" ? "Noch keine Rollen" : "Keine Historie"}
|
||||
description={
|
||||
tab === "active"
|
||||
? "Lege eine Rolle an, um Zugriffe granular zu steuern."
|
||||
: "Deaktivierte Rollen erscheinen hier."
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{visibleRoles.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{visibleRoles.map((role) => (
|
||||
<EntityCard
|
||||
key={role.id}
|
||||
area="users-roles"
|
||||
title={role.name}
|
||||
subtitle={role.key}
|
||||
badge={
|
||||
role.isSystem ? (
|
||||
<Badge variant="secondary">System-Rolle</Badge>
|
||||
) : (
|
||||
<Badge variant={role.status === "ACTIVE" ? "default" : "outline"}>
|
||||
{role.status === "ACTIVE" ? "Aktiv" : "Inaktiv"}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
fields={[
|
||||
{
|
||||
label: "Rechte",
|
||||
value: `${role.permissionKeys.length}/${catalogLength}`,
|
||||
},
|
||||
{ label: "Benutzer", value: role.userCount },
|
||||
]}
|
||||
actions={
|
||||
role.isSystem ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Systemrollen können nicht bearbeitet werden.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEditingRole(role)}
|
||||
>
|
||||
Bearbeiten
|
||||
</Button>
|
||||
{role.status === "ACTIVE" ? (
|
||||
<ConfirmDialog
|
||||
triggerLabel="Deaktivieren"
|
||||
triggerSize="sm"
|
||||
triggerDisabled={role.userCount > 0 || deactivateMutation.isPending}
|
||||
title="Rolle deaktivieren?"
|
||||
description={
|
||||
role.userCount > 0
|
||||
? `„${role.name}“ ist noch ${role.userCount} Benutzer(n) zugewiesen. Bitte zuerst umverteilen.`
|
||||
: `„${role.name}“ kann keinem Benutzer mehr neu zugewiesen werden. Kann jederzeit reaktiviert werden.`
|
||||
}
|
||||
confirmLabel="Deaktivieren"
|
||||
isPending={deactivateMutation.isPending}
|
||||
onConfirm={() => deactivateMutation.mutate(role.id)}
|
||||
/>
|
||||
) : (
|
||||
{visibleRoles.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{visibleRoles.map((role) => (
|
||||
<EntityCard
|
||||
key={role.id}
|
||||
area="users-roles"
|
||||
title={role.name}
|
||||
subtitle={role.key}
|
||||
badge={
|
||||
role.isSystem ? (
|
||||
<Badge variant="secondary">System-Rolle</Badge>
|
||||
) : (
|
||||
<Badge variant={role.status === "ACTIVE" ? "default" : "outline"}>
|
||||
{role.status === "ACTIVE" ? "Aktiv" : "Inaktiv"}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
fields={[
|
||||
{
|
||||
label: "Rechte",
|
||||
value: `${role.permissionKeys.length}/${catalogLength}`,
|
||||
},
|
||||
{ label: "Benutzer", value: role.userCount },
|
||||
]}
|
||||
actions={
|
||||
role.isSystem ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Systemrollen können nicht bearbeitet werden.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={reactivateMutation.isPending}
|
||||
onClick={() => reactivateMutation.mutate(role.id)}
|
||||
onClick={() => setEditingRole(role)}
|
||||
>
|
||||
Reaktivieren
|
||||
Bearbeiten
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{role.status === "ACTIVE" ? (
|
||||
<ConfirmDialog
|
||||
triggerLabel="Deaktivieren"
|
||||
triggerSize="sm"
|
||||
triggerDisabled={role.userCount > 0 || deactivateMutation.isPending}
|
||||
title="Rolle deaktivieren?"
|
||||
description={
|
||||
role.userCount > 0
|
||||
? `„${role.name}“ ist noch ${role.userCount} Benutzer(n) zugewiesen. Bitte zuerst umverteilen.`
|
||||
: `„${role.name}“ kann keinem Benutzer mehr neu zugewiesen werden. Kann jederzeit reaktiviert werden.`
|
||||
}
|
||||
confirmLabel="Deaktivieren"
|
||||
isPending={deactivateMutation.isPending}
|
||||
onConfirm={() => deactivateMutation.mutate(role.id)}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={reactivateMutation.isPending}
|
||||
onClick={() => reactivateMutation.mutate(role.id)}
|
||||
>
|
||||
Reaktivieren
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
activeCount={activeRoles.length}
|
||||
historyCount={historyRoles.length}
|
||||
toolbar={<CreateRoleDialog />}
|
||||
toolbarAlign="end"
|
||||
>
|
||||
{{ active: listOf("active"), history: listOf("history") }}
|
||||
</ActiveHistoryTabs>
|
||||
|
||||
<EditRoleDialog role={editingRole} onClose={() => setEditingRole(null)} />
|
||||
</div>
|
||||
|
||||
@@ -55,4 +55,12 @@ describe("StatisticsPage", () => {
|
||||
expect(fetchColonyStats).toHaveBeenCalledTimes(2);
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("the statistics page uses the segment variant", () => {
|
||||
vi.mocked(fetchColonyStats).mockReturnValue(new Promise(() => {}));
|
||||
renderWithClient(<StatisticsPage />);
|
||||
const list = screen.getByRole("tablist", { hidden: true });
|
||||
expect(list).toHaveAttribute("data-variant", "segment");
|
||||
expect(screen.getByRole("tab", { name: "Völker", hidden: true })).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
});
|
||||
@@ -35,7 +35,21 @@ export default function StoragePage() {
|
||||
const containers = containersQuery.data ?? [];
|
||||
const activeContainers = containers.filter((container) => container.status !== "OUT_OF_SERVICE");
|
||||
const historyContainers = containers.filter((container) => container.status === "OUT_OF_SERVICE");
|
||||
const visibleContainers = containerTab === "active" ? activeContainers : historyContainers;
|
||||
|
||||
const containersOf = (bucket: EntityLifecycleTab) => {
|
||||
const visibleContainers = bucket === "active" ? activeContainers : historyContainers;
|
||||
return (
|
||||
<QueryState query={containersQuery} what="Lagerbehälter">
|
||||
{() =>
|
||||
visibleContainers.length === 0 ? (
|
||||
<ContainersEmpty tab={bucket} />
|
||||
) : (
|
||||
<ContainerGrid containers={visibleContainers} />
|
||||
)
|
||||
}
|
||||
</QueryState>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-8 p-4 sm:p-6">
|
||||
@@ -47,24 +61,15 @@ export default function StoragePage() {
|
||||
actions={<CreateStorageContainerDialog />}
|
||||
/>
|
||||
{hasPermission("veterinary-medicines:read") ? <MedicinesCard /> : null}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="text-lg font-semibold tracking-tight">Lagerbehälter</h2>
|
||||
<ActiveHistoryTabs
|
||||
value={containerTab}
|
||||
onChange={setContainerTab}
|
||||
activeCount={activeContainers.length}
|
||||
historyCount={historyContainers.length}
|
||||
/>
|
||||
</div>
|
||||
<QueryState query={containersQuery} what="Lagerbehälter">
|
||||
{() =>
|
||||
visibleContainers.length === 0 ? (
|
||||
<ContainersEmpty tab={containerTab} />
|
||||
) : (
|
||||
<ContainerGrid containers={visibleContainers} />
|
||||
)
|
||||
}
|
||||
</QueryState>
|
||||
<ActiveHistoryTabs
|
||||
heading={<h2 className="text-lg font-semibold tracking-tight">Lagerbehälter</h2>}
|
||||
value={containerTab}
|
||||
onValueChange={setContainerTab}
|
||||
activeCount={activeContainers.length}
|
||||
historyCount={historyContainers.length}
|
||||
>
|
||||
{{ active: containersOf("active"), history: containersOf("history") }}
|
||||
</ActiveHistoryTabs>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-4 border-t border-border/60 pt-6">
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import TasksPage from "./page";
|
||||
import { fetchTasks } from "@/lib/tasks/api";
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ href, children, ...rest }: { href: string; children: React.ReactNode }) => (
|
||||
<a href={href} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/tasks/api", () => ({
|
||||
fetchTasks: vi.fn(),
|
||||
completeTask: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./task-edit-dialog", () => ({ TaskEditDialog: () => null }));
|
||||
|
||||
function renderPage() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<TasksPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return client;
|
||||
}
|
||||
|
||||
describe("TasksPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchTasks).mockReset();
|
||||
vi.mocked(fetchTasks).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("refetches with the chosen priority when the filter changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const client = renderPage();
|
||||
await waitFor(() => expect(fetchTasks).toHaveBeenCalledWith({ priority: undefined }));
|
||||
|
||||
await user.click(screen.getByRole("combobox", { name: "Nach Priorität filtern" }));
|
||||
await user.click(await screen.findByRole("option", { name: "Hoch" }));
|
||||
|
||||
await waitFor(() => expect(fetchTasks).toHaveBeenLastCalledWith({ priority: "HIGH" }));
|
||||
// The key still starts with "tasks", so every invalidateQueries({ queryKey: ["tasks"] }) hits it.
|
||||
const keys = client.getQueryCache().getAll().map((query) => query.queryKey);
|
||||
expect(keys.every((key) => key[0] === "tasks")).toBe(true);
|
||||
expect(keys.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,9 @@ export default function TasksPage() {
|
||||
const [onlyOverdue, setOnlyOverdue] = useState(false);
|
||||
|
||||
const { data: tasks, isLoading, isError } = useQuery({
|
||||
queryKey: ["tasks"],
|
||||
// The filter belongs to the key, or a new priority never refetches. The key keeps the
|
||||
// prefix "tasks", so every invalidateQueries({ queryKey: ["tasks"] }) still reaches it.
|
||||
queryKey: ["tasks", "list", { priority: priority || null }],
|
||||
queryFn: () => fetchTasks({ priority: priority || undefined }),
|
||||
});
|
||||
|
||||
@@ -44,11 +46,79 @@ export default function TasksPage() {
|
||||
const [now] = useState(() => Date.now());
|
||||
const activeTasks = (tasks ?? []).filter((task) => !HISTORY_STATUSES.includes(task.status));
|
||||
const historyTasks = (tasks ?? []).filter((task) => HISTORY_STATUSES.includes(task.status));
|
||||
const bucketTasks = tab === "active" ? activeTasks : historyTasks;
|
||||
const visibleTasks = bucketTasks.filter((task) => {
|
||||
if (!onlyOverdue) return true;
|
||||
return task.dueDate !== null && new Date(task.dueDate).getTime() < now;
|
||||
});
|
||||
|
||||
const listOf = (bucket: EntityLifecycleTab) => {
|
||||
const bucketTasks = bucket === "active" ? activeTasks : historyTasks;
|
||||
const visibleTasks = bucketTasks.filter((task) => {
|
||||
if (!onlyOverdue) return true;
|
||||
return task.dueDate !== null && new Date(task.dueDate).getTime() < now;
|
||||
});
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||
{isError ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Aufgaben konnten nicht geladen werden.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{tasks && visibleTasks.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={ListTodo}
|
||||
title={bucket === "active" ? "Keine Aufgaben" : "Keine Historie"}
|
||||
description={
|
||||
bucket === "active"
|
||||
? onlyOverdue
|
||||
? "Keine überfälligen Aufgaben – gut gemacht!"
|
||||
: "Lege deine erste Aufgabe an, um den Überblick zu behalten."
|
||||
: "Erledigte oder abgebrochene Aufgaben erscheinen hier."
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{visibleTasks.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{visibleTasks.map((task) => (
|
||||
<EntityCard
|
||||
key={task.id}
|
||||
area="tasks"
|
||||
title={task.title}
|
||||
badge={
|
||||
<span className="flex flex-wrap items-center gap-1.5">
|
||||
<TaskStatusPill status={task.status} />
|
||||
<TaskSourceBadge task={task} />
|
||||
</span>
|
||||
}
|
||||
fields={[
|
||||
{ label: "Priorität", value: TASK_PRIORITY_LABELS[task.priority] },
|
||||
{
|
||||
label: "Fällig",
|
||||
value: task.dueDate ? new Date(task.dueDate).toLocaleDateString("de-DE") : "—",
|
||||
},
|
||||
]}
|
||||
actions={
|
||||
<>
|
||||
<TaskEditDialog task={task} />
|
||||
{task.status !== "DONE" && task.status !== "CANCELLED" ? (
|
||||
<Button
|
||||
size="touch"
|
||||
variant="outline"
|
||||
disabled={completeMutation.isPending}
|
||||
onClick={() => completeMutation.mutate(task.id)}
|
||||
>
|
||||
<Check aria-hidden />
|
||||
Erledigt
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
@@ -63,93 +133,35 @@ export default function TasksPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
activeCount={activeTasks.length}
|
||||
historyCount={historyTasks.length}
|
||||
/>
|
||||
<EnumSelect
|
||||
ariaLabel="Nach Priorität filtern"
|
||||
className="w-40"
|
||||
value={priority}
|
||||
onChange={setPriority}
|
||||
values={TASK_PRIORITIES}
|
||||
labels={TASK_PRIORITY_LABELS}
|
||||
noneLabel="Alle Prioritäten"
|
||||
/>
|
||||
{tab === "active" ? (
|
||||
<CheckboxField
|
||||
className="w-auto"
|
||||
label="Nur überfällige"
|
||||
checked={onlyOverdue}
|
||||
onCheckedChange={setOnlyOverdue}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||
{isError ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Aufgaben konnten nicht geladen werden.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{tasks && visibleTasks.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={ListTodo}
|
||||
title={tab === "active" ? "Keine Aufgaben" : "Keine Historie"}
|
||||
description={
|
||||
tab === "active"
|
||||
? onlyOverdue
|
||||
? "Keine überfälligen Aufgaben – gut gemacht!"
|
||||
: "Lege deine erste Aufgabe an, um den Überblick zu behalten."
|
||||
: "Erledigte oder abgebrochene Aufgaben erscheinen hier."
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{visibleTasks.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{visibleTasks.map((task) => (
|
||||
<EntityCard
|
||||
key={task.id}
|
||||
area="tasks"
|
||||
title={task.title}
|
||||
badge={
|
||||
<span className="flex flex-wrap items-center gap-1.5">
|
||||
<TaskStatusPill status={task.status} />
|
||||
<TaskSourceBadge task={task} />
|
||||
</span>
|
||||
}
|
||||
fields={[
|
||||
{ label: "Priorität", value: TASK_PRIORITY_LABELS[task.priority] },
|
||||
{
|
||||
label: "Fällig",
|
||||
value: task.dueDate ? new Date(task.dueDate).toLocaleDateString("de-DE") : "—",
|
||||
},
|
||||
]}
|
||||
actions={
|
||||
<>
|
||||
<TaskEditDialog task={task} />
|
||||
{task.status !== "DONE" && task.status !== "CANCELLED" ? (
|
||||
<Button
|
||||
size="touch"
|
||||
variant="outline"
|
||||
disabled={completeMutation.isPending}
|
||||
onClick={() => completeMutation.mutate(task.id)}
|
||||
>
|
||||
<Check aria-hidden />
|
||||
Erledigt
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
activeCount={activeTasks.length}
|
||||
historyCount={historyTasks.length}
|
||||
toolbar={
|
||||
<>
|
||||
<EnumSelect
|
||||
ariaLabel="Nach Priorität filtern"
|
||||
className="w-40"
|
||||
value={priority}
|
||||
onChange={setPriority}
|
||||
values={TASK_PRIORITIES}
|
||||
labels={TASK_PRIORITY_LABELS}
|
||||
noneLabel="Alle Prioritäten"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{tab === "active" ? (
|
||||
<CheckboxField
|
||||
className="w-auto"
|
||||
label="Nur überfällige"
|
||||
checked={onlyOverdue}
|
||||
onCheckedChange={setOnlyOverdue}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{{ active: listOf("active"), history: listOf("history") }}
|
||||
</ActiveHistoryTabs>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user