diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/controller/GrowCalendarController.java b/cannamanage-api/src/main/java/de/cannamanage/api/controller/GrowCalendarController.java new file mode 100644 index 0000000..cb5b772 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/controller/GrowCalendarController.java @@ -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> listGrowEntries() { + List 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 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 getGrowEntry(@PathVariable UUID id) { + GrowEntry entry = growCalendarService.getGrowEntry(id); + List stages = growCalendarService.getStageLogs(id); + List sensors = growCalendarService.getSensorReadings(id); + List photos = growCalendarService.getPhotos(id); + List 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 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 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 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 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 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()); + } +} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/grow/AddFeedingLogRequest.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/grow/AddFeedingLogRequest.java new file mode 100644 index 0000000..c500ec1 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/grow/AddFeedingLogRequest.java @@ -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 +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/grow/AddPhotoRequest.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/grow/AddPhotoRequest.java new file mode 100644 index 0000000..ba87a1a --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/grow/AddPhotoRequest.java @@ -0,0 +1,8 @@ +package de.cannamanage.api.dto.grow; + +import jakarta.validation.constraints.NotBlank; + +public record AddPhotoRequest( + @NotBlank String filePath, + String caption +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/grow/AddSensorReadingRequest.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/grow/AddSensorReadingRequest.java new file mode 100644 index 0000000..816aac5 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/grow/AddSensorReadingRequest.java @@ -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 +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/grow/AdvanceStageRequest.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/grow/AdvanceStageRequest.java new file mode 100644 index 0000000..bb07263 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/grow/AdvanceStageRequest.java @@ -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 +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/grow/CompleteHarvestRequest.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/grow/CompleteHarvestRequest.java new file mode 100644 index 0000000..c64e886 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/grow/CompleteHarvestRequest.java @@ -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 +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/grow/CreateGrowEntryRequest.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/grow/CreateGrowEntryRequest.java new file mode 100644 index 0000000..ddd8190 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/grow/CreateGrowEntryRequest.java @@ -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 +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/grow/FeedingLogResponse.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/grow/FeedingLogResponse.java new file mode 100644 index 0000000..0adb9e2 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/grow/FeedingLogResponse.java @@ -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 +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/grow/GrowEntryDetailResponse.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/grow/GrowEntryDetailResponse.java new file mode 100644 index 0000000..371188c --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/grow/GrowEntryDetailResponse.java @@ -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 stages, + List sensors, + List photos, + List feedings +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/grow/GrowEntryResponse.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/grow/GrowEntryResponse.java new file mode 100644 index 0000000..2a458cf --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/grow/GrowEntryResponse.java @@ -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 +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/grow/GrowPhotoResponse.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/grow/GrowPhotoResponse.java new file mode 100644 index 0000000..cfdcd31 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/grow/GrowPhotoResponse.java @@ -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 +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/grow/GrowStageLogResponse.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/grow/GrowStageLogResponse.java new file mode 100644 index 0000000..d3a2f25 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/grow/GrowStageLogResponse.java @@ -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 +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/grow/SensorReadingResponse.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/grow/SensorReadingResponse.java new file mode 100644 index 0000000..538e818 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/grow/SensorReadingResponse.java @@ -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 +) {} diff --git a/cannamanage-api/src/main/resources/db/migration/V9__grow_calendar.sql b/cannamanage-api/src/main/resources/db/migration/V9__grow_calendar.sql new file mode 100644 index 0000000..605cb0d --- /dev/null +++ b/cannamanage-api/src/main/resources/db/migration/V9__grow_calendar.sql @@ -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); diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/FeedingLog.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/FeedingLog.java new file mode 100644 index 0000000..dee917d --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/FeedingLog.java @@ -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; } +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/GrowEntry.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/GrowEntry.java new file mode 100644 index 0000000..c740907 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/GrowEntry.java @@ -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; } +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/GrowPhoto.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/GrowPhoto.java new file mode 100644 index 0000000..2ca1c89 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/GrowPhoto.java @@ -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; } +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/GrowStageLog.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/GrowStageLog.java new file mode 100644 index 0000000..a401095 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/GrowStageLog.java @@ -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; } +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/SensorReading.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/SensorReading.java new file mode 100644 index 0000000..5957ab8 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/SensorReading.java @@ -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; } +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/GrowStage.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/GrowStage.java new file mode 100644 index 0000000..b06cfdc --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/GrowStage.java @@ -0,0 +1,11 @@ +package de.cannamanage.domain.enums; + +public enum GrowStage { + SEEDLING, + VEGETATIVE, + FLOWERING, + HARVEST, + DRYING, + CURING, + COMPLETE +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/SensorReadingType.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/SensorReadingType.java new file mode 100644 index 0000000..8fe2320 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/SensorReadingType.java @@ -0,0 +1,9 @@ +package de.cannamanage.domain.enums; + +public enum SensorReadingType { + TEMPERATURE, + HUMIDITY, + CO2, + PH, + EC +} diff --git a/cannamanage-frontend/messages/de.json b/cannamanage-frontend/messages/de.json index 531321d..1c7b826 100644 --- a/cannamanage-frontend/messages/de.json +++ b/cannamanage-frontend/messages/de.json @@ -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." } -} +} \ No newline at end of file diff --git a/cannamanage-frontend/messages/en.json b/cannamanage-frontend/messages/en.json index 444ff03..420eb76 100644 --- a/cannamanage-frontend/messages/en.json +++ b/cannamanage-frontend/messages/en.json @@ -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." } -} +} \ No newline at end of file diff --git a/cannamanage-frontend/src/app/(dashboard-layout)/grow/[id]/page.tsx b/cannamanage-frontend/src/app/(dashboard-layout)/grow/[id]/page.tsx new file mode 100644 index 0000000..553ec24 --- /dev/null +++ b/cannamanage-frontend/src/app/(dashboard-layout)/grow/[id]/page.tsx @@ -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 = { + 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 ( +
+ + +
+ ) + } + + // 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 ( +
+ {/* Header */} +
+ +
+

{entry.name}

+

+ {t("startedAt")}:{" "} + {format(new Date(entry.startedAt), "dd.MM.yyyy", { locale: de })} +

+
+ + {t(`stages.${entry.status}`)} + +
+ + {/* Timeline */} + + + {t("timeline")} + + +
+ {entry.stages.map((stage, _i) => ( +
+
+
+
+ + {t(`stages.${stage.stage}`)} + + + {format(new Date(stage.startedAt), "dd.MM.yyyy", { + locale: de, + })} + {stage.endedAt && + ` — ${format(new Date(stage.endedAt), "dd.MM.yyyy", { locale: de })}`} + +
+ {stage.notes && ( +

+ {stage.notes} +

+ )} +
+
+ ))} +
+ + + + {/* Sensor Chart */} + {sensorChartData.length > 0 && ( + + + + + {t("sensors")} + + + + + + + + + + + + + + + + + )} + + {/* Feeding Log */} + {entry.feedings.length > 0 && ( + + + + + {t("feeding")} + + + + + + + Datum + {t("nutrient")} + {t("amountMl")} + {t("waterLiters")} + {t("phAfter")} + {t("ecAfter")} + + + + {entry.feedings.map((f) => ( + + + {format(new Date(f.fedAt), "dd.MM.yyyy", { + locale: de, + })} + + {f.nutrientName} + {f.amountMl} ml + + {f.waterLiters ? `${f.waterLiters} L` : "—"} + + {f.phAfter ?? "—"} + {f.ecAfter ?? "—"} + + ))} + +
+
+
+ )} + + {/* Photos */} + {entry.photos.length > 0 && ( + + + + + {t("photos")} ({entry.photos.length}) + + + +
+ {entry.photos.map((photo) => ( +
+ +

+ {photo.caption ?? "Foto"} +

+

+ {format(new Date(photo.takenAt), "dd.MM.yyyy", { + locale: de, + })} +

+
+ ))} +
+
+
+ )} +
+ ) +} diff --git a/cannamanage-frontend/src/app/(dashboard-layout)/grow/page.tsx b/cannamanage-frontend/src/app/(dashboard-layout)/grow/page.tsx new file mode 100644 index 0000000..8c00ac3 --- /dev/null +++ b/cannamanage-frontend/src/app/(dashboard-layout)/grow/page.tsx @@ -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 = { + 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 ( +
+
+

{t("title")}

+
+ +
+ ) + } + + const growEntries = entries ?? [] + + return ( +
+
+

{t("title")}

+ +
+ + {growEntries.length === 0 ? ( + + + +

{t("noGrows")}

+
+
+ ) : ( +
+ {growEntries.map((entry) => { + const stageIndex = STAGES_ORDER.indexOf(entry.status) + const daysInStage = formatDistanceToNow(new Date(entry.startedAt), { + locale: de, + addSuffix: false, + }) + + return ( + + + +
+ {entry.name} + + {t(`stages.${entry.status}`)} + +
+
+ + {/* Stage progress indicator */} +
+ {STAGES_ORDER.map((stage, i) => ( +
+ ))} +
+ +
+ + + {t("daysInStage")}: {daysInStage} + + {entry.expectedHarvestAt && ( + + {t("expectedHarvest")}:{" "} + {new Date(entry.expectedHarvestAt).toLocaleDateString( + "de-DE" + )} + + )} +
+ + {entry.harvestedGrams && ( +
+ 🌿 {entry.harvestedGrams}g {t("harvestGrams")} +
+ )} + + + + ) + })} +
+ )} +
+ ) +} diff --git a/cannamanage-frontend/src/data/mock/grow.ts b/cannamanage-frontend/src/data/mock/grow.ts new file mode 100644 index 0000000..a6b2bb2 --- /dev/null +++ b/cannamanage-frontend/src/data/mock/grow.ts @@ -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", + }, + ], + } +} diff --git a/cannamanage-frontend/src/data/navigations.ts b/cannamanage-frontend/src/data/navigations.ts index be91aa4..1eb0ff4 100644 --- a/cannamanage-frontend/src/data/navigations.ts +++ b/cannamanage-frontend/src/data/navigations.ts @@ -24,6 +24,11 @@ export const navigationsData: NavigationType[] = [ href: "/stock", iconName: "Package", }, + { + title: "Anbau", + href: "/grow", + iconName: "Sprout", + }, { title: "Berichte", href: "/reports", diff --git a/cannamanage-frontend/src/services/grow.ts b/cannamanage-frontend/src/services/grow.ts new file mode 100644 index 0000000..39a9333 --- /dev/null +++ b/cannamanage-frontend/src/services/grow.ts @@ -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("/grow") + } catch { + return mockGrowEntries + } + }, + }) +} + +export function useGrowEntryQuery(id: string) { + return useQuery({ + queryKey: ["grow", id], + queryFn: async () => { + try { + return await apiClient(`/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("/grow", { method: "POST", body: data }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["grow"] }) + }, + }) +} + +export function useAdvanceStageMutation(id: string) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (stage: GrowStage) => + apiClient(`/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(`/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(`/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(`/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(`/grow/${id}/harvest`, { + method: "PUT", + body: data, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["grow"] }) + queryClient.invalidateQueries({ queryKey: ["grow", id] }) + }, + }) +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/GrowCalendarService.java b/cannamanage-service/src/main/java/de/cannamanage/service/GrowCalendarService.java new file mode 100644 index 0000000..59d8163 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/GrowCalendarService.java @@ -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 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 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 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 getStageLogs(UUID entryId) { + return growStageLogRepository.findByGrowEntryIdOrderByStartedAtAsc(entryId); + } + + @Transactional(readOnly = true) + public List getSensorReadings(UUID entryId) { + return sensorReadingRepository.findByGrowEntryIdOrderByRecordedAtDesc(entryId); + } + + @Transactional(readOnly = true) + public List getPhotos(UUID entryId) { + return growPhotoRepository.findByGrowEntryIdOrderByTakenAtDesc(entryId); + } + + @Transactional(readOnly = true) + public List getFeedingLogs(UUID entryId) { + return feedingLogRepository.findByGrowEntryIdOrderByFedAtDesc(entryId); + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/repository/FeedingLogRepository.java b/cannamanage-service/src/main/java/de/cannamanage/service/repository/FeedingLogRepository.java new file mode 100644 index 0000000..cff1d19 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/repository/FeedingLogRepository.java @@ -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 { + List findByGrowEntryIdOrderByFedAtDesc(UUID growEntryId); +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/repository/GrowEntryRepository.java b/cannamanage-service/src/main/java/de/cannamanage/service/repository/GrowEntryRepository.java new file mode 100644 index 0000000..477023d --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/repository/GrowEntryRepository.java @@ -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 { + List findAllByOrderByStartedAtDesc(); +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/repository/GrowPhotoRepository.java b/cannamanage-service/src/main/java/de/cannamanage/service/repository/GrowPhotoRepository.java new file mode 100644 index 0000000..b55687a --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/repository/GrowPhotoRepository.java @@ -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 { + List findByGrowEntryIdOrderByTakenAtDesc(UUID growEntryId); +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/repository/GrowStageLogRepository.java b/cannamanage-service/src/main/java/de/cannamanage/service/repository/GrowStageLogRepository.java new file mode 100644 index 0000000..e0c1b7a --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/repository/GrowStageLogRepository.java @@ -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 { + List findByGrowEntryIdOrderByStartedAtAsc(UUID growEntryId); +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/repository/SensorReadingRepository.java b/cannamanage-service/src/main/java/de/cannamanage/service/repository/SensorReadingRepository.java new file mode 100644 index 0000000..b7334d7 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/repository/SensorReadingRepository.java @@ -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 { + List findByGrowEntryIdOrderByRecordedAtDesc(UUID growEntryId); +}