Add themed UI, favicon, plan deletion, regrow cycles, and crop prep/post tasks

This commit is contained in:
Kai
2026-02-16 13:27:47 +01:00
parent 51181a83c8
commit a1c1ef31a3
14 changed files with 552 additions and 127 deletions

View File

@@ -6,48 +6,30 @@ import (
"strings" "strings"
) )
func buildTasksForDay(plans []Plan, month, day int) []Task { func buildTasksForDay(plans []Plan, stepMap map[int64][]CropStep, month, day int) []Task {
var tasks []Task var tasks []Task
for _, p := range plans { for _, p := range plans {
field := p.TargetName tasks = append(tasks, expandPlanDayTasks(p, stepMap[p.CropID], month, day)...)
if strings.TrimSpace(field) == "" {
field = "Unbekanntes Feld"
}
if p.StartMonth == month && p.StartDay == day {
tasks = append(tasks, Task{Type: "Aussaat", Field: field, Message: fmt.Sprintf("%s auf %s aussaeen", p.CropName, field), SortOrder: 1})
}
if p.HarvestMonth == month && p.HarvestDay == day {
tasks = append(tasks, Task{Type: "Ernte", Field: field, Message: fmt.Sprintf("%s auf %s ernten", p.CropName, field), SortOrder: 2})
}
} }
sort.Slice(tasks, func(i, j int) bool { sortTasks(tasks)
if tasks[i].SortOrder == tasks[j].SortOrder {
return tasks[i].Field < tasks[j].Field
}
return tasks[i].SortOrder < tasks[j].SortOrder
})
return tasks return tasks
} }
func buildCalendar(plans []Plan, startMonth, startDay, daysPerMonth, spanMonths int) []CalendarMonth { func buildCalendar(plans []Plan, stepMap map[int64][]CropStep, startMonth, startDay, daysPerMonth, spanMonths int) []CalendarMonth {
tasksByKey := make(map[string][]Task) tasksByKey := make(map[string][]Task)
for _, p := range plans { for _, p := range plans {
field := p.TargetName for m := 1; m <= 12; m++ {
if strings.TrimSpace(field) == "" { for d := 1; d <= daysPerMonth; d++ {
field = "Unbekanntes Feld" dayTasks := expandPlanDayTasks(p, stepMap[p.CropID], m, d)
if len(dayTasks) > 0 {
key := fmt.Sprintf("%d-%d", m, d)
tasksByKey[key] = append(tasksByKey[key], dayTasks...)
}
}
} }
tasksByKey[fmt.Sprintf("%d-%d", p.StartMonth, p.StartDay)] = append(tasksByKey[fmt.Sprintf("%d-%d", p.StartMonth, p.StartDay)], Task{ }
Type: "Aussaat", for k := range tasksByKey {
Field: field, sortTasks(tasksByKey[k])
Message: fmt.Sprintf("Aussaat %s", p.CropName),
SortOrder: 1,
})
tasksByKey[fmt.Sprintf("%d-%d", p.HarvestMonth, p.HarvestDay)] = append(tasksByKey[fmt.Sprintf("%d-%d", p.HarvestMonth, p.HarvestDay)], Task{
Type: "Ernte",
Field: field,
Message: fmt.Sprintf("Ernte %s", p.CropName),
SortOrder: 2,
})
} }
var out []CalendarMonth var out []CalendarMonth
@@ -64,16 +46,10 @@ func buildCalendar(plans []Plan, startMonth, startDay, daysPerMonth, spanMonths
fromDay = startDay fromDay = startDay
} }
var days []CalendarDay var days []CalendarDay
for day := fromDay; day <= daysPerMonth; day++ { for d := fromDay; d <= daysPerMonth; d++ {
key := fmt.Sprintf("%d-%d", month, day) key := fmt.Sprintf("%d-%d", month, d)
items := append([]Task(nil), tasksByKey[key]...) items := append([]Task(nil), tasksByKey[key]...)
sort.Slice(items, func(i, j int) bool { days = append(days, CalendarDay{Day: d, Tasks: items})
if items[i].SortOrder == items[j].SortOrder {
return items[i].Field < items[j].Field
}
return items[i].SortOrder < items[j].SortOrder
})
days = append(days, CalendarDay{Day: day, Tasks: items})
} }
out = append(out, CalendarMonth{ out = append(out, CalendarMonth{
Offset: offset, Offset: offset,
@@ -85,3 +61,111 @@ func buildCalendar(plans []Plan, startMonth, startDay, daysPerMonth, spanMonths
} }
return out return out
} }
func expandPlanDayTasks(p Plan, steps []CropStep, month, day int) []Task {
field := p.TargetName
if strings.TrimSpace(field) == "" {
field = "Unbekanntes Feld"
}
var out []Task
baseHarvest := p.HarvestMonth
if p.StartMonth == month && p.StartDay == day {
out = append(out, Task{
Type: "Aussaat",
Field: field,
Message: withOptionalNote(fmt.Sprintf("Aussaat %s", p.CropName), p.Notes),
SortOrder: 10,
})
}
harvestMonths := []int{baseHarvest}
if p.RegrowEnabled {
maxExtra := p.RegrowCycles
if maxExtra == 0 {
maxExtra = 24
}
for i := 1; i <= maxExtra; i++ {
harvestMonths = append(harvestMonths, wrapMonth(baseHarvest+(i*p.HarvestDistance())))
}
}
harvestMonths = uniqueMonths(harvestMonths)
for _, hm := range harvestMonths {
if hm == month && p.HarvestDay == day {
out = append(out, Task{
Type: "Ernte",
Field: field,
Message: withOptionalNote(fmt.Sprintf("Ernte %s", p.CropName), p.Notes),
SortOrder: 20,
})
}
}
for _, s := range steps {
switch s.Phase {
case "pre":
taskMonth := wrapMonth(p.StartMonth - s.MonthOffset)
if taskMonth == month && p.StartDay == day {
out = append(out, Task{
Type: "Vorbereitung",
Field: field,
Message: s.Title,
SortOrder: 5,
})
}
case "post":
for _, hm := range harvestMonths {
taskMonth := wrapMonth(hm + s.MonthOffset)
if taskMonth == month && p.HarvestDay == day {
out = append(out, Task{
Type: "Nachbereitung",
Field: field,
Message: s.Title,
SortOrder: 30,
})
}
}
}
}
return out
}
func (p Plan) HarvestDistance() int {
if p.GrowMonths <= 0 {
return 1
}
return p.GrowMonths
}
func withOptionalNote(base, note string) string {
n := strings.TrimSpace(note)
if n == "" {
return base
}
return fmt.Sprintf("%s (Notiz: %s)", base, n)
}
func sortTasks(tasks []Task) {
sort.Slice(tasks, func(i, j int) bool {
if tasks[i].SortOrder == tasks[j].SortOrder {
if tasks[i].Field == tasks[j].Field {
return tasks[i].Message < tasks[j].Message
}
return tasks[i].Field < tasks[j].Field
}
return tasks[i].SortOrder < tasks[j].SortOrder
})
}
func uniqueMonths(values []int) []int {
seen := make(map[int]bool)
var out []int
for _, v := range values {
if !seen[v] {
seen[v] = true
out = append(out, v)
}
}
return out
}

View File

@@ -37,7 +37,21 @@ func ensureSchema(db *sql.DB) error {
name VARCHAR(80) NOT NULL UNIQUE, name VARCHAR(80) NOT NULL UNIQUE,
sow_start_month TINYINT NOT NULL, sow_start_month TINYINT NOT NULL,
sow_end_month TINYINT NOT NULL, sow_end_month TINYINT NOT NULL,
grow_months TINYINT NOT NULL grow_months TINYINT NOT NULL,
regrow_enabled TINYINT(1) NOT NULL DEFAULT 0,
regrow_cycles INT NOT NULL DEFAULT 0
)`,
`ALTER TABLE crops ADD COLUMN IF NOT EXISTS regrow_enabled TINYINT(1) NOT NULL DEFAULT 0`,
`ALTER TABLE crops ADD COLUMN IF NOT EXISTS regrow_cycles INT NOT NULL DEFAULT 0`,
`UPDATE crops SET regrow_cycles=0 WHERE regrow_cycles < 0`,
`CREATE TABLE IF NOT EXISTS crop_steps(
id BIGINT AUTO_INCREMENT PRIMARY KEY,
crop_id BIGINT NOT NULL,
phase ENUM('pre','post') NOT NULL,
month_offset INT NOT NULL DEFAULT 0,
title VARCHAR(140) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_crop_steps_crop FOREIGN KEY(crop_id) REFERENCES crops(id) ON DELETE CASCADE
)`, )`,
`CREATE TABLE IF NOT EXISTS plans( `CREATE TABLE IF NOT EXISTS plans(
@@ -114,7 +128,7 @@ func (a *App) getFieldsByIDs(ids []int64) ([]Field, error) {
} }
func (a *App) listCrops() ([]Crop, error) { func (a *App) listCrops() ([]Crop, error) {
rows, err := a.db.Query(`SELECT id,name,sow_start_month,sow_end_month,grow_months FROM crops ORDER BY name`) rows, err := a.db.Query(`SELECT id,name,sow_start_month,sow_end_month,grow_months,regrow_enabled,regrow_cycles FROM crops ORDER BY name`)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -122,7 +136,7 @@ func (a *App) listCrops() ([]Crop, error) {
var out []Crop var out []Crop
for rows.Next() { for rows.Next() {
var c Crop var c Crop
if err := rows.Scan(&c.ID, &c.Name, &c.SowStartMonth, &c.SowEndMonth, &c.GrowMonths); err != nil { if err := rows.Scan(&c.ID, &c.Name, &c.SowStartMonth, &c.SowEndMonth, &c.GrowMonths, &c.RegrowEnabled, &c.RegrowCycles); err != nil {
return nil, err return nil, err
} }
out = append(out, c) out = append(out, c)
@@ -132,7 +146,7 @@ func (a *App) listCrops() ([]Crop, error) {
func (a *App) listPlans() ([]Plan, error) { func (a *App) listPlans() ([]Plan, error) {
rows, err := a.db.Query(` rows, err := a.db.Query(`
SELECT p.id,p.field_id,COALESCE(p.target_ref,''),COALESCE(p.target_name,''),p.crop_id,COALESCE(c.name,''),p.start_month,p.start_day,p.harvest_month,p.harvest_day,COALESCE(p.notes,'') SELECT p.id,p.field_id,COALESCE(p.target_ref,''),COALESCE(p.target_name,''),p.crop_id,COALESCE(c.name,''),COALESCE(c.grow_months,1),p.start_month,p.start_day,p.harvest_month,p.harvest_day,COALESCE(p.notes,''),COALESCE(c.regrow_enabled,0),COALESCE(c.regrow_cycles,0)
FROM plans p FROM plans p
JOIN crops c ON c.id=p.crop_id JOIN crops c ON c.id=p.crop_id
ORDER BY p.start_month,p.start_day,p.id DESC`) ORDER BY p.start_month,p.start_day,p.id DESC`)
@@ -143,7 +157,7 @@ func (a *App) listPlans() ([]Plan, error) {
var out []Plan var out []Plan
for rows.Next() { for rows.Next() {
var p Plan var p Plan
if err := rows.Scan(&p.ID, &p.FieldID, &p.TargetRef, &p.TargetName, &p.CropID, &p.CropName, &p.StartMonth, &p.StartDay, &p.HarvestMonth, &p.HarvestDay, &p.Notes); err != nil { if err := rows.Scan(&p.ID, &p.FieldID, &p.TargetRef, &p.TargetName, &p.CropID, &p.CropName, &p.GrowMonths, &p.StartMonth, &p.StartDay, &p.HarvestMonth, &p.HarvestDay, &p.Notes, &p.RegrowEnabled, &p.RegrowCycles); err != nil {
return nil, err return nil, err
} }
out = append(out, p) out = append(out, p)
@@ -151,6 +165,40 @@ func (a *App) listPlans() ([]Plan, error) {
return out, rows.Err() return out, rows.Err()
} }
func (a *App) listCropSteps() ([]CropStep, error) {
rows, err := a.db.Query(`
SELECT s.id,s.crop_id,COALESCE(c.name,''),s.phase,s.month_offset,s.title
FROM crop_steps s
JOIN crops c ON c.id=s.crop_id
ORDER BY c.name, s.phase, s.month_offset, s.id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []CropStep
for rows.Next() {
var s CropStep
if err := rows.Scan(&s.ID, &s.CropID, &s.CropName, &s.Phase, &s.MonthOffset, &s.Title); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
func (a *App) listCropStepsMap() (map[int64][]CropStep, error) {
all, err := a.listCropSteps()
if err != nil {
return nil, err
}
m := make(map[int64][]CropStep)
for _, s := range all {
m[s.CropID] = append(m[s.CropID], s)
}
return m, nil
}
func seedCrops(db *sql.DB) error { func seedCrops(db *sql.DB) error {
items := []Crop{ items := []Crop{
{Name: "Weizen", SowStartMonth: 9, SowEndMonth: 11, GrowMonths: 10}, {Name: "Weizen", SowStartMonth: 9, SowEndMonth: 11, GrowMonths: 10},
@@ -166,7 +214,7 @@ func seedCrops(db *sql.DB) error {
{Name: "Baumwolle", SowStartMonth: 2, SowEndMonth: 3, GrowMonths: 8}, {Name: "Baumwolle", SowStartMonth: 2, SowEndMonth: 3, GrowMonths: 8},
} }
for _, c := range items { for _, c := range items {
if _, err := db.Exec(`INSERT INTO crops(name,sow_start_month,sow_end_month,grow_months) VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE sow_start_month=VALUES(sow_start_month),sow_end_month=VALUES(sow_end_month),grow_months=VALUES(grow_months)`, c.Name, c.SowStartMonth, c.SowEndMonth, c.GrowMonths); err != nil { if _, err := db.Exec(`INSERT INTO crops(name,sow_start_month,sow_end_month,grow_months,regrow_enabled,regrow_cycles) VALUES (?,?,?,?,0,0) ON DUPLICATE KEY UPDATE sow_start_month=VALUES(sow_start_month),sow_end_month=VALUES(sow_end_month),grow_months=VALUES(grow_months)`, c.Name, c.SowStartMonth, c.SowEndMonth, c.GrowMonths); err != nil {
return err return err
} }
} }

View File

@@ -105,7 +105,7 @@ func wrapMonth(v int) int {
return m return m
} }
func validateCropInput(name string, start, end, grow int) error { func validateCropInput(name string, start, end, grow, regrowCycles int) error {
if name == "" { if name == "" {
return errors.New("Name der Feldfrucht fehlt") return errors.New("Name der Feldfrucht fehlt")
} }
@@ -115,5 +115,8 @@ func validateCropInput(name string, start, end, grow int) error {
if grow < 1 || grow > 24 { if grow < 1 || grow > 24 {
return errors.New("Wachstumsdauer muss 1-24 sein") return errors.New("Wachstumsdauer muss 1-24 sein")
} }
if regrowCycles < 0 || regrowCycles > 120 {
return errors.New("Regrow-Zyklen muessen 0-120 sein")
}
return nil return nil
} }

View File

@@ -210,11 +210,16 @@ func (a *App) handleCreateCrop(w http.ResponseWriter, r *http.Request) {
start := mustInt(r.FormValue("sow_start_month"), 0) start := mustInt(r.FormValue("sow_start_month"), 0)
end := mustInt(r.FormValue("sow_end_month"), 0) end := mustInt(r.FormValue("sow_end_month"), 0)
grow := mustInt(r.FormValue("grow_months"), 0) grow := mustInt(r.FormValue("grow_months"), 0)
if err := validateCropInput(name, start, end, grow); err != nil { regrowEnabled := r.FormValue("regrow_enabled") == "on"
regrowCycles := mustInt(r.FormValue("regrow_cycles"), 0)
if err := validateCropInput(name, start, end, grow, regrowCycles); err != nil {
redirectWithMessage(w, r, "/crops", "error", err.Error()) redirectWithMessage(w, r, "/crops", "error", err.Error())
return return
} }
if _, err := a.db.Exec(`INSERT INTO crops(name,sow_start_month,sow_end_month,grow_months) VALUES (?,?,?,?)`, name, start, end, grow); err != nil { if !regrowEnabled {
regrowCycles = 0
}
if _, err := a.db.Exec(`INSERT INTO crops(name,sow_start_month,sow_end_month,grow_months,regrow_enabled,regrow_cycles) VALUES (?,?,?,?,?,?)`, name, start, end, grow, regrowEnabled, regrowCycles); err != nil {
redirectWithMessage(w, r, "/crops", "error", "Feldfrucht nicht angelegt") redirectWithMessage(w, r, "/crops", "error", "Feldfrucht nicht angelegt")
return return
} }
@@ -235,15 +240,20 @@ func (a *App) handleUpdateCrop(w http.ResponseWriter, r *http.Request) {
start := mustInt(r.FormValue("sow_start_month"), 0) start := mustInt(r.FormValue("sow_start_month"), 0)
end := mustInt(r.FormValue("sow_end_month"), 0) end := mustInt(r.FormValue("sow_end_month"), 0)
grow := mustInt(r.FormValue("grow_months"), 0) grow := mustInt(r.FormValue("grow_months"), 0)
regrowEnabled := r.FormValue("regrow_enabled") == "on"
regrowCycles := mustInt(r.FormValue("regrow_cycles"), 0)
if id <= 0 { if id <= 0 {
redirectWithMessage(w, r, "/crops", "error", "Feldfrucht-ID ungueltig") redirectWithMessage(w, r, "/crops", "error", "Feldfrucht-ID ungueltig")
return return
} }
if err := validateCropInput(name, start, end, grow); err != nil { if err := validateCropInput(name, start, end, grow, regrowCycles); err != nil {
redirectWithMessage(w, r, "/crops", "error", err.Error()) redirectWithMessage(w, r, "/crops", "error", err.Error())
return return
} }
if _, err := a.db.Exec(`UPDATE crops SET name=?,sow_start_month=?,sow_end_month=?,grow_months=? WHERE id=?`, name, start, end, grow, id); err != nil { if !regrowEnabled {
regrowCycles = 0
}
if _, err := a.db.Exec(`UPDATE crops SET name=?,sow_start_month=?,sow_end_month=?,grow_months=?,regrow_enabled=?,regrow_cycles=? WHERE id=?`, name, start, end, grow, regrowEnabled, regrowCycles, id); err != nil {
redirectWithMessage(w, r, "/crops", "error", "Feldfrucht nicht aktualisiert") redirectWithMessage(w, r, "/crops", "error", "Feldfrucht nicht aktualisiert")
return return
} }
@@ -331,6 +341,72 @@ func (a *App) handleCreatePlan(w http.ResponseWriter, r *http.Request) {
redirectWithMessage(w, r, "/planning", "info", "Plan gespeichert") redirectWithMessage(w, r, "/planning", "info", "Plan gespeichert")
} }
func (a *App) handleDeletePlan(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if err := r.ParseForm(); err != nil {
redirectWithMessage(w, r, "/planning", "error", "Form ungueltig")
return
}
id := mustInt64(r.FormValue("id"), 0)
if id <= 0 {
redirectWithMessage(w, r, "/planning", "error", "Plan-ID ungueltig")
return
}
if _, err := a.db.Exec(`DELETE FROM plans WHERE id=?`, id); err != nil {
redirectWithMessage(w, r, "/planning", "error", "Plan nicht geloescht")
return
}
redirectWithMessage(w, r, "/planning", "info", "Plan geloescht")
}
func (a *App) handleCreateCropStep(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if err := r.ParseForm(); err != nil {
redirectWithMessage(w, r, "/crops", "error", "Form ungueltig")
return
}
cropID := mustInt64(r.FormValue("crop_id"), 0)
phase := strings.TrimSpace(r.FormValue("phase"))
offset := mustInt(r.FormValue("month_offset"), -1)
title := strings.TrimSpace(r.FormValue("title"))
if cropID <= 0 || (phase != "pre" && phase != "post") || offset < 0 || offset > 24 || title == "" {
redirectWithMessage(w, r, "/crops", "error", "Vor/Nachbereitung ungueltig")
return
}
if _, err := a.db.Exec(`INSERT INTO crop_steps(crop_id,phase,month_offset,title) VALUES (?,?,?,?)`, cropID, phase, offset, title); err != nil {
redirectWithMessage(w, r, "/crops", "error", "Schritt nicht gespeichert")
return
}
redirectWithMessage(w, r, "/crops", "info", "Schritt gespeichert")
}
func (a *App) handleDeleteCropStep(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if err := r.ParseForm(); err != nil {
redirectWithMessage(w, r, "/crops", "error", "Form ungueltig")
return
}
id := mustInt64(r.FormValue("id"), 0)
if id <= 0 {
redirectWithMessage(w, r, "/crops", "error", "Schritt-ID ungueltig")
return
}
if _, err := a.db.Exec(`DELETE FROM crop_steps WHERE id=?`, id); err != nil {
redirectWithMessage(w, r, "/crops", "error", "Schritt nicht geloescht")
return
}
redirectWithMessage(w, r, "/crops", "info", "Schritt geloescht")
}
func (a *App) resolvePlanningTarget(ref string) (sql.NullInt64, string, error) { func (a *App) resolvePlanningTarget(ref string) (sql.NullInt64, string, error) {
if strings.HasPrefix(ref, "f:") { if strings.HasPrefix(ref, "f:") {
id := mustInt64(strings.TrimPrefix(ref, "f:"), 0) id := mustInt64(strings.TrimPrefix(ref, "f:"), 0)

View File

@@ -31,6 +31,11 @@ func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) {
http.Error(w, "plans read failed", http.StatusInternalServerError) http.Error(w, "plans read failed", http.StatusInternalServerError)
return return
} }
stepMap, err := a.listCropStepsMap()
if err != nil {
http.Error(w, "steps read failed", http.StatusInternalServerError)
return
}
data := DashboardPage{ data := DashboardPage{
BasePage: BasePage{ BasePage: BasePage{
ActivePath: "/", ActivePath: "/",
@@ -39,8 +44,8 @@ func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) {
}, },
Settings: settings, Settings: settings,
CurrentMonth: monthNames[settings.CurrentMonth-1], CurrentMonth: monthNames[settings.CurrentMonth-1],
TodayTasks: buildTasksForDay(plans, settings.CurrentMonth, settings.CurrentDay), TodayTasks: buildTasksForDay(plans, stepMap, settings.CurrentMonth, settings.CurrentDay),
Calendar: buildCalendar(plans, settings.CurrentMonth, settings.CurrentDay, settings.DaysPerMonth, 14), Calendar: buildCalendar(plans, stepMap, settings.CurrentMonth, settings.CurrentDay, settings.DaysPerMonth, 14),
PlanningCount: len(plans), PlanningCount: len(plans),
} }
a.renderTemplate(w, "templates/dashboard.html", data) a.renderTemplate(w, "templates/dashboard.html", data)
@@ -78,13 +83,19 @@ func (a *App) handleCropsPage(w http.ResponseWriter, r *http.Request) {
http.Error(w, "crops read failed", http.StatusInternalServerError) http.Error(w, "crops read failed", http.StatusInternalServerError)
return return
} }
steps, err := a.listCropSteps()
if err != nil {
http.Error(w, "steps read failed", http.StatusInternalServerError)
return
}
data := CropsPage{ data := CropsPage{
BasePage: BasePage{ BasePage: BasePage{
ActivePath: "/crops", ActivePath: "/crops",
Error: r.URL.Query().Get("error"), Error: r.URL.Query().Get("error"),
Info: r.URL.Query().Get("info"), Info: r.URL.Query().Get("info"),
}, },
Crops: crops, Crops: crops,
CropSteps: steps,
} }
a.renderTemplate(w, "templates/crops.html", data) a.renderTemplate(w, "templates/crops.html", data)
} }

View File

@@ -59,8 +59,11 @@ func main() {
mux.HandleFunc("/crops/create", app.handleCreateCrop) mux.HandleFunc("/crops/create", app.handleCreateCrop)
mux.HandleFunc("/crops/update", app.handleUpdateCrop) mux.HandleFunc("/crops/update", app.handleUpdateCrop)
mux.HandleFunc("/crops/delete", app.handleDeleteCrop) mux.HandleFunc("/crops/delete", app.handleDeleteCrop)
mux.HandleFunc("/crop-steps/create", app.handleCreateCropStep)
mux.HandleFunc("/crop-steps/delete", app.handleDeleteCropStep)
mux.HandleFunc("/plans/create", app.handleCreatePlan) mux.HandleFunc("/plans/create", app.handleCreatePlan)
mux.HandleFunc("/plans/delete", app.handleDeletePlan)
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
srv := &http.Server{ srv := &http.Server{

View File

@@ -29,6 +29,8 @@ type Crop struct {
SowStartMonth int SowStartMonth int
SowEndMonth int SowEndMonth int
GrowMonths int GrowMonths int
RegrowEnabled bool
RegrowCycles int
} }
type Plan struct { type Plan struct {
@@ -38,11 +40,23 @@ type Plan struct {
TargetName string TargetName string
CropID int64 CropID int64
CropName string CropName string
GrowMonths int
StartMonth int StartMonth int
StartDay int StartDay int
HarvestMonth int HarvestMonth int
HarvestDay int HarvestDay int
Notes string Notes string
RegrowEnabled bool
RegrowCycles int
}
type CropStep struct {
ID int64
CropID int64
CropName string
Phase string
MonthOffset int
Title string
} }
type Task struct { type Task struct {
@@ -98,7 +112,8 @@ type FieldsPage struct {
type CropsPage struct { type CropsPage struct {
BasePage BasePage
Crops []Crop Crops []Crop
CropSteps []CropStep
} }
type PlanningPage struct { type PlanningPage struct {

24
static/farmcal-icon.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="FarmCal Icon">
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#5db6d8"/>
<stop offset="100%" stop-color="#f6d27a"/>
</linearGradient>
<linearGradient id="field" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#8dbf2f"/>
<stop offset="100%" stop-color="#4f7a1c"/>
</linearGradient>
</defs>
<rect width="256" height="256" rx="40" fill="url(#sky)"/>
<path d="M0 180 C40 150, 100 145, 256 170 L256 256 L0 256 Z" fill="url(#field)"/>
<circle cx="194" cy="62" r="20" fill="#ffe98a" opacity=".9"/>
<rect x="58" y="118" width="118" height="36" rx="8" fill="#1f2c34"/>
<rect x="136" y="84" width="48" height="40" rx="8" fill="#b8d7e9"/>
<rect x="150" y="94" width="24" height="22" rx="3" fill="#8fb3c9"/>
<rect x="82" y="98" width="54" height="26" rx="6" fill="#7fbf28"/>
<circle cx="86" cy="176" r="24" fill="#1f2c34"/>
<circle cx="86" cy="176" r="10" fill="#a73b2f"/>
<circle cx="172" cy="176" r="34" fill="#1f2c34"/>
<circle cx="172" cy="176" r="14" fill="#a73b2f"/>
<rect x="52" y="154" width="154" height="10" rx="5" fill="#141d23"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,50 +1,86 @@
:root { :root {
--bg: #f2f5ef; --sky-1: #59b8df;
--card: #ffffff; --sky-2: #f3d49a;
--text: #1f2a1d; --field-1: #87b52a;
--muted: #5d6a5a; --field-2: #3f6620;
--accent: #365f2c; --soil: #6f4e2d;
--accent-2: #93b877; --card: #ffffffee;
--danger: #9b2f2f; --text: #1c2328;
--muted: #55606a;
--accent: #7eb61a;
--accent-dark: #537b0f;
--danger: #ac2f2f;
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { body {
margin: 0; margin: 0;
font-family: "Segoe UI", Tahoma, sans-serif; font-family: "Bahnschrift", "Trebuchet MS", sans-serif;
background: radial-gradient(circle at top, #e8f1df, var(--bg));
color: var(--text); color: var(--text);
background:
radial-gradient(1100px 500px at 78% 14%, #fff6d6aa 0%, transparent 55%),
linear-gradient(180deg, var(--sky-1) 0%, var(--sky-2) 35%, #f2e4c0 55%, #d4c17f 68%, #8ea347 77%, var(--field-1) 100%);
min-height: 100vh;
} }
.top { .top {
padding: 1.2rem 1rem; position: relative;
background: linear-gradient(90deg, #2f4f2a, #4d7a40); padding: 1.1rem 1rem;
color: #fff; color: #fff;
text-shadow: 0 2px 10px #1d2b33aa;
background:
linear-gradient(130deg, #2f6e89cc, #6ca741cc),
repeating-linear-gradient(130deg, #00000011 0 10px, #ffffff10 10px 20px);
border-bottom: 2px solid #ffffff44;
}
.top h1 {
margin: 0;
display: inline-flex;
align-items: center;
gap: .55rem;
font-size: clamp(1.5rem, 2.8vw, 2.1rem);
letter-spacing: .02em;
}
.brand-icon {
width: 2rem;
height: 2rem;
border-radius: .4rem;
box-shadow: 0 4px 14px #00000055;
}
.top p {
margin: .25rem 0 0;
font-weight: 600;
opacity: .96;
} }
.top h1 { margin: 0; }
.top p { margin: .35rem 0 0; opacity: .9; }
.tabs { .tabs {
display: flex; display: flex;
gap: .5rem; gap: .5rem;
flex-wrap: wrap; flex-wrap: wrap;
padding: .7rem 1rem; padding: .75rem 1rem;
max-width: 1280px; max-width: 1320px;
margin: 0 auto; margin: 0 auto;
} }
.tabs a { .tabs a {
text-decoration: none; text-decoration: none;
color: #234020; color: #21331b;
background: #e7efdf; background: #f2f8e7d9;
border: 1px solid #cbd8c3; border: 1px solid #99bd5f;
border-radius: 999px; border-radius: 999px;
padding: .35rem .75rem; padding: .36rem .85rem;
font-weight: 600; font-weight: 700;
box-shadow: 0 2px 8px #1b331111;
} }
.tabs a.active { .tabs a.active {
color: #fff; color: #fff;
background: var(--accent); border-color: var(--accent-dark);
border-color: var(--accent); background: linear-gradient(180deg, #8fcf22, #6d9f14);
} }
.layout { .layout {
@@ -52,61 +88,85 @@ body {
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 1rem; gap: 1rem;
padding: 1rem; padding: 1rem;
max-width: 1280px; max-width: 1320px;
margin: 0 auto; margin: 0 auto;
} }
.card { .card {
background: var(--card); background: var(--card);
border: 1px solid #dbe6d5; border: 1px solid #ffffffc4;
border-radius: 12px; border-radius: 14px;
padding: 1rem; padding: 1rem;
box-shadow: 0 8px 20px rgba(0,0,0,0.05); box-shadow: 0 10px 28px #2b2d181e;
backdrop-filter: blur(2px);
} }
.card h2 { .card h2 {
margin-top: 0; margin: 0 0 .7rem;
font-size: 1.05rem; font-size: 1.06rem;
} }
.full-width { grid-column: 1 / -1; } .full-width { grid-column: 1 / -1; }
.grid { .grid {
display: grid; display: grid;
gap: .8rem; gap: .8rem;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
} }
.full { grid-column: 1 / -1; } .full { grid-column: 1 / -1; }
.mt { margin-top: .8rem; } .mt { margin-top: .8rem; }
.mt-xs { margin-top: .3rem; }
label { label {
display: grid; display: grid;
gap: .35rem; gap: .35rem;
font-weight: 600;
color: var(--muted); color: var(--muted);
}
input, select, button {
font: inherit;
padding: .55rem .65rem;
border-radius: 8px;
border: 1px solid #c9d8c2;
}
button {
background: var(--accent);
color: #fff;
border: none;
cursor: pointer;
font-weight: 700; font-weight: 700;
min-width: 0;
} }
button:hover { background: #2d5124; }
.btn-small { padding: .4rem .6rem; font-size: .9rem; } input, select, textarea {
.danger { background: #a13939; } font: inherit;
.danger:hover { background: #8f2f2f; } width: 100%;
max-width: 100%;
min-width: 0;
padding: .52rem .65rem;
border-radius: 9px;
border: 1px solid #b7c7a2;
background: #fff;
color: var(--text);
}
button {
font: inherit;
border: none;
border-radius: 9px;
padding: .55rem .85rem;
background: linear-gradient(180deg, #8fcf22, #6d9f14);
color: #fff;
cursor: pointer;
font-weight: 800;
width: auto;
max-width: 100%;
}
button:hover { background: linear-gradient(180deg, #7cbc19, #5e8a12); }
.btn-small { padding: .35rem .58rem; font-size: .9rem; }
.danger { background: linear-gradient(180deg, #cc4747, #9a2e2e); }
.danger:hover { background: linear-gradient(180deg, #b43d3d, #842626); }
.check { .check {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: .45rem; gap: .45rem;
} }
.check input { width: 1rem; height: 1rem; }
.check input {
width: 1.05rem;
height: 1.05rem;
}
.hint { .hint {
color: var(--muted); color: var(--muted);
@@ -117,65 +177,91 @@ button:hover { background: #2d5124; }
display: flex; display: flex;
gap: .45rem; gap: .45rem;
align-items: center; align-items: center;
flex-wrap: wrap;
} }
.inline-inputs { .inline-inputs {
display: inline-flex; display: inline-flex;
gap: .35rem; gap: .35rem;
align-items: center; align-items: center;
flex-wrap: wrap;
} }
.inline-inputs input { .inline-inputs input {
width: 5rem; width: 5.2rem;
} }
.tasks { .tasks {
margin: 0; margin: 0;
padding-left: 1.1rem; padding-left: 1.1rem;
} }
.tasks li { margin: .35rem 0; }
.tasks li { margin: .3rem 0; }
.task-sublist { .task-sublist {
margin: .3rem 0 0; margin: .3rem 0 0;
padding-left: 1rem; padding-left: 1rem;
} }
.muted { color: var(--muted); } .muted { color: var(--muted); }
.calendar-grid { .calendar-grid {
display: grid; display: grid;
gap: .75rem; gap: .75rem;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
} }
.month-card { .month-card {
border: 1px solid #dbe6d5; border: 1px solid #cad5ad;
border-radius: 10px; border-radius: 11px;
padding: .6rem; padding: .65rem;
background: #f9fbf7; background: linear-gradient(180deg, #fbfff5, #f0f7de);
} }
.month-card h3 { .month-card h3 {
margin: 0 0 .4rem; margin: 0 0 .45rem;
font-size: .98rem; font-size: .98rem;
} }
.month-days { .month-days {
margin: 0; margin: 0;
padding-left: 1rem; padding-left: 1rem;
max-height: 260px; max-height: 260px;
overflow: auto; overflow: auto;
} }
.month-days li { margin: .25rem 0; }
.table-wrap { overflow: auto; } .month-days li { margin: .24rem 0; }
.table-wrap {
overflow-x: auto;
max-width: 100%;
}
table { table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
min-width: 680px;
} }
th, td { th, td {
text-align: left; text-align: left;
padding: .5rem; padding: .5rem;
border-bottom: 1px solid #e2eadf; border-bottom: 1px solid #d7decb;
white-space: nowrap; vertical-align: top;
} }
th { th {
background: #eef5e8; background: #edf5de;
color: #384534; color: #2f3a24;
}
td form {
min-width: 0;
}
td input,
td select {
max-width: 100%;
} }
.toast { .toast {
@@ -186,10 +272,13 @@ th {
border-radius: 8px; border-radius: 8px;
color: #fff; color: #fff;
max-width: 380px; max-width: 380px;
box-shadow: 0 8px 18px #0000003f;
} }
.toast.error { background: var(--danger); } .toast.error { background: var(--danger); }
.toast.info { background: #2f6d7a; } .toast.info { background: #2f6d7a; }
@media (max-width: 700px) { @media (max-width: 760px) {
.layout { grid-template-columns: 1fr; } .layout { grid-template-columns: 1fr; }
table { min-width: 540px; }
} }

View File

@@ -4,10 +4,11 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>FarmCal - Feldfruechte</title> <title>FarmCal - Feldfruechte</title>
<link rel="icon" type="image/svg+xml" href="/static/farmcal-icon.svg">
<link rel="stylesheet" href="/static/styles.css"> <link rel="stylesheet" href="/static/styles.css">
</head> </head>
<body> <body>
<header class="top"><h1>FarmCal</h1><p>Feldfruechte verwalten</p></header> <header class="top"><h1><img src="/static/farmcal-icon.svg" alt="" class="brand-icon">FarmCal</h1><p>Feldfruechte verwalten</p></header>
<nav class="tabs"> <nav class="tabs">
<a href="/" class="{{if eq .ActivePath "/"}}active{{end}}">Dashboard</a> <a href="/" class="{{if eq .ActivePath "/"}}active{{end}}">Dashboard</a>
<a href="/planning" class="{{if eq .ActivePath "/planning"}}active{{end}}">Anbau planen</a> <a href="/planning" class="{{if eq .ActivePath "/planning"}}active{{end}}">Anbau planen</a>
@@ -24,6 +25,10 @@
<label>Aussaat von Monat<input type="number" name="sow_start_month" min="1" max="12" required></label> <label>Aussaat von Monat<input type="number" name="sow_start_month" min="1" max="12" required></label>
<label>Aussaat bis Monat<input type="number" name="sow_end_month" min="1" max="12" required></label> <label>Aussaat bis Monat<input type="number" name="sow_end_month" min="1" max="12" required></label>
<label>Wachstum (Monate)<input type="number" name="grow_months" min="1" max="24" required></label> <label>Wachstum (Monate)<input type="number" name="grow_months" min="1" max="24" required></label>
<label class="check"><input type="checkbox" name="regrow_enabled"> Waechst nach Ernte erneut</label>
<label>Zusatz-Ernten (0 = unendlich)
<input type="number" name="regrow_cycles" min="0" max="120" value="0">
</label>
<button type="submit">Anlegen</button> <button type="submit">Anlegen</button>
</form> </form>
</section> </section>
@@ -44,7 +49,11 @@
<span>bis</span> <span>bis</span>
<input type="number" name="sow_end_month" min="1" max="12" value="{{.SowEndMonth}}" required> <input type="number" name="sow_end_month" min="1" max="12" value="{{.SowEndMonth}}" required>
</td> </td>
<td><input type="number" name="grow_months" min="1" max="24" value="{{.GrowMonths}}" required></td> <td>
<input type="number" name="grow_months" min="1" max="24" value="{{.GrowMonths}}" required>
<label class="check mt-xs"><input type="checkbox" name="regrow_enabled" {{if .RegrowEnabled}}checked{{end}}> Regrow</label>
<input type="number" name="regrow_cycles" min="0" max="120" value="{{.RegrowCycles}}" title="0 = unendlich">
</td>
<td class="actions"> <td class="actions">
<button type="submit" class="btn-small">Speichern</button> <button type="submit" class="btn-small">Speichern</button>
<button type="submit" formaction="/crops/delete" formnovalidate class="btn-small danger" onclick="return confirm('Feldfrucht wirklich loeschen?')">Loeschen</button> <button type="submit" formaction="/crops/delete" formnovalidate class="btn-small danger" onclick="return confirm('Feldfrucht wirklich loeschen?')">Loeschen</button>
@@ -59,6 +68,59 @@
</table> </table>
</div> </div>
</section> </section>
<section class="card full-width">
<h2>Vor- und Nachbereitung je Feldfrucht</h2>
<form method="post" action="/crop-steps/create" class="grid">
<label>Feldfrucht
<select name="crop_id" required>
<option value="">Bitte waehlen</option>
{{range .Crops}}
<option value="{{.ID}}">{{.Name}}</option>
{{end}}
</select>
</label>
<label>Phase
<select name="phase" required>
<option value="pre">Vorbereitung (vor Aussaat)</option>
<option value="post">Nachbereitung (nach Ernte)</option>
</select>
</label>
<label>Monats-Offset
<input type="number" name="month_offset" min="0" max="24" value="0" required>
</label>
<label class="full">Aufgabe
<input type="text" name="title" maxlength="140" required placeholder="z.B. Kalken oder Mulchen">
</label>
<button type="submit">Schritt speichern</button>
</form>
<div class="table-wrap mt">
<table>
<thead><tr><th>Feldfrucht</th><th>Phase</th><th>Offset</th><th>Aufgabe</th><th>Aktion</th></tr></thead>
<tbody>
{{if .CropSteps}}
{{range .CropSteps}}
<tr>
<td>{{.CropName}}</td>
<td>{{if eq .Phase "pre"}}Vorbereitung{{else}}Nachbereitung{{end}}</td>
<td>{{.MonthOffset}}</td>
<td>{{.Title}}</td>
<td>
<form method="post" action="/crop-steps/delete">
<input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="btn-small danger" onclick="return confirm('Schritt loeschen?')">Loeschen</button>
</form>
</td>
</tr>
{{end}}
{{else}}
<tr><td colspan="5">Keine Schritte vorhanden.</td></tr>
{{end}}
</tbody>
</table>
</div>
</section>
</main> </main>
{{if .Error}}<div class="toast error">{{.Error}}</div>{{end}} {{if .Error}}<div class="toast error">{{.Error}}</div>{{end}}
{{if .Info}}<div class="toast info">{{.Info}}</div>{{end}} {{if .Info}}<div class="toast info">{{.Info}}</div>{{end}}

View File

@@ -4,11 +4,12 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>FarmCal - Dashboard</title> <title>FarmCal - Dashboard</title>
<link rel="icon" type="image/svg+xml" href="/static/farmcal-icon.svg">
<link rel="stylesheet" href="/static/styles.css"> <link rel="stylesheet" href="/static/styles.css">
</head> </head>
<body> <body>
<header class="top"> <header class="top">
<h1>FarmCal</h1> <h1><img src="/static/farmcal-icon.svg" alt="" class="brand-icon">FarmCal</h1>
<p>Dashboard</p> <p>Dashboard</p>
</header> </header>

View File

@@ -4,10 +4,11 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>FarmCal - Felder</title> <title>FarmCal - Felder</title>
<link rel="icon" type="image/svg+xml" href="/static/farmcal-icon.svg">
<link rel="stylesheet" href="/static/styles.css"> <link rel="stylesheet" href="/static/styles.css">
</head> </head>
<body> <body>
<header class="top"><h1>FarmCal</h1><p>Felder und Feldgruppen</p></header> <header class="top"><h1><img src="/static/farmcal-icon.svg" alt="" class="brand-icon">FarmCal</h1><p>Felder und Feldgruppen</p></header>
<nav class="tabs"> <nav class="tabs">
<a href="/" class="{{if eq .ActivePath "/"}}active{{end}}">Dashboard</a> <a href="/" class="{{if eq .ActivePath "/"}}active{{end}}">Dashboard</a>
<a href="/planning" class="{{if eq .ActivePath "/planning"}}active{{end}}">Anbau planen</a> <a href="/planning" class="{{if eq .ActivePath "/planning"}}active{{end}}">Anbau planen</a>

View File

@@ -4,10 +4,11 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>FarmCal - Allgemein</title> <title>FarmCal - Allgemein</title>
<link rel="icon" type="image/svg+xml" href="/static/farmcal-icon.svg">
<link rel="stylesheet" href="/static/styles.css"> <link rel="stylesheet" href="/static/styles.css">
</head> </head>
<body> <body>
<header class="top"><h1>FarmCal</h1><p>Allgemeine Einstellungen</p></header> <header class="top"><h1><img src="/static/farmcal-icon.svg" alt="" class="brand-icon">FarmCal</h1><p>Allgemeine Einstellungen</p></header>
<nav class="tabs"> <nav class="tabs">
<a href="/" class="{{if eq .ActivePath "/"}}active{{end}}">Dashboard</a> <a href="/" class="{{if eq .ActivePath "/"}}active{{end}}">Dashboard</a>
<a href="/planning" class="{{if eq .ActivePath "/planning"}}active{{end}}">Anbau planen</a> <a href="/planning" class="{{if eq .ActivePath "/planning"}}active{{end}}">Anbau planen</a>

View File

@@ -4,10 +4,11 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>FarmCal - Anbau planen</title> <title>FarmCal - Anbau planen</title>
<link rel="icon" type="image/svg+xml" href="/static/farmcal-icon.svg">
<link rel="stylesheet" href="/static/styles.css"> <link rel="stylesheet" href="/static/styles.css">
</head> </head>
<body> <body>
<header class="top"><h1>FarmCal</h1><p>Anbau planen</p></header> <header class="top"><h1><img src="/static/farmcal-icon.svg" alt="" class="brand-icon">FarmCal</h1><p>Anbau planen</p></header>
<nav class="tabs"> <nav class="tabs">
<a href="/" class="{{if eq .ActivePath "/"}}active{{end}}">Dashboard</a> <a href="/" class="{{if eq .ActivePath "/"}}active{{end}}">Dashboard</a>
<a href="/planning" class="{{if eq .ActivePath "/planning"}}active{{end}}">Anbau planen</a> <a href="/planning" class="{{if eq .ActivePath "/planning"}}active{{end}}">Anbau planen</a>
@@ -57,7 +58,7 @@
<h2>Geplante Durchlaeufe</h2> <h2>Geplante Durchlaeufe</h2>
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<thead><tr><th>Ziel</th><th>Frucht</th><th>Aussaat</th><th>Ernte</th><th>Notiz</th></tr></thead> <thead><tr><th>Ziel</th><th>Frucht</th><th>Aussaat</th><th>Ernte</th><th>Notiz</th><th>Aktion</th></tr></thead>
<tbody> <tbody>
{{if .Plans}} {{if .Plans}}
{{range .Plans}} {{range .Plans}}
@@ -67,10 +68,16 @@
<td>Monat {{.StartMonth}} Tag {{.StartDay}}</td> <td>Monat {{.StartMonth}} Tag {{.StartDay}}</td>
<td>Monat {{.HarvestMonth}} Tag {{.HarvestDay}}</td> <td>Monat {{.HarvestMonth}} Tag {{.HarvestDay}}</td>
<td>{{.Notes}}</td> <td>{{.Notes}}</td>
<td>
<form method="post" action="/plans/delete">
<input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="btn-small danger" onclick="return confirm('Plan wirklich loeschen?')">Loeschen</button>
</form>
</td>
</tr> </tr>
{{end}} {{end}}
{{else}} {{else}}
<tr><td colspan="5">Keine Planung vorhanden.</td></tr> <tr><td colspan="6">Keine Planung vorhanden.</td></tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>