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
+732
-395
No files matched your search
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { FileText, MapPin, Plus } from "lucide-react";
|
||||
import { FileText, Plus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { fetchApiary } from "@/lib/apiaries/api";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
@@ -50,7 +50,7 @@ export default function ApiaryDetailPage() {
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<DetailHeader
|
||||
icon={MapPin}
|
||||
area="apiaries"
|
||||
title={apiary.name}
|
||||
subtitle={apiary.location ?? undefined}
|
||||
status={
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function ApiaryMapPage() {
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<PageHeader
|
||||
icon={MapPin}
|
||||
area="apiaries"
|
||||
title="Kartenansicht"
|
||||
description="Hier siehst du alle Bienenstände, bei denen Koordinaten hinterlegt sind."
|
||||
actions={
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { LocateFixed, MapPin } from "lucide-react";
|
||||
import { LocateFixed } from "lucide-react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { createApiary } from "@/lib/apiaries/api";
|
||||
@@ -129,7 +129,7 @@ export default function NewApiaryPage() {
|
||||
draft={draft}
|
||||
/>
|
||||
<PageHeader
|
||||
icon={MapPin}
|
||||
area="apiaries"
|
||||
title="Neuer Bienenstand"
|
||||
description="Nur der Name ist Pflicht – den genauen Ort kannst du per Knopfdruck übernehmen."
|
||||
/>
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function ApiariesPage() {
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<PageHeader
|
||||
icon={MapPin}
|
||||
area="apiaries"
|
||||
title="Bienenstände"
|
||||
description="Hier verwaltest du die Standorte deiner Völker."
|
||||
actions={
|
||||
@@ -149,7 +149,7 @@ function ApiaryGrid({ apiaries }: { apiaries: Apiary[] }) {
|
||||
<EntityCard
|
||||
key={apiary.id}
|
||||
href={`/apiaries/${apiary.id}`}
|
||||
icon={MapPin}
|
||||
area="apiaries"
|
||||
title={apiary.name}
|
||||
subtitle={apiary.location ?? "—"}
|
||||
badge={
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Package } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchBottlingBatch } from "@/lib/bottling/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -32,7 +31,7 @@ export default function BottlingBatchDetailPage() {
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<DetailHeader
|
||||
icon={Package}
|
||||
area="bottling"
|
||||
title={`Abfüllung ${bottlingBatch.lotNumber}`}
|
||||
subtitle={`${bottlingBatch.jarCount} Gläser à ${bottlingBatch.jarSize} g`}
|
||||
{...BACK}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { FormEvent, useId, useMemo, useRef, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { AlertTriangle, Package } from "lucide-react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { fetchHoneyBatches } from "@/lib/honey-batches/api";
|
||||
import { fetchProducts } from "@/lib/products/api";
|
||||
import { createBottlingBatch, fetchBottlingPrefill } from "@/lib/bottling/api";
|
||||
@@ -395,7 +395,7 @@ export default function NewBottlingBatchPage() {
|
||||
draft={draft}
|
||||
/>
|
||||
<PageHeader
|
||||
icon={Package}
|
||||
area="bottling"
|
||||
title="Neue Abfüllung"
|
||||
description="Fülle eine Honigcharge in Gläser ab."
|
||||
/>
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function BottlingBatchesPage() {
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<PageHeader
|
||||
icon={Package}
|
||||
area="bottling"
|
||||
title="Abfüllungen"
|
||||
description="Hier findest du alle abgefüllten Chargen und erfasst neue Abfüllungen."
|
||||
actions={
|
||||
@@ -55,7 +55,7 @@ export default function BottlingBatchesPage() {
|
||||
<EntityCard
|
||||
key={bottlingBatch.id}
|
||||
href={`/bottling/${bottlingBatch.id}`}
|
||||
icon={Package}
|
||||
area="bottling"
|
||||
title={<span className="font-mono">{bottlingBatch.lotNumber}</span>}
|
||||
subtitle={bottlingBatch.product?.name ?? bottlingBatch.honeyBatch.batchNumber}
|
||||
fields={[
|
||||
|
||||
@@ -259,10 +259,9 @@ export default function CalendarPage() {
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<PageHeader
|
||||
icon={CalendarDays}
|
||||
area="calendar"
|
||||
title="Kalender"
|
||||
description="Was wann ansteht und was erledigt wurde – Monat für Monat."
|
||||
gradient="var(--gradient-sky)"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import Link from "next/link";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { ClipboardCheck, FileText, Layers } from "lucide-react";
|
||||
import { ClipboardCheck, FileText } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { fetchColony } from "@/lib/colonies/api";
|
||||
import { COLONY_STATUS_LABELS, COLONY_TYPE_LABELS, colonyStrengthText } from "@/lib/colonies/labels";
|
||||
@@ -56,7 +56,7 @@ export default function ColonyDetailPage() {
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<DetailHeader
|
||||
icon={Layers}
|
||||
area="colonies"
|
||||
title={colony.code}
|
||||
subtitle={COLONY_TYPE_LABELS[colony.colonyType]}
|
||||
status={<DetailStatus status={colony.status} label={COLONY_STATUS_LABELS[colony.status]} />}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { FormEvent, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Layers } from "lucide-react";
|
||||
|
||||
import { createColony } from "@/lib/colonies/api";
|
||||
import { COLONY_TYPES, COLONY_TYPE_LABELS, MAX_OCCUPIED_FRAMES } from "@/lib/colonies/labels";
|
||||
import { fetchApiaries } from "@/lib/apiaries/api";
|
||||
@@ -110,7 +110,7 @@ export default function NewColonyPage() {
|
||||
draft={draft}
|
||||
/>
|
||||
<PageHeader
|
||||
icon={Layers}
|
||||
area="colonies"
|
||||
title="Neues Volk"
|
||||
description="Nur Code und Bienenstand sind Pflicht – der Rest kann später folgen."
|
||||
/>
|
||||
|
||||
@@ -55,7 +55,7 @@ export default function ColoniesPage() {
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<PageHeader
|
||||
icon={Layers}
|
||||
area="colonies"
|
||||
title="Völker"
|
||||
description="Hier siehst du alle Bienenvölker und legst neue an."
|
||||
actions={
|
||||
@@ -173,7 +173,7 @@ function ColonyGrid({ colonies }: { colonies: Colony[] }) {
|
||||
<EntityCard
|
||||
key={colony.id}
|
||||
href={`/colonies/${colony.id}`}
|
||||
icon={Layers}
|
||||
area="colonies"
|
||||
title={<span className="font-mono">{colony.code}</span>}
|
||||
subtitle={COLONY_TYPE_LABELS[colony.colonyType]}
|
||||
badge={
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { BookOpen, FileText, RotateCcw } from "lucide-react";
|
||||
import { FileText, RotateCcw } from "lucide-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { NoComplianceAccess } from "@/components/compliance/no-compliance-access";
|
||||
import { NoticeStatus } from "@/components/compliance/notice-status";
|
||||
@@ -138,7 +138,7 @@ export default function NoticeDetailPage() {
|
||||
return (
|
||||
<main className="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4 sm:p-6">
|
||||
<DetailHeader
|
||||
icon={BookOpen}
|
||||
area="compliance"
|
||||
title={noticeTitle(notice)}
|
||||
subtitle={`Stichtag ${formatDay(noticeDay(notice.referenceDate))}`}
|
||||
status={<NoticeStatus status={notice.status} />}
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function NoticesPage() {
|
||||
return (
|
||||
<main className="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4 sm:p-6">
|
||||
<DetailHeader
|
||||
icon={BookOpen}
|
||||
area="compliance"
|
||||
title="Anzeigen ans Veterinäramt"
|
||||
subtitle="Erstanzeige, Änderungen im Bestand und die TSK-Zählung."
|
||||
backHref="/compliance"
|
||||
|
||||
@@ -3,16 +3,7 @@
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
BookOpen,
|
||||
CheckCircle2,
|
||||
FileText,
|
||||
Printer,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
TriangleAlert,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { BookOpen, CheckCircle2, FileText, Printer, Settings, TriangleAlert, XCircle } from "lucide-react";
|
||||
import { DetailHeader, type MoreAction } from "@/components/detail-header";
|
||||
import { OnlineOnlyHint, StandNote, useOnlineOnly } from "@/components/offline/stand-note";
|
||||
import { ErrorState, LoadingState } from "@/components/query-state";
|
||||
@@ -112,7 +103,7 @@ export default function CompliancePage() {
|
||||
|
||||
const header = (
|
||||
<DetailHeader
|
||||
icon={ShieldCheck}
|
||||
area="compliance"
|
||||
title="Prüfbereitschaft"
|
||||
backHref="/dashboard"
|
||||
backLabel="Start"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { OctagonAlert } from "lucide-react";
|
||||
|
||||
import { NoComplianceAccess } from "@/components/compliance/no-compliance-access";
|
||||
import { DetailHeader } from "@/components/detail-header";
|
||||
import { StandNote } from "@/components/offline/stand-note";
|
||||
@@ -109,7 +109,7 @@ export default function RestrictionsPage() {
|
||||
return (
|
||||
<main className="mx-auto flex w-full max-w-3xl flex-col gap-6 p-4 sm:p-6">
|
||||
<DetailHeader
|
||||
icon={OctagonAlert}
|
||||
area="compliance"
|
||||
title="Sperrbezirke"
|
||||
subtitle="Stände, die das Veterinäramt zum Sperrbezirk erklärt hat."
|
||||
backHref="/compliance"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { Settings } from "lucide-react";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { NoComplianceAccess } from "@/components/compliance/no-compliance-access";
|
||||
import { DetailHeader, DetailQueryFallback } from "@/components/detail-header";
|
||||
@@ -228,7 +228,7 @@ function SettingsForm({ settings, canManage }: { settings: ComplianceSettings; c
|
||||
return (
|
||||
<main className="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4 sm:p-6">
|
||||
<DetailHeader
|
||||
icon={Settings}
|
||||
area="compliance"
|
||||
title="Einstellungen Behörden"
|
||||
subtitle={readOnly ? "Du kannst die Einstellungen nur ansehen." : "Hier legst du fest, wann und wohin du meldest."}
|
||||
backHref="/compliance"
|
||||
|
||||
@@ -255,4 +255,15 @@ describe("DashboardPage – Heute", () => {
|
||||
expect(await within(row).findByText("V-07")).toBeInTheDocument();
|
||||
expect(fetchColonies).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("the dashboard header has no honey banner and no white text", async () => {
|
||||
vi.mocked(fetchAutopilotToday).mockResolvedValue(todayFixture({}));
|
||||
const { container } = renderPage();
|
||||
|
||||
const heading = await screen.findByRole("heading", { level: 1 });
|
||||
const header = heading.closest("header") as HTMLElement;
|
||||
expect(header.className).not.toMatch(/text-white|bg-|gradient/);
|
||||
expect(header.getAttribute("style")).toBeNull();
|
||||
expect(container.innerHTML).not.toMatch(/text-white|--gradient/);
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,6 @@ import { DashboardAlert, DashboardAlertType } from "@/lib/dashboard/types";
|
||||
import { fetchTasks } from "@/lib/tasks/api";
|
||||
import { tasksForToday } from "@/lib/tasks/today";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HoneycombPattern } from "@/components/honeycomb-pattern";
|
||||
import { StandNote } from "@/components/offline/stand-note";
|
||||
import { ComplianceLine } from "@/components/today/compliance-line";
|
||||
import { TodayPanel } from "@/components/today/today-panel";
|
||||
@@ -157,21 +156,15 @@ export default function DashboardPage() {
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex w-full max-w-3xl flex-col gap-8 p-4 sm:p-6">
|
||||
<header
|
||||
className="relative overflow-hidden rounded-2xl px-5 py-5 text-white shadow-[var(--shadow-elevated)] sm:px-7 sm:py-6"
|
||||
style={{ background: "var(--gradient-honey)" }}
|
||||
>
|
||||
<HoneycombPattern className="pointer-events-none absolute inset-0 h-full w-full text-white/[0.08]" />
|
||||
<div className="relative flex flex-col gap-1">
|
||||
<h1 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||
{greeting(now)}
|
||||
{firstName ? `, ${firstName}` : ""}
|
||||
</h1>
|
||||
<p className="text-sm text-white/90 sm:text-base">
|
||||
{now.toLocaleDateString("de-DE", { weekday: "long", day: "numeric", month: "long" })}
|
||||
{todayCount !== null ? ` · ${todaySummary(todayCount)}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||
{greeting(now)}
|
||||
{firstName ? `, ${firstName}` : ""}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground sm:text-base">
|
||||
{now.toLocaleDateString("de-DE", { weekday: "long", day: "numeric", month: "long" })}
|
||||
{todayCount !== null ? ` · ${todaySummary(todayCount)}` : ""}
|
||||
</p>
|
||||
</header>
|
||||
<StandNote updatedAt={standAt} isStale={hasStaleData} className="-mt-5" />
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { Wrench } from "lucide-react";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { fetchEquipment, updateEquipment } from "@/lib/equipment/api";
|
||||
@@ -42,7 +42,7 @@ export default function EquipmentDetailPage() {
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<DetailHeader
|
||||
icon={Wrench}
|
||||
area="equipment"
|
||||
title={equipment.name}
|
||||
status={
|
||||
<DetailStatus
|
||||
|
||||
@@ -4,7 +4,7 @@ import { FormEvent, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Wrench } from "lucide-react";
|
||||
|
||||
import { createEquipment } from "@/lib/equipment/api";
|
||||
import { useFormDraft } from "@/lib/forms/use-form-draft";
|
||||
import { errorMessage } from "@/lib/forms/error-message";
|
||||
@@ -92,10 +92,9 @@ export default function NewEquipmentPage() {
|
||||
draft={draft}
|
||||
/>
|
||||
<PageHeader
|
||||
icon={Wrench}
|
||||
area="equipment"
|
||||
title="Neuer Inventargegenstand"
|
||||
description="Nur Bezeichnung und Inventarnummer sind Pflicht."
|
||||
gradient="var(--gradient-sky)"
|
||||
/>
|
||||
{draft.pending ? (
|
||||
<DraftBanner
|
||||
|
||||
@@ -22,10 +22,9 @@ export default function EquipmentPage() {
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<PageHeader
|
||||
icon={Wrench}
|
||||
area="equipment"
|
||||
title="Inventar"
|
||||
description="Hier erfasst du deine Ausrüstung und ihren Zustand."
|
||||
gradient="var(--gradient-sage)"
|
||||
actions={
|
||||
<Button size="touch" nativeButton={false} render={<Link href="/equipment/new" />}>
|
||||
+ Neuer Gegenstand
|
||||
@@ -55,8 +54,7 @@ export default function EquipmentPage() {
|
||||
<EntityCard
|
||||
key={item.id}
|
||||
href={`/equipment/${item.id}`}
|
||||
icon={Wrench}
|
||||
gradient="var(--gradient-sage)"
|
||||
area="equipment"
|
||||
title={item.name}
|
||||
subtitle={item.inventoryNumber}
|
||||
badge={
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { FlaskConical } from "lucide-react";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchExtraction } from "@/lib/extractions/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -29,7 +29,7 @@ export default function ExtractionDetailPage() {
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<DetailHeader
|
||||
icon={FlaskConical}
|
||||
area="extractions"
|
||||
title={`Schleuderung vom ${new Date(extraction.date).toLocaleDateString("de-DE")}`}
|
||||
primaryAction={
|
||||
<Button size="touch" nativeButton={false} render={<Link href="/honey-batches" />}>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { FormEvent, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Droplet } from "lucide-react";
|
||||
|
||||
import { fetchHarvests } from "@/lib/harvests/api";
|
||||
import {
|
||||
createExtraction,
|
||||
@@ -150,7 +150,7 @@ export default function NewExtractionPage() {
|
||||
draft={draft}
|
||||
/>
|
||||
<PageHeader
|
||||
icon={Droplet}
|
||||
area="extractions"
|
||||
title="Neue Schleuderung"
|
||||
description="Pflicht sind Datum und mindestens eine Ernte."
|
||||
/>
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function ExtractionsPage() {
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<PageHeader
|
||||
icon={FlaskConical}
|
||||
area="extractions"
|
||||
title="Schleuderungen"
|
||||
description="Hier erfasst du Schleuderungen aus einer oder mehreren Ernten."
|
||||
actions={
|
||||
@@ -50,7 +50,7 @@ export default function ExtractionsPage() {
|
||||
<EntityCard
|
||||
key={extraction.id}
|
||||
href={`/extractions/${extraction.id}`}
|
||||
icon={FlaskConical}
|
||||
area="extractions"
|
||||
title={new Date(extraction.date).toLocaleDateString("de-DE")}
|
||||
subtitle={extraction.room ?? "—"}
|
||||
fields={[
|
||||
|
||||
@@ -4,7 +4,7 @@ import { FormEvent, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Utensils } from "lucide-react";
|
||||
|
||||
import { createFeeding } from "@/lib/feedings/api";
|
||||
import { createFeedingsBulk } from "@/lib/bulk/api";
|
||||
import {
|
||||
@@ -193,7 +193,7 @@ export default function NewFeedingPage() {
|
||||
draft={draft}
|
||||
/>
|
||||
<PageHeader
|
||||
icon={Utensils}
|
||||
area="feedings"
|
||||
title="Neue Fütterung"
|
||||
description="Nur Volk, Datum, Futterart und Menge sind Pflicht."
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Boxes } from "lucide-react";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchHarvest } from "@/lib/harvests/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -27,7 +27,7 @@ export default function HarvestDetailPage() {
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<DetailHeader
|
||||
icon={Boxes}
|
||||
area="harvests"
|
||||
title={`Ernte vom ${new Date(harvest.date).toLocaleDateString("de-DE")}`}
|
||||
subtitle={harvest.honeyType ?? undefined}
|
||||
primaryAction={
|
||||
|
||||
@@ -4,7 +4,7 @@ import { FormEvent, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Scissors, AlertTriangle } from "lucide-react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { fetchApiaries } from "@/lib/apiaries/api";
|
||||
import { fetchColonies } from "@/lib/colonies/api";
|
||||
import { colonyOptionLabel } from "@/lib/colonies/labels";
|
||||
@@ -172,7 +172,7 @@ export default function NewHarvestPage() {
|
||||
draft={draft}
|
||||
/>
|
||||
<PageHeader
|
||||
icon={Scissors}
|
||||
area="harvests"
|
||||
title="Neue Ernte"
|
||||
description="Pflicht sind Bienenstand, Datum und mindestens ein Volk."
|
||||
/>
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function HarvestsPage() {
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<PageHeader
|
||||
icon={Scissors}
|
||||
area="harvests"
|
||||
title="Ernten"
|
||||
description="Hier erfasst du deine Ernten je Bienenstand."
|
||||
actions={
|
||||
@@ -55,7 +55,7 @@ export default function HarvestsPage() {
|
||||
<EntityCard
|
||||
key={harvest.id}
|
||||
href={`/harvests/${harvest.id}`}
|
||||
icon={Scissors}
|
||||
area="harvests"
|
||||
title={new Date(harvest.date).toLocaleDateString("de-DE")}
|
||||
subtitle={apiaryNameById.get(harvest.apiaryId) ?? "—"}
|
||||
fields={[
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Droplet, FileText, FlaskConical } from "lucide-react";
|
||||
import { FileText, FlaskConical } from "lucide-react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { fetchHoneyBatch, fetchHoneyBatchTraceability } from "@/lib/honey-batches/api";
|
||||
@@ -55,7 +55,7 @@ export default function HoneyBatchDetailPage() {
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<DetailHeader
|
||||
icon={Droplet}
|
||||
area="honey-batches"
|
||||
title={honeyBatch.batchNumber}
|
||||
subtitle={honeyBatch.honeyType ?? undefined}
|
||||
status={
|
||||
|
||||
@@ -47,7 +47,7 @@ export default function HoneyBatchesPage() {
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<PageHeader
|
||||
icon={Droplet}
|
||||
area="honey-batches"
|
||||
title="Honigchargen"
|
||||
description="Hier verfolgst du jede Charge vom Schleudern bis zur Abfüllung."
|
||||
actions={
|
||||
@@ -112,7 +112,7 @@ function HoneyBatchGrid({ honeyBatches }: { honeyBatches: HoneyBatch[] }) {
|
||||
<EntityCard
|
||||
key={honeyBatch.id}
|
||||
href={`/honey-batches/${honeyBatch.id}`}
|
||||
icon={Droplet}
|
||||
area="honey-batches"
|
||||
title={<span className="font-mono">{honeyBatch.batchNumber}</span>}
|
||||
subtitle={honeyBatch.honeyType ?? "—"}
|
||||
badge={
|
||||
|
||||
@@ -4,7 +4,7 @@ import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { AlertTriangle, ClipboardCheck } from "lucide-react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { createInspection } from "@/lib/inspections/api";
|
||||
import { fetchColonies } from "@/lib/colonies/api";
|
||||
import { fetchApiaryWeather } from "@/lib/weather/api";
|
||||
@@ -668,10 +668,9 @@ function InspectionCaptureForm({
|
||||
</div>
|
||||
) : null}
|
||||
<PageHeader
|
||||
icon={ClipboardCheck}
|
||||
area="inspections"
|
||||
title="Neue Durchsicht"
|
||||
description="Nur Volk und Datum sind Pflicht – dazu, was du gesehen hast."
|
||||
gradient="var(--gradient-sage)"
|
||||
/>
|
||||
{draft.pending ? (
|
||||
<DraftBanner
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user