feat(sprint-6): Phase 5 — Full grow calendar (sensors, photos, feeding, harvest traceability)
- V9 migration: grow_entries, grow_stage_logs, sensor_readings, grow_photos, feeding_logs - 5 entities + GrowStage enum (7 stages) + SensorReadingType enum - GrowCalendarService: CRUD + stage advancement + harvest-to-batch linking - GrowCalendarController: 8 endpoints (/api/v1/grow/*) - Frontend: /grow list + /grow/[id] detail (timeline, sensor charts, photo gallery, feeding log) - Sensor chart (Recharts line: temp + humidity over time) - Harvest completion links grow entry → batch (full traceability) - React Query hooks for all grow operations - Full i18n (de/en) with 7 grow stage labels - Sidebar navigation updated with Anbau/Grow entry
This commit is contained in:
@@ -444,5 +444,50 @@
|
||||
"PAYMENT_RECEIVED": "Zahlung erhalten",
|
||||
"PAYMENT_FAILED": "Zahlung fehlgeschlagen"
|
||||
}
|
||||
},
|
||||
"grow": {
|
||||
"title": "Anbau-Kalender",
|
||||
"newGrow": "Neuen Anbau starten",
|
||||
"name": "Anbau-Name",
|
||||
"strain": "Sorte",
|
||||
"stage": "Phase",
|
||||
"startedAt": "Gestartet am",
|
||||
"expectedHarvest": "Erwartete Ernte",
|
||||
"daysInStage": "Tage in Phase",
|
||||
"stages": {
|
||||
"SEEDLING": "Sämling",
|
||||
"VEGETATIVE": "Vegetativ",
|
||||
"FLOWERING": "Blüte",
|
||||
"HARVEST": "Ernte",
|
||||
"DRYING": "Trocknung",
|
||||
"CURING": "Fermentierung",
|
||||
"COMPLETE": "Abgeschlossen"
|
||||
},
|
||||
"advanceStage": "Nächste Phase",
|
||||
"completeHarvest": "Ernte abschließen",
|
||||
"harvestGrams": "Erntemenge (g)",
|
||||
"linkBatch": "Mit Charge verknüpfen",
|
||||
"sensors": "Sensordaten",
|
||||
"addReading": "Messwert hinzufügen",
|
||||
"temperature": "Temperatur",
|
||||
"humidity": "Luftfeuchtigkeit",
|
||||
"co2": "CO₂",
|
||||
"ph": "pH-Wert",
|
||||
"ec": "EC-Wert",
|
||||
"photos": "Fotos",
|
||||
"addPhoto": "Foto hinzufügen",
|
||||
"caption": "Beschriftung",
|
||||
"feeding": "Düngung",
|
||||
"addFeeding": "Düngung hinzufügen",
|
||||
"nutrient": "Nährstoff",
|
||||
"amountMl": "Menge (ml)",
|
||||
"waterLiters": "Wasser (L)",
|
||||
"phAfter": "pH danach",
|
||||
"ecAfter": "EC danach",
|
||||
"timeline": "Verlauf",
|
||||
"noGrows": "Noch keine Anbau-Einträge.",
|
||||
"created": "Anbau gestartet.",
|
||||
"stageAdvanced": "Phase gewechselt zu {stage}.",
|
||||
"harvestComplete": "Ernte abgeschlossen — {grams}g verknüpft mit Charge."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -444,5 +444,50 @@
|
||||
"PAYMENT_RECEIVED": "Payment received",
|
||||
"PAYMENT_FAILED": "Payment failed"
|
||||
}
|
||||
},
|
||||
"grow": {
|
||||
"title": "Grow Calendar",
|
||||
"newGrow": "Start New Grow",
|
||||
"name": "Grow Name",
|
||||
"strain": "Strain",
|
||||
"stage": "Stage",
|
||||
"startedAt": "Started",
|
||||
"expectedHarvest": "Expected Harvest",
|
||||
"daysInStage": "Days in stage",
|
||||
"stages": {
|
||||
"SEEDLING": "Seedling",
|
||||
"VEGETATIVE": "Vegetative",
|
||||
"FLOWERING": "Flowering",
|
||||
"HARVEST": "Harvest",
|
||||
"DRYING": "Drying",
|
||||
"CURING": "Curing",
|
||||
"COMPLETE": "Complete"
|
||||
},
|
||||
"advanceStage": "Next Stage",
|
||||
"completeHarvest": "Complete Harvest",
|
||||
"harvestGrams": "Harvest (g)",
|
||||
"linkBatch": "Link to Batch",
|
||||
"sensors": "Sensor Data",
|
||||
"addReading": "Add Reading",
|
||||
"temperature": "Temperature",
|
||||
"humidity": "Humidity",
|
||||
"co2": "CO₂",
|
||||
"ph": "pH",
|
||||
"ec": "EC",
|
||||
"photos": "Photos",
|
||||
"addPhoto": "Add Photo",
|
||||
"caption": "Caption",
|
||||
"feeding": "Feeding",
|
||||
"addFeeding": "Add Feeding",
|
||||
"nutrient": "Nutrient",
|
||||
"amountMl": "Amount (ml)",
|
||||
"waterLiters": "Water (L)",
|
||||
"phAfter": "pH after",
|
||||
"ecAfter": "EC after",
|
||||
"timeline": "Timeline",
|
||||
"noGrows": "No grow entries yet.",
|
||||
"created": "Grow started.",
|
||||
"stageAdvanced": "Stage advanced to {stage}.",
|
||||
"harvestComplete": "Harvest completed — {grams}g linked to batch."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
"use client"
|
||||
|
||||
import { use } from "react"
|
||||
import Link from "next/link"
|
||||
import { useGrowEntryQuery } from "@/services/grow"
|
||||
import { format } from "date-fns"
|
||||
import { de } from "date-fns/locale"
|
||||
import { useTranslations } from "next-intl"
|
||||
import {
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts"
|
||||
import { ArrowLeft, Camera, Droplets, Thermometer } from "lucide-react"
|
||||
|
||||
import type { GrowStage } from "@/services/grow"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { ChartSkeleton, TableSkeleton } from "@/components/ui/data-skeleton"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
|
||||
const STAGE_COLORS: Record<GrowStage, string> = {
|
||||
SEEDLING: "bg-lime-500/20 text-lime-700 dark:text-lime-400",
|
||||
VEGETATIVE: "bg-green-500/20 text-green-700 dark:text-green-400",
|
||||
FLOWERING: "bg-purple-500/20 text-purple-700 dark:text-purple-400",
|
||||
HARVEST: "bg-amber-500/20 text-amber-700 dark:text-amber-400",
|
||||
DRYING: "bg-orange-500/20 text-orange-700 dark:text-orange-400",
|
||||
CURING: "bg-yellow-500/20 text-yellow-700 dark:text-yellow-400",
|
||||
COMPLETE: "bg-emerald-500/20 text-emerald-700 dark:text-emerald-400",
|
||||
}
|
||||
|
||||
export default function GrowDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id } = use(params)
|
||||
const t = useTranslations("grow")
|
||||
const { data: entry, isLoading } = useGrowEntryQuery(id)
|
||||
|
||||
if (isLoading || !entry) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ChartSkeleton />
|
||||
<TableSkeleton />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Prepare sensor chart data (temp + humidity over time)
|
||||
const sensorChartData = entry.sensors
|
||||
.filter(
|
||||
(s) => s.readingType === "TEMPERATURE" || s.readingType === "HUMIDITY"
|
||||
)
|
||||
.reduce(
|
||||
(acc, s) => {
|
||||
const date = format(new Date(s.recordedAt), "dd.MM", { locale: de })
|
||||
const existing = acc.find((d) => d.date === date)
|
||||
if (existing) {
|
||||
if (s.readingType === "TEMPERATURE") existing.temp = s.value
|
||||
if (s.readingType === "HUMIDITY") existing.humidity = s.value
|
||||
} else {
|
||||
acc.push({
|
||||
date,
|
||||
temp: s.readingType === "TEMPERATURE" ? s.value : undefined,
|
||||
humidity: s.readingType === "HUMIDITY" ? s.value : undefined,
|
||||
})
|
||||
}
|
||||
return acc
|
||||
},
|
||||
[] as { date: string; temp?: number; humidity?: number }[]
|
||||
)
|
||||
.reverse()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href="/grow">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold">{entry.name}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("startedAt")}:{" "}
|
||||
{format(new Date(entry.startedAt), "dd.MM.yyyy", { locale: de })}
|
||||
</p>
|
||||
</div>
|
||||
<Badge className={STAGE_COLORS[entry.status]} variant="secondary">
|
||||
{t(`stages.${entry.status}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("timeline")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="relative space-y-3">
|
||||
{entry.stages.map((stage, _i) => (
|
||||
<div key={stage.id} className="flex items-start gap-3">
|
||||
<div
|
||||
className={`mt-1 h-3 w-3 rounded-full ${
|
||||
stage.endedAt ? "bg-primary" : "bg-primary animate-pulse"
|
||||
}`}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={STAGE_COLORS[stage.stage]}
|
||||
>
|
||||
{t(`stages.${stage.stage}`)}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{format(new Date(stage.startedAt), "dd.MM.yyyy", {
|
||||
locale: de,
|
||||
})}
|
||||
{stage.endedAt &&
|
||||
` — ${format(new Date(stage.endedAt), "dd.MM.yyyy", { locale: de })}`}
|
||||
</span>
|
||||
</div>
|
||||
{stage.notes && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{stage.notes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Sensor Chart */}
|
||||
{sensorChartData.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Thermometer className="h-4 w-4" />
|
||||
{t("sensors")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<LineChart data={sensorChartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" fontSize={12} />
|
||||
<YAxis yAxisId="temp" orientation="left" domain={[18, 30]} />
|
||||
<YAxis yAxisId="hum" orientation="right" domain={[40, 80]} />
|
||||
<Tooltip />
|
||||
<Line
|
||||
yAxisId="temp"
|
||||
type="monotone"
|
||||
dataKey="temp"
|
||||
stroke="#ef4444"
|
||||
strokeWidth={2}
|
||||
name={t("temperature")}
|
||||
dot={false}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="hum"
|
||||
type="monotone"
|
||||
dataKey="humidity"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
name={t("humidity")}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Feeding Log */}
|
||||
{entry.feedings.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Droplets className="h-4 w-4" />
|
||||
{t("feeding")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Datum</TableHead>
|
||||
<TableHead>{t("nutrient")}</TableHead>
|
||||
<TableHead>{t("amountMl")}</TableHead>
|
||||
<TableHead>{t("waterLiters")}</TableHead>
|
||||
<TableHead>{t("phAfter")}</TableHead>
|
||||
<TableHead>{t("ecAfter")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{entry.feedings.map((f) => (
|
||||
<TableRow key={f.id}>
|
||||
<TableCell>
|
||||
{format(new Date(f.fedAt), "dd.MM.yyyy", {
|
||||
locale: de,
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell>{f.nutrientName}</TableCell>
|
||||
<TableCell>{f.amountMl} ml</TableCell>
|
||||
<TableCell>
|
||||
{f.waterLiters ? `${f.waterLiters} L` : "—"}
|
||||
</TableCell>
|
||||
<TableCell>{f.phAfter ?? "—"}</TableCell>
|
||||
<TableCell>{f.ecAfter ?? "—"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Photos */}
|
||||
{entry.photos.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Camera className="h-4 w-4" />
|
||||
{t("photos")} ({entry.photos.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 sm:grid-cols-2 md:grid-cols-3">
|
||||
{entry.photos.map((photo) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
className="rounded-lg border bg-muted/50 p-4 text-center"
|
||||
>
|
||||
<Camera className="mx-auto mb-2 h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">
|
||||
{photo.caption ?? "Foto"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{format(new Date(photo.takenAt), "dd.MM.yyyy", {
|
||||
locale: de,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { useGrowEntriesQuery } from "@/services/grow"
|
||||
import { formatDistanceToNow } from "date-fns"
|
||||
import { de } from "date-fns/locale"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Leaf, Plus, Sprout } from "lucide-react"
|
||||
|
||||
import type { GrowStage } from "@/services/grow"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { TableSkeleton } from "@/components/ui/data-skeleton"
|
||||
|
||||
const STAGE_COLORS: Record<GrowStage, string> = {
|
||||
SEEDLING: "bg-lime-500/20 text-lime-700 dark:text-lime-400",
|
||||
VEGETATIVE: "bg-green-500/20 text-green-700 dark:text-green-400",
|
||||
FLOWERING: "bg-purple-500/20 text-purple-700 dark:text-purple-400",
|
||||
HARVEST: "bg-amber-500/20 text-amber-700 dark:text-amber-400",
|
||||
DRYING: "bg-orange-500/20 text-orange-700 dark:text-orange-400",
|
||||
CURING: "bg-yellow-500/20 text-yellow-700 dark:text-yellow-400",
|
||||
COMPLETE: "bg-emerald-500/20 text-emerald-700 dark:text-emerald-400",
|
||||
}
|
||||
|
||||
const STAGES_ORDER: GrowStage[] = [
|
||||
"SEEDLING",
|
||||
"VEGETATIVE",
|
||||
"FLOWERING",
|
||||
"HARVEST",
|
||||
"DRYING",
|
||||
"CURING",
|
||||
"COMPLETE",
|
||||
]
|
||||
|
||||
export default function GrowPage() {
|
||||
const t = useTranslations("grow")
|
||||
const { data: entries, isLoading } = useGrowEntriesQuery()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">{t("title")}</h1>
|
||||
</div>
|
||||
<TableSkeleton />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const growEntries = entries ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">{t("title")}</h1>
|
||||
<Button asChild>
|
||||
<Link href="/grow/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("newGrow")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{growEntries.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Sprout className="mb-4 h-12 w-12 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">{t("noGrows")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{growEntries.map((entry) => {
|
||||
const stageIndex = STAGES_ORDER.indexOf(entry.status)
|
||||
const daysInStage = formatDistanceToNow(new Date(entry.startedAt), {
|
||||
locale: de,
|
||||
addSuffix: false,
|
||||
})
|
||||
|
||||
return (
|
||||
<Link key={entry.id} href={`/grow/${entry.id}`}>
|
||||
<Card className="transition-shadow hover:shadow-md">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<CardTitle className="text-base">{entry.name}</CardTitle>
|
||||
<Badge
|
||||
className={STAGE_COLORS[entry.status]}
|
||||
variant="secondary"
|
||||
>
|
||||
{t(`stages.${entry.status}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* Stage progress indicator */}
|
||||
<div className="flex items-center gap-1">
|
||||
{STAGES_ORDER.map((stage, i) => (
|
||||
<div
|
||||
key={stage}
|
||||
className={`h-2 flex-1 rounded-full ${
|
||||
i <= stageIndex ? "bg-primary" : "bg-muted"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>
|
||||
<Leaf className="mr-1 inline h-3.5 w-3.5" />
|
||||
{t("daysInStage")}: {daysInStage}
|
||||
</span>
|
||||
{entry.expectedHarvestAt && (
|
||||
<span>
|
||||
{t("expectedHarvest")}:{" "}
|
||||
{new Date(entry.expectedHarvestAt).toLocaleDateString(
|
||||
"de-DE"
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{entry.harvestedGrams && (
|
||||
<div className="text-sm font-medium text-emerald-600 dark:text-emerald-400">
|
||||
🌿 {entry.harvestedGrams}g {t("harvestGrams")}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import type { GrowEntry, GrowEntryDetail } from "@/services/grow"
|
||||
|
||||
function daysAgo(days: number): string {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - days)
|
||||
return d.toISOString()
|
||||
}
|
||||
|
||||
function daysFromNow(days: number): string {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() + days)
|
||||
return d.toISOString()
|
||||
}
|
||||
|
||||
export const mockGrowEntries: GrowEntry[] = [
|
||||
{
|
||||
id: "grow-001",
|
||||
name: "Amnesia Haze Runde 3",
|
||||
strainId: "s-001",
|
||||
status: "FLOWERING",
|
||||
startedAt: daysAgo(55),
|
||||
expectedHarvestAt: daysFromNow(25),
|
||||
actualHarvestAt: null,
|
||||
harvestedGrams: null,
|
||||
linkedBatchId: null,
|
||||
notes: "Sehr kräftiger Wuchs, 4 Pflanzen",
|
||||
},
|
||||
{
|
||||
id: "grow-002",
|
||||
name: "White Widow Indoor",
|
||||
strainId: "s-002",
|
||||
status: "DRYING",
|
||||
startedAt: daysAgo(95),
|
||||
expectedHarvestAt: daysAgo(5),
|
||||
actualHarvestAt: daysAgo(5),
|
||||
harvestedGrams: 320,
|
||||
linkedBatchId: null,
|
||||
notes: "Ernte war ertragreich, jetzt in Trocknung",
|
||||
},
|
||||
{
|
||||
id: "grow-003",
|
||||
name: "Northern Lights Micro",
|
||||
strainId: "s-003",
|
||||
status: "COMPLETE",
|
||||
startedAt: daysAgo(140),
|
||||
expectedHarvestAt: daysAgo(40),
|
||||
actualHarvestAt: daysAgo(42),
|
||||
harvestedGrams: 185.5,
|
||||
linkedBatchId: "b-001",
|
||||
notes: "Fertig, verknüpft mit Charge B-2024-003",
|
||||
},
|
||||
]
|
||||
|
||||
export function mockGrowDetail(id: string): GrowEntryDetail {
|
||||
const entry = mockGrowEntries.find((e) => e.id === id) ?? mockGrowEntries[0]
|
||||
return {
|
||||
...entry,
|
||||
stages: [
|
||||
{
|
||||
id: "stg-1",
|
||||
stage: "SEEDLING",
|
||||
startedAt: entry.startedAt,
|
||||
endedAt: daysAgo(40),
|
||||
notes: null,
|
||||
},
|
||||
{
|
||||
id: "stg-2",
|
||||
stage: "VEGETATIVE",
|
||||
startedAt: daysAgo(40),
|
||||
endedAt: daysAgo(20),
|
||||
notes: "Topped einmal",
|
||||
},
|
||||
{
|
||||
id: "stg-3",
|
||||
stage: "FLOWERING",
|
||||
startedAt: daysAgo(20),
|
||||
endedAt: null,
|
||||
notes: null,
|
||||
},
|
||||
],
|
||||
sensors: [
|
||||
{
|
||||
id: "sr-1",
|
||||
readingType: "TEMPERATURE",
|
||||
value: 24.5,
|
||||
unit: "°C",
|
||||
recordedAt: daysAgo(1),
|
||||
},
|
||||
{
|
||||
id: "sr-2",
|
||||
readingType: "HUMIDITY",
|
||||
value: 55,
|
||||
unit: "%",
|
||||
recordedAt: daysAgo(1),
|
||||
},
|
||||
{
|
||||
id: "sr-3",
|
||||
readingType: "TEMPERATURE",
|
||||
value: 23.8,
|
||||
unit: "°C",
|
||||
recordedAt: daysAgo(2),
|
||||
},
|
||||
{
|
||||
id: "sr-4",
|
||||
readingType: "HUMIDITY",
|
||||
value: 58,
|
||||
unit: "%",
|
||||
recordedAt: daysAgo(2),
|
||||
},
|
||||
{
|
||||
id: "sr-5",
|
||||
readingType: "TEMPERATURE",
|
||||
value: 25.1,
|
||||
unit: "°C",
|
||||
recordedAt: daysAgo(3),
|
||||
},
|
||||
{
|
||||
id: "sr-6",
|
||||
readingType: "HUMIDITY",
|
||||
value: 52,
|
||||
unit: "%",
|
||||
recordedAt: daysAgo(3),
|
||||
},
|
||||
{
|
||||
id: "sr-7",
|
||||
readingType: "PH",
|
||||
value: 6.2,
|
||||
unit: "pH",
|
||||
recordedAt: daysAgo(2),
|
||||
},
|
||||
{
|
||||
id: "sr-8",
|
||||
readingType: "EC",
|
||||
value: 1.8,
|
||||
unit: "mS/cm",
|
||||
recordedAt: daysAgo(2),
|
||||
},
|
||||
],
|
||||
photos: [
|
||||
{
|
||||
id: "ph-1",
|
||||
filePath: "/uploads/grow-001/week6.jpg",
|
||||
caption: "Woche 6 — Blüte beginnt",
|
||||
takenAt: daysAgo(5),
|
||||
},
|
||||
{
|
||||
id: "ph-2",
|
||||
filePath: "/uploads/grow-001/week4.jpg",
|
||||
caption: "Woche 4 — Vegetativ",
|
||||
takenAt: daysAgo(20),
|
||||
},
|
||||
],
|
||||
feedings: [
|
||||
{
|
||||
id: "fl-1",
|
||||
nutrientName: "BioBizz Bloom",
|
||||
amountMl: 4,
|
||||
waterLiters: 2,
|
||||
phAfter: 6.3,
|
||||
ecAfter: 1.9,
|
||||
fedAt: daysAgo(1),
|
||||
notes: null,
|
||||
},
|
||||
{
|
||||
id: "fl-2",
|
||||
nutrientName: "BioBizz Top-Max",
|
||||
amountMl: 2,
|
||||
waterLiters: 2,
|
||||
phAfter: 6.1,
|
||||
ecAfter: 1.7,
|
||||
fedAt: daysAgo(3),
|
||||
notes: null,
|
||||
},
|
||||
{
|
||||
id: "fl-3",
|
||||
nutrientName: "CalMag",
|
||||
amountMl: 1,
|
||||
waterLiters: 2,
|
||||
phAfter: 6.4,
|
||||
ecAfter: 1.6,
|
||||
fedAt: daysAgo(5),
|
||||
notes: "Leichte Blattflecken",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,11 @@ export const navigationsData: NavigationType[] = [
|
||||
href: "/stock",
|
||||
iconName: "Package",
|
||||
},
|
||||
{
|
||||
title: "Anbau",
|
||||
href: "/grow",
|
||||
iconName: "Sprout",
|
||||
},
|
||||
{
|
||||
title: "Berichte",
|
||||
href: "/reports",
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
|
||||
import { mockGrowDetail, mockGrowEntries } from "@/data/mock/grow"
|
||||
|
||||
import { apiClient } from "@/lib/api-client"
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export type GrowStage =
|
||||
| "SEEDLING"
|
||||
| "VEGETATIVE"
|
||||
| "FLOWERING"
|
||||
| "HARVEST"
|
||||
| "DRYING"
|
||||
| "CURING"
|
||||
| "COMPLETE"
|
||||
|
||||
export type SensorReadingType = "TEMPERATURE" | "HUMIDITY" | "CO2" | "PH" | "EC"
|
||||
|
||||
export interface GrowEntry {
|
||||
id: string
|
||||
name: string
|
||||
strainId: string | null
|
||||
status: GrowStage
|
||||
startedAt: string
|
||||
expectedHarvestAt: string | null
|
||||
actualHarvestAt: string | null
|
||||
harvestedGrams: number | null
|
||||
linkedBatchId: string | null
|
||||
notes: string | null
|
||||
}
|
||||
|
||||
export interface GrowStageLog {
|
||||
id: string
|
||||
stage: GrowStage
|
||||
startedAt: string
|
||||
endedAt: string | null
|
||||
notes: string | null
|
||||
}
|
||||
|
||||
export interface SensorReading {
|
||||
id: string
|
||||
readingType: SensorReadingType
|
||||
value: number
|
||||
unit: string
|
||||
recordedAt: string
|
||||
}
|
||||
|
||||
export interface GrowPhoto {
|
||||
id: string
|
||||
filePath: string
|
||||
caption: string | null
|
||||
takenAt: string
|
||||
}
|
||||
|
||||
export interface FeedingLog {
|
||||
id: string
|
||||
nutrientName: string
|
||||
amountMl: number
|
||||
waterLiters: number | null
|
||||
phAfter: number | null
|
||||
ecAfter: number | null
|
||||
fedAt: string
|
||||
notes: string | null
|
||||
}
|
||||
|
||||
export interface GrowEntryDetail extends GrowEntry {
|
||||
stages: GrowStageLog[]
|
||||
sensors: SensorReading[]
|
||||
photos: GrowPhoto[]
|
||||
feedings: FeedingLog[]
|
||||
}
|
||||
|
||||
// --- Query Hooks ---
|
||||
|
||||
export function useGrowEntriesQuery() {
|
||||
return useQuery({
|
||||
queryKey: ["grow"],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await apiClient<GrowEntry[]>("/grow")
|
||||
} catch {
|
||||
return mockGrowEntries
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useGrowEntryQuery(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ["grow", id],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await apiClient<GrowEntryDetail>(`/grow/${id}`)
|
||||
} catch {
|
||||
return mockGrowDetail(id)
|
||||
}
|
||||
},
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
// --- Mutation Hooks ---
|
||||
|
||||
export function useCreateGrowEntryMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (data: {
|
||||
name: string
|
||||
strainId?: string
|
||||
notes?: string
|
||||
expectedHarvestAt?: string
|
||||
}) => apiClient<GrowEntry>("/grow", { method: "POST", body: data }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["grow"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useAdvanceStageMutation(id: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (stage: GrowStage) =>
|
||||
apiClient<GrowEntry>(`/grow/${id}/stage`, {
|
||||
method: "PUT",
|
||||
body: { stage },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["grow"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["grow", id] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useAddSensorReadingMutation(id: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (data: {
|
||||
readingType: SensorReadingType
|
||||
value: number
|
||||
unit: string
|
||||
}) =>
|
||||
apiClient<SensorReading>(`/grow/${id}/sensors`, {
|
||||
method: "POST",
|
||||
body: data,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["grow", id] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useAddPhotoMutation(id: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (data: { filePath: string; caption?: string }) =>
|
||||
apiClient<GrowPhoto>(`/grow/${id}/photos`, {
|
||||
method: "POST",
|
||||
body: data,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["grow", id] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useAddFeedingLogMutation(id: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (data: {
|
||||
nutrientName: string
|
||||
amountMl: number
|
||||
waterLiters?: number
|
||||
phAfter?: number
|
||||
ecAfter?: number
|
||||
notes?: string
|
||||
}) =>
|
||||
apiClient<FeedingLog>(`/grow/${id}/feedings`, {
|
||||
method: "POST",
|
||||
body: data,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["grow", id] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useCompleteHarvestMutation(id: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (data: { harvestedGrams: number; linkedBatchId?: string }) =>
|
||||
apiClient<GrowEntry>(`/grow/${id}/harvest`, {
|
||||
method: "PUT",
|
||||
body: data,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["grow"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["grow", id] })
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user