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 { useMutation, useQuery } from "@tanstack/react-query";
import { FileText, MapPin, Plus } from "lucide-react";
import { FileText, Plus } from "lucide-react";
import { toast } from "sonner";
import { fetchApiary } from "@/lib/apiaries/api";
import { useAuth } from "@/lib/auth/auth-context";
@@ -50,7 +50,7 @@ export default function ApiaryDetailPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<DetailHeader
icon={MapPin}
area="apiaries"
title={apiary.name}
subtitle={apiary.location ?? undefined}
status={
@@ -31,7 +31,7 @@ export default function ApiaryMapPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={MapPin}
area="apiaries"
title="Kartenansicht"
description="Hier siehst du alle Bienenstände, bei denen Koordinaten hinterlegt sind."
actions={
@@ -2,7 +2,7 @@
import { FormEvent, useState } from "react";
import { useRouter } from "next/navigation";
import { LocateFixed, MapPin } from "lucide-react";
import { LocateFixed } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { createApiary } from "@/lib/apiaries/api";
@@ -129,7 +129,7 @@ export default function NewApiaryPage() {
draft={draft}
/>
<PageHeader
icon={MapPin}
area="apiaries"
title="Neuer Bienenstand"
description="Nur der Name ist Pflicht – den genauen Ort kannst du per Knopfdruck übernehmen."
/>
@@ -40,7 +40,7 @@ export default function ApiariesPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={MapPin}
area="apiaries"
title="Bienenstände"
description="Hier verwaltest du die Standorte deiner Völker."
actions={
@@ -149,7 +149,7 @@ function ApiaryGrid({ apiaries }: { apiaries: Apiary[] }) {
<EntityCard
key={apiary.id}
href={`/apiaries/${apiary.id}`}
icon={MapPin}
area="apiaries"
title={apiary.name}
subtitle={apiary.location ?? "—"}
badge={
@@ -2,7 +2,6 @@
import Link from "next/link";
import { useParams } from "next/navigation";
import { Package } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { fetchBottlingBatch } from "@/lib/bottling/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -32,7 +31,7 @@ export default function BottlingBatchDetailPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<DetailHeader
icon={Package}
area="bottling"
title={`Abfüllung ${bottlingBatch.lotNumber}`}
subtitle={`${bottlingBatch.jarCount} Gläser à ${bottlingBatch.jarSize} g`}
{...BACK}
@@ -4,7 +4,7 @@ import { FormEvent, useId, useMemo, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { AlertTriangle, Package } from "lucide-react";
import { AlertTriangle } from "lucide-react";
import { fetchHoneyBatches } from "@/lib/honey-batches/api";
import { fetchProducts } from "@/lib/products/api";
import { createBottlingBatch, fetchBottlingPrefill } from "@/lib/bottling/api";
@@ -395,7 +395,7 @@ export default function NewBottlingBatchPage() {
draft={draft}
/>
<PageHeader
icon={Package}
area="bottling"
title="Neue Abfüllung"
description="Fülle eine Honigcharge in Gläser ab."
/>
@@ -23,7 +23,7 @@ export default function BottlingBatchesPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Package}
area="bottling"
title="Abfüllungen"
description="Hier findest du alle abgefüllten Chargen und erfasst neue Abfüllungen."
actions={
@@ -55,7 +55,7 @@ export default function BottlingBatchesPage() {
<EntityCard
key={bottlingBatch.id}
href={`/bottling/${bottlingBatch.id}`}
icon={Package}
area="bottling"
title={<span className="font-mono">{bottlingBatch.lotNumber}</span>}
subtitle={bottlingBatch.product?.name ?? bottlingBatch.honeyBatch.batchNumber}
fields={[
@@ -259,10 +259,9 @@ export default function CalendarPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={CalendarDays}
area="calendar"
title="Kalender"
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">
@@ -3,7 +3,7 @@
import Link from "next/link";
import { useParams, useSearchParams } from "next/navigation";
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 { fetchColony } from "@/lib/colonies/api";
import { COLONY_STATUS_LABELS, COLONY_TYPE_LABELS, colonyStrengthText } from "@/lib/colonies/labels";
@@ -56,7 +56,7 @@ export default function ColonyDetailPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<DetailHeader
icon={Layers}
area="colonies"
title={colony.code}
subtitle={COLONY_TYPE_LABELS[colony.colonyType]}
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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Layers } from "lucide-react";
import { createColony } from "@/lib/colonies/api";
import { COLONY_TYPES, COLONY_TYPE_LABELS, MAX_OCCUPIED_FRAMES } from "@/lib/colonies/labels";
import { fetchApiaries } from "@/lib/apiaries/api";
@@ -110,7 +110,7 @@ export default function NewColonyPage() {
draft={draft}
/>
<PageHeader
icon={Layers}
area="colonies"
title="Neues Volk"
description="Nur Code und Bienenstand sind Pflicht – der Rest kann später folgen."
/>
@@ -55,7 +55,7 @@ export default function ColoniesPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Layers}
area="colonies"
title="Völker"
description="Hier siehst du alle Bienenvölker und legst neue an."
actions={
@@ -173,7 +173,7 @@ function ColonyGrid({ colonies }: { colonies: Colony[] }) {
<EntityCard
key={colony.id}
href={`/colonies/${colony.id}`}
icon={Layers}
area="colonies"
title={<span className="font-mono">{colony.code}</span>}
subtitle={COLONY_TYPE_LABELS[colony.colonyType]}
badge={
@@ -2,7 +2,7 @@
import { useRef, useState } from "react";
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 { NoComplianceAccess } from "@/components/compliance/no-compliance-access";
import { NoticeStatus } from "@/components/compliance/notice-status";
@@ -138,7 +138,7 @@ export default function NoticeDetailPage() {
return (
<main className="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4 sm:p-6">
<DetailHeader
icon={BookOpen}
area="compliance"
title={noticeTitle(notice)}
subtitle={`Stichtag ${formatDay(noticeDay(notice.referenceDate))}`}
status={<NoticeStatus status={notice.status} />}
@@ -44,7 +44,7 @@ export default function NoticesPage() {
return (
<main className="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4 sm:p-6">
<DetailHeader
icon={BookOpen}
area="compliance"
title="Anzeigen ans Veterinäramt"
subtitle="Erstanzeige, Änderungen im Bestand und die TSK-Zählung."
backHref="/compliance"
@@ -3,16 +3,7 @@
import Link from "next/link";
import { useMemo, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import {
BookOpen,
CheckCircle2,
FileText,
Printer,
Settings,
ShieldCheck,
TriangleAlert,
XCircle,
} from "lucide-react";
import { BookOpen, CheckCircle2, FileText, Printer, Settings, TriangleAlert, XCircle } from "lucide-react";
import { DetailHeader, type MoreAction } from "@/components/detail-header";
import { OnlineOnlyHint, StandNote, useOnlineOnly } from "@/components/offline/stand-note";
import { ErrorState, LoadingState } from "@/components/query-state";
@@ -112,7 +103,7 @@ export default function CompliancePage() {
const header = (
<DetailHeader
icon={ShieldCheck}
area="compliance"
title="Prüfbereitschaft"
backHref="/dashboard"
backLabel="Start"
@@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { OctagonAlert } from "lucide-react";
import { NoComplianceAccess } from "@/components/compliance/no-compliance-access";
import { DetailHeader } from "@/components/detail-header";
import { StandNote } from "@/components/offline/stand-note";
@@ -109,7 +109,7 @@ export default function RestrictionsPage() {
return (
<main className="mx-auto flex w-full max-w-3xl flex-col gap-6 p-4 sm:p-6">
<DetailHeader
icon={OctagonAlert}
area="compliance"
title="Sperrbezirke"
subtitle="Stände, die das Veterinäramt zum Sperrbezirk erklärt hat."
backHref="/compliance"
@@ -1,7 +1,7 @@
"use client";
import { FormEvent, useEffect, useState } from "react";
import { Settings } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { NoComplianceAccess } from "@/components/compliance/no-compliance-access";
import { DetailHeader, DetailQueryFallback } from "@/components/detail-header";
@@ -228,7 +228,7 @@ function SettingsForm({ settings, canManage }: { settings: ComplianceSettings; c
return (
<main className="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4 sm:p-6">
<DetailHeader
icon={Settings}
area="compliance"
title="Einstellungen Behörden"
subtitle={readOnly ? "Du kannst die Einstellungen nur ansehen." : "Hier legst du fest, wann und wohin du meldest."}
backHref="/compliance"
@@ -255,4 +255,15 @@ describe("DashboardPage – Heute", () => {
expect(await within(row).findByText("V-07")).toBeInTheDocument();
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 { tasksForToday } from "@/lib/tasks/today";
import { Button } from "@/components/ui/button";
import { HoneycombPattern } from "@/components/honeycomb-pattern";
import { StandNote } from "@/components/offline/stand-note";
import { ComplianceLine } from "@/components/today/compliance-line";
import { TodayPanel } from "@/components/today/today-panel";
@@ -157,21 +156,15 @@ export default function DashboardPage() {
return (
<main className="mx-auto flex w-full max-w-3xl flex-col gap-8 p-4 sm:p-6">
<header
className="relative overflow-hidden rounded-2xl px-5 py-5 text-white shadow-[var(--shadow-elevated)] sm:px-7 sm:py-6"
style={{ background: "var(--gradient-honey)" }}
>
<HoneycombPattern className="pointer-events-none absolute inset-0 h-full w-full text-white/[0.08]" />
<div className="relative flex flex-col gap-1">
<h1 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
{greeting(now)}
{firstName ? `, ${firstName}` : ""}
</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 className="flex flex-col gap-1">
<h1 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
{greeting(now)}
{firstName ? `, ${firstName}` : ""}
</h1>
<p className="text-sm text-muted-foreground sm:text-base">
{now.toLocaleDateString("de-DE", { weekday: "long", day: "numeric", month: "long" })}
{todayCount !== null ? ` · ${todaySummary(todayCount)}` : ""}
</p>
</header>
<StandNote updatedAt={standAt} isStale={hasStaleData} className="-mt-5" />
@@ -1,7 +1,7 @@
"use client";
import { useParams } from "next/navigation";
import { Wrench } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { fetchEquipment, updateEquipment } from "@/lib/equipment/api";
@@ -42,7 +42,7 @@ export default function EquipmentDetailPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<DetailHeader
icon={Wrench}
area="equipment"
title={equipment.name}
status={
<DetailStatus
@@ -4,7 +4,7 @@ import { FormEvent, useState } from "react";
import { useRouter } from "next/navigation";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Wrench } from "lucide-react";
import { createEquipment } from "@/lib/equipment/api";
import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message";
@@ -92,10 +92,9 @@ export default function NewEquipmentPage() {
draft={draft}
/>
<PageHeader
icon={Wrench}
area="equipment"
title="Neuer Inventargegenstand"
description="Nur Bezeichnung und Inventarnummer sind Pflicht."
gradient="var(--gradient-sky)"
/>
{draft.pending ? (
<DraftBanner
@@ -22,10 +22,9 @@ export default function EquipmentPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Wrench}
area="equipment"
title="Inventar"
description="Hier erfasst du deine Ausrüstung und ihren Zustand."
gradient="var(--gradient-sage)"
actions={
<Button size="touch" nativeButton={false} render={<Link href="/equipment/new" />}>
+ Neuer Gegenstand
@@ -55,8 +54,7 @@ export default function EquipmentPage() {
<EntityCard
key={item.id}
href={`/equipment/${item.id}`}
icon={Wrench}
gradient="var(--gradient-sage)"
area="equipment"
title={item.name}
subtitle={item.inventoryNumber}
badge={
@@ -2,7 +2,7 @@
import Link from "next/link";
import { useParams } from "next/navigation";
import { FlaskConical } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { fetchExtraction } from "@/lib/extractions/api";
import { Button } from "@/components/ui/button";
@@ -29,7 +29,7 @@ export default function ExtractionDetailPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<DetailHeader
icon={FlaskConical}
area="extractions"
title={`Schleuderung vom ${new Date(extraction.date).toLocaleDateString("de-DE")}`}
primaryAction={
<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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Droplet } from "lucide-react";
import { fetchHarvests } from "@/lib/harvests/api";
import {
createExtraction,
@@ -150,7 +150,7 @@ export default function NewExtractionPage() {
draft={draft}
/>
<PageHeader
icon={Droplet}
area="extractions"
title="Neue Schleuderung"
description="Pflicht sind Datum und mindestens eine Ernte."
/>
@@ -16,7 +16,7 @@ export default function ExtractionsPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={FlaskConical}
area="extractions"
title="Schleuderungen"
description="Hier erfasst du Schleuderungen aus einer oder mehreren Ernten."
actions={
@@ -50,7 +50,7 @@ export default function ExtractionsPage() {
<EntityCard
key={extraction.id}
href={`/extractions/${extraction.id}`}
icon={FlaskConical}
area="extractions"
title={new Date(extraction.date).toLocaleDateString("de-DE")}
subtitle={extraction.room ?? "—"}
fields={[
@@ -4,7 +4,7 @@ import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Utensils } from "lucide-react";
import { createFeeding } from "@/lib/feedings/api";
import { createFeedingsBulk } from "@/lib/bulk/api";
import {
@@ -193,7 +193,7 @@ export default function NewFeedingPage() {
draft={draft}
/>
<PageHeader
icon={Utensils}
area="feedings"
title="Neue Fütterung"
description="Nur Volk, Datum, Futterart und Menge sind Pflicht."
/>
@@ -2,7 +2,7 @@
import Link from "next/link";
import { useParams } from "next/navigation";
import { Boxes } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { fetchHarvest } from "@/lib/harvests/api";
import { Button } from "@/components/ui/button";
@@ -27,7 +27,7 @@ export default function HarvestDetailPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<DetailHeader
icon={Boxes}
area="harvests"
title={`Ernte vom ${new Date(harvest.date).toLocaleDateString("de-DE")}`}
subtitle={harvest.honeyType ?? undefined}
primaryAction={
@@ -4,7 +4,7 @@ import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Scissors, AlertTriangle } from "lucide-react";
import { AlertTriangle } from "lucide-react";
import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchColonies } from "@/lib/colonies/api";
import { colonyOptionLabel } from "@/lib/colonies/labels";
@@ -172,7 +172,7 @@ export default function NewHarvestPage() {
draft={draft}
/>
<PageHeader
icon={Scissors}
area="harvests"
title="Neue Ernte"
description="Pflicht sind Bienenstand, Datum und mindestens ein Volk."
/>
@@ -21,7 +21,7 @@ export default function HarvestsPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Scissors}
area="harvests"
title="Ernten"
description="Hier erfasst du deine Ernten je Bienenstand."
actions={
@@ -55,7 +55,7 @@ export default function HarvestsPage() {
<EntityCard
key={harvest.id}
href={`/harvests/${harvest.id}`}
icon={Scissors}
area="harvests"
title={new Date(harvest.date).toLocaleDateString("de-DE")}
subtitle={apiaryNameById.get(harvest.apiaryId) ?? "—"}
fields={[
@@ -2,7 +2,7 @@
import Link from "next/link";
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 { toast } from "sonner";
import { fetchHoneyBatch, fetchHoneyBatchTraceability } from "@/lib/honey-batches/api";
@@ -55,7 +55,7 @@ export default function HoneyBatchDetailPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<DetailHeader
icon={Droplet}
area="honey-batches"
title={honeyBatch.batchNumber}
subtitle={honeyBatch.honeyType ?? undefined}
status={
@@ -47,7 +47,7 @@ export default function HoneyBatchesPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Droplet}
area="honey-batches"
title="Honigchargen"
description="Hier verfolgst du jede Charge vom Schleudern bis zur Abfüllung."
actions={
@@ -112,7 +112,7 @@ function HoneyBatchGrid({ honeyBatches }: { honeyBatches: HoneyBatch[] }) {
<EntityCard
key={honeyBatch.id}
href={`/honey-batches/${honeyBatch.id}`}
icon={Droplet}
area="honey-batches"
title={<span className="font-mono">{honeyBatch.batchNumber}</span>}
subtitle={honeyBatch.honeyType ?? "—"}
badge={
@@ -4,7 +4,7 @@ import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { AlertTriangle, ClipboardCheck } from "lucide-react";
import { AlertTriangle } from "lucide-react";
import { createInspection } from "@/lib/inspections/api";
import { fetchColonies } from "@/lib/colonies/api";
import { fetchApiaryWeather } from "@/lib/weather/api";
@@ -668,10 +668,9 @@ function InspectionCaptureForm({
</div>
) : null}
<PageHeader
icon={ClipboardCheck}
area="inspections"
title="Neue Durchsicht"
description="Nur Volk und Datum sind Pflicht – dazu, was du gesehen hast."
gradient="var(--gradient-sage)"
/>
{draft.pending ? (
<DraftBanner
+4 -9
View File
@@ -3,12 +3,13 @@
import { ReactNode, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { Hexagon, Sun } from "lucide-react";
import { Sun } from "lucide-react";
import { toast } from "sonner";
import { useAuth } from "@/lib/auth/auth-context";
import { useAppearance } from "@/lib/appearance/appearance";
import { finishHandOvers, purgeStaleDraftPhotos } from "@/lib/photos/store";
import { LogoutConfirmDialog } from "@/components/auth/logout-confirm-dialog";
import { BrandMark } from "@/components/brand-mark";
import { NotificationBell } from "@/components/notification-bell";
import { OfflineBand } from "@/components/offline/offline-band";
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">
<Link
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
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
<BrandMark withWordmark />
</Link>
<div className="flex shrink-0 items-center gap-2 text-sm">
<QuickAddSheet user={user} variant="header" />
@@ -2,7 +2,7 @@
import { useId, type ReactNode } from "react";
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 { toast } from "sonner";
import { useAuth } from "@/lib/auth/auth-context";
@@ -62,7 +62,7 @@ export default function MedicineDetailPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<DetailHeader
icon={Pill}
area="medicines"
title={medicine.name}
subtitle={[medicine.activeIngredient, MEDICINE_UNIT_LABELS[medicine.unit]].filter(Boolean).join(" · ")}
status={
@@ -133,7 +133,7 @@ export default function MedicineDetailPage() {
</h2>
{medicine.lots.length === 0 ? (
<EmptyState
icon={Package}
icon={Pill}
title="Noch keine Packung"
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
key={lot.id}
href={`/medicines/lots/${lot.id}`}
icon={Package}
area="medicines"
title={lot.batchNumber ?? "Ohne Chargennummer"}
subtitle={`Gekauft am ${formatDay(lot.purchasedOn)}`}
fields={[
@@ -3,7 +3,7 @@
import type { ReactNode } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { Package } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/lib/auth/auth-context";
import { buildBook } from "@/lib/medicines/book";
@@ -58,7 +58,7 @@ export default function LotDetailPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<DetailHeader
icon={Package}
area="medicines"
title={`Packung ${lot.batchNumber ?? formatDay(lot.purchasedOn)}`}
subtitle={
<>
@@ -40,7 +40,7 @@ export default function MedicinesPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Pill}
area="medicines"
title="Tierarzneimittel"
description="Dein Katalog mit Packungen und dem Bewegungsbuch. Behandlungen buchen aus der Packung ab."
actions={canCreate ? <MedicineFormDialog disabled={isOffline} describedBy={describedBy} /> : undefined}
@@ -90,7 +90,7 @@ function MedicineGrid({ medicines }: { medicines: MedicineView[] }) {
<EntityCard
key={medicine.id}
href={`/medicines/${medicine.id}`}
icon={Pill}
area="medicines"
title={medicine.name}
subtitle={medicine.activeIngredient ?? "—"}
badge={medicine.active ? undefined : <Badge variant="secondary">Deaktiviert</Badge>}
+76 -86
View File
@@ -1,33 +1,5 @@
import {
Activity,
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 { LucideIcon } from "lucide-react";
import { AREAS, type AreaKey } from "@/lib/areas";
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.
*/
export interface NavItem {
/** The area this entry opens. */
area: AreaKey;
label: string;
/** Short name for the cramped bottom nav. */
shortLabel?: string;
@@ -51,6 +25,14 @@ export interface NavItem {
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 {
label: string | null;
items: NavItem[];
@@ -60,99 +42,101 @@ export const NAV_GROUPS: NavGroup[] = [
{
label: null,
items: [
{
navItem("dashboard", {
label: "Dashboard",
shortLabel: "Start",
href: "/dashboard",
icon: LayoutDashboard,
},
{ label: "Suche", href: "/search", icon: Search },
}),
navItem("search", { label: "Suche", href: "/search" }),
],
},
{
label: "Betrieb",
items: [
{ label: "Bienenstände", href: "/apiaries", icon: MapPin },
{ label: "Völker", href: "/colonies", icon: Layers },
{ label: "Königinnen", href: "/queens", icon: Crown },
{
navItem("apiaries", { label: "Bienenstände", href: "/apiaries" }),
navItem("colonies", { label: "Völker", href: "/colonies" }),
navItem("queens", { label: "Königinnen", href: "/queens" }),
navItem("compliance", {
label: "Prüfbereitschaft",
href: "/compliance",
icon: ShieldCheck,
permission: "compliance:read",
},
{
}),
navItem("winter", {
label: "Überwinterung",
href: "/winter",
icon: Snowflake,
permission: "winter-checks:read",
},
}),
],
},
{
label: "Arbeit",
items: [
{ label: "Aufgaben", href: "/tasks", icon: ListTodo },
{
navItem("tasks", { label: "Aufgaben", href: "/tasks" }),
navItem("suggestions", {
label: "Vorschläge",
href: "/suggestions",
icon: Lightbulb,
permission: "tasks:read",
counter: "suggestions",
},
{ label: "Kalender", href: "/calendar", icon: CalendarDays },
{
}),
navItem("calendar", { label: "Kalender", href: "/calendar" }),
navItem("voice", {
label: "Sprachnotizen",
href: "/voice",
icon: Mic,
permission: "voice-captures:use",
alsoRequires: ["inspections:create"],
},
}),
],
},
{
label: "Ernte & Lager",
items: [
{ label: "Ernten", href: "/harvests", icon: Scissors },
{ label: "Schleuderungen", href: "/extractions", icon: FlaskConical },
{ label: "Honigchargen", href: "/honey-batches", icon: Droplet },
{ label: "Abfüllungen", href: "/bottling", icon: Package },
{
navItem("harvests", { label: "Ernten", href: "/harvests" }),
navItem("extractions", { label: "Schleuderungen", href: "/extractions" }),
navItem("honey-batches", {
label: "Honigchargen",
href: "/honey-batches",
}),
navItem("bottling", { label: "Abfüllungen", href: "/bottling" }),
navItem("raw-materials", {
label: "Rohstoffe",
href: "/raw-materials",
icon: Boxes,
permission: "raw-material:read",
},
{
}),
navItem("production-batches", {
label: "Herstellungschargen",
href: "/production-batches",
icon: Factory,
permission: "production-batch:read",
},
{ label: "Produkte", href: "/products", icon: ShoppingBag },
{ label: "Lager", href: "/storage", icon: Warehouse },
{ label: "Inventar", href: "/equipment", icon: Wrench },
}),
navItem("products", { label: "Produkte", href: "/products" }),
navItem("storage", { label: "Lager", href: "/storage" }),
navItem("equipment", { label: "Inventar", href: "/equipment" }),
],
},
{
label: "Auswertung",
items: [{ label: "Statistik", href: "/statistics", icon: BarChart3 }],
items: [navItem("statistics", { label: "Statistik", href: "/statistics" })],
},
];
export const ADMIN_GROUP: NavGroup = {
label: "Verwaltung",
items: [
{
navItem("users-roles", {
label: "Benutzer & Rollen",
href: "/settings/users-roles",
icon: Users,
permission: ["users:read", "roles:read"],
},
{
}),
navItem("automation", {
label: "Automatisierung",
href: "/settings/automation",
icon: Zap,
permission: [
"webhooks:read",
"automation:read",
@@ -162,31 +146,31 @@ export const ADMIN_GROUP: NavGroup = {
"calendar:configure",
"api-keys:manage",
],
},
{
}),
navItem("hive-systems", {
label: "Beutensysteme",
href: "/settings/hive-systems",
icon: Box,
permission: "hive-systems:manage",
},
{
}),
navItem("backup", {
label: "Backup",
href: "/settings/backup",
icon: Database,
permission: ["backup:export", "backup:import", "import:execute"],
},
{
}),
navItem("audit-log", {
label: "Audit-Log",
href: "/settings/audit-log",
icon: History,
permission: "audit:read",
},
{
}),
navItem("system", {
label: "System",
href: "/settings/system",
icon: Activity,
permission: "system:read",
},
}),
],
};
@@ -210,14 +194,18 @@ function isVisible(
}
/** 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]
.map((group) => ({
...group,
items: group.items.filter(
(item) =>
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);
@@ -226,7 +214,9 @@ export function visibleNavGroups(user: Pick<AuthUser, "permissions">): NavGroup[
/** The bottom nav entries, resolved from the config in their fixed order. */
export function bottomNavItems(): NavItem[] {
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 { useSearchParams } from "next/navigation";
import { MapPin } from "lucide-react";
import { useCurrentUserId } from "@/lib/auth/auth-context";
import type { StoredPack } from "@/lib/offline/db";
import { DetailHeader } from "@/components/detail-header";
@@ -74,7 +73,7 @@ function OfflineApiaryView({ stored, readAt, returnHref }: OfflineApiaryViewProp
return (
<OfflinePageFrame>
<DetailHeader
icon={MapPin}
area="apiaries"
title={apiary.name}
subtitle={apiary.location ?? undefined}
primaryAction={<StartRoundButton apiary={{ id: apiary.id, name: apiary.name }} />}
@@ -4,7 +4,7 @@ import { Suspense } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { Layers } from "lucide-react";
import { useAuth, useCurrentUserId } from "@/lib/auth/auth-context";
import {
OFFLINE_PACKS_QUERY_KEY,
@@ -126,7 +126,7 @@ function OfflineColonyView({ stored, colony, readAt, returnHref }: OfflineColony
return (
<OfflinePageFrame>
<DetailHeader
icon={Layers}
area="colonies"
title={colony.code}
subtitle={`${COLONY_TYPE_LABELS[colony.colonyType]} · ${pack.apiary.name}`}
status={
@@ -43,7 +43,7 @@ export default function OfflinePage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={CloudOff}
area="system"
title="Offline"
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 { useParams, useRouter } from "next/navigation";
import { Factory } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
@@ -60,7 +60,7 @@ export default function ProductionBatchDetailPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<DetailHeader
icon={Factory}
area="production-batches"
title={`Charge ${batch.lotNumber}`}
subtitle={`${batch.product.name} · ${batch.quantity} Stück hergestellt`}
{...BACK}
@@ -2,7 +2,7 @@
import { FormEvent, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Factory } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { createProductionBatch } from "@/lib/production-batches/api";
@@ -132,7 +132,7 @@ export default function NewProductionBatchPage() {
dirty={dirty}
/>
<PageHeader
icon={Factory}
area="production-batches"
title="Neue Herstellungscharge"
description="Was hast du hergestellt, und wie viele Stück?"
/>
@@ -32,7 +32,7 @@ export default function ProductionBatchesPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Factory}
area="production-batches"
title="Herstellungschargen"
description="Kerzen, Propolis-Produkte und andere hergestellte Waren - je Charge mit hergestellter Menge und Rest."
actions={newButton}
@@ -56,7 +56,7 @@ export default function ProductionBatchesPage() {
<EntityCard
key={batch.id}
href={`/production-batches/${batch.id}`}
icon={Factory}
area="production-batches"
title={<span className="font-mono">{batch.lotNumber}</span>}
subtitle={batch.product.name}
fields={[
@@ -2,7 +2,7 @@
import Link from "next/link";
import { useParams } from "next/navigation";
import { ShoppingBag } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { fetchProduct } from "@/lib/products/api";
import { fetchProductionBatches } from "@/lib/production-batches/api";
@@ -54,7 +54,7 @@ export default function ProductDetailPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<DetailHeader
icon={ShoppingBag}
area="products"
title={product.name}
subtitle={PRODUCT_KIND_LABELS[product.kind]}
{...BACK}
@@ -4,7 +4,7 @@ import { FormEvent, useState } from "react";
import { useRouter } from "next/navigation";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { ShoppingBag } from "lucide-react";
import { createProduct } from "@/lib/products/api";
import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message";
@@ -93,7 +93,7 @@ export default function NewProductPage() {
draft={draft}
/>
<PageHeader
icon={ShoppingBag}
area="products"
title="Neues Produkt"
description="Nur der Name ist Pflicht – alles andere kannst du später ergänzen."
/>
@@ -32,7 +32,7 @@ export default function ProductsPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={ShoppingBag}
area="products"
title="Produkte"
description="Hier legst du deine verkaufsfertigen Produkte an und pflegst sie."
actions={
@@ -85,7 +85,7 @@ function ProductGrid({ products }: { products: Product[] }) {
{products.map((product) => (
<EntityCard
key={product.id}
icon={ShoppingBag}
area="products"
title={product.name}
badge={
<Badge variant={product.status === "ACTIVE" ? "default" : "secondary"}>
@@ -1,7 +1,7 @@
"use client";
import { ChangeEvent, FormEvent, useRef, useState } from "react";
import { UserRound } from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { useAuth } from "@/lib/auth/auth-context";
@@ -297,10 +297,9 @@ export default function ProfilePage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={UserRound}
area="profile"
title="Mein Profil"
description="Persönliche Daten, Passwort und Sicherheit"
gradient="var(--gradient-sage)"
/>
<div className="grid gap-4 sm:max-w-xl">
<AvatarSection />
@@ -2,7 +2,7 @@
import { useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { Crown } from "lucide-react";
import { fetchQueen } from "@/lib/queens/api";
import { QUEEN_STATUS_LABELS } from "@/lib/queens/labels";
import { QrCodeDialog } from "@/components/qr-code-dialog";
@@ -31,7 +31,7 @@ export default function QueenDetailPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<DetailHeader
icon={Crown}
area="queens"
title={queen.code}
subtitle={queen.hatchYear ? `Schlupfjahr ${queen.hatchYear}` : undefined}
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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Crown } from "lucide-react";
import { createQueen, fetchQueens } from "@/lib/queens/api";
import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message";
@@ -121,10 +121,9 @@ export default function NewQueenPage() {
draft={draft}
/>
<PageHeader
icon={Crown}
area="queens"
title="Neue Königin"
description="Nur der Code ist Pflicht."
gradient="var(--gradient-sage)"
/>
{draft.pending ? (
<DraftBanner
@@ -34,10 +34,9 @@ export default function QueensPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Crown}
area="queens"
title="Königinnen"
description="Hier verwaltest du deine Königinnen mit Zuchtlinie und Bewertung."
gradient="var(--gradient-sage)"
actions={
<Button size="touch" nativeButton={false} render={<Link href="/queens/new" />}>
+ Neue Königin
@@ -136,8 +135,7 @@ function QueenGrid({ queens }: { queens: Queen[] }) {
<EntityCard
key={queen.id}
href={`/queens/${queen.id}`}
icon={Crown}
gradient="var(--gradient-sage)"
area="queens"
title={queen.code}
subtitle={queen.breed ?? "—"}
badge={
+34 -35
View File
@@ -1,16 +1,8 @@
import {
Bug,
ClipboardCheck,
ListTodo,
type LucideIcon,
Mic,
Scissors,
Sprout,
Syringe,
Utensils,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { AREAS, type AreaKey } from "@/lib/areas";
export interface QuickAction {
area: AreaKey;
label: string;
href: string;
icon: LucideIcon;
@@ -22,55 +14,62 @@ export interface QuickAction {
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[] = [
{
quickAction("inspections", {
label: "Durchsicht",
href: "/inspections/new",
icon: ClipboardCheck,
permission: "inspections:create",
},
{
}),
quickAction("voice", {
label: "Sprachnotiz",
href: "/voice/new",
icon: Mic,
permission: "voice-captures:use",
alsoPermission: "inspections:create",
needsVoiceRecording: true,
},
{
}),
quickAction("varroa", {
label: "Varroa-Messung",
href: "/varroa/new",
icon: Bug,
permission: "varroa:create",
},
{
}),
quickAction("swarm-control", {
label: "Schwarmkontrolle",
href: "/swarm-control/new",
icon: Sprout,
permission: "swarm-control:create",
},
{
}),
quickAction("treatments", {
label: "Behandlung",
href: "/treatments/new",
icon: Syringe,
permission: "treatments:create",
},
{
}),
quickAction("feedings", {
label: "Fütterung",
href: "/feedings/new",
icon: Utensils,
permission: "feedings:create",
},
{
}),
quickAction("tasks", {
label: "Aufgabe",
href: "/tasks/new",
icon: ListTodo,
permission: "tasks:create",
},
{
}),
quickAction("harvests", {
label: "Ernte",
href: "/harvests/new",
icon: Scissors,
permission: "harvests:create",
},
}),
];
@@ -45,7 +45,7 @@ describe("QuickAddSheet", () => {
const link = await screen.findByRole("link", { name: "Durchsicht" });
expect(link).toHaveAttribute("href", "/inspections/new");
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" });
expect(screen.getAllByRole("link")).toHaveLength(QUICK_ACTIONS.length);
});
@@ -4,6 +4,7 @@ import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Plus } from "lucide-react";
import { AreaIcon } from "@/components/area-icon";
import { Button } from "@/components/ui/button";
import { SheetTrigger } from "@/components/ui/sheet";
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">
{actions.map((action) => {
const Icon = action.icon;
return (
<Link
key={action.href}
@@ -92,10 +92,7 @@ export function QuickAddSheet({ user, variant }: QuickAddSheetProps) {
onClick={() => setOpen(false)}
className={`${NAV_SHEET_ROW_CLASS} text-foreground hover:bg-accent/70`}
>
<Icon
className="size-6 shrink-0 text-primary"
aria-hidden="true"
/>
<AreaIcon area={action.area} />
{action.label}
</Link>
);
@@ -2,7 +2,7 @@
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { Boxes } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
@@ -56,7 +56,7 @@ export default function RawMaterialDetailPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<DetailHeader
icon={Boxes}
area="raw-materials"
title={`${RAW_MATERIAL_KIND_LABELS[lot.kind]} ${lot.lotNumber}`}
subtitle={`${formatGrams(lot.quantity)} gewonnen am ${new Date(lot.date).toLocaleDateString("de-DE")}`}
{...BACK}
@@ -2,7 +2,7 @@
import { FormEvent, useState } from "react";
import { useRouter } from "next/navigation";
import { Boxes } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { createRawMaterialLot } from "@/lib/raw-materials/api";
@@ -89,7 +89,7 @@ export default function NewRawMaterialPage() {
dirty={dirty}
/>
<PageHeader
icon={Boxes}
area="raw-materials"
title="Neuer Rohstoff"
description="Was hast du gewonnen, und wie viel?"
/>
@@ -34,7 +34,7 @@ export default function RawMaterialsPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Boxes}
area="raw-materials"
title="Rohstoffe"
description="Wachs und Propolis je Charge - mit gewonnener Menge, Verbrauch in der Herstellung und Rest."
actions={newButton}
@@ -57,7 +57,7 @@ export default function RawMaterialsPage() {
<EntityCard
key={lot.id}
href={`/raw-materials/${lot.id}`}
icon={Boxes}
area="raw-materials"
title={<span className="font-mono">{lot.lotNumber}</span>}
subtitle={RAW_MATERIAL_KIND_LABELS[lot.kind]}
fields={[
@@ -50,7 +50,7 @@ export default function SearchPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={SearchIcon}
area="search"
title="Suche"
description="Finde Bienenstände, Völker, Königinnen, Aufgaben und Honigchargen."
/>
@@ -50,10 +50,9 @@ export default function AuditLogPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={History}
area="audit-log"
title="Audit-Log"
description="Wer hat wann was geändert – neueste Einträge zuerst."
gradient="var(--gradient-sage)"
/>
<form
@@ -58,8 +58,7 @@ export function LabelTemplatesTab() {
{activeTemplates.map((template) => (
<EntityCard
key={template.id}
icon={Tag}
gradient="var(--gradient-sky)"
area="label-templates"
title={template.name}
badge={template.isDefault ? <Badge>Standard</Badge> : undefined}
fields={[
@@ -2,7 +2,7 @@
import { Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Zap } from "lucide-react";
import { PageHeader } from "@/components/page-header";
import { TabSwitcher } from "@/components/tab-switcher";
import { WebhooksTab } from "./webhooks-tab";
@@ -50,10 +50,9 @@ function AutomationPageContent() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Zap}
area="automation"
title="Automatisierung"
description="Was ApiCore dir abnimmt – und wie es mit anderen Systemen verbunden ist."
gradient="var(--gradient-sky)"
/>
<TabSwitcher tabs={TABS} value={tab} onChange={setTab} />
@@ -86,8 +86,7 @@ export function TaskTemplatesTab() {
{visibleTemplates.map((template) => (
<EntityCard
key={template.id}
icon={Repeat}
gradient="var(--gradient-sky)"
area="tasks"
title={template.title}
badge={
<Badge variant={template.active ? "default" : "outline"}>
@@ -1,7 +1,7 @@
"use client";
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 { toast } from "sonner";
import {
@@ -573,10 +573,9 @@ export default function BackupPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Database}
area="backup"
title="Backup"
description="Daten sichern, wiederherstellen und übertragen"
gradient="var(--gradient-sky)"
/>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
@@ -106,7 +106,7 @@ function HiveSystemsContent() {
return (
<div className="flex flex-col gap-6">
<PageHeader
icon={Boxes}
area="hive-systems"
title="Beutensysteme"
description="Welche Teile es pro System gibt, wie groß sie sind und was ihr Zustand zeigt."
actions={
@@ -2,7 +2,6 @@
import { useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { Tag } from "lucide-react";
import { fetchLabelTemplates, updateLabelTemplate } from "@/lib/label-templates/api";
import { PageHeader } from "@/components/page-header";
import { LabelDesigner, LabelDesignerValue } from "../designer/label-designer";
@@ -22,10 +21,9 @@ export default function EditLabelTemplatePage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Tag}
area="label-templates"
title="Etikettenvorlage bearbeiten"
description="Etikett per Drag & Drop gestalten"
gradient="var(--gradient-sky)"
/>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError || (!isLoading && !template) ? (
@@ -1,6 +1,6 @@
"use client";
import { Tag } from "lucide-react";
import { createLabelTemplate } from "@/lib/label-templates/api";
import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header";
@@ -19,10 +19,9 @@ export default function NewLabelTemplatePage() {
dirty={false}
/>
<PageHeader
icon={Tag}
area="label-templates"
title="Neue Etikettenvorlage"
description="Etikett per Drag & Drop gestalten"
gradient="var(--gradient-sky)"
/>
<LabelDesigner onSubmit={handleSubmit} submitLabel="Anlegen" />
</main>
@@ -1,7 +1,7 @@
"use client";
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 { toast } from "sonner";
import {
@@ -278,7 +278,7 @@ export default function SystemPage() {
return (
<div className="flex flex-col gap-6">
<PageHeader
icon={Activity}
area="system"
title="System"
description="Hier siehst du, ob die Hintergrundarbeit läuft."
/>
@@ -2,7 +2,7 @@
import { Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Users } from "lucide-react";
import { PageHeader } from "@/components/page-header";
import { TabSwitcher } from "@/components/tab-switcher";
import { UsersTab } from "./users-tab";
@@ -32,10 +32,9 @@ function UsersRolesPageContent() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Users}
area="users-roles"
title="Benutzer & Rollen"
description="Zugänge, Rollen und Berechtigungen verwalten"
gradient="var(--gradient-sage)"
/>
<TabSwitcher tabs={TABS} value={tab} onChange={setTab} />
@@ -93,8 +93,7 @@ export function RolesTab() {
{visibleRoles.map((role) => (
<EntityCard
key={role.id}
icon={Shield}
gradient="var(--gradient-sky)"
area="users-roles"
title={role.name}
subtitle={role.key}
badge={
@@ -2,7 +2,7 @@
import { useSyncExternalStore } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ShieldCheck } from "lucide-react";
import { toast } from "sonner";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { useAuth } from "@/lib/auth/auth-context";
@@ -219,7 +219,7 @@ export default function VoiceSettingsPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={ShieldCheck}
area="voice"
title="Sprache & Datenschutz"
description="Was mit deiner Stimme und dem Text passiert."
/>
+3 -11
View File
@@ -2,7 +2,8 @@
import Link from "next/link";
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 { NavCounter } from "@/components/suggestions/nav-counter";
import type { AuthUser } from "@/lib/auth/auth-context";
@@ -48,16 +49,7 @@ export function Sidebar({ user }: { user: AuthUser }) {
href="/dashboard"
className="mb-5 flex items-center gap-2.5 px-2 font-heading text-lg font-semibold"
>
<span
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
<BrandMark size={32} withWordmark className="gap-2.5" />
</Link>
<nav className="flex flex-1 flex-col gap-5">
{groups.map((group, index) => (
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { BarChart3, Info } from "lucide-react";
import { Info } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchColonies } from "@/lib/colonies/api";
@@ -369,10 +369,9 @@ export default function StatisticsPage() {
return (
<main className="flex flex-col gap-6 p-4 sm:p-6">
<PageHeader
icon={BarChart3}
area="statistics"
title="Statistik"
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)}>
@@ -1,7 +1,7 @@
"use client";
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 { useAuth } from "@/lib/auth/auth-context";
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">
<section className="flex flex-col gap-4">
<PageHeader
icon={Warehouse}
area="storage"
title="Lager"
description="Hier verwaltest du Lagerbehälter und den Bestand deiner Lagerartikel."
gradient="var(--gradient-sky)"
actions={<CreateStorageContainerDialog />}
/>
{hasPermission("veterinary-medicines:read") ? <MedicinesCard /> : null}
@@ -83,7 +82,7 @@ export default function StoragePage() {
what="Lagerartikel"
empty={
<EmptyState
icon={Package}
icon={Warehouse}
title="Noch keine Lagerartikel"
description="Lege deinen ersten Artikel an, um den Bestand zu verfolgen."
action={<CreateStockItemDialog />}
@@ -102,8 +101,7 @@ function MedicinesCard() {
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
<EntityCard
href="/medicines"
icon={Pill}
gradient="var(--gradient-sky)"
area="medicines"
title="Tierarzneimittel"
subtitle="Katalog, Packungen und Bewegungsbuch"
/>
@@ -137,8 +135,7 @@ function ContainerGrid({ containers }: { containers: StorageContainer[] }) {
{containers.map((container) => (
<EntityCard
key={container.id}
icon={Warehouse}
gradient="var(--gradient-sky)"
area="storage"
title={<span className="font-mono">{container.code}</span>}
subtitle={container.containerType ?? "—"}
badge={
@@ -169,8 +166,7 @@ function StockItemGrid({ stockItems }: { stockItems: StockItem[] }) {
{stockItems.map((item) => (
<EntityCard
key={item.id}
icon={Package}
gradient="var(--gradient-sky)"
area="storage"
title={item.name}
subtitle={STOCK_CATEGORY_LABELS[item.category]}
badge={
@@ -271,10 +271,9 @@ export default function SuggestionsPage() {
return (
<main className="flex flex-col gap-5 p-4 sm:p-6">
<PageHeader
icon={Lightbulb}
area="suggestions"
title="Vorschläge"
description={EXPLANATION}
gradient="var(--gradient-sky)"
/>
<p
@@ -4,7 +4,6 @@ import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Sprout } from "lucide-react";
import { createSwarmControlCheck } from "@/lib/swarm-control/api";
import {
SWARM_ACTION_LABELS,
@@ -127,10 +126,9 @@ export default function NewSwarmControlCheckPage() {
draft={draft}
/>
<PageHeader
icon={Sprout}
area="swarm-control"
title="Neue Schwarmkontrolle"
description="Nur Volk und Datum sind Pflicht – dazu, was du an Weiselzellen gefunden hast."
gradient="var(--gradient-sky)"
/>
{draft.pending ? (
<DraftBanner
@@ -4,7 +4,7 @@ import { FormEvent, useState } from "react";
import { useRouter } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { ListTodo } from "lucide-react";
import { createTask } from "@/lib/tasks/api";
import { TASK_PRIORITIES, TASK_PRIORITY_LABELS } from "@/lib/tasks/labels";
import { fetchUsers } from "@/lib/users/api";
@@ -116,10 +116,9 @@ export default function NewTaskPage() {
draft={draft}
/>
<PageHeader
icon={ListTodo}
area="tasks"
title="Neue Aufgabe"
description="Nur der Titel ist Pflicht."
gradient="var(--gradient-sky)"
/>
{draft.pending ? (
<DraftBanner
@@ -52,10 +52,9 @@ export default function TasksPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={ListTodo}
area="tasks"
title="Aufgaben"
description="Was als Nächstes ansteht"
gradient="var(--gradient-sky)"
actions={
<Button nativeButton={false} render={<Link href="/tasks/new" />}>
+ Neue Aufgabe
@@ -117,8 +116,7 @@ export default function TasksPage() {
{visibleTasks.map((task) => (
<EntityCard
key={task.id}
icon={ListTodo}
gradient="var(--gradient-sky)"
area="tasks"
title={task.title}
badge={
<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 { useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Syringe } from "lucide-react";
import { saveErrorText } from "@/lib/honey-batches/errors";
import { useAuth } from "@/lib/auth/auth-context";
import { fetchColonies } from "@/lib/colonies/api";
@@ -265,10 +265,9 @@ export default function NewTreatmentPage() {
draft={draft}
/>
<PageHeader
icon={Syringe}
area="treatments"
title="Neue Behandlung"
description="Nur Volk, Datum und Mittel sind Pflicht."
gradient="var(--gradient-sky)"
/>
{draft.pending ? (
<DraftBanner
@@ -4,7 +4,6 @@ import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Bug } from "lucide-react";
import { createVarroaMeasurement } from "@/lib/varroa/api";
import { createVarroaBulk } from "@/lib/bulk/api";
import {
@@ -235,10 +234,9 @@ export default function NewVarroaMeasurementPage() {
draft={draft}
/>
<PageHeader
icon={Bug}
area="varroa"
title="Neue Varroamessung"
description="Nur Volk und Datum sind Pflicht – dazu, wie und was du gezählt hast."
gradient="var(--gradient-sky)"
/>
{draft.pending ? (
<DraftBanner
@@ -4,7 +4,7 @@ import { useState } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { ArrowRight, Mic } from "lucide-react";
import { ArrowRight } from "lucide-react";
import { fetchColonies } from "@/lib/colonies/api";
import { useAuth, useCurrentUserId } from "@/lib/auth/auth-context";
import { OFFLINE_PACKS_QUERY_KEY, listPacks, livingColoniesFromPacks } from "@/lib/apiaries/offline-pack";
@@ -140,7 +140,7 @@ function VoiceCaptureForm({ initialColonyId, initialDate, roundApiaryId }: Captu
return (
<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" />
<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">
<div className="flex items-center justify-between gap-3">
<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 }) {
return (
<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">
{children}
</p>
@@ -55,7 +55,7 @@ export default function VoiceListPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Mic}
area="voice"
title="Sprachnotizen"
description="Aufnahmen, die noch warten, und fertige Vorschläge zum Prüfen."
actions={newAction}
@@ -249,7 +249,7 @@ function WinterContent() {
return (
<div className="flex flex-col gap-6">
<PageHeader
icon={Snowflake}
area="winter"
title="Überwinterung"
description="Ein- und Auswinterung je Volk, Verluste mit Ursache."
actions={
@@ -3,7 +3,7 @@
import { isNetworkError } from "@/lib/http/fetch-with-timeout";
import { FormEvent, Suspense, useEffect, useState } from "react";
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 { ssoLoginUrl, ssoStatusRequest } from "@/lib/auth/api";
import { useConnectivity } from "@/lib/offline/connectivity";
@@ -159,12 +159,7 @@ function LoginPageContent() {
<Card className="relative w-full max-w-sm shadow-[var(--shadow-elevated)]">
<CardHeader>
<div className="mb-1 flex items-center gap-2.5">
<span
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>
<BrandMark size={32} />
<CardTitle className="text-lg">ApiCore</CardTitle>
</div>
<CardDescription>
+1 -1
View File
@@ -598,7 +598,7 @@
position: absolute;
inset: 0.375rem;
border-radius: 0.6rem;
background: var(--gradient-honey);
background: var(--primary);
clip-path: circle(0% at 50% 60%);
transition: clip-path 420ms var(--ease-out-expo);
}
@@ -3,7 +3,6 @@
import { useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import {
Hexagon,
Droplet,
MapPin,
CalendarDays,
@@ -17,6 +16,7 @@ import {
} from "lucide-react";
import { fetchPublicBatch } from "@/lib/public-batch/api";
import { originText } from "@/lib/public-batch/origin-text";
import { BrandMark } from "@/components/brand-mark";
import { HoneycombPattern } from "@/components/honeycomb-pattern";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
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)]">
<CardHeader>
<div className="mb-1 flex items-center gap-2.5">
<span
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>
<BrandMark size={32} />
<div>
<CardTitle className="text-lg">Herkunftsnachweis</CardTitle>
<p className="text-xs text-muted-foreground">Wabenhain · ApiCore</p>