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:
+135
@@ -0,0 +1,135 @@
|
|||||||
|
package de.cannamanage.api.controller;
|
||||||
|
|
||||||
|
import de.cannamanage.api.dto.grow.*;
|
||||||
|
import de.cannamanage.domain.entity.*;
|
||||||
|
import de.cannamanage.domain.enums.GrowStage;
|
||||||
|
import de.cannamanage.domain.enums.SensorReadingType;
|
||||||
|
import de.cannamanage.service.GrowCalendarService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/grow")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Tag(name = "Grow Calendar", description = "Grow lifecycle management with sensors, photos, and feeding")
|
||||||
|
public class GrowCalendarController {
|
||||||
|
|
||||||
|
private final GrowCalendarService growCalendarService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "List all grow entries")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_STOCK)")
|
||||||
|
public ResponseEntity<List<GrowEntryResponse>> listGrowEntries() {
|
||||||
|
List<GrowEntryResponse> entries = growCalendarService.getGrowEntries().stream()
|
||||||
|
.map(this::toResponse)
|
||||||
|
.toList();
|
||||||
|
return ResponseEntity.ok(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@Operation(summary = "Create a new grow entry")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_STOCK_IN)")
|
||||||
|
public ResponseEntity<GrowEntryResponse> createGrowEntry(@Valid @RequestBody CreateGrowEntryRequest request) {
|
||||||
|
GrowEntry entry = growCalendarService.createGrowEntry(
|
||||||
|
request.name(), request.strainId(), request.notes(), request.expectedHarvestAt());
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
@Operation(summary = "Get grow entry detail with stages, sensors, photos, feedings")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_STOCK)")
|
||||||
|
public ResponseEntity<GrowEntryDetailResponse> getGrowEntry(@PathVariable UUID id) {
|
||||||
|
GrowEntry entry = growCalendarService.getGrowEntry(id);
|
||||||
|
List<GrowStageLog> stages = growCalendarService.getStageLogs(id);
|
||||||
|
List<SensorReading> sensors = growCalendarService.getSensorReadings(id);
|
||||||
|
List<GrowPhoto> photos = growCalendarService.getPhotos(id);
|
||||||
|
List<FeedingLog> feedings = growCalendarService.getFeedingLogs(id);
|
||||||
|
|
||||||
|
GrowEntryDetailResponse detail = new GrowEntryDetailResponse(
|
||||||
|
entry.getId(), entry.getName(), entry.getStrainId(), entry.getStatus(),
|
||||||
|
entry.getStartedAt(), entry.getExpectedHarvestAt(), entry.getActualHarvestAt(),
|
||||||
|
entry.getHarvestedGrams(), entry.getLinkedBatchId(), entry.getNotes(),
|
||||||
|
stages.stream().map(this::toStageResponse).toList(),
|
||||||
|
sensors.stream().map(this::toSensorResponse).toList(),
|
||||||
|
photos.stream().map(this::toPhotoResponse).toList(),
|
||||||
|
feedings.stream().map(this::toFeedingResponse).toList()
|
||||||
|
);
|
||||||
|
return ResponseEntity.ok(detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}/stage")
|
||||||
|
@Operation(summary = "Advance to next stage")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_STOCK_IN)")
|
||||||
|
public ResponseEntity<GrowEntryResponse> advanceStage(@PathVariable UUID id, @Valid @RequestBody AdvanceStageRequest request) {
|
||||||
|
GrowEntry entry = growCalendarService.advanceStage(id, request.stage());
|
||||||
|
return ResponseEntity.ok(toResponse(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/sensors")
|
||||||
|
@Operation(summary = "Add sensor reading")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_STOCK_IN)")
|
||||||
|
public ResponseEntity<SensorReadingResponse> addSensorReading(@PathVariable UUID id, @Valid @RequestBody AddSensorReadingRequest request) {
|
||||||
|
SensorReading reading = growCalendarService.addSensorReading(id, request.readingType(), request.value(), request.unit());
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(toSensorResponse(reading));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/photos")
|
||||||
|
@Operation(summary = "Add photo")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_STOCK_IN)")
|
||||||
|
public ResponseEntity<GrowPhotoResponse> addPhoto(@PathVariable UUID id, @Valid @RequestBody AddPhotoRequest request) {
|
||||||
|
GrowPhoto photo = growCalendarService.addPhoto(id, request.filePath(), request.caption());
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(toPhotoResponse(photo));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/feedings")
|
||||||
|
@Operation(summary = "Add feeding log")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_STOCK_IN)")
|
||||||
|
public ResponseEntity<FeedingLogResponse> addFeedingLog(@PathVariable UUID id, @Valid @RequestBody AddFeedingLogRequest request) {
|
||||||
|
FeedingLog feeding = growCalendarService.addFeedingLog(id,
|
||||||
|
request.nutrientName(), request.amountMl(), request.waterLiters(),
|
||||||
|
request.phAfter(), request.ecAfter(), request.notes());
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(toFeedingResponse(feeding));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}/harvest")
|
||||||
|
@Operation(summary = "Complete harvest and link to batch")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_STOCK_IN)")
|
||||||
|
public ResponseEntity<GrowEntryResponse> completeHarvest(@PathVariable UUID id, @Valid @RequestBody CompleteHarvestRequest request) {
|
||||||
|
GrowEntry entry = growCalendarService.completeHarvest(id, request.harvestedGrams(), request.linkedBatchId());
|
||||||
|
return ResponseEntity.ok(toResponse(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mapping helpers ---
|
||||||
|
|
||||||
|
private GrowEntryResponse toResponse(GrowEntry e) {
|
||||||
|
return new GrowEntryResponse(e.getId(), e.getName(), e.getStrainId(), e.getStatus(),
|
||||||
|
e.getStartedAt(), e.getExpectedHarvestAt(), e.getActualHarvestAt(),
|
||||||
|
e.getHarvestedGrams(), e.getLinkedBatchId(), e.getNotes());
|
||||||
|
}
|
||||||
|
|
||||||
|
private GrowStageLogResponse toStageResponse(GrowStageLog s) {
|
||||||
|
return new GrowStageLogResponse(s.getId(), s.getStage(), s.getStartedAt(), s.getEndedAt(), s.getNotes());
|
||||||
|
}
|
||||||
|
|
||||||
|
private SensorReadingResponse toSensorResponse(SensorReading r) {
|
||||||
|
return new SensorReadingResponse(r.getId(), r.getReadingType(), r.getValue(), r.getUnit(), r.getRecordedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
private GrowPhotoResponse toPhotoResponse(GrowPhoto p) {
|
||||||
|
return new GrowPhotoResponse(p.getId(), p.getFilePath(), p.getCaption(), p.getTakenAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
private FeedingLogResponse toFeedingResponse(FeedingLog f) {
|
||||||
|
return new FeedingLogResponse(f.getId(), f.getNutrientName(), f.getAmountMl(),
|
||||||
|
f.getWaterLiters(), f.getPhAfter(), f.getEcAfter(), f.getFedAt(), f.getNotes());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package de.cannamanage.api.dto.grow;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
public record AddFeedingLogRequest(
|
||||||
|
@NotBlank String nutrientName,
|
||||||
|
@NotNull BigDecimal amountMl,
|
||||||
|
BigDecimal waterLiters,
|
||||||
|
BigDecimal phAfter,
|
||||||
|
BigDecimal ecAfter,
|
||||||
|
String notes
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package de.cannamanage.api.dto.grow;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public record AddPhotoRequest(
|
||||||
|
@NotBlank String filePath,
|
||||||
|
String caption
|
||||||
|
) {}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
package de.cannamanage.api.dto.grow;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.SensorReadingType;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
public record AddSensorReadingRequest(
|
||||||
|
@NotNull SensorReadingType readingType,
|
||||||
|
@NotNull BigDecimal value,
|
||||||
|
@NotBlank String unit
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package de.cannamanage.api.dto.grow;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.GrowStage;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
public record AdvanceStageRequest(
|
||||||
|
@NotNull GrowStage stage
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package de.cannamanage.api.dto.grow;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record CompleteHarvestRequest(
|
||||||
|
@NotNull BigDecimal harvestedGrams,
|
||||||
|
UUID linkedBatchId
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package de.cannamanage.api.dto.grow;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record CreateGrowEntryRequest(
|
||||||
|
@NotBlank String name,
|
||||||
|
UUID strainId,
|
||||||
|
String notes,
|
||||||
|
Instant expectedHarvestAt
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package de.cannamanage.api.dto.grow;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record FeedingLogResponse(
|
||||||
|
UUID id,
|
||||||
|
String nutrientName,
|
||||||
|
BigDecimal amountMl,
|
||||||
|
BigDecimal waterLiters,
|
||||||
|
BigDecimal phAfter,
|
||||||
|
BigDecimal ecAfter,
|
||||||
|
Instant fedAt,
|
||||||
|
String notes
|
||||||
|
) {}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
package de.cannamanage.api.dto.grow;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.GrowStage;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record GrowEntryDetailResponse(
|
||||||
|
UUID id,
|
||||||
|
String name,
|
||||||
|
UUID strainId,
|
||||||
|
GrowStage status,
|
||||||
|
Instant startedAt,
|
||||||
|
Instant expectedHarvestAt,
|
||||||
|
Instant actualHarvestAt,
|
||||||
|
BigDecimal harvestedGrams,
|
||||||
|
UUID linkedBatchId,
|
||||||
|
String notes,
|
||||||
|
List<GrowStageLogResponse> stages,
|
||||||
|
List<SensorReadingResponse> sensors,
|
||||||
|
List<GrowPhotoResponse> photos,
|
||||||
|
List<FeedingLogResponse> feedings
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package de.cannamanage.api.dto.grow;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.GrowStage;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record GrowEntryResponse(
|
||||||
|
UUID id,
|
||||||
|
String name,
|
||||||
|
UUID strainId,
|
||||||
|
GrowStage status,
|
||||||
|
Instant startedAt,
|
||||||
|
Instant expectedHarvestAt,
|
||||||
|
Instant actualHarvestAt,
|
||||||
|
BigDecimal harvestedGrams,
|
||||||
|
UUID linkedBatchId,
|
||||||
|
String notes
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package de.cannamanage.api.dto.grow;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record GrowPhotoResponse(
|
||||||
|
UUID id,
|
||||||
|
String filePath,
|
||||||
|
String caption,
|
||||||
|
Instant takenAt
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package de.cannamanage.api.dto.grow;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.GrowStage;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record GrowStageLogResponse(
|
||||||
|
UUID id,
|
||||||
|
GrowStage stage,
|
||||||
|
Instant startedAt,
|
||||||
|
Instant endedAt,
|
||||||
|
String notes
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package de.cannamanage.api.dto.grow;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.SensorReadingType;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record SensorReadingResponse(
|
||||||
|
UUID id,
|
||||||
|
SensorReadingType readingType,
|
||||||
|
BigDecimal value,
|
||||||
|
String unit,
|
||||||
|
Instant recordedAt
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
-- Grow entries (one per plant batch lifecycle)
|
||||||
|
CREATE TABLE IF NOT EXISTS grow_entries (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
strain_id UUID REFERENCES strains(id),
|
||||||
|
status VARCHAR(30) NOT NULL DEFAULT 'SEEDLING',
|
||||||
|
started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
expected_harvest_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
actual_harvest_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
harvested_grams NUMERIC(8,1),
|
||||||
|
linked_batch_id UUID REFERENCES batches(id),
|
||||||
|
notes TEXT,
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Grow stage transitions
|
||||||
|
CREATE TABLE IF NOT EXISTS grow_stage_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
grow_entry_id UUID NOT NULL REFERENCES grow_entries(id) ON DELETE CASCADE,
|
||||||
|
stage VARCHAR(30) NOT NULL,
|
||||||
|
started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
ended_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
notes TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Sensor readings
|
||||||
|
CREATE TABLE IF NOT EXISTS sensor_readings (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
grow_entry_id UUID NOT NULL REFERENCES grow_entries(id) ON DELETE CASCADE,
|
||||||
|
reading_type VARCHAR(30) NOT NULL,
|
||||||
|
value NUMERIC(8,2) NOT NULL,
|
||||||
|
unit VARCHAR(10) NOT NULL,
|
||||||
|
recorded_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Photos
|
||||||
|
CREATE TABLE IF NOT EXISTS grow_photos (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
grow_entry_id UUID NOT NULL REFERENCES grow_entries(id) ON DELETE CASCADE,
|
||||||
|
file_path VARCHAR(500) NOT NULL,
|
||||||
|
caption VARCHAR(255),
|
||||||
|
taken_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Feeding schedule entries
|
||||||
|
CREATE TABLE IF NOT EXISTS feeding_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
grow_entry_id UUID NOT NULL REFERENCES grow_entries(id) ON DELETE CASCADE,
|
||||||
|
nutrient_name VARCHAR(100) NOT NULL,
|
||||||
|
amount_ml NUMERIC(8,1) NOT NULL,
|
||||||
|
water_liters NUMERIC(8,1),
|
||||||
|
ph_after NUMERIC(4,2),
|
||||||
|
ec_after NUMERIC(6,2),
|
||||||
|
fed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
notes TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_grow_entries_tenant ON grow_entries(tenant_id);
|
||||||
|
CREATE INDEX idx_grow_entries_status ON grow_entries(status);
|
||||||
|
CREATE INDEX idx_sensor_readings_entry ON sensor_readings(grow_entry_id, recorded_at DESC);
|
||||||
|
CREATE INDEX idx_feeding_logs_entry ON feeding_logs(grow_entry_id, fed_at DESC);
|
||||||
|
CREATE INDEX idx_grow_photos_entry ON grow_photos(grow_entry_id);
|
||||||
|
CREATE INDEX idx_grow_stage_logs_entry ON grow_stage_logs(grow_entry_id);
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package de.cannamanage.domain.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "feeding_logs")
|
||||||
|
public class FeedingLog {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
@Column(name = "id", nullable = false, updatable = false)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@Column(name = "grow_entry_id", nullable = false)
|
||||||
|
private UUID growEntryId;
|
||||||
|
|
||||||
|
@Column(name = "nutrient_name", nullable = false, length = 100)
|
||||||
|
private String nutrientName;
|
||||||
|
|
||||||
|
@Column(name = "amount_ml", nullable = false, precision = 8, scale = 1)
|
||||||
|
private BigDecimal amountMl;
|
||||||
|
|
||||||
|
@Column(name = "water_liters", precision = 8, scale = 1)
|
||||||
|
private BigDecimal waterLiters;
|
||||||
|
|
||||||
|
@Column(name = "ph_after", precision = 4, scale = 2)
|
||||||
|
private BigDecimal phAfter;
|
||||||
|
|
||||||
|
@Column(name = "ec_after", precision = 6, scale = 2)
|
||||||
|
private BigDecimal ecAfter;
|
||||||
|
|
||||||
|
@Column(name = "fed_at", nullable = false)
|
||||||
|
private Instant fedAt;
|
||||||
|
|
||||||
|
@Column(name = "notes", columnDefinition = "TEXT")
|
||||||
|
private String notes;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
void onCreate() {
|
||||||
|
if (this.fedAt == null) this.fedAt = Instant.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getId() { return id; }
|
||||||
|
public void setId(UUID id) { this.id = id; }
|
||||||
|
|
||||||
|
public UUID getGrowEntryId() { return growEntryId; }
|
||||||
|
public void setGrowEntryId(UUID growEntryId) { this.growEntryId = growEntryId; }
|
||||||
|
|
||||||
|
public String getNutrientName() { return nutrientName; }
|
||||||
|
public void setNutrientName(String nutrientName) { this.nutrientName = nutrientName; }
|
||||||
|
|
||||||
|
public BigDecimal getAmountMl() { return amountMl; }
|
||||||
|
public void setAmountMl(BigDecimal amountMl) { this.amountMl = amountMl; }
|
||||||
|
|
||||||
|
public BigDecimal getWaterLiters() { return waterLiters; }
|
||||||
|
public void setWaterLiters(BigDecimal waterLiters) { this.waterLiters = waterLiters; }
|
||||||
|
|
||||||
|
public BigDecimal getPhAfter() { return phAfter; }
|
||||||
|
public void setPhAfter(BigDecimal phAfter) { this.phAfter = phAfter; }
|
||||||
|
|
||||||
|
public BigDecimal getEcAfter() { return ecAfter; }
|
||||||
|
public void setEcAfter(BigDecimal ecAfter) { this.ecAfter = ecAfter; }
|
||||||
|
|
||||||
|
public Instant getFedAt() { return fedAt; }
|
||||||
|
public void setFedAt(Instant fedAt) { this.fedAt = fedAt; }
|
||||||
|
|
||||||
|
public String getNotes() { return notes; }
|
||||||
|
public void setNotes(String notes) { this.notes = notes; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package de.cannamanage.domain.entity;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.GrowStage;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "grow_entries")
|
||||||
|
public class GrowEntry extends AbstractTenantEntity {
|
||||||
|
|
||||||
|
@Column(name = "name", nullable = false)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(name = "strain_id")
|
||||||
|
private UUID strainId;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "status", nullable = false, length = 30)
|
||||||
|
private GrowStage status = GrowStage.SEEDLING;
|
||||||
|
|
||||||
|
@Column(name = "started_at", nullable = false)
|
||||||
|
private Instant startedAt;
|
||||||
|
|
||||||
|
@Column(name = "expected_harvest_at")
|
||||||
|
private Instant expectedHarvestAt;
|
||||||
|
|
||||||
|
@Column(name = "actual_harvest_at")
|
||||||
|
private Instant actualHarvestAt;
|
||||||
|
|
||||||
|
@Column(name = "harvested_grams", precision = 8, scale = 1)
|
||||||
|
private BigDecimal harvestedGrams;
|
||||||
|
|
||||||
|
@Column(name = "linked_batch_id")
|
||||||
|
private UUID linkedBatchId;
|
||||||
|
|
||||||
|
@Column(name = "notes", columnDefinition = "TEXT")
|
||||||
|
private String notes;
|
||||||
|
|
||||||
|
@Column(name = "updated_at")
|
||||||
|
private Instant updatedAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
void onCreateGrow() {
|
||||||
|
if (this.startedAt == null) this.startedAt = Instant.now();
|
||||||
|
this.updatedAt = Instant.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreUpdate
|
||||||
|
void onUpdate() {
|
||||||
|
this.updatedAt = Instant.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() { return name; }
|
||||||
|
public void setName(String name) { this.name = name; }
|
||||||
|
|
||||||
|
public UUID getStrainId() { return strainId; }
|
||||||
|
public void setStrainId(UUID strainId) { this.strainId = strainId; }
|
||||||
|
|
||||||
|
public GrowStage getStatus() { return status; }
|
||||||
|
public void setStatus(GrowStage status) { this.status = status; }
|
||||||
|
|
||||||
|
public Instant getStartedAt() { return startedAt; }
|
||||||
|
public void setStartedAt(Instant startedAt) { this.startedAt = startedAt; }
|
||||||
|
|
||||||
|
public Instant getExpectedHarvestAt() { return expectedHarvestAt; }
|
||||||
|
public void setExpectedHarvestAt(Instant expectedHarvestAt) { this.expectedHarvestAt = expectedHarvestAt; }
|
||||||
|
|
||||||
|
public Instant getActualHarvestAt() { return actualHarvestAt; }
|
||||||
|
public void setActualHarvestAt(Instant actualHarvestAt) { this.actualHarvestAt = actualHarvestAt; }
|
||||||
|
|
||||||
|
public BigDecimal getHarvestedGrams() { return harvestedGrams; }
|
||||||
|
public void setHarvestedGrams(BigDecimal harvestedGrams) { this.harvestedGrams = harvestedGrams; }
|
||||||
|
|
||||||
|
public UUID getLinkedBatchId() { return linkedBatchId; }
|
||||||
|
public void setLinkedBatchId(UUID linkedBatchId) { this.linkedBatchId = linkedBatchId; }
|
||||||
|
|
||||||
|
public String getNotes() { return notes; }
|
||||||
|
public void setNotes(String notes) { this.notes = notes; }
|
||||||
|
|
||||||
|
public Instant getUpdatedAt() { return updatedAt; }
|
||||||
|
public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package de.cannamanage.domain.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "grow_photos")
|
||||||
|
public class GrowPhoto {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
@Column(name = "id", nullable = false, updatable = false)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@Column(name = "grow_entry_id", nullable = false)
|
||||||
|
private UUID growEntryId;
|
||||||
|
|
||||||
|
@Column(name = "file_path", nullable = false, length = 500)
|
||||||
|
private String filePath;
|
||||||
|
|
||||||
|
@Column(name = "caption", length = 255)
|
||||||
|
private String caption;
|
||||||
|
|
||||||
|
@Column(name = "taken_at", nullable = false)
|
||||||
|
private Instant takenAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
void onCreate() {
|
||||||
|
if (this.takenAt == null) this.takenAt = Instant.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getId() { return id; }
|
||||||
|
public void setId(UUID id) { this.id = id; }
|
||||||
|
|
||||||
|
public UUID getGrowEntryId() { return growEntryId; }
|
||||||
|
public void setGrowEntryId(UUID growEntryId) { this.growEntryId = growEntryId; }
|
||||||
|
|
||||||
|
public String getFilePath() { return filePath; }
|
||||||
|
public void setFilePath(String filePath) { this.filePath = filePath; }
|
||||||
|
|
||||||
|
public String getCaption() { return caption; }
|
||||||
|
public void setCaption(String caption) { this.caption = caption; }
|
||||||
|
|
||||||
|
public Instant getTakenAt() { return takenAt; }
|
||||||
|
public void setTakenAt(Instant takenAt) { this.takenAt = takenAt; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package de.cannamanage.domain.entity;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.GrowStage;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "grow_stage_logs")
|
||||||
|
public class GrowStageLog {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
@Column(name = "id", nullable = false, updatable = false)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@Column(name = "grow_entry_id", nullable = false)
|
||||||
|
private UUID growEntryId;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "stage", nullable = false, length = 30)
|
||||||
|
private GrowStage stage;
|
||||||
|
|
||||||
|
@Column(name = "started_at", nullable = false)
|
||||||
|
private Instant startedAt;
|
||||||
|
|
||||||
|
@Column(name = "ended_at")
|
||||||
|
private Instant endedAt;
|
||||||
|
|
||||||
|
@Column(name = "notes", columnDefinition = "TEXT")
|
||||||
|
private String notes;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
void onCreate() {
|
||||||
|
if (this.startedAt == null) this.startedAt = Instant.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getId() { return id; }
|
||||||
|
public void setId(UUID id) { this.id = id; }
|
||||||
|
|
||||||
|
public UUID getGrowEntryId() { return growEntryId; }
|
||||||
|
public void setGrowEntryId(UUID growEntryId) { this.growEntryId = growEntryId; }
|
||||||
|
|
||||||
|
public GrowStage getStage() { return stage; }
|
||||||
|
public void setStage(GrowStage stage) { this.stage = stage; }
|
||||||
|
|
||||||
|
public Instant getStartedAt() { return startedAt; }
|
||||||
|
public void setStartedAt(Instant startedAt) { this.startedAt = startedAt; }
|
||||||
|
|
||||||
|
public Instant getEndedAt() { return endedAt; }
|
||||||
|
public void setEndedAt(Instant endedAt) { this.endedAt = endedAt; }
|
||||||
|
|
||||||
|
public String getNotes() { return notes; }
|
||||||
|
public void setNotes(String notes) { this.notes = notes; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package de.cannamanage.domain.entity;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.SensorReadingType;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "sensor_readings")
|
||||||
|
public class SensorReading {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
@Column(name = "id", nullable = false, updatable = false)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@Column(name = "grow_entry_id", nullable = false)
|
||||||
|
private UUID growEntryId;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "reading_type", nullable = false, length = 30)
|
||||||
|
private SensorReadingType readingType;
|
||||||
|
|
||||||
|
@Column(name = "value", nullable = false, precision = 8, scale = 2)
|
||||||
|
private BigDecimal value;
|
||||||
|
|
||||||
|
@Column(name = "unit", nullable = false, length = 10)
|
||||||
|
private String unit;
|
||||||
|
|
||||||
|
@Column(name = "recorded_at", nullable = false)
|
||||||
|
private Instant recordedAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
void onCreate() {
|
||||||
|
if (this.recordedAt == null) this.recordedAt = Instant.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getId() { return id; }
|
||||||
|
public void setId(UUID id) { this.id = id; }
|
||||||
|
|
||||||
|
public UUID getGrowEntryId() { return growEntryId; }
|
||||||
|
public void setGrowEntryId(UUID growEntryId) { this.growEntryId = growEntryId; }
|
||||||
|
|
||||||
|
public SensorReadingType getReadingType() { return readingType; }
|
||||||
|
public void setReadingType(SensorReadingType readingType) { this.readingType = readingType; }
|
||||||
|
|
||||||
|
public BigDecimal getValue() { return value; }
|
||||||
|
public void setValue(BigDecimal value) { this.value = value; }
|
||||||
|
|
||||||
|
public String getUnit() { return unit; }
|
||||||
|
public void setUnit(String unit) { this.unit = unit; }
|
||||||
|
|
||||||
|
public Instant getRecordedAt() { return recordedAt; }
|
||||||
|
public void setRecordedAt(Instant recordedAt) { this.recordedAt = recordedAt; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package de.cannamanage.domain.enums;
|
||||||
|
|
||||||
|
public enum GrowStage {
|
||||||
|
SEEDLING,
|
||||||
|
VEGETATIVE,
|
||||||
|
FLOWERING,
|
||||||
|
HARVEST,
|
||||||
|
DRYING,
|
||||||
|
CURING,
|
||||||
|
COMPLETE
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package de.cannamanage.domain.enums;
|
||||||
|
|
||||||
|
public enum SensorReadingType {
|
||||||
|
TEMPERATURE,
|
||||||
|
HUMIDITY,
|
||||||
|
CO2,
|
||||||
|
PH,
|
||||||
|
EC
|
||||||
|
}
|
||||||
@@ -444,5 +444,50 @@
|
|||||||
"PAYMENT_RECEIVED": "Zahlung erhalten",
|
"PAYMENT_RECEIVED": "Zahlung erhalten",
|
||||||
"PAYMENT_FAILED": "Zahlung fehlgeschlagen"
|
"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_RECEIVED": "Payment received",
|
||||||
"PAYMENT_FAILED": "Payment failed"
|
"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",
|
href: "/stock",
|
||||||
iconName: "Package",
|
iconName: "Package",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Anbau",
|
||||||
|
href: "/grow",
|
||||||
|
iconName: "Sprout",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Berichte",
|
title: "Berichte",
|
||||||
href: "/reports",
|
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] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package de.cannamanage.service;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.*;
|
||||||
|
import de.cannamanage.domain.enums.GrowStage;
|
||||||
|
import de.cannamanage.domain.enums.SensorReadingType;
|
||||||
|
import de.cannamanage.service.repository.*;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class GrowCalendarService {
|
||||||
|
|
||||||
|
private final GrowEntryRepository growEntryRepository;
|
||||||
|
private final GrowStageLogRepository growStageLogRepository;
|
||||||
|
private final SensorReadingRepository sensorReadingRepository;
|
||||||
|
private final GrowPhotoRepository growPhotoRepository;
|
||||||
|
private final FeedingLogRepository feedingLogRepository;
|
||||||
|
private final BatchRepository batchRepository;
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<GrowEntry> getGrowEntries() {
|
||||||
|
return growEntryRepository.findAllByOrderByStartedAtDesc();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public GrowEntry getGrowEntry(UUID id) {
|
||||||
|
return growEntryRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Grow entry not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public GrowEntry createGrowEntry(String name, UUID strainId, String notes, Instant expectedHarvestAt) {
|
||||||
|
GrowEntry entry = new GrowEntry();
|
||||||
|
entry.setName(name);
|
||||||
|
entry.setStrainId(strainId);
|
||||||
|
entry.setStatus(GrowStage.SEEDLING);
|
||||||
|
entry.setStartedAt(Instant.now());
|
||||||
|
entry.setExpectedHarvestAt(expectedHarvestAt);
|
||||||
|
entry.setNotes(notes);
|
||||||
|
|
||||||
|
GrowEntry saved = growEntryRepository.save(entry);
|
||||||
|
|
||||||
|
// Create initial stage log
|
||||||
|
GrowStageLog stageLog = new GrowStageLog();
|
||||||
|
stageLog.setGrowEntryId(saved.getId());
|
||||||
|
stageLog.setStage(GrowStage.SEEDLING);
|
||||||
|
stageLog.setStartedAt(Instant.now());
|
||||||
|
growStageLogRepository.save(stageLog);
|
||||||
|
|
||||||
|
log.debug("Created grow entry: {} with stage SEEDLING", saved.getId());
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public GrowEntry advanceStage(UUID entryId, GrowStage newStage) {
|
||||||
|
GrowEntry entry = getGrowEntry(entryId);
|
||||||
|
|
||||||
|
// Close the current stage log
|
||||||
|
List<GrowStageLog> logs = growStageLogRepository.findByGrowEntryIdOrderByStartedAtAsc(entryId);
|
||||||
|
if (!logs.isEmpty()) {
|
||||||
|
GrowStageLog currentLog = logs.get(logs.size() - 1);
|
||||||
|
if (currentLog.getEndedAt() == null) {
|
||||||
|
currentLog.setEndedAt(Instant.now());
|
||||||
|
growStageLogRepository.save(currentLog);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new stage log
|
||||||
|
GrowStageLog newLog = new GrowStageLog();
|
||||||
|
newLog.setGrowEntryId(entryId);
|
||||||
|
newLog.setStage(newStage);
|
||||||
|
newLog.setStartedAt(Instant.now());
|
||||||
|
growStageLogRepository.save(newLog);
|
||||||
|
|
||||||
|
// Update entry status
|
||||||
|
entry.setStatus(newStage);
|
||||||
|
log.debug("Advanced grow entry {} to stage {}", entryId, newStage);
|
||||||
|
return growEntryRepository.save(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public SensorReading addSensorReading(UUID entryId, SensorReadingType type, BigDecimal value, String unit) {
|
||||||
|
// Verify entry exists
|
||||||
|
getGrowEntry(entryId);
|
||||||
|
|
||||||
|
SensorReading reading = new SensorReading();
|
||||||
|
reading.setGrowEntryId(entryId);
|
||||||
|
reading.setReadingType(type);
|
||||||
|
reading.setValue(value);
|
||||||
|
reading.setUnit(unit);
|
||||||
|
reading.setRecordedAt(Instant.now());
|
||||||
|
return sensorReadingRepository.save(reading);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public GrowPhoto addPhoto(UUID entryId, String filePath, String caption) {
|
||||||
|
getGrowEntry(entryId);
|
||||||
|
|
||||||
|
GrowPhoto photo = new GrowPhoto();
|
||||||
|
photo.setGrowEntryId(entryId);
|
||||||
|
photo.setFilePath(filePath);
|
||||||
|
photo.setCaption(caption);
|
||||||
|
photo.setTakenAt(Instant.now());
|
||||||
|
return growPhotoRepository.save(photo);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public FeedingLog addFeedingLog(UUID entryId, String nutrientName, BigDecimal amountMl,
|
||||||
|
BigDecimal waterLiters, BigDecimal phAfter, BigDecimal ecAfter, String notes) {
|
||||||
|
getGrowEntry(entryId);
|
||||||
|
|
||||||
|
FeedingLog feeding = new FeedingLog();
|
||||||
|
feeding.setGrowEntryId(entryId);
|
||||||
|
feeding.setNutrientName(nutrientName);
|
||||||
|
feeding.setAmountMl(amountMl);
|
||||||
|
feeding.setWaterLiters(waterLiters);
|
||||||
|
feeding.setPhAfter(phAfter);
|
||||||
|
feeding.setEcAfter(ecAfter);
|
||||||
|
feeding.setFedAt(Instant.now());
|
||||||
|
feeding.setNotes(notes);
|
||||||
|
return feedingLogRepository.save(feeding);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public GrowEntry completeHarvest(UUID entryId, BigDecimal grams, UUID batchId) {
|
||||||
|
GrowEntry entry = getGrowEntry(entryId);
|
||||||
|
|
||||||
|
if (batchId != null) {
|
||||||
|
batchRepository.findById(batchId)
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Batch not found"));
|
||||||
|
entry.setLinkedBatchId(batchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.setHarvestedGrams(grams);
|
||||||
|
entry.setActualHarvestAt(Instant.now());
|
||||||
|
entry.setStatus(GrowStage.COMPLETE);
|
||||||
|
|
||||||
|
// Close stage log
|
||||||
|
List<GrowStageLog> logs = growStageLogRepository.findByGrowEntryIdOrderByStartedAtAsc(entryId);
|
||||||
|
if (!logs.isEmpty()) {
|
||||||
|
GrowStageLog currentLog = logs.get(logs.size() - 1);
|
||||||
|
if (currentLog.getEndedAt() == null) {
|
||||||
|
currentLog.setEndedAt(Instant.now());
|
||||||
|
growStageLogRepository.save(currentLog);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create COMPLETE stage log
|
||||||
|
GrowStageLog completeLog = new GrowStageLog();
|
||||||
|
completeLog.setGrowEntryId(entryId);
|
||||||
|
completeLog.setStage(GrowStage.COMPLETE);
|
||||||
|
completeLog.setStartedAt(Instant.now());
|
||||||
|
growStageLogRepository.save(completeLog);
|
||||||
|
|
||||||
|
log.debug("Completed harvest for grow entry {}: {}g", entryId, grams);
|
||||||
|
return growEntryRepository.save(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<GrowStageLog> getStageLogs(UUID entryId) {
|
||||||
|
return growStageLogRepository.findByGrowEntryIdOrderByStartedAtAsc(entryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<SensorReading> getSensorReadings(UUID entryId) {
|
||||||
|
return sensorReadingRepository.findByGrowEntryIdOrderByRecordedAtDesc(entryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<GrowPhoto> getPhotos(UUID entryId) {
|
||||||
|
return growPhotoRepository.findByGrowEntryIdOrderByTakenAtDesc(entryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<FeedingLog> getFeedingLogs(UUID entryId) {
|
||||||
|
return feedingLogRepository.findByGrowEntryIdOrderByFedAtDesc(entryId);
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
package de.cannamanage.service.repository;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.FeedingLog;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public interface FeedingLogRepository extends JpaRepository<FeedingLog, UUID> {
|
||||||
|
List<FeedingLog> findByGrowEntryIdOrderByFedAtDesc(UUID growEntryId);
|
||||||
|
}
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
package de.cannamanage.service.repository;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.GrowEntry;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public interface GrowEntryRepository extends JpaRepository<GrowEntry, UUID> {
|
||||||
|
List<GrowEntry> findAllByOrderByStartedAtDesc();
|
||||||
|
}
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
package de.cannamanage.service.repository;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.GrowPhoto;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public interface GrowPhotoRepository extends JpaRepository<GrowPhoto, UUID> {
|
||||||
|
List<GrowPhoto> findByGrowEntryIdOrderByTakenAtDesc(UUID growEntryId);
|
||||||
|
}
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
package de.cannamanage.service.repository;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.GrowStageLog;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public interface GrowStageLogRepository extends JpaRepository<GrowStageLog, UUID> {
|
||||||
|
List<GrowStageLog> findByGrowEntryIdOrderByStartedAtAsc(UUID growEntryId);
|
||||||
|
}
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
package de.cannamanage.service.repository;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.SensorReading;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public interface SensorReadingRepository extends JpaRepository<SensorReading, UUID> {
|
||||||
|
List<SensorReading> findByGrowEntryIdOrderByRecordedAtDesc(UUID growEntryId);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user