feat(t5): Bereiche mit eigenem Symbol und Ton, BrandMark, Verläufe raus (T5a A3)

AREAS (37 Bereiche, je Symbol genau einmal; neu compliance, medicines,
voice, hive-systems, system) als einzige Quelle für Kopfzeilen, Menü
und Schnellanlage; PageHeader, DetailHeader und EntityCard nehmen
area statt icon/gradient (Codemod über 72 Dateien). Doppelte Symbole
aufgelöst. BrandMark (Wabe) ersetzt das Verlaufs-Sechseck;
Honig-Banner auf der Startseite durch Überschrift mit Datum ersetzt;
Verläufe aus 33 Verbrauchern entfernt (Token folgen in A4).

Refs #26

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick.RosenloecherandClaude Opus 5.5 committed 2026-10-16 16:47:56 +02:00
1 parent cf53cf35ac
commit 258e90828f
97 files changed
+732 -395

No files matched your search

@@ -2,7 +2,7 @@
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { FileText, MapPin, Plus } from "lucide-react"; import { FileText, Plus } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { fetchApiary } from "@/lib/apiaries/api"; import { fetchApiary } from "@/lib/apiaries/api";
import { useAuth } from "@/lib/auth/auth-context"; import { useAuth } from "@/lib/auth/auth-context";
@@ -50,7 +50,7 @@ export default function ApiaryDetailPage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<DetailHeader <DetailHeader
icon={MapPin} area="apiaries"
title={apiary.name} title={apiary.name}
subtitle={apiary.location ?? undefined} subtitle={apiary.location ?? undefined}
status={ status={
@@ -31,7 +31,7 @@ export default function ApiaryMapPage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader <PageHeader
icon={MapPin} area="apiaries"
title="Kartenansicht" title="Kartenansicht"
description="Hier siehst du alle Bienenstände, bei denen Koordinaten hinterlegt sind." description="Hier siehst du alle Bienenstände, bei denen Koordinaten hinterlegt sind."
actions={ actions={
@@ -2,7 +2,7 @@
import { FormEvent, useState } from "react"; import { FormEvent, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { LocateFixed, MapPin } from "lucide-react"; import { LocateFixed } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { createApiary } from "@/lib/apiaries/api"; import { createApiary } from "@/lib/apiaries/api";
@@ -129,7 +129,7 @@ export default function NewApiaryPage() {
draft={draft} draft={draft}
/> />
<PageHeader <PageHeader
icon={MapPin} area="apiaries"
title="Neuer Bienenstand" title="Neuer Bienenstand"
description="Nur der Name ist Pflicht – den genauen Ort kannst du per Knopfdruck übernehmen." description="Nur der Name ist Pflicht – den genauen Ort kannst du per Knopfdruck übernehmen."
/> />
@@ -40,7 +40,7 @@ export default function ApiariesPage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader <PageHeader
icon={MapPin} area="apiaries"
title="Bienenstände" title="Bienenstände"
description="Hier verwaltest du die Standorte deiner Völker." description="Hier verwaltest du die Standorte deiner Völker."
actions={ actions={
@@ -149,7 +149,7 @@ function ApiaryGrid({ apiaries }: { apiaries: Apiary[] }) {
<EntityCard <EntityCard
key={apiary.id} key={apiary.id}
href={`/apiaries/${apiary.id}`} href={`/apiaries/${apiary.id}`}
icon={MapPin} area="apiaries"
title={apiary.name} title={apiary.name}
subtitle={apiary.location ?? "—"} subtitle={apiary.location ?? "—"}
badge={ badge={
@@ -2,7 +2,6 @@
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { Package } from "lucide-react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { fetchBottlingBatch } from "@/lib/bottling/api"; import { fetchBottlingBatch } from "@/lib/bottling/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -32,7 +31,7 @@ export default function BottlingBatchDetailPage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<DetailHeader <DetailHeader
icon={Package} area="bottling"
title={`Abfüllung ${bottlingBatch.lotNumber}`} title={`Abfüllung ${bottlingBatch.lotNumber}`}
subtitle={`${bottlingBatch.jarCount} Gläser à ${bottlingBatch.jarSize} g`} subtitle={`${bottlingBatch.jarCount} Gläser à ${bottlingBatch.jarSize} g`}
{...BACK} {...BACK}
@@ -4,7 +4,7 @@ import { FormEvent, useId, useMemo, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { AlertTriangle, Package } from "lucide-react"; import { AlertTriangle } from "lucide-react";
import { fetchHoneyBatches } from "@/lib/honey-batches/api"; import { fetchHoneyBatches } from "@/lib/honey-batches/api";
import { fetchProducts } from "@/lib/products/api"; import { fetchProducts } from "@/lib/products/api";
import { createBottlingBatch, fetchBottlingPrefill } from "@/lib/bottling/api"; import { createBottlingBatch, fetchBottlingPrefill } from "@/lib/bottling/api";
@@ -395,7 +395,7 @@ export default function NewBottlingBatchPage() {
draft={draft} draft={draft}
/> />
<PageHeader <PageHeader
icon={Package} area="bottling"
title="Neue Abfüllung" title="Neue Abfüllung"
description="Fülle eine Honigcharge in Gläser ab." description="Fülle eine Honigcharge in Gläser ab."
/> />
@@ -23,7 +23,7 @@ export default function BottlingBatchesPage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader <PageHeader
icon={Package} area="bottling"
title="Abfüllungen" title="Abfüllungen"
description="Hier findest du alle abgefüllten Chargen und erfasst neue Abfüllungen." description="Hier findest du alle abgefüllten Chargen und erfasst neue Abfüllungen."
actions={ actions={
@@ -55,7 +55,7 @@ export default function BottlingBatchesPage() {
<EntityCard <EntityCard
key={bottlingBatch.id} key={bottlingBatch.id}
href={`/bottling/${bottlingBatch.id}`} href={`/bottling/${bottlingBatch.id}`}
icon={Package} area="bottling"
title={<span className="font-mono">{bottlingBatch.lotNumber}</span>} title={<span className="font-mono">{bottlingBatch.lotNumber}</span>}
subtitle={bottlingBatch.product?.name ?? bottlingBatch.honeyBatch.batchNumber} subtitle={bottlingBatch.product?.name ?? bottlingBatch.honeyBatch.batchNumber}
fields={[ fields={[
@@ -259,10 +259,9 @@ export default function CalendarPage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader <PageHeader
icon={CalendarDays} area="calendar"
title="Kalender" title="Kalender"
description="Was wann ansteht und was erledigt wurde – Monat für Monat." description="Was wann ansteht und was erledigt wurde – Monat für Monat."
gradient="var(--gradient-sky)"
/> />
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@@ -3,7 +3,7 @@
import Link from "next/link"; import Link from "next/link";
import { useParams, useSearchParams } from "next/navigation"; import { useParams, useSearchParams } from "next/navigation";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { ClipboardCheck, FileText, Layers } from "lucide-react"; import { ClipboardCheck, FileText } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { fetchColony } from "@/lib/colonies/api"; import { fetchColony } from "@/lib/colonies/api";
import { COLONY_STATUS_LABELS, COLONY_TYPE_LABELS, colonyStrengthText } from "@/lib/colonies/labels"; import { COLONY_STATUS_LABELS, COLONY_TYPE_LABELS, colonyStrengthText } from "@/lib/colonies/labels";
@@ -56,7 +56,7 @@ export default function ColonyDetailPage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<DetailHeader <DetailHeader
icon={Layers} area="colonies"
title={colony.code} title={colony.code}
subtitle={COLONY_TYPE_LABELS[colony.colonyType]} subtitle={COLONY_TYPE_LABELS[colony.colonyType]}
status={<DetailStatus status={colony.status} label={COLONY_STATUS_LABELS[colony.status]} />} status={<DetailStatus status={colony.status} label={COLONY_STATUS_LABELS[colony.status]} />}
@@ -4,7 +4,7 @@ import { FormEvent, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { Layers } from "lucide-react";
import { createColony } from "@/lib/colonies/api"; import { createColony } from "@/lib/colonies/api";
import { COLONY_TYPES, COLONY_TYPE_LABELS, MAX_OCCUPIED_FRAMES } from "@/lib/colonies/labels"; import { COLONY_TYPES, COLONY_TYPE_LABELS, MAX_OCCUPIED_FRAMES } from "@/lib/colonies/labels";
import { fetchApiaries } from "@/lib/apiaries/api"; import { fetchApiaries } from "@/lib/apiaries/api";
@@ -110,7 +110,7 @@ export default function NewColonyPage() {
draft={draft} draft={draft}
/> />
<PageHeader <PageHeader
icon={Layers} area="colonies"
title="Neues Volk" title="Neues Volk"
description="Nur Code und Bienenstand sind Pflicht – der Rest kann später folgen." description="Nur Code und Bienenstand sind Pflicht – der Rest kann später folgen."
/> />
@@ -55,7 +55,7 @@ export default function ColoniesPage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader <PageHeader
icon={Layers} area="colonies"
title="Völker" title="Völker"
description="Hier siehst du alle Bienenvölker und legst neue an." description="Hier siehst du alle Bienenvölker und legst neue an."
actions={ actions={
@@ -173,7 +173,7 @@ function ColonyGrid({ colonies }: { colonies: Colony[] }) {
<EntityCard <EntityCard
key={colony.id} key={colony.id}
href={`/colonies/${colony.id}`} href={`/colonies/${colony.id}`}
icon={Layers} area="colonies"
title={<span className="font-mono">{colony.code}</span>} title={<span className="font-mono">{colony.code}</span>}
subtitle={COLONY_TYPE_LABELS[colony.colonyType]} subtitle={COLONY_TYPE_LABELS[colony.colonyType]}
badge={ badge={
@@ -2,7 +2,7 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { BookOpen, FileText, RotateCcw } from "lucide-react"; import { FileText, RotateCcw } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { NoComplianceAccess } from "@/components/compliance/no-compliance-access"; import { NoComplianceAccess } from "@/components/compliance/no-compliance-access";
import { NoticeStatus } from "@/components/compliance/notice-status"; import { NoticeStatus } from "@/components/compliance/notice-status";
@@ -138,7 +138,7 @@ export default function NoticeDetailPage() {
return ( return (
<main className="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4 sm:p-6"> <main className="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4 sm:p-6">
<DetailHeader <DetailHeader
icon={BookOpen} area="compliance"
title={noticeTitle(notice)} title={noticeTitle(notice)}
subtitle={`Stichtag ${formatDay(noticeDay(notice.referenceDate))}`} subtitle={`Stichtag ${formatDay(noticeDay(notice.referenceDate))}`}
status={<NoticeStatus status={notice.status} />} status={<NoticeStatus status={notice.status} />}
@@ -44,7 +44,7 @@ export default function NoticesPage() {
return ( return (
<main className="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4 sm:p-6"> <main className="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4 sm:p-6">
<DetailHeader <DetailHeader
icon={BookOpen} area="compliance"
title="Anzeigen ans Veterinäramt" title="Anzeigen ans Veterinäramt"
subtitle="Erstanzeige, Änderungen im Bestand und die TSK-Zählung." subtitle="Erstanzeige, Änderungen im Bestand und die TSK-Zählung."
backHref="/compliance" backHref="/compliance"
@@ -3,16 +3,7 @@
import Link from "next/link"; import Link from "next/link";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { import { BookOpen, CheckCircle2, FileText, Printer, Settings, TriangleAlert, XCircle } from "lucide-react";
BookOpen,
CheckCircle2,
FileText,
Printer,
Settings,
ShieldCheck,
TriangleAlert,
XCircle,
} from "lucide-react";
import { DetailHeader, type MoreAction } from "@/components/detail-header"; import { DetailHeader, type MoreAction } from "@/components/detail-header";
import { OnlineOnlyHint, StandNote, useOnlineOnly } from "@/components/offline/stand-note"; import { OnlineOnlyHint, StandNote, useOnlineOnly } from "@/components/offline/stand-note";
import { ErrorState, LoadingState } from "@/components/query-state"; import { ErrorState, LoadingState } from "@/components/query-state";
@@ -112,7 +103,7 @@ export default function CompliancePage() {
const header = ( const header = (
<DetailHeader <DetailHeader
icon={ShieldCheck} area="compliance"
title="Prüfbereitschaft" title="Prüfbereitschaft"
backHref="/dashboard" backHref="/dashboard"
backLabel="Start" backLabel="Start"
@@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { OctagonAlert } from "lucide-react";
import { NoComplianceAccess } from "@/components/compliance/no-compliance-access"; import { NoComplianceAccess } from "@/components/compliance/no-compliance-access";
import { DetailHeader } from "@/components/detail-header"; import { DetailHeader } from "@/components/detail-header";
import { StandNote } from "@/components/offline/stand-note"; import { StandNote } from "@/components/offline/stand-note";
@@ -109,7 +109,7 @@ export default function RestrictionsPage() {
return ( return (
<main className="mx-auto flex w-full max-w-3xl flex-col gap-6 p-4 sm:p-6"> <main className="mx-auto flex w-full max-w-3xl flex-col gap-6 p-4 sm:p-6">
<DetailHeader <DetailHeader
icon={OctagonAlert} area="compliance"
title="Sperrbezirke" title="Sperrbezirke"
subtitle="Stände, die das Veterinäramt zum Sperrbezirk erklärt hat." subtitle="Stände, die das Veterinäramt zum Sperrbezirk erklärt hat."
backHref="/compliance" backHref="/compliance"
@@ -1,7 +1,7 @@
"use client"; "use client";
import { FormEvent, useEffect, useState } from "react"; import { FormEvent, useEffect, useState } from "react";
import { Settings } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { NoComplianceAccess } from "@/components/compliance/no-compliance-access"; import { NoComplianceAccess } from "@/components/compliance/no-compliance-access";
import { DetailHeader, DetailQueryFallback } from "@/components/detail-header"; import { DetailHeader, DetailQueryFallback } from "@/components/detail-header";
@@ -228,7 +228,7 @@ function SettingsForm({ settings, canManage }: { settings: ComplianceSettings; c
return ( return (
<main className="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4 sm:p-6"> <main className="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4 sm:p-6">
<DetailHeader <DetailHeader
icon={Settings} area="compliance"
title="Einstellungen Behörden" title="Einstellungen Behörden"
subtitle={readOnly ? "Du kannst die Einstellungen nur ansehen." : "Hier legst du fest, wann und wohin du meldest."} subtitle={readOnly ? "Du kannst die Einstellungen nur ansehen." : "Hier legst du fest, wann und wohin du meldest."}
backHref="/compliance" backHref="/compliance"
@@ -255,4 +255,15 @@ describe("DashboardPage – Heute", () => {
expect(await within(row).findByText("V-07")).toBeInTheDocument(); expect(await within(row).findByText("V-07")).toBeInTheDocument();
expect(fetchColonies).toHaveBeenCalledTimes(1); expect(fetchColonies).toHaveBeenCalledTimes(1);
}); });
it("the dashboard header has no honey banner and no white text", async () => {
vi.mocked(fetchAutopilotToday).mockResolvedValue(todayFixture({}));
const { container } = renderPage();
const heading = await screen.findByRole("heading", { level: 1 });
const header = heading.closest("header") as HTMLElement;
expect(header.className).not.toMatch(/text-white|bg-|gradient/);
expect(header.getAttribute("style")).toBeNull();
expect(container.innerHTML).not.toMatch(/text-white|--gradient/);
});
}); });
@@ -24,7 +24,6 @@ import { DashboardAlert, DashboardAlertType } from "@/lib/dashboard/types";
import { fetchTasks } from "@/lib/tasks/api"; import { fetchTasks } from "@/lib/tasks/api";
import { tasksForToday } from "@/lib/tasks/today"; import { tasksForToday } from "@/lib/tasks/today";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { HoneycombPattern } from "@/components/honeycomb-pattern";
import { StandNote } from "@/components/offline/stand-note"; import { StandNote } from "@/components/offline/stand-note";
import { ComplianceLine } from "@/components/today/compliance-line"; import { ComplianceLine } from "@/components/today/compliance-line";
import { TodayPanel } from "@/components/today/today-panel"; import { TodayPanel } from "@/components/today/today-panel";
@@ -157,21 +156,15 @@ export default function DashboardPage() {
return ( return (
<main className="mx-auto flex w-full max-w-3xl flex-col gap-8 p-4 sm:p-6"> <main className="mx-auto flex w-full max-w-3xl flex-col gap-8 p-4 sm:p-6">
<header <header className="flex flex-col gap-1">
className="relative overflow-hidden rounded-2xl px-5 py-5 text-white shadow-[var(--shadow-elevated)] sm:px-7 sm:py-6" <h1 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
style={{ background: "var(--gradient-honey)" }} {greeting(now)}
> {firstName ? `, ${firstName}` : ""}
<HoneycombPattern className="pointer-events-none absolute inset-0 h-full w-full text-white/[0.08]" /> </h1>
<div className="relative flex flex-col gap-1"> <p className="text-sm text-muted-foreground sm:text-base">
<h1 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl"> {now.toLocaleDateString("de-DE", { weekday: "long", day: "numeric", month: "long" })}
{greeting(now)} {todayCount !== null ? ` · ${todaySummary(todayCount)}` : ""}
{firstName ? `, ${firstName}` : ""} </p>
</h1>
<p className="text-sm text-white/90 sm:text-base">
{now.toLocaleDateString("de-DE", { weekday: "long", day: "numeric", month: "long" })}
{todayCount !== null ? ` · ${todaySummary(todayCount)}` : ""}
</p>
</div>
</header> </header>
<StandNote updatedAt={standAt} isStale={hasStaleData} className="-mt-5" /> <StandNote updatedAt={standAt} isStale={hasStaleData} className="-mt-5" />
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { Wrench } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { fetchEquipment, updateEquipment } from "@/lib/equipment/api"; import { fetchEquipment, updateEquipment } from "@/lib/equipment/api";
@@ -42,7 +42,7 @@ export default function EquipmentDetailPage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<DetailHeader <DetailHeader
icon={Wrench} area="equipment"
title={equipment.name} title={equipment.name}
status={ status={
<DetailStatus <DetailStatus
@@ -4,7 +4,7 @@ import { FormEvent, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { Wrench } from "lucide-react";
import { createEquipment } from "@/lib/equipment/api"; import { createEquipment } from "@/lib/equipment/api";
import { useFormDraft } from "@/lib/forms/use-form-draft"; import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message"; import { errorMessage } from "@/lib/forms/error-message";
@@ -92,10 +92,9 @@ export default function NewEquipmentPage() {
draft={draft} draft={draft}
/> />
<PageHeader <PageHeader
icon={Wrench} area="equipment"
title="Neuer Inventargegenstand" title="Neuer Inventargegenstand"
description="Nur Bezeichnung und Inventarnummer sind Pflicht." description="Nur Bezeichnung und Inventarnummer sind Pflicht."
gradient="var(--gradient-sky)"
/> />
{draft.pending ? ( {draft.pending ? (
<DraftBanner <DraftBanner
@@ -22,10 +22,9 @@ export default function EquipmentPage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader <PageHeader
icon={Wrench} area="equipment"
title="Inventar" title="Inventar"
description="Hier erfasst du deine Ausrüstung und ihren Zustand." description="Hier erfasst du deine Ausrüstung und ihren Zustand."
gradient="var(--gradient-sage)"
actions={ actions={
<Button size="touch" nativeButton={false} render={<Link href="/equipment/new" />}> <Button size="touch" nativeButton={false} render={<Link href="/equipment/new" />}>
+ Neuer Gegenstand + Neuer Gegenstand
@@ -55,8 +54,7 @@ export default function EquipmentPage() {
<EntityCard <EntityCard
key={item.id} key={item.id}
href={`/equipment/${item.id}`} href={`/equipment/${item.id}`}
icon={Wrench} area="equipment"
gradient="var(--gradient-sage)"
title={item.name} title={item.name}
subtitle={item.inventoryNumber} subtitle={item.inventoryNumber}
badge={ badge={
@@ -2,7 +2,7 @@
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { FlaskConical } from "lucide-react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { fetchExtraction } from "@/lib/extractions/api"; import { fetchExtraction } from "@/lib/extractions/api";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -29,7 +29,7 @@ export default function ExtractionDetailPage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<DetailHeader <DetailHeader
icon={FlaskConical} area="extractions"
title={`Schleuderung vom ${new Date(extraction.date).toLocaleDateString("de-DE")}`} title={`Schleuderung vom ${new Date(extraction.date).toLocaleDateString("de-DE")}`}
primaryAction={ primaryAction={
<Button size="touch" nativeButton={false} render={<Link href="/honey-batches" />}> <Button size="touch" nativeButton={false} render={<Link href="/honey-batches" />}>
@@ -4,7 +4,7 @@ import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { Droplet } from "lucide-react";
import { fetchHarvests } from "@/lib/harvests/api"; import { fetchHarvests } from "@/lib/harvests/api";
import { import {
createExtraction, createExtraction,
@@ -150,7 +150,7 @@ export default function NewExtractionPage() {
draft={draft} draft={draft}
/> />
<PageHeader <PageHeader
icon={Droplet} area="extractions"
title="Neue Schleuderung" title="Neue Schleuderung"
description="Pflicht sind Datum und mindestens eine Ernte." description="Pflicht sind Datum und mindestens eine Ernte."
/> />
@@ -16,7 +16,7 @@ export default function ExtractionsPage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader <PageHeader
icon={FlaskConical} area="extractions"
title="Schleuderungen" title="Schleuderungen"
description="Hier erfasst du Schleuderungen aus einer oder mehreren Ernten." description="Hier erfasst du Schleuderungen aus einer oder mehreren Ernten."
actions={ actions={
@@ -50,7 +50,7 @@ export default function ExtractionsPage() {
<EntityCard <EntityCard
key={extraction.id} key={extraction.id}
href={`/extractions/${extraction.id}`} href={`/extractions/${extraction.id}`}
icon={FlaskConical} area="extractions"
title={new Date(extraction.date).toLocaleDateString("de-DE")} title={new Date(extraction.date).toLocaleDateString("de-DE")}
subtitle={extraction.room ?? "—"} subtitle={extraction.room ?? "—"}
fields={[ fields={[
@@ -4,7 +4,7 @@ import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { Utensils } from "lucide-react";
import { createFeeding } from "@/lib/feedings/api"; import { createFeeding } from "@/lib/feedings/api";
import { createFeedingsBulk } from "@/lib/bulk/api"; import { createFeedingsBulk } from "@/lib/bulk/api";
import { import {
@@ -193,7 +193,7 @@ export default function NewFeedingPage() {
draft={draft} draft={draft}
/> />
<PageHeader <PageHeader
icon={Utensils} area="feedings"
title="Neue Fütterung" title="Neue Fütterung"
description="Nur Volk, Datum, Futterart und Menge sind Pflicht." description="Nur Volk, Datum, Futterart und Menge sind Pflicht."
/> />
@@ -2,7 +2,7 @@
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { Boxes } from "lucide-react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { fetchHarvest } from "@/lib/harvests/api"; import { fetchHarvest } from "@/lib/harvests/api";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -27,7 +27,7 @@ export default function HarvestDetailPage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<DetailHeader <DetailHeader
icon={Boxes} area="harvests"
title={`Ernte vom ${new Date(harvest.date).toLocaleDateString("de-DE")}`} title={`Ernte vom ${new Date(harvest.date).toLocaleDateString("de-DE")}`}
subtitle={harvest.honeyType ?? undefined} subtitle={harvest.honeyType ?? undefined}
primaryAction={ primaryAction={
@@ -4,7 +4,7 @@ import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query"; import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { Scissors, AlertTriangle } from "lucide-react"; import { AlertTriangle } from "lucide-react";
import { fetchApiaries } from "@/lib/apiaries/api"; import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchColonies } from "@/lib/colonies/api"; import { fetchColonies } from "@/lib/colonies/api";
import { colonyOptionLabel } from "@/lib/colonies/labels"; import { colonyOptionLabel } from "@/lib/colonies/labels";
@@ -172,7 +172,7 @@ export default function NewHarvestPage() {
draft={draft} draft={draft}
/> />
<PageHeader <PageHeader
icon={Scissors} area="harvests"
title="Neue Ernte" title="Neue Ernte"
description="Pflicht sind Bienenstand, Datum und mindestens ein Volk." description="Pflicht sind Bienenstand, Datum und mindestens ein Volk."
/> />
@@ -21,7 +21,7 @@ export default function HarvestsPage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader <PageHeader
icon={Scissors} area="harvests"
title="Ernten" title="Ernten"
description="Hier erfasst du deine Ernten je Bienenstand." description="Hier erfasst du deine Ernten je Bienenstand."
actions={ actions={
@@ -55,7 +55,7 @@ export default function HarvestsPage() {
<EntityCard <EntityCard
key={harvest.id} key={harvest.id}
href={`/harvests/${harvest.id}`} href={`/harvests/${harvest.id}`}
icon={Scissors} area="harvests"
title={new Date(harvest.date).toLocaleDateString("de-DE")} title={new Date(harvest.date).toLocaleDateString("de-DE")}
subtitle={apiaryNameById.get(harvest.apiaryId) ?? "—"} subtitle={apiaryNameById.get(harvest.apiaryId) ?? "—"}
fields={[ fields={[
@@ -2,7 +2,7 @@
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { Droplet, FileText, FlaskConical } from "lucide-react"; import { FileText, FlaskConical } from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { fetchHoneyBatch, fetchHoneyBatchTraceability } from "@/lib/honey-batches/api"; import { fetchHoneyBatch, fetchHoneyBatchTraceability } from "@/lib/honey-batches/api";
@@ -55,7 +55,7 @@ export default function HoneyBatchDetailPage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<DetailHeader <DetailHeader
icon={Droplet} area="honey-batches"
title={honeyBatch.batchNumber} title={honeyBatch.batchNumber}
subtitle={honeyBatch.honeyType ?? undefined} subtitle={honeyBatch.honeyType ?? undefined}
status={ status={
@@ -47,7 +47,7 @@ export default function HoneyBatchesPage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader <PageHeader
icon={Droplet} area="honey-batches"
title="Honigchargen" title="Honigchargen"
description="Hier verfolgst du jede Charge vom Schleudern bis zur Abfüllung." description="Hier verfolgst du jede Charge vom Schleudern bis zur Abfüllung."
actions={ actions={
@@ -112,7 +112,7 @@ function HoneyBatchGrid({ honeyBatches }: { honeyBatches: HoneyBatch[] }) {
<EntityCard <EntityCard
key={honeyBatch.id} key={honeyBatch.id}
href={`/honey-batches/${honeyBatch.id}`} href={`/honey-batches/${honeyBatch.id}`}
icon={Droplet} area="honey-batches"
title={<span className="font-mono">{honeyBatch.batchNumber}</span>} title={<span className="font-mono">{honeyBatch.batchNumber}</span>}
subtitle={honeyBatch.honeyType ?? "—"} subtitle={honeyBatch.honeyType ?? "—"}
badge={ badge={
@@ -4,7 +4,7 @@ import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { AlertTriangle, ClipboardCheck } from "lucide-react"; import { AlertTriangle } from "lucide-react";
import { createInspection } from "@/lib/inspections/api"; import { createInspection } from "@/lib/inspections/api";
import { fetchColonies } from "@/lib/colonies/api"; import { fetchColonies } from "@/lib/colonies/api";
import { fetchApiaryWeather } from "@/lib/weather/api"; import { fetchApiaryWeather } from "@/lib/weather/api";
@@ -668,10 +668,9 @@ function InspectionCaptureForm({
</div> </div>
) : null} ) : null}
<PageHeader <PageHeader
icon={ClipboardCheck} area="inspections"
title="Neue Durchsicht" title="Neue Durchsicht"
description="Nur Volk und Datum sind Pflicht – dazu, was du gesehen hast." description="Nur Volk und Datum sind Pflicht – dazu, was du gesehen hast."
gradient="var(--gradient-sage)"
/> />
{draft.pending ? ( {draft.pending ? (
<DraftBanner <DraftBanner
+4 -9
View File
@@ -3,12 +3,13 @@
import { ReactNode, useEffect, useRef, useState } from "react"; import { ReactNode, useEffect, useRef, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { Hexagon, Sun } from "lucide-react"; import { Sun } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useAuth } from "@/lib/auth/auth-context"; import { useAuth } from "@/lib/auth/auth-context";
import { useAppearance } from "@/lib/appearance/appearance"; import { useAppearance } from "@/lib/appearance/appearance";
import { finishHandOvers, purgeStaleDraftPhotos } from "@/lib/photos/store"; import { finishHandOvers, purgeStaleDraftPhotos } from "@/lib/photos/store";
import { LogoutConfirmDialog } from "@/components/auth/logout-confirm-dialog"; import { LogoutConfirmDialog } from "@/components/auth/logout-confirm-dialog";
import { BrandMark } from "@/components/brand-mark";
import { NotificationBell } from "@/components/notification-bell"; import { NotificationBell } from "@/components/notification-bell";
import { OfflineBand } from "@/components/offline/offline-band"; import { OfflineBand } from "@/components/offline/offline-band";
import { UserAvatar } from "@/components/user-avatar"; import { UserAvatar } from "@/components/user-avatar";
@@ -93,15 +94,9 @@ export default function AppLayout({ children }: { children: ReactNode }) {
<header className="sticky top-0 z-10 flex items-center justify-between gap-3 bg-card/85 px-4 py-3 shadow-[var(--shadow-soft)] backdrop-blur-md sm:justify-end sm:px-6"> <header className="sticky top-0 z-10 flex items-center justify-between gap-3 bg-card/85 px-4 py-3 shadow-[var(--shadow-soft)] backdrop-blur-md sm:justify-end sm:px-6">
<Link <Link
href="/dashboard" href="/dashboard"
className="flex items-center gap-1.5 font-heading font-semibold sm:hidden" className="flex items-center font-heading font-semibold sm:hidden"
> >
<span <BrandMark withWordmark />
className="flex size-7 items-center justify-center rounded-lg"
style={{ background: "var(--gradient-honey)" }}
>
<Hexagon className="size-4 fill-white/25 text-white" strokeWidth={2.25} />
</span>
ApiCore
</Link> </Link>
<div className="flex shrink-0 items-center gap-2 text-sm"> <div className="flex shrink-0 items-center gap-2 text-sm">
<QuickAddSheet user={user} variant="header" /> <QuickAddSheet user={user} variant="header" />
@@ -2,7 +2,7 @@
import { useId, type ReactNode } from "react"; import { useId, type ReactNode } from "react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { Package, Pill } from "lucide-react"; import { Pill } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { useAuth } from "@/lib/auth/auth-context"; import { useAuth } from "@/lib/auth/auth-context";
@@ -62,7 +62,7 @@ export default function MedicineDetailPage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<DetailHeader <DetailHeader
icon={Pill} area="medicines"
title={medicine.name} title={medicine.name}
subtitle={[medicine.activeIngredient, MEDICINE_UNIT_LABELS[medicine.unit]].filter(Boolean).join(" · ")} subtitle={[medicine.activeIngredient, MEDICINE_UNIT_LABELS[medicine.unit]].filter(Boolean).join(" · ")}
status={ status={
@@ -133,7 +133,7 @@ export default function MedicineDetailPage() {
</h2> </h2>
{medicine.lots.length === 0 ? ( {medicine.lots.length === 0 ? (
<EmptyState <EmptyState
icon={Package} icon={Pill}
title="Noch keine Packung" title="Noch keine Packung"
description="Erfasse die gekaufte Packung mit Chargennummer und Beleg. Behandlungen buchen dann aus ihr ab." description="Erfasse die gekaufte Packung mit Chargennummer und Beleg. Behandlungen buchen dann aus ihr ab."
/> />
@@ -192,7 +192,7 @@ function LotGrid({ lots }: { lots: LotView[] }) {
<EntityCard <EntityCard
key={lot.id} key={lot.id}
href={`/medicines/lots/${lot.id}`} href={`/medicines/lots/${lot.id}`}
icon={Package} area="medicines"
title={lot.batchNumber ?? "Ohne Chargennummer"} title={lot.batchNumber ?? "Ohne Chargennummer"}
subtitle={`Gekauft am ${formatDay(lot.purchasedOn)}`} subtitle={`Gekauft am ${formatDay(lot.purchasedOn)}`}
fields={[ fields={[
@@ -3,7 +3,7 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { Package } from "lucide-react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/lib/auth/auth-context"; import { useAuth } from "@/lib/auth/auth-context";
import { buildBook } from "@/lib/medicines/book"; import { buildBook } from "@/lib/medicines/book";
@@ -58,7 +58,7 @@ export default function LotDetailPage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<DetailHeader <DetailHeader
icon={Package} area="medicines"
title={`Packung ${lot.batchNumber ?? formatDay(lot.purchasedOn)}`} title={`Packung ${lot.batchNumber ?? formatDay(lot.purchasedOn)}`}
subtitle={ subtitle={
<> <>
@@ -40,7 +40,7 @@ export default function MedicinesPage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader <PageHeader
icon={Pill} area="medicines"
title="Tierarzneimittel" title="Tierarzneimittel"
description="Dein Katalog mit Packungen und dem Bewegungsbuch. Behandlungen buchen aus der Packung ab." description="Dein Katalog mit Packungen und dem Bewegungsbuch. Behandlungen buchen aus der Packung ab."
actions={canCreate ? <MedicineFormDialog disabled={isOffline} describedBy={describedBy} /> : undefined} actions={canCreate ? <MedicineFormDialog disabled={isOffline} describedBy={describedBy} /> : undefined}
@@ -90,7 +90,7 @@ function MedicineGrid({ medicines }: { medicines: MedicineView[] }) {
<EntityCard <EntityCard
key={medicine.id} key={medicine.id}
href={`/medicines/${medicine.id}`} href={`/medicines/${medicine.id}`}
icon={Pill} area="medicines"
title={medicine.name} title={medicine.name}
subtitle={medicine.activeIngredient ?? "—"} subtitle={medicine.activeIngredient ?? "—"}
badge={medicine.active ? undefined : <Badge variant="secondary">Deaktiviert</Badge>} badge={medicine.active ? undefined : <Badge variant="secondary">Deaktiviert</Badge>}
+76 -86
View File
@@ -1,33 +1,5 @@
import { import type { LucideIcon } from "lucide-react";
Activity, import { AREAS, type AreaKey } from "@/lib/areas";
BarChart3,
Box,
Boxes,
CalendarDays,
Crown,
Database,
Droplet,
Factory,
FlaskConical,
History,
Layers,
LayoutDashboard,
Lightbulb,
ListTodo,
type LucideIcon,
MapPin,
Mic,
Package,
Scissors,
Search,
ShieldCheck,
ShoppingBag,
Snowflake,
Users,
Warehouse,
Wrench,
Zap,
} from "lucide-react";
import type { AuthUser } from "@/lib/auth/auth-context"; import type { AuthUser } from "@/lib/auth/auth-context";
/** /**
@@ -36,6 +8,8 @@ import type { AuthUser } from "@/lib/auth/auth-context";
* can never be reachable on one screen size and missing on another. * can never be reachable on one screen size and missing on another.
*/ */
export interface NavItem { export interface NavItem {
/** The area this entry opens. */
area: AreaKey;
label: string; label: string;
/** Short name for the cramped bottom nav. */ /** Short name for the cramped bottom nav. */
shortLabel?: string; shortLabel?: string;
@@ -51,6 +25,14 @@ export interface NavItem {
counter?: "suggestions"; counter?: "suggestions";
} }
/** An entry for an area; the icon always comes from AREAS (one icon per area). */
function navItem(
area: AreaKey,
props: Omit<NavItem, "area" | "icon">,
): NavItem {
return { area, icon: AREAS[area].icon, ...props };
}
export interface NavGroup { export interface NavGroup {
label: string | null; label: string | null;
items: NavItem[]; items: NavItem[];
@@ -60,99 +42,101 @@ export const NAV_GROUPS: NavGroup[] = [
{ {
label: null, label: null,
items: [ items: [
{ navItem("dashboard", {
label: "Dashboard", label: "Dashboard",
shortLabel: "Start", shortLabel: "Start",
href: "/dashboard", href: "/dashboard",
icon: LayoutDashboard, }),
}, navItem("search", { label: "Suche", href: "/search" }),
{ label: "Suche", href: "/search", icon: Search },
], ],
}, },
{ {
label: "Betrieb", label: "Betrieb",
items: [ items: [
{ label: "Bienenstände", href: "/apiaries", icon: MapPin }, navItem("apiaries", { label: "Bienenstände", href: "/apiaries" }),
{ label: "Völker", href: "/colonies", icon: Layers }, navItem("colonies", { label: "Völker", href: "/colonies" }),
{ label: "Königinnen", href: "/queens", icon: Crown }, navItem("queens", { label: "Königinnen", href: "/queens" }),
{ navItem("compliance", {
label: "Prüfbereitschaft", label: "Prüfbereitschaft",
href: "/compliance", href: "/compliance",
icon: ShieldCheck,
permission: "compliance:read", permission: "compliance:read",
}, }),
{ navItem("winter", {
label: "Überwinterung", label: "Überwinterung",
href: "/winter", href: "/winter",
icon: Snowflake,
permission: "winter-checks:read", permission: "winter-checks:read",
}, }),
], ],
}, },
{ {
label: "Arbeit", label: "Arbeit",
items: [ items: [
{ label: "Aufgaben", href: "/tasks", icon: ListTodo }, navItem("tasks", { label: "Aufgaben", href: "/tasks" }),
{ navItem("suggestions", {
label: "Vorschläge", label: "Vorschläge",
href: "/suggestions", href: "/suggestions",
icon: Lightbulb,
permission: "tasks:read", permission: "tasks:read",
counter: "suggestions", counter: "suggestions",
}, }),
{ label: "Kalender", href: "/calendar", icon: CalendarDays }, navItem("calendar", { label: "Kalender", href: "/calendar" }),
{ navItem("voice", {
label: "Sprachnotizen", label: "Sprachnotizen",
href: "/voice", href: "/voice",
icon: Mic,
permission: "voice-captures:use", permission: "voice-captures:use",
alsoRequires: ["inspections:create"], alsoRequires: ["inspections:create"],
}, }),
], ],
}, },
{ {
label: "Ernte & Lager", label: "Ernte & Lager",
items: [ items: [
{ label: "Ernten", href: "/harvests", icon: Scissors }, navItem("harvests", { label: "Ernten", href: "/harvests" }),
{ label: "Schleuderungen", href: "/extractions", icon: FlaskConical }, navItem("extractions", { label: "Schleuderungen", href: "/extractions" }),
{ label: "Honigchargen", href: "/honey-batches", icon: Droplet }, navItem("honey-batches", {
{ label: "Abfüllungen", href: "/bottling", icon: Package }, label: "Honigchargen",
{ href: "/honey-batches",
}),
navItem("bottling", { label: "Abfüllungen", href: "/bottling" }),
navItem("raw-materials", {
label: "Rohstoffe", label: "Rohstoffe",
href: "/raw-materials", href: "/raw-materials",
icon: Boxes,
permission: "raw-material:read", permission: "raw-material:read",
}, }),
{ navItem("production-batches", {
label: "Herstellungschargen", label: "Herstellungschargen",
href: "/production-batches", href: "/production-batches",
icon: Factory,
permission: "production-batch:read", permission: "production-batch:read",
}, }),
{ label: "Produkte", href: "/products", icon: ShoppingBag }, navItem("products", { label: "Produkte", href: "/products" }),
{ label: "Lager", href: "/storage", icon: Warehouse }, navItem("storage", { label: "Lager", href: "/storage" }),
{ label: "Inventar", href: "/equipment", icon: Wrench }, navItem("equipment", { label: "Inventar", href: "/equipment" }),
], ],
}, },
{ {
label: "Auswertung", label: "Auswertung",
items: [{ label: "Statistik", href: "/statistics", icon: BarChart3 }], items: [navItem("statistics", { label: "Statistik", href: "/statistics" })],
}, },
]; ];
export const ADMIN_GROUP: NavGroup = { export const ADMIN_GROUP: NavGroup = {
label: "Verwaltung", label: "Verwaltung",
items: [ items: [
{ navItem("users-roles", {
label: "Benutzer & Rollen", label: "Benutzer & Rollen",
href: "/settings/users-roles", href: "/settings/users-roles",
icon: Users,
permission: ["users:read", "roles:read"], permission: ["users:read", "roles:read"],
}, }),
{ navItem("automation", {
label: "Automatisierung", label: "Automatisierung",
href: "/settings/automation", href: "/settings/automation",
icon: Zap,
permission: [ permission: [
"webhooks:read", "webhooks:read",
"automation:read", "automation:read",
@@ -162,31 +146,31 @@ export const ADMIN_GROUP: NavGroup = {
"calendar:configure", "calendar:configure",
"api-keys:manage", "api-keys:manage",
], ],
}, }),
{ navItem("hive-systems", {
label: "Beutensysteme", label: "Beutensysteme",
href: "/settings/hive-systems", href: "/settings/hive-systems",
icon: Box,
permission: "hive-systems:manage", permission: "hive-systems:manage",
}, }),
{ navItem("backup", {
label: "Backup", label: "Backup",
href: "/settings/backup", href: "/settings/backup",
icon: Database,
permission: ["backup:export", "backup:import", "import:execute"], permission: ["backup:export", "backup:import", "import:execute"],
}, }),
{ navItem("audit-log", {
label: "Audit-Log", label: "Audit-Log",
href: "/settings/audit-log", href: "/settings/audit-log",
icon: History,
permission: "audit:read", permission: "audit:read",
}, }),
{ navItem("system", {
label: "System", label: "System",
href: "/settings/system", href: "/settings/system",
icon: Activity,
permission: "system:read", permission: "system:read",
}, }),
], ],
}; };
@@ -210,14 +194,18 @@ function isVisible(
} }
/** All groups the user may see, without empty ones; admin group last. */ /** All groups the user may see, without empty ones; admin group last. */
export function visibleNavGroups(user: Pick<AuthUser, "permissions">): NavGroup[] { export function visibleNavGroups(
user: Pick<AuthUser, "permissions">,
): NavGroup[] {
return [...NAV_GROUPS, ADMIN_GROUP] return [...NAV_GROUPS, ADMIN_GROUP]
.map((group) => ({ .map((group) => ({
...group, ...group,
items: group.items.filter( items: group.items.filter(
(item) => (item) =>
isVisible(user, item.permission) && isVisible(user, item.permission) &&
(item.alsoRequires ?? []).every((key) => user.permissions.includes(key)), (item.alsoRequires ?? []).every((key) =>
user.permissions.includes(key),
),
), ),
})) }))
.filter((group) => group.items.length > 0); .filter((group) => group.items.length > 0);
@@ -226,7 +214,9 @@ export function visibleNavGroups(user: Pick<AuthUser, "permissions">): NavGroup[
/** The bottom nav entries, resolved from the config in their fixed order. */ /** The bottom nav entries, resolved from the config in their fixed order. */
export function bottomNavItems(): NavItem[] { export function bottomNavItems(): NavItem[] {
const all = NAV_GROUPS.flatMap((group) => group.items); const all = NAV_GROUPS.flatMap((group) => group.items);
return BOTTOM_NAV_HREFS.flatMap((href) => all.filter((item) => item.href === href)); return BOTTOM_NAV_HREFS.flatMap((href) =>
all.filter((item) => item.href === href),
);
} }
/** /**
@@ -2,7 +2,6 @@
import { Suspense } from "react"; import { Suspense } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { MapPin } from "lucide-react";
import { useCurrentUserId } from "@/lib/auth/auth-context"; import { useCurrentUserId } from "@/lib/auth/auth-context";
import type { StoredPack } from "@/lib/offline/db"; import type { StoredPack } from "@/lib/offline/db";
import { DetailHeader } from "@/components/detail-header"; import { DetailHeader } from "@/components/detail-header";
@@ -74,7 +73,7 @@ function OfflineApiaryView({ stored, readAt, returnHref }: OfflineApiaryViewProp
return ( return (
<OfflinePageFrame> <OfflinePageFrame>
<DetailHeader <DetailHeader
icon={MapPin} area="apiaries"
title={apiary.name} title={apiary.name}
subtitle={apiary.location ?? undefined} subtitle={apiary.location ?? undefined}
primaryAction={<StartRoundButton apiary={{ id: apiary.id, name: apiary.name }} />} primaryAction={<StartRoundButton apiary={{ id: apiary.id, name: apiary.name }} />}
@@ -4,7 +4,7 @@ import { Suspense } from "react";
import Link from "next/link"; import Link from "next/link";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Layers } from "lucide-react";
import { useAuth, useCurrentUserId } from "@/lib/auth/auth-context"; import { useAuth, useCurrentUserId } from "@/lib/auth/auth-context";
import { import {
OFFLINE_PACKS_QUERY_KEY, OFFLINE_PACKS_QUERY_KEY,
@@ -126,7 +126,7 @@ function OfflineColonyView({ stored, colony, readAt, returnHref }: OfflineColony
return ( return (
<OfflinePageFrame> <OfflinePageFrame>
<DetailHeader <DetailHeader
icon={Layers} area="colonies"
title={colony.code} title={colony.code}
subtitle={`${COLONY_TYPE_LABELS[colony.colonyType]} · ${pack.apiary.name}`} subtitle={`${COLONY_TYPE_LABELS[colony.colonyType]} · ${pack.apiary.name}`}
status={ status={
@@ -43,7 +43,7 @@ export default function OfflinePage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader <PageHeader
icon={CloudOff} area="system"
title="Offline" title="Offline"
description="Ohne Netz zeigt ApiCore die Stände, die du für offline geladen hast." description="Ohne Netz zeigt ApiCore die Stände, die du für offline geladen hast."
/> />
@@ -2,7 +2,7 @@
import Link from "next/link"; import Link from "next/link";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { Factory } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@@ -60,7 +60,7 @@ export default function ProductionBatchDetailPage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<DetailHeader <DetailHeader
icon={Factory} area="production-batches"
title={`Charge ${batch.lotNumber}`} title={`Charge ${batch.lotNumber}`}
subtitle={`${batch.product.name} · ${batch.quantity} Stück hergestellt`} subtitle={`${batch.product.name} · ${batch.quantity} Stück hergestellt`}
{...BACK} {...BACK}
@@ -2,7 +2,7 @@
import { FormEvent, useRef, useState } from "react"; import { FormEvent, useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Factory } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { createProductionBatch } from "@/lib/production-batches/api"; import { createProductionBatch } from "@/lib/production-batches/api";
@@ -132,7 +132,7 @@ export default function NewProductionBatchPage() {
dirty={dirty} dirty={dirty}
/> />
<PageHeader <PageHeader
icon={Factory} area="production-batches"
title="Neue Herstellungscharge" title="Neue Herstellungscharge"
description="Was hast du hergestellt, und wie viele Stück?" description="Was hast du hergestellt, und wie viele Stück?"
/> />
@@ -32,7 +32,7 @@ export default function ProductionBatchesPage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader <PageHeader
icon={Factory} area="production-batches"
title="Herstellungschargen" title="Herstellungschargen"
description="Kerzen, Propolis-Produkte und andere hergestellte Waren - je Charge mit hergestellter Menge und Rest." description="Kerzen, Propolis-Produkte und andere hergestellte Waren - je Charge mit hergestellter Menge und Rest."
actions={newButton} actions={newButton}
@@ -56,7 +56,7 @@ export default function ProductionBatchesPage() {
<EntityCard <EntityCard
key={batch.id} key={batch.id}
href={`/production-batches/${batch.id}`} href={`/production-batches/${batch.id}`}
icon={Factory} area="production-batches"
title={<span className="font-mono">{batch.lotNumber}</span>} title={<span className="font-mono">{batch.lotNumber}</span>}
subtitle={batch.product.name} subtitle={batch.product.name}
fields={[ fields={[
@@ -2,7 +2,7 @@
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { ShoppingBag } from "lucide-react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { fetchProduct } from "@/lib/products/api"; import { fetchProduct } from "@/lib/products/api";
import { fetchProductionBatches } from "@/lib/production-batches/api"; import { fetchProductionBatches } from "@/lib/production-batches/api";
@@ -54,7 +54,7 @@ export default function ProductDetailPage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<DetailHeader <DetailHeader
icon={ShoppingBag} area="products"
title={product.name} title={product.name}
subtitle={PRODUCT_KIND_LABELS[product.kind]} subtitle={PRODUCT_KIND_LABELS[product.kind]}
{...BACK} {...BACK}
@@ -4,7 +4,7 @@ import { FormEvent, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { ShoppingBag } from "lucide-react";
import { createProduct } from "@/lib/products/api"; import { createProduct } from "@/lib/products/api";
import { useFormDraft } from "@/lib/forms/use-form-draft"; import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message"; import { errorMessage } from "@/lib/forms/error-message";
@@ -93,7 +93,7 @@ export default function NewProductPage() {
draft={draft} draft={draft}
/> />
<PageHeader <PageHeader
icon={ShoppingBag} area="products"
title="Neues Produkt" title="Neues Produkt"
description="Nur der Name ist Pflicht – alles andere kannst du später ergänzen." description="Nur der Name ist Pflicht – alles andere kannst du später ergänzen."
/> />
@@ -32,7 +32,7 @@ export default function ProductsPage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader <PageHeader
icon={ShoppingBag} area="products"
title="Produkte" title="Produkte"
description="Hier legst du deine verkaufsfertigen Produkte an und pflegst sie." description="Hier legst du deine verkaufsfertigen Produkte an und pflegst sie."
actions={ actions={
@@ -85,7 +85,7 @@ function ProductGrid({ products }: { products: Product[] }) {
{products.map((product) => ( {products.map((product) => (
<EntityCard <EntityCard
key={product.id} key={product.id}
icon={ShoppingBag} area="products"
title={product.name} title={product.name}
badge={ badge={
<Badge variant={product.status === "ACTIVE" ? "default" : "secondary"}> <Badge variant={product.status === "ACTIVE" ? "default" : "secondary"}>
@@ -1,7 +1,7 @@
"use client"; "use client";
import { ChangeEvent, FormEvent, useRef, useState } from "react"; import { ChangeEvent, FormEvent, useRef, useState } from "react";
import { UserRound } from "lucide-react";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { useAuth } from "@/lib/auth/auth-context"; import { useAuth } from "@/lib/auth/auth-context";
@@ -297,10 +297,9 @@ export default function ProfilePage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader <PageHeader
icon={UserRound} area="profile"
title="Mein Profil" title="Mein Profil"
description="Persönliche Daten, Passwort und Sicherheit" description="Persönliche Daten, Passwort und Sicherheit"
gradient="var(--gradient-sage)"
/> />
<div className="grid gap-4 sm:max-w-xl"> <div className="grid gap-4 sm:max-w-xl">
<AvatarSection /> <AvatarSection />
@@ -2,7 +2,7 @@
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Crown } from "lucide-react";
import { fetchQueen } from "@/lib/queens/api"; import { fetchQueen } from "@/lib/queens/api";
import { QUEEN_STATUS_LABELS } from "@/lib/queens/labels"; import { QUEEN_STATUS_LABELS } from "@/lib/queens/labels";
import { QrCodeDialog } from "@/components/qr-code-dialog"; import { QrCodeDialog } from "@/components/qr-code-dialog";
@@ -31,7 +31,7 @@ export default function QueenDetailPage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<DetailHeader <DetailHeader
icon={Crown} area="queens"
title={queen.code} title={queen.code}
subtitle={queen.hatchYear ? `Schlupfjahr ${queen.hatchYear}` : undefined} subtitle={queen.hatchYear ? `Schlupfjahr ${queen.hatchYear}` : undefined}
status={<DetailStatus status={queen.status} label={QUEEN_STATUS_LABELS[queen.status]} />} status={<DetailStatus status={queen.status} label={QUEEN_STATUS_LABELS[queen.status]} />}
@@ -4,7 +4,7 @@ import { FormEvent, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { Crown } from "lucide-react";
import { createQueen, fetchQueens } from "@/lib/queens/api"; import { createQueen, fetchQueens } from "@/lib/queens/api";
import { useFormDraft } from "@/lib/forms/use-form-draft"; import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message"; import { errorMessage } from "@/lib/forms/error-message";
@@ -121,10 +121,9 @@ export default function NewQueenPage() {
draft={draft} draft={draft}
/> />
<PageHeader <PageHeader
icon={Crown} area="queens"
title="Neue Königin" title="Neue Königin"
description="Nur der Code ist Pflicht." description="Nur der Code ist Pflicht."
gradient="var(--gradient-sage)"
/> />
{draft.pending ? ( {draft.pending ? (
<DraftBanner <DraftBanner
@@ -34,10 +34,9 @@ export default function QueensPage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader <PageHeader
icon={Crown} area="queens"
title="Königinnen" title="Königinnen"
description="Hier verwaltest du deine Königinnen mit Zuchtlinie und Bewertung." description="Hier verwaltest du deine Königinnen mit Zuchtlinie und Bewertung."
gradient="var(--gradient-sage)"
actions={ actions={
<Button size="touch" nativeButton={false} render={<Link href="/queens/new" />}> <Button size="touch" nativeButton={false} render={<Link href="/queens/new" />}>
+ Neue Königin + Neue Königin
@@ -136,8 +135,7 @@ function QueenGrid({ queens }: { queens: Queen[] }) {
<EntityCard <EntityCard
key={queen.id} key={queen.id}
href={`/queens/${queen.id}`} href={`/queens/${queen.id}`}
icon={Crown} area="queens"
gradient="var(--gradient-sage)"
title={queen.code} title={queen.code}
subtitle={queen.breed ?? "—"} subtitle={queen.breed ?? "—"}
badge={ badge={
+34 -35
View File
@@ -1,16 +1,8 @@
import { import type { LucideIcon } from "lucide-react";
Bug, import { AREAS, type AreaKey } from "@/lib/areas";
ClipboardCheck,
ListTodo,
type LucideIcon,
Mic,
Scissors,
Sprout,
Syringe,
Utensils,
} from "lucide-react";
export interface QuickAction { export interface QuickAction {
area: AreaKey;
label: string; label: string;
href: string; href: string;
icon: LucideIcon; icon: LucideIcon;
@@ -22,55 +14,62 @@ export interface QuickAction {
needsVoiceRecording?: boolean; needsVoiceRecording?: boolean;
} }
function quickAction(
area: AreaKey,
props: Omit<QuickAction, "area" | "icon">,
): QuickAction {
return { area, icon: AREAS[area].icon, ...props };
}
export const QUICK_ACTIONS: readonly QuickAction[] = [ export const QUICK_ACTIONS: readonly QuickAction[] = [
{ quickAction("inspections", {
label: "Durchsicht", label: "Durchsicht",
href: "/inspections/new", href: "/inspections/new",
icon: ClipboardCheck,
permission: "inspections:create", permission: "inspections:create",
}, }),
{ quickAction("voice", {
label: "Sprachnotiz", label: "Sprachnotiz",
href: "/voice/new", href: "/voice/new",
icon: Mic,
permission: "voice-captures:use", permission: "voice-captures:use",
alsoPermission: "inspections:create", alsoPermission: "inspections:create",
needsVoiceRecording: true, needsVoiceRecording: true,
}, }),
{ quickAction("varroa", {
label: "Varroa-Messung", label: "Varroa-Messung",
href: "/varroa/new", href: "/varroa/new",
icon: Bug,
permission: "varroa:create", permission: "varroa:create",
}, }),
{ quickAction("swarm-control", {
label: "Schwarmkontrolle", label: "Schwarmkontrolle",
href: "/swarm-control/new", href: "/swarm-control/new",
icon: Sprout,
permission: "swarm-control:create", permission: "swarm-control:create",
}, }),
{ quickAction("treatments", {
label: "Behandlung", label: "Behandlung",
href: "/treatments/new", href: "/treatments/new",
icon: Syringe,
permission: "treatments:create", permission: "treatments:create",
}, }),
{ quickAction("feedings", {
label: "Fütterung", label: "Fütterung",
href: "/feedings/new", href: "/feedings/new",
icon: Utensils,
permission: "feedings:create", permission: "feedings:create",
}, }),
{ quickAction("tasks", {
label: "Aufgabe", label: "Aufgabe",
href: "/tasks/new", href: "/tasks/new",
icon: ListTodo,
permission: "tasks:create", permission: "tasks:create",
}, }),
{ quickAction("harvests", {
label: "Ernte", label: "Ernte",
href: "/harvests/new", href: "/harvests/new",
icon: Scissors,
permission: "harvests:create", permission: "harvests:create",
}, }),
]; ];
@@ -45,7 +45,7 @@ describe("QuickAddSheet", () => {
const link = await screen.findByRole("link", { name: "Durchsicht" }); const link = await screen.findByRole("link", { name: "Durchsicht" });
expect(link).toHaveAttribute("href", "/inspections/new"); expect(link).toHaveAttribute("href", "/inspections/new");
expect(link).toHaveClass("min-h-14"); expect(link).toHaveClass("min-h-14");
expect(link.querySelector("svg.size-6")).not.toBeNull(); expect(link.querySelector("[data-tone=erfassung] svg")).not.toBeNull();
await screen.findByRole("link", { name: "Sprachnotiz" }); await screen.findByRole("link", { name: "Sprachnotiz" });
expect(screen.getAllByRole("link")).toHaveLength(QUICK_ACTIONS.length); expect(screen.getAllByRole("link")).toHaveLength(QUICK_ACTIONS.length);
}); });
@@ -4,6 +4,7 @@ import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { AreaIcon } from "@/components/area-icon";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { SheetTrigger } from "@/components/ui/sheet"; import { SheetTrigger } from "@/components/ui/sheet";
import type { AuthUser } from "@/lib/auth/auth-context"; import type { AuthUser } from "@/lib/auth/auth-context";
@@ -84,7 +85,6 @@ export function QuickAddSheet({ user, variant }: QuickAddSheetProps) {
> >
<nav aria-label="Neu erfassen" className="flex flex-col px-2 pb-4"> <nav aria-label="Neu erfassen" className="flex flex-col px-2 pb-4">
{actions.map((action) => { {actions.map((action) => {
const Icon = action.icon;
return ( return (
<Link <Link
key={action.href} key={action.href}
@@ -92,10 +92,7 @@ export function QuickAddSheet({ user, variant }: QuickAddSheetProps) {
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
className={`${NAV_SHEET_ROW_CLASS} text-foreground hover:bg-accent/70`} className={`${NAV_SHEET_ROW_CLASS} text-foreground hover:bg-accent/70`}
> >
<Icon <AreaIcon area={action.area} />
className="size-6 shrink-0 text-primary"
aria-hidden="true"
/>
{action.label} {action.label}
</Link> </Link>
); );
@@ -2,7 +2,7 @@
import Link from "next/link"; import Link from "next/link";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { Boxes } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@@ -56,7 +56,7 @@ export default function RawMaterialDetailPage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<DetailHeader <DetailHeader
icon={Boxes} area="raw-materials"
title={`${RAW_MATERIAL_KIND_LABELS[lot.kind]} ${lot.lotNumber}`} title={`${RAW_MATERIAL_KIND_LABELS[lot.kind]} ${lot.lotNumber}`}
subtitle={`${formatGrams(lot.quantity)} gewonnen am ${new Date(lot.date).toLocaleDateString("de-DE")}`} subtitle={`${formatGrams(lot.quantity)} gewonnen am ${new Date(lot.date).toLocaleDateString("de-DE")}`}
{...BACK} {...BACK}
@@ -2,7 +2,7 @@
import { FormEvent, useState } from "react"; import { FormEvent, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Boxes } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { createRawMaterialLot } from "@/lib/raw-materials/api"; import { createRawMaterialLot } from "@/lib/raw-materials/api";
@@ -89,7 +89,7 @@ export default function NewRawMaterialPage() {
dirty={dirty} dirty={dirty}
/> />
<PageHeader <PageHeader
icon={Boxes} area="raw-materials"
title="Neuer Rohstoff" title="Neuer Rohstoff"
description="Was hast du gewonnen, und wie viel?" description="Was hast du gewonnen, und wie viel?"
/> />
@@ -34,7 +34,7 @@ export default function RawMaterialsPage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader <PageHeader
icon={Boxes} area="raw-materials"
title="Rohstoffe" title="Rohstoffe"
description="Wachs und Propolis je Charge - mit gewonnener Menge, Verbrauch in der Herstellung und Rest." description="Wachs und Propolis je Charge - mit gewonnener Menge, Verbrauch in der Herstellung und Rest."
actions={newButton} actions={newButton}
@@ -57,7 +57,7 @@ export default function RawMaterialsPage() {
<EntityCard <EntityCard
key={lot.id} key={lot.id}
href={`/raw-materials/${lot.id}`} href={`/raw-materials/${lot.id}`}
icon={Boxes} area="raw-materials"
title={<span className="font-mono">{lot.lotNumber}</span>} title={<span className="font-mono">{lot.lotNumber}</span>}
subtitle={RAW_MATERIAL_KIND_LABELS[lot.kind]} subtitle={RAW_MATERIAL_KIND_LABELS[lot.kind]}
fields={[ fields={[
@@ -50,7 +50,7 @@ export default function SearchPage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader <PageHeader
icon={SearchIcon} area="search"
title="Suche" title="Suche"
description="Finde Bienenstände, Völker, Königinnen, Aufgaben und Honigchargen." description="Finde Bienenstände, Völker, Königinnen, Aufgaben und Honigchargen."
/> />
@@ -50,10 +50,9 @@ export default function AuditLogPage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader <PageHeader
icon={History} area="audit-log"
title="Audit-Log" title="Audit-Log"
description="Wer hat wann was geändert – neueste Einträge zuerst." description="Wer hat wann was geändert – neueste Einträge zuerst."
gradient="var(--gradient-sage)"
/> />
<form <form
@@ -58,8 +58,7 @@ export function LabelTemplatesTab() {
{activeTemplates.map((template) => ( {activeTemplates.map((template) => (
<EntityCard <EntityCard
key={template.id} key={template.id}
icon={Tag} area="label-templates"
gradient="var(--gradient-sky)"
title={template.name} title={template.name}
badge={template.isDefault ? <Badge>Standard</Badge> : undefined} badge={template.isDefault ? <Badge>Standard</Badge> : undefined}
fields={[ fields={[
@@ -2,7 +2,7 @@
import { Suspense } from "react"; import { Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { Zap } from "lucide-react";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
import { TabSwitcher } from "@/components/tab-switcher"; import { TabSwitcher } from "@/components/tab-switcher";
import { WebhooksTab } from "./webhooks-tab"; import { WebhooksTab } from "./webhooks-tab";
@@ -50,10 +50,9 @@ function AutomationPageContent() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader <PageHeader
icon={Zap} area="automation"
title="Automatisierung" title="Automatisierung"
description="Was ApiCore dir abnimmt – und wie es mit anderen Systemen verbunden ist." description="Was ApiCore dir abnimmt – und wie es mit anderen Systemen verbunden ist."
gradient="var(--gradient-sky)"
/> />
<TabSwitcher tabs={TABS} value={tab} onChange={setTab} /> <TabSwitcher tabs={TABS} value={tab} onChange={setTab} />
@@ -86,8 +86,7 @@ export function TaskTemplatesTab() {
{visibleTemplates.map((template) => ( {visibleTemplates.map((template) => (
<EntityCard <EntityCard
key={template.id} key={template.id}
icon={Repeat} area="tasks"
gradient="var(--gradient-sky)"
title={template.title} title={template.title}
badge={ badge={
<Badge variant={template.active ? "default" : "outline"}> <Badge variant={template.active ? "default" : "outline"}>
@@ -1,7 +1,7 @@
"use client"; "use client";
import { ChangeEvent, FormEvent, useState } from "react"; import { ChangeEvent, FormEvent, useState } from "react";
import { AlertTriangle, Database } from "lucide-react"; import { AlertTriangle } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@@ -573,10 +573,9 @@ export default function BackupPage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader <PageHeader
icon={Database} area="backup"
title="Backup" title="Backup"
description="Daten sichern, wiederherstellen und übertragen" description="Daten sichern, wiederherstellen und übertragen"
gradient="var(--gradient-sky)"
/> />
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
@@ -106,7 +106,7 @@ function HiveSystemsContent() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<PageHeader <PageHeader
icon={Boxes} area="hive-systems"
title="Beutensysteme" title="Beutensysteme"
description="Welche Teile es pro System gibt, wie groß sie sind und was ihr Zustand zeigt." description="Welche Teile es pro System gibt, wie groß sie sind und was ihr Zustand zeigt."
actions={ actions={
@@ -2,7 +2,6 @@
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Tag } from "lucide-react";
import { fetchLabelTemplates, updateLabelTemplate } from "@/lib/label-templates/api"; import { fetchLabelTemplates, updateLabelTemplate } from "@/lib/label-templates/api";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
import { LabelDesigner, LabelDesignerValue } from "../designer/label-designer"; import { LabelDesigner, LabelDesignerValue } from "../designer/label-designer";
@@ -22,10 +21,9 @@ export default function EditLabelTemplatePage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader <PageHeader
icon={Tag} area="label-templates"
title="Etikettenvorlage bearbeiten" title="Etikettenvorlage bearbeiten"
description="Etikett per Drag & Drop gestalten" description="Etikett per Drag & Drop gestalten"
gradient="var(--gradient-sky)"
/> />
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null} {isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError || (!isLoading && !template) ? ( {isError || (!isLoading && !template) ? (
@@ -1,6 +1,6 @@
"use client"; "use client";
import { Tag } from "lucide-react";
import { createLabelTemplate } from "@/lib/label-templates/api"; import { createLabelTemplate } from "@/lib/label-templates/api";
import { FormBackLink } from "@/components/form/form-back-link"; import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
@@ -19,10 +19,9 @@ export default function NewLabelTemplatePage() {
dirty={false} dirty={false}
/> />
<PageHeader <PageHeader
icon={Tag} area="label-templates"
title="Neue Etikettenvorlage" title="Neue Etikettenvorlage"
description="Etikett per Drag & Drop gestalten" description="Etikett per Drag & Drop gestalten"
gradient="var(--gradient-sky)"
/> />
<LabelDesigner onSubmit={handleSubmit} submitLabel="Anlegen" /> <LabelDesigner onSubmit={handleSubmit} submitLabel="Anlegen" />
</main> </main>
@@ -1,7 +1,7 @@
"use client"; "use client";
import { ReactNode } from "react"; import { ReactNode } from "react";
import { Activity, AlertTriangle, CircleCheck, CircleX, Loader } from "lucide-react"; import { AlertTriangle, CircleCheck, CircleX, Loader } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@@ -278,7 +278,7 @@ export default function SystemPage() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<PageHeader <PageHeader
icon={Activity} area="system"
title="System" title="System"
description="Hier siehst du, ob die Hintergrundarbeit läuft." description="Hier siehst du, ob die Hintergrundarbeit läuft."
/> />
@@ -2,7 +2,7 @@
import { Suspense } from "react"; import { Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { Users } from "lucide-react";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
import { TabSwitcher } from "@/components/tab-switcher"; import { TabSwitcher } from "@/components/tab-switcher";
import { UsersTab } from "./users-tab"; import { UsersTab } from "./users-tab";
@@ -32,10 +32,9 @@ function UsersRolesPageContent() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader <PageHeader
icon={Users} area="users-roles"
title="Benutzer & Rollen" title="Benutzer & Rollen"
description="Zugänge, Rollen und Berechtigungen verwalten" description="Zugänge, Rollen und Berechtigungen verwalten"
gradient="var(--gradient-sage)"
/> />
<TabSwitcher tabs={TABS} value={tab} onChange={setTab} /> <TabSwitcher tabs={TABS} value={tab} onChange={setTab} />
@@ -93,8 +93,7 @@ export function RolesTab() {
{visibleRoles.map((role) => ( {visibleRoles.map((role) => (
<EntityCard <EntityCard
key={role.id} key={role.id}
icon={Shield} area="users-roles"
gradient="var(--gradient-sky)"
title={role.name} title={role.name}
subtitle={role.key} subtitle={role.key}
badge={ badge={
@@ -2,7 +2,7 @@
import { useSyncExternalStore } from "react"; import { useSyncExternalStore } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ShieldCheck } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { ConfirmDialog } from "@/components/confirm-dialog"; import { ConfirmDialog } from "@/components/confirm-dialog";
import { useAuth } from "@/lib/auth/auth-context"; import { useAuth } from "@/lib/auth/auth-context";
@@ -219,7 +219,7 @@ export default function VoiceSettingsPage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader <PageHeader
icon={ShieldCheck} area="voice"
title="Sprache & Datenschutz" title="Sprache & Datenschutz"
description="Was mit deiner Stimme und dem Text passiert." description="Was mit deiner Stimme und dem Text passiert."
/> />
+3 -11
View File
@@ -2,7 +2,8 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { FileClock, Hexagon } from "lucide-react"; import { FileClock } from "lucide-react";
import { BrandMark } from "@/components/brand-mark";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { NavCounter } from "@/components/suggestions/nav-counter"; import { NavCounter } from "@/components/suggestions/nav-counter";
import type { AuthUser } from "@/lib/auth/auth-context"; import type { AuthUser } from "@/lib/auth/auth-context";
@@ -48,16 +49,7 @@ export function Sidebar({ user }: { user: AuthUser }) {
href="/dashboard" href="/dashboard"
className="mb-5 flex items-center gap-2.5 px-2 font-heading text-lg font-semibold" className="mb-5 flex items-center gap-2.5 px-2 font-heading text-lg font-semibold"
> >
<span <BrandMark size={32} withWordmark className="gap-2.5" />
className="flex size-8 items-center justify-center rounded-xl shadow-[var(--shadow-soft)]"
style={{ background: "var(--gradient-honey)" }}
>
<Hexagon
className="size-4.5 fill-white/25 text-white"
strokeWidth={2.25}
/>
</span>
ApiCore
</Link> </Link>
<nav className="flex flex-1 flex-col gap-5"> <nav className="flex flex-1 flex-col gap-5">
{groups.map((group, index) => ( {groups.map((group, index) => (
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { BarChart3, Info } from "lucide-react"; import { Info } from "lucide-react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { fetchApiaries } from "@/lib/apiaries/api"; import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchColonies } from "@/lib/colonies/api"; import { fetchColonies } from "@/lib/colonies/api";
@@ -369,10 +369,9 @@ export default function StatisticsPage() {
return ( return (
<main className="flex flex-col gap-6 p-4 sm:p-6"> <main className="flex flex-col gap-6 p-4 sm:p-6">
<PageHeader <PageHeader
icon={BarChart3} area="statistics"
title="Statistik" title="Statistik"
description="Hier siehst du Auswertungen zu Völkern, Königinnen, Ernte und Futter." description="Hier siehst du Auswertungen zu Völkern, Königinnen, Ernte und Futter."
gradient="var(--gradient-sky)"
/> />
<Tabs value={tab} onValueChange={(next) => setTab(toStatsTab(next) ?? tab)}> <Tabs value={tab} onValueChange={(next) => setTab(toStatsTab(next) ?? tab)}>
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { AlertTriangle, Package, Pill, Warehouse } from "lucide-react"; import { AlertTriangle, Warehouse } from "lucide-react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/lib/auth/auth-context"; import { useAuth } from "@/lib/auth/auth-context";
import { fetchStockItems, fetchStorageContainers } from "@/lib/storage/api"; import { fetchStockItems, fetchStorageContainers } from "@/lib/storage/api";
@@ -41,10 +41,9 @@ export default function StoragePage() {
<main className="flex flex-col gap-8 p-4 sm:p-6"> <main className="flex flex-col gap-8 p-4 sm:p-6">
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<PageHeader <PageHeader
icon={Warehouse} area="storage"
title="Lager" title="Lager"
description="Hier verwaltest du Lagerbehälter und den Bestand deiner Lagerartikel." description="Hier verwaltest du Lagerbehälter und den Bestand deiner Lagerartikel."
gradient="var(--gradient-sky)"
actions={<CreateStorageContainerDialog />} actions={<CreateStorageContainerDialog />}
/> />
{hasPermission("veterinary-medicines:read") ? <MedicinesCard /> : null} {hasPermission("veterinary-medicines:read") ? <MedicinesCard /> : null}
@@ -83,7 +82,7 @@ export default function StoragePage() {
what="Lagerartikel" what="Lagerartikel"
empty={ empty={
<EmptyState <EmptyState
icon={Package} icon={Warehouse}
title="Noch keine Lagerartikel" title="Noch keine Lagerartikel"
description="Lege deinen ersten Artikel an, um den Bestand zu verfolgen." description="Lege deinen ersten Artikel an, um den Bestand zu verfolgen."
action={<CreateStockItemDialog />} action={<CreateStockItemDialog />}
@@ -102,8 +101,7 @@ function MedicinesCard() {
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
<EntityCard <EntityCard
href="/medicines" href="/medicines"
icon={Pill} area="medicines"
gradient="var(--gradient-sky)"
title="Tierarzneimittel" title="Tierarzneimittel"
subtitle="Katalog, Packungen und Bewegungsbuch" subtitle="Katalog, Packungen und Bewegungsbuch"
/> />
@@ -137,8 +135,7 @@ function ContainerGrid({ containers }: { containers: StorageContainer[] }) {
{containers.map((container) => ( {containers.map((container) => (
<EntityCard <EntityCard
key={container.id} key={container.id}
icon={Warehouse} area="storage"
gradient="var(--gradient-sky)"
title={<span className="font-mono">{container.code}</span>} title={<span className="font-mono">{container.code}</span>}
subtitle={container.containerType ?? "—"} subtitle={container.containerType ?? "—"}
badge={ badge={
@@ -169,8 +166,7 @@ function StockItemGrid({ stockItems }: { stockItems: StockItem[] }) {
{stockItems.map((item) => ( {stockItems.map((item) => (
<EntityCard <EntityCard
key={item.id} key={item.id}
icon={Package} area="storage"
gradient="var(--gradient-sky)"
title={item.name} title={item.name}
subtitle={STOCK_CATEGORY_LABELS[item.category]} subtitle={STOCK_CATEGORY_LABELS[item.category]}
badge={ badge={
@@ -271,10 +271,9 @@ export default function SuggestionsPage() {
return ( return (
<main className="flex flex-col gap-5 p-4 sm:p-6"> <main className="flex flex-col gap-5 p-4 sm:p-6">
<PageHeader <PageHeader
icon={Lightbulb} area="suggestions"
title="Vorschläge" title="Vorschläge"
description={EXPLANATION} description={EXPLANATION}
gradient="var(--gradient-sky)"
/> />
<p <p
@@ -4,7 +4,6 @@ import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { Sprout } from "lucide-react";
import { createSwarmControlCheck } from "@/lib/swarm-control/api"; import { createSwarmControlCheck } from "@/lib/swarm-control/api";
import { import {
SWARM_ACTION_LABELS, SWARM_ACTION_LABELS,
@@ -127,10 +126,9 @@ export default function NewSwarmControlCheckPage() {
draft={draft} draft={draft}
/> />
<PageHeader <PageHeader
icon={Sprout} area="swarm-control"
title="Neue Schwarmkontrolle" title="Neue Schwarmkontrolle"
description="Nur Volk und Datum sind Pflicht – dazu, was du an Weiselzellen gefunden hast." description="Nur Volk und Datum sind Pflicht – dazu, was du an Weiselzellen gefunden hast."
gradient="var(--gradient-sky)"
/> />
{draft.pending ? ( {draft.pending ? (
<DraftBanner <DraftBanner
@@ -4,7 +4,7 @@ import { FormEvent, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { ListTodo } from "lucide-react";
import { createTask } from "@/lib/tasks/api"; import { createTask } from "@/lib/tasks/api";
import { TASK_PRIORITIES, TASK_PRIORITY_LABELS } from "@/lib/tasks/labels"; import { TASK_PRIORITIES, TASK_PRIORITY_LABELS } from "@/lib/tasks/labels";
import { fetchUsers } from "@/lib/users/api"; import { fetchUsers } from "@/lib/users/api";
@@ -116,10 +116,9 @@ export default function NewTaskPage() {
draft={draft} draft={draft}
/> />
<PageHeader <PageHeader
icon={ListTodo} area="tasks"
title="Neue Aufgabe" title="Neue Aufgabe"
description="Nur der Titel ist Pflicht." description="Nur der Titel ist Pflicht."
gradient="var(--gradient-sky)"
/> />
{draft.pending ? ( {draft.pending ? (
<DraftBanner <DraftBanner
@@ -52,10 +52,9 @@ export default function TasksPage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader <PageHeader
icon={ListTodo} area="tasks"
title="Aufgaben" title="Aufgaben"
description="Was als Nächstes ansteht" description="Was als Nächstes ansteht"
gradient="var(--gradient-sky)"
actions={ actions={
<Button nativeButton={false} render={<Link href="/tasks/new" />}> <Button nativeButton={false} render={<Link href="/tasks/new" />}>
+ Neue Aufgabe + Neue Aufgabe
@@ -117,8 +116,7 @@ export default function TasksPage() {
{visibleTasks.map((task) => ( {visibleTasks.map((task) => (
<EntityCard <EntityCard
key={task.id} key={task.id}
icon={ListTodo} area="tasks"
gradient="var(--gradient-sky)"
title={task.title} title={task.title}
badge={ badge={
<span className="flex flex-wrap items-center gap-1.5"> <span className="flex flex-wrap items-center gap-1.5">
@@ -4,7 +4,7 @@ import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { Syringe } from "lucide-react";
import { saveErrorText } from "@/lib/honey-batches/errors"; import { saveErrorText } from "@/lib/honey-batches/errors";
import { useAuth } from "@/lib/auth/auth-context"; import { useAuth } from "@/lib/auth/auth-context";
import { fetchColonies } from "@/lib/colonies/api"; import { fetchColonies } from "@/lib/colonies/api";
@@ -265,10 +265,9 @@ export default function NewTreatmentPage() {
draft={draft} draft={draft}
/> />
<PageHeader <PageHeader
icon={Syringe} area="treatments"
title="Neue Behandlung" title="Neue Behandlung"
description="Nur Volk, Datum und Mittel sind Pflicht." description="Nur Volk, Datum und Mittel sind Pflicht."
gradient="var(--gradient-sky)"
/> />
{draft.pending ? ( {draft.pending ? (
<DraftBanner <DraftBanner
@@ -4,7 +4,6 @@ import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { Bug } from "lucide-react";
import { createVarroaMeasurement } from "@/lib/varroa/api"; import { createVarroaMeasurement } from "@/lib/varroa/api";
import { createVarroaBulk } from "@/lib/bulk/api"; import { createVarroaBulk } from "@/lib/bulk/api";
import { import {
@@ -235,10 +234,9 @@ export default function NewVarroaMeasurementPage() {
draft={draft} draft={draft}
/> />
<PageHeader <PageHeader
icon={Bug} area="varroa"
title="Neue Varroamessung" title="Neue Varroamessung"
description="Nur Volk und Datum sind Pflicht – dazu, wie und was du gezählt hast." description="Nur Volk und Datum sind Pflicht – dazu, wie und was du gezählt hast."
gradient="var(--gradient-sky)"
/> />
{draft.pending ? ( {draft.pending ? (
<DraftBanner <DraftBanner
@@ -4,7 +4,7 @@ import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { ArrowRight, Mic } from "lucide-react"; import { ArrowRight } from "lucide-react";
import { fetchColonies } from "@/lib/colonies/api"; import { fetchColonies } from "@/lib/colonies/api";
import { useAuth, useCurrentUserId } from "@/lib/auth/auth-context"; import { useAuth, useCurrentUserId } from "@/lib/auth/auth-context";
import { OFFLINE_PACKS_QUERY_KEY, listPacks, livingColoniesFromPacks } from "@/lib/apiaries/offline-pack"; import { OFFLINE_PACKS_QUERY_KEY, listPacks, livingColoniesFromPacks } from "@/lib/apiaries/offline-pack";
@@ -140,7 +140,7 @@ function VoiceCaptureForm({ initialColonyId, initialDate, roundApiaryId }: Captu
return ( return (
<main className="flex flex-col gap-5 p-4 sm:p-6"> <main className="flex flex-col gap-5 p-4 sm:p-6">
<FormBackLink href={target.href} label={target.label} dirty={busy} className="min-h-14" /> <FormBackLink href={target.href} label={target.label} dirty={busy} className="min-h-14" />
<PageHeader icon={Mic} title="Sprachnotiz" description="Sprich in ganzen Sätzen, nur du." /> <PageHeader area="voice" title="Sprachnotiz" description="Sprich in ganzen Sätzen, nur du." />
<section aria-label="Volk und Datum" className="flex flex-col gap-3"> <section aria-label="Volk und Datum" className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<p className="font-heading text-xl font-semibold">{heading}</p> <p className="font-heading text-xl font-semibold">{heading}</p>
@@ -226,7 +226,7 @@ function VoiceCaptureForm({ initialColonyId, initialDate, roundApiaryId }: Captu
function Notice({ children }: { children: string }) { function Notice({ children }: { children: string }) {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader icon={Mic} title="Sprachnotiz" /> <PageHeader area="voice" title="Sprachnotiz" />
<p role="alert" className="rounded-xl border border-border bg-card p-4 text-sm"> <p role="alert" className="rounded-xl border border-border bg-card p-4 text-sm">
{children} {children}
</p> </p>
@@ -55,7 +55,7 @@ export default function VoiceListPage() {
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader <PageHeader
icon={Mic} area="voice"
title="Sprachnotizen" title="Sprachnotizen"
description="Aufnahmen, die noch warten, und fertige Vorschläge zum Prüfen." description="Aufnahmen, die noch warten, und fertige Vorschläge zum Prüfen."
actions={newAction} actions={newAction}
@@ -249,7 +249,7 @@ function WinterContent() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<PageHeader <PageHeader
icon={Snowflake} area="winter"
title="Überwinterung" title="Überwinterung"
description="Ein- und Auswinterung je Volk, Verluste mit Ursache." description="Ein- und Auswinterung je Volk, Verluste mit Ursache."
actions={ actions={
@@ -3,7 +3,7 @@
import { isNetworkError } from "@/lib/http/fetch-with-timeout"; import { isNetworkError } from "@/lib/http/fetch-with-timeout";
import { FormEvent, Suspense, useEffect, useState } from "react"; import { FormEvent, Suspense, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { Hexagon } from "lucide-react"; import { BrandMark } from "@/components/brand-mark";
import { useAuth } from "@/lib/auth/auth-context"; import { useAuth } from "@/lib/auth/auth-context";
import { ssoLoginUrl, ssoStatusRequest } from "@/lib/auth/api"; import { ssoLoginUrl, ssoStatusRequest } from "@/lib/auth/api";
import { useConnectivity } from "@/lib/offline/connectivity"; import { useConnectivity } from "@/lib/offline/connectivity";
@@ -159,12 +159,7 @@ function LoginPageContent() {
<Card className="relative w-full max-w-sm shadow-[var(--shadow-elevated)]"> <Card className="relative w-full max-w-sm shadow-[var(--shadow-elevated)]">
<CardHeader> <CardHeader>
<div className="mb-1 flex items-center gap-2.5"> <div className="mb-1 flex items-center gap-2.5">
<span <BrandMark size={32} />
className="flex size-9 items-center justify-center rounded-xl shadow-[var(--shadow-soft)]"
style={{ background: "var(--gradient-honey)" }}
>
<Hexagon className="size-5 fill-white/25 text-white" strokeWidth={2.25} />
</span>
<CardTitle className="text-lg">ApiCore</CardTitle> <CardTitle className="text-lg">ApiCore</CardTitle>
</div> </div>
<CardDescription> <CardDescription>
+1 -1
View File
@@ -598,7 +598,7 @@
position: absolute; position: absolute;
inset: 0.375rem; inset: 0.375rem;
border-radius: 0.6rem; border-radius: 0.6rem;
background: var(--gradient-honey); background: var(--primary);
clip-path: circle(0% at 50% 60%); clip-path: circle(0% at 50% 60%);
transition: clip-path 420ms var(--ease-out-expo); transition: clip-path 420ms var(--ease-out-expo);
} }
@@ -3,7 +3,6 @@
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import {
Hexagon,
Droplet, Droplet,
MapPin, MapPin,
CalendarDays, CalendarDays,
@@ -17,6 +16,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { fetchPublicBatch } from "@/lib/public-batch/api"; import { fetchPublicBatch } from "@/lib/public-batch/api";
import { originText } from "@/lib/public-batch/origin-text"; import { originText } from "@/lib/public-batch/origin-text";
import { BrandMark } from "@/components/brand-mark";
import { HoneycombPattern } from "@/components/honeycomb-pattern"; import { HoneycombPattern } from "@/components/honeycomb-pattern";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -115,12 +115,7 @@ export default function PublicBatchPage() {
<Card className="relative w-full max-w-md shadow-[var(--shadow-elevated)]"> <Card className="relative w-full max-w-md shadow-[var(--shadow-elevated)]">
<CardHeader> <CardHeader>
<div className="mb-1 flex items-center gap-2.5"> <div className="mb-1 flex items-center gap-2.5">
<span <BrandMark size={32} />
className="flex size-9 items-center justify-center rounded-xl shadow-[var(--shadow-soft)]"
style={{ background: "var(--gradient-honey)" }}
>
<Hexagon className="size-5 fill-white/25 text-white" strokeWidth={2.25} />
</span>
<div> <div>
<CardTitle className="text-lg">Herkunftsnachweis</CardTitle> <CardTitle className="text-lg">Herkunftsnachweis</CardTitle>
<p className="text-xs text-muted-foreground">Wabenhain · ApiCore</p> <p className="text-xs text-muted-foreground">Wabenhain · ApiCore</p>
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { render } from "@testing-library/react";
import { AreaIcon } from "./area-icon";
describe("AreaIcon", () => {
it("draws the icon inside a hexagon tinted with the area tone and is aria-hidden", () => {
const { container } = render(<AreaIcon area="harvests" />);
const root = container.firstElementChild as HTMLElement;
expect(root).toHaveAttribute("aria-hidden", "true");
expect(root.querySelector("svg polygon")).not.toBeNull();
expect(root.querySelectorAll("svg").length).toBe(2);
expect(root.getAttribute("data-tone")).toBe("ernte");
expect(root.style.getPropertyValue("--tone")).toBe("var(--area-ernte)");
});
it("is 40 px by default and 32 px small", () => {
const { container, rerender } = render(<AreaIcon area="colonies" />);
expect((container.firstElementChild as HTMLElement).className).toContain("size-10");
rerender(<AreaIcon area="colonies" size="sm" />);
expect((container.firstElementChild as HTMLElement).className).toContain("size-8");
});
it("uses a card fill and a 2 px edge in sun mode", () => {
const { container } = render(<AreaIcon area="tasks" />);
const polygon = container.querySelector("polygon") as SVGElement;
expect(polygon.getAttribute("class")).toContain("sun:fill-card");
expect(polygon.getAttribute("class")).toContain("sun:stroke-2");
});
});
@@ -0,0 +1,44 @@
import type { CSSProperties } from "react";
import { cn } from "@/lib/utils";
import { AREAS, type AreaKey } from "@/lib/areas";
interface AreaIconProps {
area: AreaKey;
size?: "md" | "sm";
className?: string;
}
// Pointy-top hexagon in a 100 x 100 box.
const HEXAGON_POINTS = "50,3 91,26.5 91,73.5 50,97 9,73.5 9,26.5";
/**
* The icon of an area inside a honeycomb cell. The tone only groups; the
* cell is decoration (aria-hidden), the heading next to it names the area.
* Sun mode: card fill and a 2 px edge instead of the 14 % tint.
*/
export function AreaIcon({ area, size = "md", className }: AreaIconProps) {
const { icon: Icon, tone } = AREAS[area];
const style = { "--tone": `var(--area-${tone})` } as CSSProperties;
return (
<span
aria-hidden="true"
data-tone={tone}
style={style}
className={cn(
"relative inline-flex shrink-0 items-center justify-center text-(--tone)",
size === "md" ? "size-10" : "size-8",
className,
)}
>
<svg viewBox="0 0 100 100" className="absolute inset-0 size-full overflow-visible">
<polygon
points={HEXAGON_POINTS}
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
className="fill-[color-mix(in_oklch,var(--tone)_14%,transparent)] stroke-(--tone) stroke-1 sun:fill-card sun:stroke-2"
/>
</svg>
<Icon className={size === "md" ? "relative size-5" : "relative size-4"} />
</span>
);
}
@@ -0,0 +1,70 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const SRC = join(__dirname, "..");
function sourceFiles(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
const path = join(dir, name);
if (statSync(path).isDirectory()) {
return sourceFiles(path);
}
return /\.(tsx?|css)$/.test(name) && !/\.spec\./.test(name) ? [path] : [];
});
}
function read(relative: string): string {
return readFileSync(join(SRC, relative), "utf8");
}
describe("area migration", () => {
it("no file passes a gradient prop any more", () => {
const offenders = sourceFiles(SRC).filter((file) => {
const text = readFileSync(file, "utf8");
return /gradient=|var\(--gradient-|\[background-image:var\(--gradient/.test(text);
});
expect(offenders).toEqual([]);
});
it("no source other than globals.css uses the --gradient tokens", () => {
const offenders = sourceFiles(SRC)
.filter((file) => !file.endsWith("globals.css"))
.filter((file) => /--gradient-(honey|sage|sky)/.test(readFileSync(file, "utf8")));
expect(offenders).toEqual([]);
});
it("harvest detail uses Scissors, extraction create FlaskConical and storage only Warehouse", () => {
expect(read("app/(app)/harvests/[id]/page.tsx")).toContain('area="harvests"');
expect(read("app/(app)/extractions/new/page.tsx")).toContain('area="extractions"');
const storage = read("app/(app)/storage/page.tsx");
expect(storage).toContain('area="storage"');
expect(storage).not.toMatch(/icon=\{Package\}/);
});
it("medicine packs, hive systems and voice settings use their own area icon", () => {
expect(read("app/(app)/medicines/[id]/page.tsx")).not.toMatch(/icon=\{Package\}/);
expect(read("app/(app)/medicines/lots/[id]/page.tsx")).toContain('area="medicines"');
expect(read("app/(app)/settings/hive-systems/page.tsx")).toContain('area="hive-systems"');
expect(read("app/(app)/settings/voice/page.tsx")).toContain('area="voice"');
});
it("no component passes a raw icon to the headers any more", () => {
const offenders = sourceFiles(SRC).filter((file) => {
const text = readFileSync(file, "utf8");
return /<(PageHeader|DetailHeader|EntityCard)\b[^>]*\bicon=/.test(text);
});
expect(offenders).toEqual([]);
});
it("no white text outside the hive stage", () => {
const offenders = sourceFiles(SRC)
.filter((file) => !file.replaceAll("\\", "/").includes("/components/hive/"))
.filter((file) => /\btext-white\b/.test(readFileSync(file, "utf8")));
expect(offenders).toEqual([]);
});
it("the stage overlay on the hive stage uses the holo label colour instead of white", () => {
expect(read("components/hive/editor/editor-stage-overlay.tsx")).not.toMatch(/\btext-white\b/);
});
});
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { BRAND_CELL_PATHS, BrandMark } from "./brand-mark";
describe("BrandMark", () => {
it("renders the capped cell and the ApiCore wordmark", () => {
const { container } = render(<BrandMark withWordmark />);
const paths = container.querySelectorAll("svg path");
expect(paths).toHaveLength(2);
expect(paths[0]).toHaveAttribute("d", BRAND_CELL_PATHS.cell);
expect(paths[1]).toHaveAttribute("d", BRAND_CELL_PATHS.lid);
expect(screen.getByText("ApiCore")).toBeInTheDocument();
});
it("is decorative without a wordmark and sized 24 or 32 px", () => {
const { container, rerender } = render(<BrandMark />);
const svg = container.querySelector("svg") as SVGElement;
expect(svg).toHaveAttribute("aria-hidden", "true");
expect(svg).toHaveAttribute("width", "24");
expect(screen.queryByText("ApiCore")).not.toBeInTheDocument();
rerender(<BrandMark size={32} />);
expect(container.querySelector("svg")).toHaveAttribute("width", "32");
});
});
@@ -0,0 +1,48 @@
import { cn } from "@/lib/utils";
/**
* Geometry of the capped honeycomb cell (64 x 64 box): the cell and a smaller
* wax lid lifted a little. Shared with the icon script (spec 12.2).
*/
export const BRAND_CELL_PATHS = {
cell: "M32 4 L56 18 V46 L32 60 L8 46 V18 Z",
lid: "M32 13.6 L45.2 21.3 V36.7 L32 44.4 L19.8 36.7 V21.3 Z",
} as const;
interface BrandMarkProps {
size?: 24 | 32;
withWordmark?: boolean;
className?: string;
}
/** The ApiCore mark: a capped cell, optionally followed by the wordmark. */
export function BrandMark({ size = 24, withWordmark = false, className }: BrandMarkProps) {
const mark = (
<svg
viewBox="0 0 64 64"
width={size}
height={size}
aria-hidden="true"
focusable="false"
className="shrink-0"
>
<path
d={BRAND_CELL_PATHS.cell}
fill="var(--primary)"
stroke="var(--foreground)"
strokeWidth="3.84"
strokeLinejoin="round"
/>
<path d={BRAND_CELL_PATHS.lid} fill="oklch(0.88 0.11 85)" />
</svg>
);
if (!withWordmark) {
return mark;
}
return (
<span className={cn("inline-flex items-center gap-2 font-heading font-semibold", className)}>
{mark}
<span>ApiCore</span>
</span>
);
}
@@ -1,14 +1,13 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { Layers } from "lucide-react";
import { DetailHeader, DetailStatus } from "./detail-header"; import { DetailHeader, DetailStatus } from "./detail-header";
describe("DetailHeader", () => { describe("DetailHeader", () => {
it("shows the title as heading, a back link and the status as a word", () => { it("shows the title as heading, a back link and the status as a word", () => {
render( render(
<DetailHeader <DetailHeader
icon={Layers} area="colonies"
title="V-01" title="V-01"
status={<DetailStatus status="ACTIVE" label="Aktiv" />} status={<DetailStatus status="ACTIVE" label="Aktiv" />}
backHref="/colonies" backHref="/colonies"
@@ -29,7 +28,7 @@ describe("DetailHeader", () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
render( render(
<DetailHeader <DetailHeader
icon={Layers} area="colonies"
title="V-01" title="V-01"
primaryAction={<button type="button">Durchsicht erfassen</button>} primaryAction={<button type="button">Durchsicht erfassen</button>}
moreActions={[ moreActions={[
@@ -11,6 +11,7 @@ import {
XCircle, XCircle,
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import { AreaIcon } from "@/components/area-icon";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -20,6 +21,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { ErrorState, LoadingState } from "@/components/query-state"; import { ErrorState, LoadingState } from "@/components/query-state";
import type { AreaKey } from "@/lib/areas";
import { statusTone, type StatusTone } from "@/lib/status-tone"; import { statusTone, type StatusTone } from "@/lib/status-tone";
export interface MoreAction { export interface MoreAction {
@@ -33,7 +35,7 @@ export interface MoreAction {
} }
interface DetailHeaderProps { interface DetailHeaderProps {
icon: LucideIcon; area: AreaKey;
title: string; title: string;
subtitle?: ReactNode; subtitle?: ReactNode;
/** Icon plus word, never colour alone - see DetailStatus. */ /** Icon plus word, never colour alone - see DetailStatus. */
@@ -52,7 +54,7 @@ interface DetailHeaderProps {
* in "Mehr" so the page does not ask for a decision on arrival. * in "Mehr" so the page does not ask for a decision on arrival.
*/ */
export function DetailHeader({ export function DetailHeader({
icon: Icon, area,
title, title,
subtitle, subtitle,
status, status,
@@ -67,12 +69,7 @@ export function DetailHeader({
<BackLink href={backHref} label={backLabel} /> <BackLink href={backHref} label={backLabel} />
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
<span <AreaIcon area={area} />
className="flex size-10 shrink-0 items-center justify-center rounded-xl text-white shadow-[var(--shadow-soft)]"
style={{ background: "var(--gradient-honey)" }}
>
<Icon className="size-5" aria-hidden />
</span>
<div className="flex min-w-0 flex-col gap-1"> <div className="flex min-w-0 flex-col gap-1">
<h1 className="font-heading text-2xl font-semibold tracking-tight break-words"> <h1 className="font-heading text-2xl font-semibold tracking-tight break-words">
{title} {title}
@@ -1,7 +1,8 @@
import { ReactNode } from "react"; import { ReactNode } from "react";
import Link from "next/link"; import Link from "next/link";
import { type LucideIcon } from "lucide-react"; import { AreaIcon } from "@/components/area-icon";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import type { AreaKey } from "@/lib/areas";
interface EntityCardField { interface EntityCardField {
label: string; label: string;
@@ -10,8 +11,7 @@ interface EntityCardField {
interface EntityCardProps { interface EntityCardProps {
href?: string; href?: string;
icon: LucideIcon; area: AreaKey;
gradient?: string;
title: ReactNode; title: ReactNode;
subtitle?: ReactNode; subtitle?: ReactNode;
badge?: ReactNode; badge?: ReactNode;
@@ -22,8 +22,7 @@ interface EntityCardProps {
export function EntityCard({ export function EntityCard({
href, href,
icon: Icon, area,
gradient = "var(--gradient-honey)",
title, title,
subtitle, subtitle,
badge, badge,
@@ -36,12 +35,7 @@ export function EntityCard({
<CardContent className="flex h-full flex-col gap-3"> <CardContent className="flex h-full flex-col gap-3">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
<div <AreaIcon area={area} />
className="flex size-10 shrink-0 items-center justify-center rounded-xl text-white shadow-[var(--shadow-soft)]"
style={{ background: gradient }}
>
<Icon className="size-5" />
</div>
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate font-medium">{title}</p> <p className="truncate font-medium">{title}</p>
{subtitle ? ( {subtitle ? (
@@ -52,7 +52,7 @@ export function RemoveZone({ zoneRef, active }: { zoneRef: RefObject<HTMLDivElem
<div <div
ref={zoneRef} ref={zoneRef}
className={`pointer-events-none absolute inset-x-0 bottom-3 mx-auto flex w-fit items-center gap-2 rounded-2xl border-2 border-dashed px-6 py-4 text-sm font-medium transition-colors duration-150 ${ className={`pointer-events-none absolute inset-x-0 bottom-3 mx-auto flex w-fit items-center gap-2 rounded-2xl border-2 border-dashed px-6 py-4 text-sm font-medium transition-colors duration-150 ${
active ? "border-red-300 bg-red-500/35 text-white" : `border-white/30 ${GLASS_SURFACE}` active ? "border-red-300 bg-red-500/35 text-holo-label-fg" : `border-white/30 ${GLASS_SURFACE}`
}`} }`}
> >
<Trash2 aria-hidden className="size-5" /> <Trash2 aria-hidden className="size-5" />
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { PageHeader } from "./page-header";
describe("PageHeader", () => {
it("renders the AreaIcon of its area and has no gradient prop", () => {
const { container } = render(<PageHeader area="queens" title="Königinnen" />);
expect(screen.getByRole("heading", { level: 1, name: "Königinnen" })).toBeInTheDocument();
expect(container.querySelector("[data-tone='betrieb']")).not.toBeNull();
// @ts-expect-error the gradient prop is gone
render(<PageHeader area="queens" title="x" gradient="red" />);
});
});
@@ -1,30 +1,19 @@
import { ReactNode } from "react"; import { ReactNode } from "react";
import type { LucideIcon } from "lucide-react"; import { AreaIcon } from "@/components/area-icon";
import type { AreaKey } from "@/lib/areas";
interface PageHeaderProps { interface PageHeaderProps {
icon: LucideIcon; area: AreaKey;
title: string; title: string;
description?: string; description?: string;
gradient?: string;
actions?: ReactNode; actions?: ReactNode;
} }
export function PageHeader({ export function PageHeader({ area, title, description, actions }: PageHeaderProps) {
icon: Icon,
title,
description,
gradient = "var(--gradient-honey)",
actions,
}: PageHeaderProps) {
return ( return (
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span <AreaIcon area={area} />
className="flex size-10 shrink-0 items-center justify-center rounded-xl text-white shadow-[var(--shadow-soft)]"
style={{ background: gradient }}
>
<Icon className="size-5" />
</span>
<div> <div>
<h1 className="font-heading text-2xl font-semibold tracking-tight">{title}</h1> <h1 className="font-heading text-2xl font-semibold tracking-tight">{title}</h1>
{description ? ( {description ? (
@@ -9,7 +9,7 @@ const buttonVariants = cva(
variants: { variants: {
variant: { variant: {
default: default:
"bg-primary text-primary-foreground shadow-[var(--shadow-soft)] [background-image:var(--gradient-honey)] hover:shadow-[var(--shadow-elevated)] hover:brightness-105 active:brightness-95", "bg-primary text-primary-foreground shadow-[var(--shadow-soft)] hover:shadow-[var(--shadow-elevated)] hover:brightness-105 active:brightness-95",
outline: outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary: secondary:
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { AREAS, type AreaKey } from "./areas";
import { ADMIN_GROUP, NAV_GROUPS } from "@/app/(app)/nav-config";
import { QUICK_ACTIONS } from "@/app/(app)/quick-actions";
const STATUS_TONES = ["success", "warning", "destructive", "condition", "ok", "error"];
describe("AREAS", () => {
it("every NAV_GROUPS entry and QUICK_ACTION maps to an AREAS key with the same icon", () => {
const navItems = [...NAV_GROUPS, ADMIN_GROUP].flatMap((group) => group.items);
expect(navItems.length).toBeGreaterThan(0);
for (const item of navItems) {
expect(AREAS[item.area], item.href).toBeDefined();
expect(item.icon, item.href).toBe(AREAS[item.area].icon);
}
for (const action of QUICK_ACTIONS) {
expect(AREAS[action.area], action.href).toBeDefined();
expect(action.icon, action.href).toBe(AREAS[action.area].icon);
}
});
it("contains the areas added after the spec: compliance, medicines, voice, hive-systems, system", () => {
const keys: AreaKey[] = ["compliance", "medicines", "voice", "hive-systems", "system"];
for (const key of keys) {
expect(AREAS[key].key).toBe(key);
}
expect(AREAS.compliance.tone).toBe("betrieb");
expect(AREAS.medicines.tone).toBe("erfassung");
expect(AREAS.voice.tone).toBe("erfassung");
expect(AREAS["hive-systems"].tone).toBe("neutral");
expect(AREAS.system.tone).toBe("neutral");
});
it("gives every icon to exactly one area", () => {
const seen = new Map<unknown, string>();
for (const area of Object.values(AREAS)) {
expect(seen.get(area.icon), `${area.key} clashes with ${seen.get(area.icon)}`).toBeUndefined();
seen.set(area.icon, area.key);
}
});
it("no area tone is a status colour", () => {
for (const area of Object.values(AREAS)) {
expect(STATUS_TONES).not.toContain(area.tone);
expect(["betrieb", "erfassung", "arbeit", "ernte", "neutral"]).toContain(area.tone);
}
});
it("keeps the key and label of each entry", () => {
for (const [key, area] of Object.entries(AREAS)) {
expect(area.key).toBe(key);
expect(area.label.length).toBeGreaterThan(0);
}
});
});
+149
View File
@@ -0,0 +1,149 @@
import {
Activity,
BarChart3,
Bell,
Box,
Boxes,
Bug,
CalendarDays,
ClipboardCheck,
Crown,
Database,
Droplet,
Factory,
FlaskConical,
Flower2,
History,
Layers,
LayoutDashboard,
Lightbulb,
ListTodo,
type LucideIcon,
MapPin,
Mic,
Package,
Pill,
Scissors,
Search,
ShieldCheck,
ShoppingBag,
Snowflake,
Sprout,
Syringe,
Tag,
Users,
UserRound,
Utensils,
Warehouse,
Wrench,
Zap,
} from "lucide-react";
export type AreaKey =
| "dashboard"
| "search"
| "apiaries"
| "colonies"
| "queens"
| "compliance"
| "winter"
| "phenology"
| "inspections"
| "varroa"
| "swarm-control"
| "treatments"
| "feedings"
| "medicines"
| "voice"
| "tasks"
| "calendar"
| "suggestions"
| "notifications"
| "harvests"
| "extractions"
| "honey-batches"
| "bottling"
| "products"
| "raw-materials"
| "production-batches"
| "storage"
| "equipment"
| "statistics"
| "users-roles"
| "automation"
| "hive-systems"
| "label-templates"
| "backup"
| "audit-log"
| "system"
| "profile";
/**
* A tone groups areas visually. It is never a status colour: red, green and
* yellow belong to states (traffic light, warning, destructive).
*/
export type AreaTone = "betrieb" | "erfassung" | "arbeit" | "ernte" | "neutral";
export interface Area {
key: AreaKey;
label: string;
icon: LucideIcon;
tone: AreaTone;
href?: string;
}
function area(
key: AreaKey,
label: string,
icon: LucideIcon,
tone: AreaTone,
href?: string,
): readonly [AreaKey, Area] {
return [key, { key, label, icon, tone, href }];
}
/**
* One icon means one area, everywhere (spec 11). Nav, quick actions, page
* headers and cards all read from here.
*/
export const AREAS: Readonly<Record<AreaKey, Area>> = Object.freeze(
Object.fromEntries([
area("dashboard", "Start", LayoutDashboard, "neutral", "/dashboard"),
area("search", "Suche", Search, "neutral", "/search"),
area("apiaries", "Bienenstände", MapPin, "betrieb", "/apiaries"),
area("colonies", "Völker", Layers, "betrieb", "/colonies"),
area("queens", "Königinnen", Crown, "betrieb", "/queens"),
area("compliance", "Prüfbereitschaft", ShieldCheck, "betrieb", "/compliance"),
area("winter", "Überwinterung", Snowflake, "betrieb", "/winter"),
area("phenology", "Pflanzenentwicklung", Flower2, "betrieb"),
area("inspections", "Durchsicht", ClipboardCheck, "erfassung", "/inspections"),
area("varroa", "Varroa", Bug, "erfassung", "/varroa"),
area("swarm-control", "Schwarmkontrolle", Sprout, "erfassung", "/swarm-control"),
area("treatments", "Behandlung", Syringe, "erfassung", "/treatments"),
area("feedings", "Fütterung", Utensils, "erfassung", "/feedings"),
area("medicines", "Arzneimittel", Pill, "erfassung", "/medicines"),
area("voice", "Sprachnotizen", Mic, "erfassung", "/voice"),
area("tasks", "Aufgaben", ListTodo, "arbeit", "/tasks"),
area("calendar", "Kalender", CalendarDays, "arbeit", "/calendar"),
area("suggestions", "Vorschläge", Lightbulb, "arbeit", "/suggestions"),
area("notifications", "Meldungen", Bell, "arbeit"),
area("harvests", "Ernten", Scissors, "ernte", "/harvests"),
area("extractions", "Schleuderungen", FlaskConical, "ernte", "/extractions"),
area("honey-batches", "Honigchargen", Droplet, "ernte", "/honey-batches"),
area("bottling", "Abfüllungen", Package, "ernte", "/bottling"),
area("products", "Produkte", ShoppingBag, "ernte", "/products"),
area("raw-materials", "Rohstoffe", Boxes, "ernte", "/raw-materials"),
area("production-batches", "Herstellungschargen", Factory, "ernte", "/production-batches"),
area("storage", "Lager", Warehouse, "ernte", "/storage"),
area("equipment", "Inventar", Wrench, "ernte", "/equipment"),
area("statistics", "Statistik", BarChart3, "neutral", "/statistics"),
area("users-roles", "Benutzer & Rollen", Users, "neutral", "/settings/users-roles"),
area("automation", "Automatisierung", Zap, "neutral", "/settings/automation"),
area("hive-systems", "Beutensysteme", Box, "neutral", "/settings/hive-systems"),
area("label-templates", "Etikettenvorlagen", Tag, "neutral", "/settings/label-templates"),
area("backup", "Backup", Database, "neutral", "/settings/backup"),
area("audit-log", "Audit-Log", History, "neutral", "/settings/audit-log"),
area("system", "System", Activity, "neutral", "/settings/system"),
area("profile", "Profil", UserRound, "neutral", "/profile"),
]) as Record<AreaKey, Area>,
);