Add crop CRUD, owned fields, and field grouping for planning
This commit is contained in:
@@ -30,6 +30,23 @@ type Field struct {
|
|||||||
ID int64
|
ID int64
|
||||||
Number int
|
Number int
|
||||||
Name string
|
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 {
|
type Crop struct {
|
||||||
@@ -42,9 +59,9 @@ type Crop struct {
|
|||||||
|
|
||||||
type Plan struct {
|
type Plan struct {
|
||||||
ID int64
|
ID int64
|
||||||
FieldID int64
|
FieldID sql.NullInt64
|
||||||
FieldNumber int
|
TargetRef string
|
||||||
FieldName string
|
TargetName string
|
||||||
CropID int64
|
CropID int64
|
||||||
CropName string
|
CropName string
|
||||||
StartMonth int
|
StartMonth int
|
||||||
@@ -61,6 +78,11 @@ type Task struct {
|
|||||||
SortOrder int
|
SortOrder int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PlanningTarget struct {
|
||||||
|
Ref string
|
||||||
|
Label string
|
||||||
|
}
|
||||||
|
|
||||||
type MonthOption struct {
|
type MonthOption struct {
|
||||||
Value int
|
Value int
|
||||||
Label string
|
Label string
|
||||||
@@ -72,9 +94,11 @@ type PageData struct {
|
|||||||
DaysPerMonth int
|
DaysPerMonth int
|
||||||
Months []MonthOption
|
Months []MonthOption
|
||||||
Fields []Field
|
Fields []Field
|
||||||
|
Groups []FieldGroup
|
||||||
Crops []Crop
|
Crops []Crop
|
||||||
Plans []Plan
|
Plans []Plan
|
||||||
Tasks []Task
|
Tasks []Task
|
||||||
|
PlanningTargets []PlanningTarget
|
||||||
Error string
|
Error string
|
||||||
Info string
|
Info string
|
||||||
}
|
}
|
||||||
@@ -104,9 +128,16 @@ func main() {
|
|||||||
app := &App{db: db, tmpl: tmpl}
|
app := &App{db: db, tmpl: tmpl}
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/", app.handleIndex)
|
mux.HandleFunc("/", app.handleIndex)
|
||||||
mux.HandleFunc("/fields", app.handleCreateField)
|
|
||||||
mux.HandleFunc("/plans", app.handleCreatePlan)
|
|
||||||
mux.HandleFunc("/settings", app.handleSettings)
|
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"))))
|
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
|
||||||
|
|
||||||
srv := &http.Server{
|
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)
|
http.Error(w, "fields read failed", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
groups := buildFieldGroups(fields)
|
||||||
|
|
||||||
crops, err := a.listCrops()
|
crops, err := a.listCrops()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "crops read failed", http.StatusInternalServerError)
|
http.Error(w, "crops read failed", http.StatusInternalServerError)
|
||||||
@@ -162,9 +195,11 @@ func (a *App) handleIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
DaysPerMonth: daysPerMonth,
|
DaysPerMonth: daysPerMonth,
|
||||||
Months: monthOptions(),
|
Months: monthOptions(),
|
||||||
Fields: fields,
|
Fields: fields,
|
||||||
|
Groups: groups,
|
||||||
Crops: crops,
|
Crops: crops,
|
||||||
Plans: plans,
|
Plans: plans,
|
||||||
Tasks: buildTasksForDay(plans, nowMonth, nowDay),
|
Tasks: buildTasksForDay(plans, nowMonth, nowDay),
|
||||||
|
PlanningTargets: buildPlanningTargets(fields),
|
||||||
Error: r.URL.Query().Get("error"),
|
Error: r.URL.Query().Get("error"),
|
||||||
Info: r.URL.Query().Get("info"),
|
Info: r.URL.Query().Get("info"),
|
||||||
}
|
}
|
||||||
@@ -174,26 +209,242 @@ func (a *App) handleIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
redirectWithMessage(w, r, "error", "Form ungueltig")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
daysPerMonth := mustInt(r.FormValue("days_per_month"), 2)
|
||||||
|
if daysPerMonth < 1 || daysPerMonth > 31 {
|
||||||
|
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 {
|
||||||
|
redirectWithMessage(w, r, "error", "Einstellungen nicht gespeichert")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
redirectWithMessage(w, r, "info", "Einstellungen gespeichert")
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) handleCreateField(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleCreateField(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
http.Redirect(w, r, "/?error=form+ungueltig", http.StatusSeeOther)
|
redirectWithMessage(w, r, "error", "Form ungueltig")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
number := mustInt(r.FormValue("number"), 0)
|
number := mustInt(r.FormValue("number"), 0)
|
||||||
name := strings.TrimSpace(r.FormValue("name"))
|
name := strings.TrimSpace(r.FormValue("name"))
|
||||||
|
owned := r.FormValue("owned") == "on"
|
||||||
if number <= 0 {
|
if number <= 0 {
|
||||||
http.Redirect(w, r, "/?error=Feldnummer+ungueltig", http.StatusSeeOther)
|
redirectWithMessage(w, r, "error", "Feldnummer ungueltig")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := a.db.Exec(`INSERT INTO fields(number,name) VALUES (?,?)`, number, name); err != nil {
|
if _, err := a.db.Exec(`INSERT INTO fields(number,name,owned) VALUES (?,?,?)`, number, name, owned); err != nil {
|
||||||
http.Redirect(w, r, "/?error=Feld+nicht+angelegt", http.StatusSeeOther)
|
redirectWithMessage(w, r, "error", "Feld nicht angelegt (Nummer evtl. schon vorhanden)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, "/?info=Feld+angelegt", http.StatusSeeOther)
|
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) {
|
func (a *App) handleCreatePlan(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -202,28 +453,27 @@ func (a *App) handleCreatePlan(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
http.Redirect(w, r, "/?error=form+ungueltig", http.StatusSeeOther)
|
redirectWithMessage(w, r, "error", "Form ungueltig")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
fieldID := mustInt64(r.FormValue("field_id"), 0)
|
targetRef := strings.TrimSpace(r.FormValue("target_ref"))
|
||||||
cropID := mustInt64(r.FormValue("crop_id"), 0)
|
cropID := mustInt64(r.FormValue("crop_id"), 0)
|
||||||
startMonth := mustInt(r.FormValue("start_month"), 1)
|
startMonth := mustInt(r.FormValue("start_month"), 1)
|
||||||
startDay := mustInt(r.FormValue("start_day"), 1)
|
startDay := mustInt(r.FormValue("start_day"), 1)
|
||||||
notes := strings.TrimSpace(r.FormValue("notes"))
|
notes := strings.TrimSpace(r.FormValue("notes"))
|
||||||
|
if targetRef == "" || cropID <= 0 {
|
||||||
if fieldID <= 0 || cropID <= 0 {
|
redirectWithMessage(w, r, "error", "Ziel oder Feldfrucht ungueltig")
|
||||||
http.Redirect(w, r, "/?error=Feld+oder+Frucht+ungueltig", http.StatusSeeOther)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
daysPerMonth, err := a.getDaysPerMonth()
|
daysPerMonth, err := a.getDaysPerMonth()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Redirect(w, r, "/?error=Einstellungen+nicht+lesbar", http.StatusSeeOther)
|
redirectWithMessage(w, r, "error", "Einstellungen nicht lesbar")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if startMonth < 1 || startMonth > 12 || startDay < 1 || startDay > daysPerMonth {
|
if startMonth < 1 || startMonth > 12 || startDay < 1 || startDay > daysPerMonth {
|
||||||
http.Redirect(w, r, "/?error=Startdatum+ungueltig", http.StatusSeeOther)
|
redirectWithMessage(w, r, "error", "Startdatum ungueltig")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,47 +481,81 @@ func (a *App) handleCreatePlan(w http.ResponseWriter, r *http.Request) {
|
|||||||
err = a.db.QueryRow(`SELECT id,name,sow_start_month,sow_end_month,grow_months FROM crops WHERE id=?`, cropID).
|
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)
|
Scan(&c.ID, &c.Name, &c.SowStartMonth, &c.SowEndMonth, &c.GrowMonths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Redirect(w, r, "/?error=Feldfrucht+nicht+gefunden", http.StatusSeeOther)
|
redirectWithMessage(w, r, "error", "Feldfrucht nicht gefunden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !monthInWindow(startMonth, c.SowStartMonth, c.SowEndMonth) {
|
||||||
|
redirectWithMessage(w, r, "error", "Aussaat ausserhalb des Zeitfensters")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !monthInWindow(startMonth, c.SowStartMonth, c.SowEndMonth) {
|
fieldID, targetName, err := a.resolvePlanningTarget(targetRef)
|
||||||
http.Redirect(w, r, "/?error=Aussaat+ausserhalb+des+Zeitfensters", http.StatusSeeOther)
|
if err != nil {
|
||||||
|
redirectWithMessage(w, r, "error", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
harvestMonth := wrapMonth(startMonth + c.GrowMonths)
|
harvestMonth := wrapMonth(startMonth + c.GrowMonths)
|
||||||
harvestDay := startDay
|
harvestDay := startDay
|
||||||
_, err = a.db.Exec(
|
_, err = a.db.Exec(
|
||||||
`INSERT INTO plans(field_id,crop_id,start_month,start_day,harvest_month,harvest_day,notes) VALUES (?,?,?,?,?,?,?)`,
|
`INSERT INTO plans(field_id,target_ref,target_name,crop_id,start_month,start_day,harvest_month,harvest_day,notes) VALUES (?,?,?,?,?,?,?,?,?)`,
|
||||||
fieldID, cropID, startMonth, startDay, harvestMonth, harvestDay, notes,
|
fieldID, targetRef, targetName, cropID, startMonth, startDay, harvestMonth, harvestDay, notes,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Redirect(w, r, "/?error=Plan+nicht+gespeichert", http.StatusSeeOther)
|
redirectWithMessage(w, r, "error", "Plan nicht gespeichert")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, "/?info=Plan+gespeichert", http.StatusSeeOther)
|
redirectWithMessage(w, r, "info", "Plan gespeichert")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handleSettings(w http.ResponseWriter, r *http.Request) {
|
func (a *App) resolvePlanningTarget(ref string) (sql.NullInt64, string, error) {
|
||||||
if r.Method != http.MethodPost {
|
if strings.HasPrefix(ref, "f:") {
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
id := mustInt64(strings.TrimPrefix(ref, "f:"), 0)
|
||||||
return
|
if id <= 0 {
|
||||||
|
return sql.NullInt64{}, "", errors.New("Feld-Ziel ungueltig")
|
||||||
}
|
}
|
||||||
if err := r.ParseForm(); err != nil {
|
var f Field
|
||||||
http.Redirect(w, r, "/?error=form+ungueltig", http.StatusSeeOther)
|
err := a.db.QueryRow(`SELECT id,number,name,owned FROM fields WHERE id=?`, id).
|
||||||
return
|
Scan(&f.ID, &f.Number, &f.Name, &f.Owned)
|
||||||
|
if err != nil {
|
||||||
|
return sql.NullInt64{}, "", errors.New("Feld nicht gefunden")
|
||||||
}
|
}
|
||||||
daysPerMonth := mustInt(r.FormValue("days_per_month"), 2)
|
if !f.Owned {
|
||||||
if daysPerMonth < 1 || daysPerMonth > 31 {
|
return sql.NullInt64{}, "", errors.New("Feld ist nicht im Besitz")
|
||||||
http.Redirect(w, r, "/?error=Tage+pro+Monat+1-31", http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if _, err := a.db.Exec(`UPDATE settings SET days_per_month=? WHERE id=1`, daysPerMonth); err != nil {
|
return sql.NullInt64{Int64: f.ID, Valid: true}, f.Label(), nil
|
||||||
http.Redirect(w, r, "/?error=Einstellungen+nicht+gespeichert", http.StatusSeeOther)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, "/?info=Einstellungen+gespeichert", http.StatusSeeOther)
|
|
||||||
|
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) {
|
func (a *App) getDaysPerMonth() (int, error) {
|
||||||
@@ -281,7 +565,7 @@ func (a *App) getDaysPerMonth() (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) listFields() ([]Field, 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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -289,7 +573,37 @@ func (a *App) listFields() ([]Field, error) {
|
|||||||
var out []Field
|
var out []Field
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var x Field
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
out = append(out, x)
|
out = append(out, x)
|
||||||
@@ -316,11 +630,10 @@ 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,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
|
FROM plans p
|
||||||
JOIN fields f ON f.id=p.field_id
|
|
||||||
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,f.number`)
|
ORDER BY p.start_month,p.start_day,p.id DESC`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -328,7 +641,7 @@ func (a *App) listPlans() ([]Plan, error) {
|
|||||||
var out []Plan
|
var out []Plan
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var x Plan
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
out = append(out, x)
|
out = append(out, x)
|
||||||
@@ -340,15 +653,54 @@ func ensureSchema(db *sql.DB) error {
|
|||||||
stmts := []string{
|
stmts := []string{
|
||||||
`CREATE TABLE IF NOT EXISTS settings(id TINYINT PRIMARY KEY,days_per_month INT NOT NULL DEFAULT 2)`,
|
`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`,
|
`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 fields(
|
||||||
`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)`,
|
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 {
|
for _, stmt := range stmts {
|
||||||
if _, err := db.Exec(stmt); err != nil {
|
if _, err := db.Exec(stmt); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return seedCrops(db)
|
return seedCrops(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,12 +726,91 @@ func seedCrops(db *sql.DB) error {
|
|||||||
return nil
|
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 {
|
func buildTasksForDay(plans []Plan, month, day int) []Task {
|
||||||
var tasks []Task
|
var tasks []Task
|
||||||
for _, p := range plans {
|
for _, p := range plans {
|
||||||
field := fmt.Sprintf("Feld %d", p.FieldNumber)
|
field := p.TargetName
|
||||||
if p.FieldName != "" {
|
if strings.TrimSpace(field) == "" {
|
||||||
field = fmt.Sprintf("Feld %d (%s)", p.FieldNumber, p.FieldName)
|
field = "Unbekanntes Feld"
|
||||||
}
|
}
|
||||||
if p.StartMonth == month && p.StartDay == day {
|
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})
|
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
|
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 {
|
func monthInWindow(month, start, end int) bool {
|
||||||
if start <= end {
|
if start <= end {
|
||||||
return month >= start && month <= end
|
return month >= start && month <= end
|
||||||
@@ -420,6 +864,34 @@ func wrapMonth(v int) int {
|
|||||||
return m
|
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 {
|
func mustInt(v string, fallback int) int {
|
||||||
n, err := strconv.Atoi(strings.TrimSpace(v))
|
n, err := strconv.Atoi(strings.TrimSpace(v))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -73,6 +73,36 @@ button {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
button:hover { background: #2d5124; }
|
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 {
|
.tasks {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<header class="top">
|
<header class="top">
|
||||||
<h1>FarmCal</h1>
|
<h1>FarmCal</h1>
|
||||||
<p>Planung fuer Felder, Aussaat und Ernte</p>
|
<p>Planung fuer Felder, Feldgruppen, Aussaat und Ernte</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="layout">
|
<main class="layout">
|
||||||
@@ -28,7 +28,6 @@
|
|||||||
</label>
|
</label>
|
||||||
<button type="submit">Anzeigen</button>
|
<button type="submit">Anzeigen</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<form method="post" action="/settings" class="grid mt">
|
<form method="post" action="/settings" class="grid mt">
|
||||||
<label>Tage pro Monat
|
<label>Tage pro Monat
|
||||||
<input type="number" name="days_per_month" min="1" max="31" value="{{.DaysPerMonth}}">
|
<input type="number" name="days_per_month" min="1" max="31" value="{{.DaysPerMonth}}">
|
||||||
@@ -42,9 +41,7 @@
|
|||||||
{{if .Tasks}}
|
{{if .Tasks}}
|
||||||
<ul class="tasks">
|
<ul class="tasks">
|
||||||
{{range .Tasks}}
|
{{range .Tasks}}
|
||||||
<li>
|
<li><strong>{{.Type}}:</strong> {{.Message}}</li>
|
||||||
<strong>{{.Type}}:</strong> {{.Message}}
|
|
||||||
</li>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</ul>
|
||||||
{{else}}
|
{{else}}
|
||||||
@@ -53,39 +50,197 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>Feld anlegen</h2>
|
<h2>Felder verwalten</h2>
|
||||||
<form method="post" action="/fields" class="grid">
|
<form method="post" action="/fields/create" class="grid">
|
||||||
<label>Feldnummer
|
<label>Feldnummer
|
||||||
<input type="number" name="number" min="1" required>
|
<input type="number" name="number" min="1" required>
|
||||||
</label>
|
</label>
|
||||||
<label>Name (optional)
|
<label>Name (optional)
|
||||||
<input type="text" name="name" maxlength="120">
|
<input type="text" name="name" maxlength="120">
|
||||||
</label>
|
</label>
|
||||||
<button type="submit">Feld speichern</button>
|
<label class="check">
|
||||||
|
<input type="checkbox" name="owned">
|
||||||
|
Im Besitz
|
||||||
|
</label>
|
||||||
|
<button type="submit">Feld anlegen</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div class="table-wrap mt">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nummer</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Besitz</th>
|
||||||
|
<th>Gruppe</th>
|
||||||
|
<th>Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{if .Fields}}
|
||||||
|
{{range .Fields}}
|
||||||
|
<tr>
|
||||||
|
<form method="post" action="/fields/update">
|
||||||
|
<td>
|
||||||
|
Feld {{.Number}}
|
||||||
|
<input type="hidden" name="id" value="{{.ID}}">
|
||||||
|
</td>
|
||||||
|
<td><input type="text" name="name" value="{{.Name}}" maxlength="120"></td>
|
||||||
|
<td>
|
||||||
|
<label class="check">
|
||||||
|
<input type="checkbox" name="owned" {{if .Owned}}checked{{end}}>
|
||||||
|
Im Besitz
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
<td>{{if .GroupName}}{{.GroupName}}{{else}}-{{end}}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<button type="submit" class="btn-small">Speichern</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/fields/delete" onsubmit="return confirm('Feld wirklich loeschen?');">
|
||||||
|
<input type="hidden" name="id" value="{{.ID}}">
|
||||||
|
<button type="submit" class="btn-small danger">Loeschen</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="5">Noch keine Felder vorhanden.</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>Anbau planen</h2>
|
<h2>Feldgruppen</h2>
|
||||||
<form method="post" action="/plans" class="grid">
|
<form method="post" action="/field-groups/create" class="grid">
|
||||||
<label>Feld
|
<label class="full">Name (optional)
|
||||||
<select name="field_id" required>
|
<input type="text" name="name" maxlength="120" placeholder="z.B. Feld 1+2">
|
||||||
<option value="">Bitte waehlen</option>
|
</label>
|
||||||
|
<label class="full">Felder (1 bis X)
|
||||||
|
<select name="field_ids" multiple size="6">
|
||||||
{{range .Fields}}
|
{{range .Fields}}
|
||||||
<option value="{{.ID}}">Feld {{.Number}}{{if .Name}} ({{.Name}}){{end}}</option>
|
<option value="{{.ID}}">Feld {{.Number}}{{if .Name}} ({{.Name}}){{end}}</option>
|
||||||
{{end}}
|
{{end}}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<button type="submit">Gruppe speichern</button>
|
||||||
|
</form>
|
||||||
|
<p class="hint">Mehrfachauswahl: mit Strg/Cmd klicken.</p>
|
||||||
|
|
||||||
|
<div class="table-wrap mt">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Felder</th>
|
||||||
|
<th>Aktion</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{if .Groups}}
|
||||||
|
{{range .Groups}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.Name}}</td>
|
||||||
|
<td>{{.Numbers}}</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/field-groups/delete" onsubmit="return confirm('Gruppe aufloesen?');">
|
||||||
|
<input type="hidden" name="group_key" value="{{.Key}}">
|
||||||
|
<button type="submit" class="btn-small danger">Aufloesen</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="3">Noch keine Feldgruppen vorhanden.</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Feldfruechte verwalten</h2>
|
||||||
|
<form method="post" action="/crops/create" class="grid">
|
||||||
|
<label>Name
|
||||||
|
<input type="text" name="name" maxlength="80" 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>Wachstum (Monate)
|
||||||
|
<input type="number" name="grow_months" min="1" max="24" required>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Feldfrucht anlegen</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="table-wrap mt">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Aussaat</th>
|
||||||
|
<th>Wachstum</th>
|
||||||
|
<th>Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{if .Crops}}
|
||||||
|
{{range .Crops}}
|
||||||
|
<tr>
|
||||||
|
<form method="post" action="/crops/update">
|
||||||
|
<td>
|
||||||
|
<input type="hidden" name="id" value="{{.ID}}">
|
||||||
|
<input type="text" name="name" value="{{.Name}}" maxlength="80" required>
|
||||||
|
</td>
|
||||||
|
<td class="inline-inputs">
|
||||||
|
<input type="number" name="sow_start_month" min="1" max="12" value="{{.SowStartMonth}}" required>
|
||||||
|
<span>bis</span>
|
||||||
|
<input type="number" name="sow_end_month" min="1" max="12" value="{{.SowEndMonth}}" required>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="number" name="grow_months" min="1" max="24" value="{{.GrowMonths}}" required>
|
||||||
|
</td>
|
||||||
|
<td class="actions">
|
||||||
|
<button type="submit" class="btn-small">Speichern</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/crops/delete" onsubmit="return confirm('Feldfrucht wirklich loeschen?');">
|
||||||
|
<input type="hidden" name="id" value="{{.ID}}">
|
||||||
|
<button type="submit" class="btn-small danger">Loeschen</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="4">Keine Feldfruechte vorhanden.</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Anbau planen</h2>
|
||||||
|
<form method="post" action="/plans/create" class="grid">
|
||||||
|
<label>Planungsziel
|
||||||
|
<select name="target_ref" required>
|
||||||
|
<option value="">Bitte waehlen</option>
|
||||||
|
{{range .PlanningTargets}}
|
||||||
|
<option value="{{.Ref}}">{{.Label}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label>Feldfrucht
|
<label>Feldfrucht
|
||||||
<select name="crop_id" required>
|
<select name="crop_id" required>
|
||||||
<option value="">Bitte waehlen</option>
|
<option value="">Bitte waehlen</option>
|
||||||
{{range .Crops}}
|
{{range .Crops}}
|
||||||
<option value="{{.ID}}">{{.Name}} (Aussaat Monat {{.SowStartMonth}}-{{.SowEndMonth}})</option>
|
<option value="{{.ID}}">{{.Name}} (Monat {{.SowStartMonth}}-{{.SowEndMonth}})</option>
|
||||||
{{end}}
|
{{end}}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label>Startmonat
|
<label>Startmonat
|
||||||
<select name="start_month" required>
|
<select name="start_month" required>
|
||||||
{{range .Months}}
|
{{range .Months}}
|
||||||
@@ -93,15 +248,12 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label>Starttag
|
<label>Starttag
|
||||||
<input type="number" name="start_day" min="1" max="{{.DaysPerMonth}}" value="{{.NowDay}}" required>
|
<input type="number" name="start_day" min="1" max="{{.DaysPerMonth}}" value="{{.NowDay}}" required>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="full">Notiz
|
<label class="full">Notiz
|
||||||
<input type="text" name="notes" maxlength="255">
|
<input type="text" name="notes" maxlength="255">
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button type="submit">Plan speichern</button>
|
<button type="submit">Plan speichern</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
@@ -112,7 +264,7 @@
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Feld</th>
|
<th>Ziel</th>
|
||||||
<th>Frucht</th>
|
<th>Frucht</th>
|
||||||
<th>Aussaat</th>
|
<th>Aussaat</th>
|
||||||
<th>Ernte</th>
|
<th>Ernte</th>
|
||||||
@@ -123,7 +275,7 @@
|
|||||||
{{if .Plans}}
|
{{if .Plans}}
|
||||||
{{range .Plans}}
|
{{range .Plans}}
|
||||||
<tr>
|
<tr>
|
||||||
<td>Feld {{.FieldNumber}}{{if .FieldName}} ({{.FieldName}}){{end}}</td>
|
<td>{{.TargetName}}</td>
|
||||||
<td>{{.CropName}}</td>
|
<td>{{.CropName}}</td>
|
||||||
<td>Monat {{.StartMonth}} Tag {{.StartDay}}</td>
|
<td>Monat {{.StartMonth}} Tag {{.StartDay}}</td>
|
||||||
<td>Monat {{.HarvestMonth}} Tag {{.HarvestDay}}</td>
|
<td>Monat {{.HarvestMonth}} Tag {{.HarvestDay}}</td>
|
||||||
|
|||||||
Reference in New Issue
Block a user