diff --git a/cmd/server/main.go b/cmd/server/main.go index 54592bf..e0cfb45 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -27,9 +27,26 @@ type App struct { } type Field struct { - ID int64 - Number int - Name string + ID int64 + Number int + Name string + Owned bool + GroupKey string + GroupName string +} + +func (f Field) Label() string { + base := fmt.Sprintf("Feld %d", f.Number) + if f.Name != "" { + base += " (" + f.Name + ")" + } + return base +} + +type FieldGroup struct { + Key string + Name string + Numbers string } type Crop struct { @@ -42,9 +59,9 @@ type Crop struct { type Plan struct { ID int64 - FieldID int64 - FieldNumber int - FieldName string + FieldID sql.NullInt64 + TargetRef string + TargetName string CropID int64 CropName string StartMonth int @@ -61,22 +78,29 @@ type Task struct { SortOrder int } +type PlanningTarget struct { + Ref string + Label string +} + type MonthOption struct { Value int Label string } type PageData struct { - NowMonth int - NowDay int - DaysPerMonth int - Months []MonthOption - Fields []Field - Crops []Crop - Plans []Plan - Tasks []Task - Error string - Info string + NowMonth int + NowDay int + DaysPerMonth int + Months []MonthOption + Fields []Field + Groups []FieldGroup + Crops []Crop + Plans []Plan + Tasks []Task + PlanningTargets []PlanningTarget + Error string + Info string } func main() { @@ -104,9 +128,16 @@ func main() { app := &App{db: db, tmpl: tmpl} mux := http.NewServeMux() mux.HandleFunc("/", app.handleIndex) - mux.HandleFunc("/fields", app.handleCreateField) - mux.HandleFunc("/plans", app.handleCreatePlan) mux.HandleFunc("/settings", app.handleSettings) + mux.HandleFunc("/fields/create", app.handleCreateField) + mux.HandleFunc("/fields/update", app.handleUpdateField) + mux.HandleFunc("/fields/delete", app.handleDeleteField) + mux.HandleFunc("/field-groups/create", app.handleCreateFieldGroup) + mux.HandleFunc("/field-groups/delete", app.handleDeleteFieldGroup) + mux.HandleFunc("/crops/create", app.handleCreateCrop) + mux.HandleFunc("/crops/update", app.handleUpdateCrop) + mux.HandleFunc("/crops/delete", app.handleDeleteCrop) + mux.HandleFunc("/plans/create", app.handleCreatePlan) mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) srv := &http.Server{ @@ -145,6 +176,8 @@ func (a *App) handleIndex(w http.ResponseWriter, r *http.Request) { http.Error(w, "fields read failed", http.StatusInternalServerError) return } + groups := buildFieldGroups(fields) + crops, err := a.listCrops() if err != nil { http.Error(w, "crops read failed", http.StatusInternalServerError) @@ -157,16 +190,18 @@ func (a *App) handleIndex(w http.ResponseWriter, r *http.Request) { } data := PageData{ - NowMonth: nowMonth, - NowDay: nowDay, - DaysPerMonth: daysPerMonth, - Months: monthOptions(), - Fields: fields, - Crops: crops, - Plans: plans, - Tasks: buildTasksForDay(plans, nowMonth, nowDay), - Error: r.URL.Query().Get("error"), - Info: r.URL.Query().Get("info"), + NowMonth: nowMonth, + NowDay: nowDay, + DaysPerMonth: daysPerMonth, + Months: monthOptions(), + Fields: fields, + Groups: groups, + Crops: crops, + Plans: plans, + Tasks: buildTasksForDay(plans, nowMonth, nowDay), + PlanningTargets: buildPlanningTargets(fields), + Error: r.URL.Query().Get("error"), + Info: r.URL.Query().Get("info"), } if err := a.tmpl.Execute(w, data); err != nil { @@ -174,104 +209,353 @@ func (a *App) handleIndex(w http.ResponseWriter, r *http.Request) { } } -func (a *App) handleCreateField(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 { - http.Redirect(w, r, "/?error=form+ungueltig", http.StatusSeeOther) - return - } - number := mustInt(r.FormValue("number"), 0) - name := strings.TrimSpace(r.FormValue("name")) - if number <= 0 { - http.Redirect(w, r, "/?error=Feldnummer+ungueltig", http.StatusSeeOther) - return - } - if _, err := a.db.Exec(`INSERT INTO fields(number,name) VALUES (?,?)`, number, name); err != nil { - http.Redirect(w, r, "/?error=Feld+nicht+angelegt", http.StatusSeeOther) - return - } - http.Redirect(w, r, "/?info=Feld+angelegt", http.StatusSeeOther) -} - -func (a *App) handleCreatePlan(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 { - http.Redirect(w, r, "/?error=form+ungueltig", http.StatusSeeOther) - return - } - - fieldID := mustInt64(r.FormValue("field_id"), 0) - cropID := mustInt64(r.FormValue("crop_id"), 0) - startMonth := mustInt(r.FormValue("start_month"), 1) - startDay := mustInt(r.FormValue("start_day"), 1) - notes := strings.TrimSpace(r.FormValue("notes")) - - if fieldID <= 0 || cropID <= 0 { - http.Redirect(w, r, "/?error=Feld+oder+Frucht+ungueltig", http.StatusSeeOther) - return - } - - daysPerMonth, err := a.getDaysPerMonth() - if err != nil { - http.Redirect(w, r, "/?error=Einstellungen+nicht+lesbar", http.StatusSeeOther) - return - } - if startMonth < 1 || startMonth > 12 || startDay < 1 || startDay > daysPerMonth { - http.Redirect(w, r, "/?error=Startdatum+ungueltig", http.StatusSeeOther) - return - } - - var c Crop - err = a.db.QueryRow(`SELECT id,name,sow_start_month,sow_end_month,grow_months FROM crops WHERE id=?`, cropID). - Scan(&c.ID, &c.Name, &c.SowStartMonth, &c.SowEndMonth, &c.GrowMonths) - if err != nil { - http.Redirect(w, r, "/?error=Feldfrucht+nicht+gefunden", http.StatusSeeOther) - return - } - - if !monthInWindow(startMonth, c.SowStartMonth, c.SowEndMonth) { - http.Redirect(w, r, "/?error=Aussaat+ausserhalb+des+Zeitfensters", http.StatusSeeOther) - return - } - - harvestMonth := wrapMonth(startMonth + c.GrowMonths) - harvestDay := startDay - _, err = a.db.Exec( - `INSERT INTO plans(field_id,crop_id,start_month,start_day,harvest_month,harvest_day,notes) VALUES (?,?,?,?,?,?,?)`, - fieldID, cropID, startMonth, startDay, harvestMonth, harvestDay, notes, - ) - if err != nil { - http.Redirect(w, r, "/?error=Plan+nicht+gespeichert", http.StatusSeeOther) - return - } - http.Redirect(w, r, "/?info=Plan+gespeichert", http.StatusSeeOther) -} - func (a *App) handleSettings(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 { - http.Redirect(w, r, "/?error=form+ungueltig", http.StatusSeeOther) + redirectWithMessage(w, r, "error", "Form ungueltig") return } daysPerMonth := mustInt(r.FormValue("days_per_month"), 2) if daysPerMonth < 1 || daysPerMonth > 31 { - http.Redirect(w, r, "/?error=Tage+pro+Monat+1-31", http.StatusSeeOther) + redirectWithMessage(w, r, "error", "Tage pro Monat 1-31") return } if _, err := a.db.Exec(`UPDATE settings SET days_per_month=? WHERE id=1`, daysPerMonth); err != nil { - http.Redirect(w, r, "/?error=Einstellungen+nicht+gespeichert", http.StatusSeeOther) + redirectWithMessage(w, r, "error", "Einstellungen nicht gespeichert") return } - http.Redirect(w, r, "/?info=Einstellungen+gespeichert", http.StatusSeeOther) + redirectWithMessage(w, r, "info", "Einstellungen gespeichert") +} + +func (a *App) handleCreateField(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, "error", "Form ungueltig") + return + } + number := mustInt(r.FormValue("number"), 0) + name := strings.TrimSpace(r.FormValue("name")) + owned := r.FormValue("owned") == "on" + if number <= 0 { + redirectWithMessage(w, r, "error", "Feldnummer ungueltig") + return + } + if _, err := a.db.Exec(`INSERT INTO fields(number,name,owned) VALUES (?,?,?)`, number, name, owned); err != nil { + redirectWithMessage(w, r, "error", "Feld nicht angelegt (Nummer evtl. schon vorhanden)") + return + } + redirectWithMessage(w, r, "info", "Feld angelegt") +} + +func (a *App) handleUpdateField(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, "error", "Form ungueltig") + return + } + id := mustInt64(r.FormValue("id"), 0) + name := strings.TrimSpace(r.FormValue("name")) + owned := r.FormValue("owned") == "on" + if id <= 0 { + redirectWithMessage(w, r, "error", "Feld-ID ungueltig") + return + } + + if owned { + if _, err := a.db.Exec(`UPDATE fields SET name=?,owned=? WHERE id=?`, name, owned, id); err != nil { + redirectWithMessage(w, r, "error", "Feld nicht aktualisiert") + return + } + } else { + if _, err := a.db.Exec(`UPDATE fields SET name=?,owned=?,group_key='',group_name='' WHERE id=?`, name, owned, id); err != nil { + redirectWithMessage(w, r, "error", "Feld nicht aktualisiert") + return + } + } + redirectWithMessage(w, r, "info", "Feld aktualisiert") +} + +func (a *App) handleDeleteField(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, "error", "Form ungueltig") + return + } + id := mustInt64(r.FormValue("id"), 0) + if id <= 0 { + redirectWithMessage(w, r, "error", "Feld-ID ungueltig") + return + } + if _, err := a.db.Exec(`DELETE FROM fields WHERE id=?`, id); err != nil { + redirectWithMessage(w, r, "error", "Feld nicht geloescht") + return + } + redirectWithMessage(w, r, "info", "Feld geloescht") +} + +func (a *App) handleCreateFieldGroup(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, "error", "Form ungueltig") + return + } + ids, err := parseInt64List(r.Form["field_ids"]) + if err != nil || len(ids) == 0 { + redirectWithMessage(w, r, "error", "Mindestens ein Feld auswaehlen") + return + } + + fields, err := a.getFieldsByIDs(ids) + if err != nil || len(fields) != len(ids) { + redirectWithMessage(w, r, "error", "Felder nicht eindeutig gefunden") + return + } + + groupName := strings.TrimSpace(r.FormValue("name")) + if groupName == "" { + groupName = autoGroupName(fields) + } + groupKey := fmt.Sprintf("g%d", time.Now().UnixNano()) + + tx, err := a.db.Begin() + if err != nil { + redirectWithMessage(w, r, "error", "Gruppierung fehlgeschlagen") + return + } + defer tx.Rollback() + + for _, f := range fields { + if _, err := tx.Exec(`UPDATE fields SET group_key=?,group_name=? WHERE id=?`, groupKey, groupName, f.ID); err != nil { + redirectWithMessage(w, r, "error", "Gruppierung fehlgeschlagen") + return + } + } + if err := tx.Commit(); err != nil { + redirectWithMessage(w, r, "error", "Gruppierung fehlgeschlagen") + return + } + redirectWithMessage(w, r, "info", "Feldgruppe gespeichert") +} + +func (a *App) handleDeleteFieldGroup(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, "error", "Form ungueltig") + return + } + key := strings.TrimSpace(r.FormValue("group_key")) + if key == "" { + redirectWithMessage(w, r, "error", "Gruppen-ID ungueltig") + return + } + if _, err := a.db.Exec(`UPDATE fields SET group_key='',group_name='' WHERE group_key=?`, key); err != nil { + redirectWithMessage(w, r, "error", "Gruppe nicht aufgeloest") + return + } + redirectWithMessage(w, r, "info", "Gruppe aufgeloest") +} + +func (a *App) handleCreateCrop(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, "error", "Form ungueltig") + return + } + name := strings.TrimSpace(r.FormValue("name")) + start := mustInt(r.FormValue("sow_start_month"), 0) + end := mustInt(r.FormValue("sow_end_month"), 0) + grow := mustInt(r.FormValue("grow_months"), 0) + if err := validateCropInput(name, start, end, grow); err != nil { + redirectWithMessage(w, r, "error", err.Error()) + 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 { + redirectWithMessage(w, r, "error", "Feldfrucht nicht angelegt") + return + } + redirectWithMessage(w, r, "info", "Feldfrucht angelegt") +} + +func (a *App) handleUpdateCrop(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, "error", "Form ungueltig") + return + } + id := mustInt64(r.FormValue("id"), 0) + name := strings.TrimSpace(r.FormValue("name")) + start := mustInt(r.FormValue("sow_start_month"), 0) + end := mustInt(r.FormValue("sow_end_month"), 0) + grow := mustInt(r.FormValue("grow_months"), 0) + if id <= 0 { + redirectWithMessage(w, r, "error", "Feldfrucht-ID ungueltig") + return + } + if err := validateCropInput(name, start, end, grow); err != nil { + redirectWithMessage(w, r, "error", err.Error()) + 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 { + redirectWithMessage(w, r, "error", "Feldfrucht nicht aktualisiert") + return + } + redirectWithMessage(w, r, "info", "Feldfrucht aktualisiert") +} + +func (a *App) handleDeleteCrop(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, "error", "Form ungueltig") + return + } + id := mustInt64(r.FormValue("id"), 0) + if id <= 0 { + redirectWithMessage(w, r, "error", "Feldfrucht-ID ungueltig") + return + } + if _, err := a.db.Exec(`DELETE FROM crops WHERE id=?`, id); err != nil { + redirectWithMessage(w, r, "error", "Feldfrucht nicht geloescht (ggf. in Verwendung)") + return + } + redirectWithMessage(w, r, "info", "Feldfrucht geloescht") +} + +func (a *App) handleCreatePlan(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, "error", "Form ungueltig") + return + } + + targetRef := strings.TrimSpace(r.FormValue("target_ref")) + cropID := mustInt64(r.FormValue("crop_id"), 0) + startMonth := mustInt(r.FormValue("start_month"), 1) + startDay := mustInt(r.FormValue("start_day"), 1) + notes := strings.TrimSpace(r.FormValue("notes")) + if targetRef == "" || cropID <= 0 { + redirectWithMessage(w, r, "error", "Ziel oder Feldfrucht ungueltig") + return + } + + daysPerMonth, err := a.getDaysPerMonth() + if err != nil { + redirectWithMessage(w, r, "error", "Einstellungen nicht lesbar") + return + } + if startMonth < 1 || startMonth > 12 || startDay < 1 || startDay > daysPerMonth { + redirectWithMessage(w, r, "error", "Startdatum ungueltig") + return + } + + var c Crop + err = a.db.QueryRow(`SELECT id,name,sow_start_month,sow_end_month,grow_months FROM crops WHERE id=?`, cropID). + Scan(&c.ID, &c.Name, &c.SowStartMonth, &c.SowEndMonth, &c.GrowMonths) + if err != nil { + redirectWithMessage(w, r, "error", "Feldfrucht nicht gefunden") + return + } + if !monthInWindow(startMonth, c.SowStartMonth, c.SowEndMonth) { + redirectWithMessage(w, r, "error", "Aussaat ausserhalb des Zeitfensters") + return + } + + fieldID, targetName, err := a.resolvePlanningTarget(targetRef) + if err != nil { + redirectWithMessage(w, r, "error", err.Error()) + return + } + + harvestMonth := wrapMonth(startMonth + c.GrowMonths) + harvestDay := startDay + _, err = a.db.Exec( + `INSERT INTO plans(field_id,target_ref,target_name,crop_id,start_month,start_day,harvest_month,harvest_day,notes) VALUES (?,?,?,?,?,?,?,?,?)`, + fieldID, targetRef, targetName, cropID, startMonth, startDay, harvestMonth, harvestDay, notes, + ) + if err != nil { + redirectWithMessage(w, r, "error", "Plan nicht gespeichert") + return + } + redirectWithMessage(w, r, "info", "Plan gespeichert") +} + +func (a *App) resolvePlanningTarget(ref string) (sql.NullInt64, string, error) { + if strings.HasPrefix(ref, "f:") { + id := mustInt64(strings.TrimPrefix(ref, "f:"), 0) + if id <= 0 { + return sql.NullInt64{}, "", errors.New("Feld-Ziel ungueltig") + } + var f Field + err := a.db.QueryRow(`SELECT id,number,name,owned FROM fields WHERE id=?`, id). + Scan(&f.ID, &f.Number, &f.Name, &f.Owned) + if err != nil { + return sql.NullInt64{}, "", errors.New("Feld nicht gefunden") + } + if !f.Owned { + return sql.NullInt64{}, "", errors.New("Feld ist nicht im Besitz") + } + return sql.NullInt64{Int64: f.ID, Valid: true}, f.Label(), nil + } + + if strings.HasPrefix(ref, "g:") { + groupKey := strings.TrimPrefix(ref, "g:") + if groupKey == "" { + return sql.NullInt64{}, "", errors.New("Gruppen-Ziel ungueltig") + } + rows, err := a.db.Query(`SELECT number FROM fields WHERE group_key=? AND owned=1 ORDER BY number`, groupKey) + if err != nil { + return sql.NullInt64{}, "", errors.New("Gruppe nicht lesbar") + } + defer rows.Close() + var nums []string + for rows.Next() { + var n int + if err := rows.Scan(&n); err != nil { + return sql.NullInt64{}, "", errors.New("Gruppe nicht lesbar") + } + nums = append(nums, strconv.Itoa(n)) + } + if len(nums) == 0 { + return sql.NullInt64{}, "", errors.New("Gruppe hat keine Felder im Besitz") + } + var groupName string + _ = a.db.QueryRow(`SELECT COALESCE(group_name,'') FROM fields WHERE group_key=? LIMIT 1`, groupKey).Scan(&groupName) + if strings.TrimSpace(groupName) == "" { + groupName = "Feld " + strings.Join(nums, "+") + } + return sql.NullInt64{}, groupName, nil + } + + return sql.NullInt64{}, "", errors.New("Planungsziel ungueltig") } func (a *App) getDaysPerMonth() (int, error) { @@ -281,7 +565,7 @@ func (a *App) getDaysPerMonth() (int, error) { } func (a *App) listFields() ([]Field, error) { - rows, err := a.db.Query(`SELECT id,number,name FROM fields ORDER BY number`) + rows, err := a.db.Query(`SELECT id,number,name,owned,COALESCE(group_key,''),COALESCE(group_name,'') FROM fields ORDER BY number`) if err != nil { return nil, err } @@ -289,7 +573,37 @@ func (a *App) listFields() ([]Field, error) { var out []Field for rows.Next() { var x Field - if err := rows.Scan(&x.ID, &x.Number, &x.Name); err != nil { + if err := rows.Scan(&x.ID, &x.Number, &x.Name, &x.Owned, &x.GroupKey, &x.GroupName); err != nil { + return nil, err + } + out = append(out, x) + } + return out, rows.Err() +} + +func (a *App) getFieldsByIDs(ids []int64) ([]Field, error) { + if len(ids) == 0 { + return nil, nil + } + ph := placeholders(len(ids)) + args := make([]any, 0, len(ids)) + for _, id := range ids { + args = append(args, id) + } + + rows, err := a.db.Query( + fmt.Sprintf(`SELECT id,number,name,owned,COALESCE(group_key,''),COALESCE(group_name,'') FROM fields WHERE id IN (%s)`, ph), + args..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []Field + for rows.Next() { + var x Field + if err := rows.Scan(&x.ID, &x.Number, &x.Name, &x.Owned, &x.GroupKey, &x.GroupName); err != nil { return nil, err } out = append(out, x) @@ -316,11 +630,10 @@ func (a *App) listCrops() ([]Crop, error) { func (a *App) listPlans() ([]Plan, error) { rows, err := a.db.Query(` - SELECT p.id,p.field_id,f.number,COALESCE(f.name,''),p.crop_id,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,''),p.start_month,p.start_day,p.harvest_month,p.harvest_day,COALESCE(p.notes,'') FROM plans p - JOIN fields f ON f.id=p.field_id JOIN crops c ON c.id=p.crop_id - ORDER BY p.start_month,p.start_day,f.number`) + ORDER BY p.start_month,p.start_day,p.id DESC`) if err != nil { return nil, err } @@ -328,7 +641,7 @@ func (a *App) listPlans() ([]Plan, error) { var out []Plan for rows.Next() { var x Plan - if err := rows.Scan(&x.ID, &x.FieldID, &x.FieldNumber, &x.FieldName, &x.CropID, &x.CropName, &x.StartMonth, &x.StartDay, &x.HarvestMonth, &x.HarvestDay, &x.Notes); err != nil { + if err := rows.Scan(&x.ID, &x.FieldID, &x.TargetRef, &x.TargetName, &x.CropID, &x.CropName, &x.StartMonth, &x.StartDay, &x.HarvestMonth, &x.HarvestDay, &x.Notes); err != nil { return nil, err } out = append(out, x) @@ -340,15 +653,54 @@ func ensureSchema(db *sql.DB) error { stmts := []string{ `CREATE TABLE IF NOT EXISTS settings(id TINYINT PRIMARY KEY,days_per_month INT NOT NULL DEFAULT 2)`, `INSERT INTO settings(id,days_per_month) VALUES (1,2) ON DUPLICATE KEY UPDATE id=id`, - `CREATE TABLE IF NOT EXISTS fields(id BIGINT AUTO_INCREMENT PRIMARY KEY,number INT NOT NULL UNIQUE,name VARCHAR(120) NOT NULL DEFAULT '',created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)`, - `CREATE TABLE IF NOT EXISTS crops(id BIGINT AUTO_INCREMENT PRIMARY KEY,name VARCHAR(80) NOT NULL UNIQUE,sow_start_month TINYINT NOT NULL,sow_end_month TINYINT NOT NULL,grow_months TINYINT NOT NULL)`, - `CREATE TABLE IF NOT EXISTS plans(id BIGINT AUTO_INCREMENT PRIMARY KEY,field_id BIGINT NOT NULL,crop_id BIGINT NOT NULL,start_month TINYINT NOT NULL,start_day TINYINT NOT NULL,harvest_month TINYINT NOT NULL,harvest_day TINYINT NOT NULL,notes VARCHAR(255) NOT NULL DEFAULT '',created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,CONSTRAINT fk_plans_fields FOREIGN KEY(field_id) REFERENCES fields(id) ON DELETE CASCADE,CONSTRAINT fk_plans_crops FOREIGN KEY(crop_id) REFERENCES crops(id) ON DELETE RESTRICT)`, + + `CREATE TABLE IF NOT EXISTS fields( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + number INT NOT NULL UNIQUE, + name VARCHAR(120) NOT NULL DEFAULT '', + owned TINYINT(1) NOT NULL DEFAULT 0, + group_key VARCHAR(64) NOT NULL DEFAULT '', + group_name VARCHAR(120) NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + )`, + `ALTER TABLE fields ADD COLUMN IF NOT EXISTS owned TINYINT(1) NOT NULL DEFAULT 0`, + `ALTER TABLE fields ADD COLUMN IF NOT EXISTS group_key VARCHAR(64) NOT NULL DEFAULT ''`, + `ALTER TABLE fields ADD COLUMN IF NOT EXISTS group_name VARCHAR(120) NOT NULL DEFAULT ''`, + + `CREATE TABLE IF NOT EXISTS crops( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(80) NOT NULL UNIQUE, + sow_start_month TINYINT NOT NULL, + sow_end_month TINYINT NOT NULL, + grow_months TINYINT NOT NULL + )`, + + `CREATE TABLE IF NOT EXISTS plans( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + field_id BIGINT NULL, + target_ref VARCHAR(80) NOT NULL DEFAULT '', + target_name VARCHAR(140) NOT NULL DEFAULT '', + crop_id BIGINT NOT NULL, + start_month TINYINT NOT NULL, + start_day TINYINT NOT NULL, + harvest_month TINYINT NOT NULL, + harvest_day TINYINT NOT NULL, + notes VARCHAR(255) NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_plans_fields FOREIGN KEY(field_id) REFERENCES fields(id) ON DELETE CASCADE, + CONSTRAINT fk_plans_crops FOREIGN KEY(crop_id) REFERENCES crops(id) ON DELETE RESTRICT + )`, + `ALTER TABLE plans MODIFY COLUMN field_id BIGINT NULL`, + `ALTER TABLE plans ADD COLUMN IF NOT EXISTS target_ref VARCHAR(80) NOT NULL DEFAULT '' AFTER field_id`, + `ALTER TABLE plans ADD COLUMN IF NOT EXISTS target_name VARCHAR(140) NOT NULL DEFAULT '' AFTER target_ref`, } + for _, stmt := range stmts { if _, err := db.Exec(stmt); err != nil { return err } } + return seedCrops(db) } @@ -374,12 +726,91 @@ func seedCrops(db *sql.DB) error { return nil } +func buildPlanningTargets(fields []Field) []PlanningTarget { + groupMap := make(map[string][]Field) + var out []PlanningTarget + + for _, f := range fields { + if !f.Owned { + continue + } + if f.GroupKey == "" { + out = append(out, PlanningTarget{ + Ref: fmt.Sprintf("f:%d", f.ID), + Label: f.Label(), + }) + continue + } + groupMap[f.GroupKey] = append(groupMap[f.GroupKey], f) + } + + groupKeys := make([]string, 0, len(groupMap)) + for k := range groupMap { + groupKeys = append(groupKeys, k) + } + sort.Strings(groupKeys) + for _, k := range groupKeys { + members := groupMap[k] + sort.Slice(members, func(i, j int) bool { return members[i].Number < members[j].Number }) + label := members[0].GroupName + if strings.TrimSpace(label) == "" { + label = autoGroupName(members) + } + out = append(out, PlanningTarget{ + Ref: "g:" + k, + Label: label, + }) + } + + sort.Slice(out, func(i, j int) bool { return out[i].Label < out[j].Label }) + return out +} + +func buildFieldGroups(fields []Field) []FieldGroup { + groupMap := make(map[string][]Field) + for _, f := range fields { + if f.GroupKey == "" { + continue + } + groupMap[f.GroupKey] = append(groupMap[f.GroupKey], f) + } + + var out []FieldGroup + for k, members := range groupMap { + sort.Slice(members, func(i, j int) bool { return members[i].Number < members[j].Number }) + nums := make([]string, 0, len(members)) + for _, m := range members { + nums = append(nums, strconv.Itoa(m.Number)) + } + name := members[0].GroupName + if strings.TrimSpace(name) == "" { + name = autoGroupName(members) + } + out = append(out, FieldGroup{ + Key: k, + Name: name, + Numbers: strings.Join(nums, "+"), + }) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +func autoGroupName(fields []Field) string { + sort.Slice(fields, func(i, j int) bool { return fields[i].Number < fields[j].Number }) + parts := make([]string, 0, len(fields)) + for _, f := range fields { + parts = append(parts, strconv.Itoa(f.Number)) + } + return "Feld " + strings.Join(parts, "+") +} + func buildTasksForDay(plans []Plan, month, day int) []Task { var tasks []Task for _, p := range plans { - field := fmt.Sprintf("Feld %d", p.FieldNumber) - if p.FieldName != "" { - field = fmt.Sprintf("Feld %d (%s)", p.FieldNumber, p.FieldName) + field := p.TargetName + 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}) @@ -405,6 +836,19 @@ func monthOptions() []MonthOption { return out } +func validateCropInput(name string, start, end, grow int) error { + if name == "" { + return errors.New("Name der Feldfrucht fehlt") + } + if start < 1 || start > 12 || end < 1 || end > 12 { + return errors.New("Aussaatmonat muss 1-12 sein") + } + if grow < 1 || grow > 24 { + return errors.New("Wachstumsdauer muss 1-24 sein") + } + return nil +} + func monthInWindow(month, start, end int) bool { if start <= end { return month >= start && month <= end @@ -420,6 +864,34 @@ func wrapMonth(v int) int { return m } +func placeholders(n int) string { + if n <= 0 { + return "" + } + parts := make([]string, 0, n) + for i := 0; i < n; i++ { + parts = append(parts, "?") + } + return strings.Join(parts, ",") +} + +func parseInt64List(values []string) ([]int64, error) { + var out []int64 + for _, v := range values { + id := mustInt64(v, 0) + if id <= 0 { + return nil, errors.New("invalid id") + } + out = append(out, id) + } + return out, nil +} + +func redirectWithMessage(w http.ResponseWriter, r *http.Request, key, value string) { + target := fmt.Sprintf("/?%s=%s", key, strings.ReplaceAll(value, " ", "+")) + http.Redirect(w, r, target, http.StatusSeeOther) +} + func mustInt(v string, fallback int) int { n, err := strconv.Atoi(strings.TrimSpace(v)) if err != nil { diff --git a/static/styles.css b/static/styles.css index ea44250..79e52d6 100644 --- a/static/styles.css +++ b/static/styles.css @@ -73,6 +73,36 @@ button { font-weight: 700; } button:hover { background: #2d5124; } +.btn-small { padding: .4rem .6rem; font-size: .9rem; } +.danger { background: #a13939; } +.danger:hover { background: #8f2f2f; } + +.check { + display: inline-flex; + align-items: center; + gap: .45rem; +} +.check input { width: 1rem; height: 1rem; } + +.hint { + color: var(--muted); + font-size: .9rem; +} + +.actions { + display: flex; + gap: .45rem; + align-items: center; +} + +.inline-inputs { + display: inline-flex; + gap: .35rem; + align-items: center; +} +.inline-inputs input { + width: 5rem; +} .tasks { margin: 0; diff --git a/templates/index.html b/templates/index.html index 8134690..1856ebf 100644 --- a/templates/index.html +++ b/templates/index.html @@ -9,7 +9,7 @@
Planung fuer Felder, Aussaat und Ernte
+Planung fuer Felder, Feldgruppen, Aussaat und Ernte