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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user