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