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:
1 parent
cf53cf35ac
commit
258e90828f
97 files changed
+725
-388
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">
|
||||
<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-white/90 sm:text-base">
|
||||
<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>
|
||||
</div>
|
||||
</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
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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."
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Layers } from "lucide-react";
|
||||
import { DetailHeader, DetailStatus } from "./detail-header";
|
||||
|
||||
describe("DetailHeader", () => {
|
||||
it("shows the title as heading, a back link and the status as a word", () => {
|
||||
render(
|
||||
<DetailHeader
|
||||
icon={Layers}
|
||||
area="colonies"
|
||||
title="V-01"
|
||||
status={<DetailStatus status="ACTIVE" label="Aktiv" />}
|
||||
backHref="/colonies"
|
||||
@@ -29,7 +28,7 @@ describe("DetailHeader", () => {
|
||||
const onSelect = vi.fn();
|
||||
render(
|
||||
<DetailHeader
|
||||
icon={Layers}
|
||||
area="colonies"
|
||||
title="V-01"
|
||||
primaryAction={<button type="button">Durchsicht erfassen</button>}
|
||||
moreActions={[
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
XCircle,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { AreaIcon } from "@/components/area-icon";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ErrorState, LoadingState } from "@/components/query-state";
|
||||
import type { AreaKey } from "@/lib/areas";
|
||||
import { statusTone, type StatusTone } from "@/lib/status-tone";
|
||||
|
||||
export interface MoreAction {
|
||||
@@ -33,7 +35,7 @@ export interface MoreAction {
|
||||
}
|
||||
|
||||
interface DetailHeaderProps {
|
||||
icon: LucideIcon;
|
||||
area: AreaKey;
|
||||
title: string;
|
||||
subtitle?: ReactNode;
|
||||
/** 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.
|
||||
*/
|
||||
export function DetailHeader({
|
||||
icon: Icon,
|
||||
area,
|
||||
title,
|
||||
subtitle,
|
||||
status,
|
||||
@@ -67,12 +69,7 @@ export function DetailHeader({
|
||||
<BackLink href={backHref} label={backLabel} />
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span
|
||||
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>
|
||||
<AreaIcon area={area} />
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<h1 className="font-heading text-2xl font-semibold tracking-tight break-words">
|
||||
{title}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ReactNode } from "react";
|
||||
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 type { AreaKey } from "@/lib/areas";
|
||||
|
||||
interface EntityCardField {
|
||||
label: string;
|
||||
@@ -10,8 +11,7 @@ interface EntityCardField {
|
||||
|
||||
interface EntityCardProps {
|
||||
href?: string;
|
||||
icon: LucideIcon;
|
||||
gradient?: string;
|
||||
area: AreaKey;
|
||||
title: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
badge?: ReactNode;
|
||||
@@ -22,8 +22,7 @@ interface EntityCardProps {
|
||||
|
||||
export function EntityCard({
|
||||
href,
|
||||
icon: Icon,
|
||||
gradient = "var(--gradient-honey)",
|
||||
area,
|
||||
title,
|
||||
subtitle,
|
||||
badge,
|
||||
@@ -36,12 +35,7 @@ export function EntityCard({
|
||||
<CardContent className="flex h-full flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div
|
||||
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>
|
||||
<AreaIcon area={area} />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{title}</p>
|
||||
{subtitle ? (
|
||||
|
||||
@@ -52,7 +52,7 @@ export function RemoveZone({ zoneRef, active }: { zoneRef: RefObject<HTMLDivElem
|
||||
<div
|
||||
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 ${
|
||||
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" />
|
||||
|
||||
@@ -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 type { LucideIcon } from "lucide-react";
|
||||
import { AreaIcon } from "@/components/area-icon";
|
||||
import type { AreaKey } from "@/lib/areas";
|
||||
|
||||
interface PageHeaderProps {
|
||||
icon: LucideIcon;
|
||||
area: AreaKey;
|
||||
title: string;
|
||||
description?: string;
|
||||
gradient?: string;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
gradient = "var(--gradient-honey)",
|
||||
actions,
|
||||
}: PageHeaderProps) {
|
||||
export function PageHeader({ area, title, description, actions }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
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>
|
||||
<AreaIcon area={area} />
|
||||
<div>
|
||||
<h1 className="font-heading text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
{description ? (
|
||||
|
||||
@@ -9,7 +9,7 @@ const buttonVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
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:
|
||||
"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:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>,
|
||||
);
|
||||
Reference in new issue
Block a user