Split app into subpages, persist ingame time, and add 14-month dashboard calendar
This commit is contained in:
87
cmd/server/calendar.go
Normal file
87
cmd/server/calendar.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func buildTasksForDay(plans []Plan, month, day int) []Task {
|
||||
var tasks []Task
|
||||
for _, p := range plans {
|
||||
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})
|
||||
}
|
||||
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 {
|
||||
if tasks[i].SortOrder == tasks[j].SortOrder {
|
||||
return tasks[i].Field < tasks[j].Field
|
||||
}
|
||||
return tasks[i].SortOrder < tasks[j].SortOrder
|
||||
})
|
||||
return tasks
|
||||
}
|
||||
|
||||
func buildCalendar(plans []Plan, startMonth, startDay, daysPerMonth, spanMonths int) []CalendarMonth {
|
||||
tasksByKey := make(map[string][]Task)
|
||||
for _, p := range plans {
|
||||
field := p.TargetName
|
||||
if strings.TrimSpace(field) == "" {
|
||||
field = "Unbekanntes Feld"
|
||||
}
|
||||
tasksByKey[fmt.Sprintf("%d-%d", p.StartMonth, p.StartDay)] = append(tasksByKey[fmt.Sprintf("%d-%d", p.StartMonth, p.StartDay)], Task{
|
||||
Type: "Aussaat",
|
||||
Field: field,
|
||||
Message: fmt.Sprintf("%s auf %s", p.CropName, field),
|
||||
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("%s auf %s", p.CropName, field),
|
||||
SortOrder: 2,
|
||||
})
|
||||
}
|
||||
|
||||
var out []CalendarMonth
|
||||
for offset := 0; offset < spanMonths; offset++ {
|
||||
month := wrapMonth(startMonth + offset)
|
||||
yearOffset := (startMonth - 1 + offset) / 12
|
||||
label := monthNames[month-1]
|
||||
if yearOffset > 0 {
|
||||
label = fmt.Sprintf("%s (+%d Jahr)", label, yearOffset)
|
||||
}
|
||||
|
||||
fromDay := 1
|
||||
if offset == 0 {
|
||||
fromDay = startDay
|
||||
}
|
||||
var days []CalendarDay
|
||||
for day := fromDay; day <= daysPerMonth; day++ {
|
||||
key := fmt.Sprintf("%d-%d", month, day)
|
||||
items := append([]Task(nil), tasksByKey[key]...)
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
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{
|
||||
Offset: offset,
|
||||
Month: month,
|
||||
Label: label,
|
||||
YearOffset: yearOffset,
|
||||
Days: days,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
172
cmd/server/db.go
Normal file
172
cmd/server/db.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
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,
|
||||
current_month TINYINT NOT NULL DEFAULT 1,
|
||||
current_day TINYINT NOT NULL DEFAULT 1
|
||||
)`,
|
||||
`INSERT INTO settings(id,days_per_month,current_month,current_day) VALUES (1,2,1,1) ON DUPLICATE KEY UPDATE id=id`,
|
||||
`ALTER TABLE settings ADD COLUMN IF NOT EXISTS current_month TINYINT NOT NULL DEFAULT 1`,
|
||||
`ALTER TABLE settings ADD COLUMN IF NOT EXISTS current_day TINYINT NOT NULL DEFAULT 1`,
|
||||
|
||||
`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)
|
||||
}
|
||||
|
||||
func (a *App) getSettings() (Settings, error) {
|
||||
var s Settings
|
||||
err := a.db.QueryRow(`SELECT days_per_month,current_month,current_day FROM settings WHERE id=1`).
|
||||
Scan(&s.DaysPerMonth, &s.CurrentMonth, &s.CurrentDay)
|
||||
return s, err
|
||||
}
|
||||
|
||||
func (a *App) listFields() ([]Field, error) {
|
||||
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
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Field
|
||||
for rows.Next() {
|
||||
var f Field
|
||||
if err := rows.Scan(&f.ID, &f.Number, &f.Name, &f.Owned, &f.GroupKey, &f.GroupName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (a *App) getFieldsByIDs(ids []int64) ([]Field, error) {
|
||||
args := make([]any, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
args = append(args, id)
|
||||
}
|
||||
q := fmt.Sprintf(`SELECT id,number,name,owned,COALESCE(group_key,''),COALESCE(group_name,'') FROM fields WHERE id IN (%s)`, placeholders(len(ids)))
|
||||
rows, err := a.db.Query(q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Field
|
||||
for rows.Next() {
|
||||
var f Field
|
||||
if err := rows.Scan(&f.ID, &f.Number, &f.Name, &f.Owned, &f.GroupKey, &f.GroupName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
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`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Crop
|
||||
for rows.Next() {
|
||||
var c Crop
|
||||
if err := rows.Scan(&c.ID, &c.Name, &c.SowStartMonth, &c.SowEndMonth, &c.GrowMonths); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (a *App) listPlans() ([]Plan, error) {
|
||||
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,'')
|
||||
FROM plans p
|
||||
JOIN crops c ON c.id=p.crop_id
|
||||
ORDER BY p.start_month,p.start_day,p.id DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Plan
|
||||
for rows.Next() {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func seedCrops(db *sql.DB) error {
|
||||
items := []Crop{
|
||||
{Name: "Weizen", SowStartMonth: 9, SowEndMonth: 11, GrowMonths: 10},
|
||||
{Name: "Gerste", SowStartMonth: 9, SowEndMonth: 10, GrowMonths: 9},
|
||||
{Name: "Hafer", SowStartMonth: 3, SowEndMonth: 4, GrowMonths: 4},
|
||||
{Name: "Raps", SowStartMonth: 8, SowEndMonth: 9, GrowMonths: 11},
|
||||
{Name: "Mais", SowStartMonth: 4, SowEndMonth: 5, GrowMonths: 6},
|
||||
{Name: "Sorghum", SowStartMonth: 4, SowEndMonth: 5, GrowMonths: 5},
|
||||
{Name: "Sojabohnen", SowStartMonth: 4, SowEndMonth: 5, GrowMonths: 5},
|
||||
{Name: "Sonnenblumen", SowStartMonth: 4, SowEndMonth: 5, GrowMonths: 5},
|
||||
{Name: "Kartoffeln", SowStartMonth: 3, SowEndMonth: 4, GrowMonths: 7},
|
||||
{Name: "Zuckerrueben", SowStartMonth: 3, SowEndMonth: 4, GrowMonths: 8},
|
||||
{Name: "Baumwolle", SowStartMonth: 2, SowEndMonth: 3, GrowMonths: 8},
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
119
cmd/server/domain.go
Normal file
119
cmd/server/domain.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
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: fieldLabel(f)})
|
||||
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 })
|
||||
name := members[0].GroupName
|
||||
if strings.TrimSpace(name) == "" {
|
||||
name = autoGroupName(members)
|
||||
}
|
||||
out = append(out, PlanningTarget{Ref: "g:" + k, Label: name})
|
||||
}
|
||||
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 strings.TrimSpace(f.GroupKey) == "" {
|
||||
continue
|
||||
}
|
||||
groupMap[f.GroupKey] = append(groupMap[f.GroupKey], f)
|
||||
}
|
||||
|
||||
var out []FieldGroup
|
||||
for key, 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: key, 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 fieldLabel(f Field) string {
|
||||
base := fmt.Sprintf("Feld %d", f.Number)
|
||||
if strings.TrimSpace(f.Name) != "" {
|
||||
base += " (" + f.Name + ")"
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func monthOptions() []MonthOption {
|
||||
out := make([]MonthOption, 0, 12)
|
||||
for i, m := range monthNames {
|
||||
out = append(out, MonthOption{Value: i + 1, Label: m})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func monthInWindow(month, start, end int) bool {
|
||||
if start <= end {
|
||||
return month >= start && month <= end
|
||||
}
|
||||
return month >= start || month <= end
|
||||
}
|
||||
|
||||
func wrapMonth(v int) int {
|
||||
m := v % 12
|
||||
if m == 0 {
|
||||
return 12
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
379
cmd/server/handlers_actions.go
Normal file
379
cmd/server/handlers_actions.go
Normal file
@@ -0,0 +1,379 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (a *App) handleSetDaysPerMonth(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, "/general", "error", "Form ungueltig")
|
||||
return
|
||||
}
|
||||
days := mustInt(r.FormValue("days_per_month"), 2)
|
||||
if days < 1 || days > 31 {
|
||||
redirectWithMessage(w, r, "/general", "error", "Tage pro Monat 1-31")
|
||||
return
|
||||
}
|
||||
if _, err := a.db.Exec(`UPDATE settings SET days_per_month=? WHERE id=1`, days); err != nil {
|
||||
redirectWithMessage(w, r, "/general", "error", "Einstellung nicht gespeichert")
|
||||
return
|
||||
}
|
||||
redirectWithMessage(w, r, "/general", "info", "Tage pro Monat gespeichert")
|
||||
}
|
||||
|
||||
func (a *App) handleSetCurrentTime(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
|
||||
}
|
||||
settings, err := a.getSettings()
|
||||
if err != nil {
|
||||
redirectWithMessage(w, r, "/", "error", "Einstellungen nicht lesbar")
|
||||
return
|
||||
}
|
||||
month := mustInt(r.FormValue("current_month"), 1)
|
||||
day := mustInt(r.FormValue("current_day"), 1)
|
||||
if month < 1 || month > 12 || day < 1 || day > settings.DaysPerMonth {
|
||||
redirectWithMessage(w, r, "/", "error", "Ingame-Zeit ungueltig")
|
||||
return
|
||||
}
|
||||
if _, err := a.db.Exec(`UPDATE settings SET current_month=?, current_day=? WHERE id=1`, month, day); err != nil {
|
||||
redirectWithMessage(w, r, "/", "error", "Ingame-Zeit nicht gespeichert")
|
||||
return
|
||||
}
|
||||
redirectWithMessage(w, r, "/", "info", "Ingame-Zeit 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, "/fields", "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, "/fields", "error", "Feldnummer ungueltig")
|
||||
return
|
||||
}
|
||||
if _, err := a.db.Exec(`INSERT INTO fields(number,name,owned) VALUES (?,?,?)`, number, name, owned); err != nil {
|
||||
redirectWithMessage(w, r, "/fields", "error", "Feld nicht angelegt")
|
||||
return
|
||||
}
|
||||
redirectWithMessage(w, r, "/fields", "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, "/fields", "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, "/fields", "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, "/fields", "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, "/fields", "error", "Feld nicht aktualisiert")
|
||||
return
|
||||
}
|
||||
}
|
||||
redirectWithMessage(w, r, "/fields", "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, "/fields", "error", "Form ungueltig")
|
||||
return
|
||||
}
|
||||
id := mustInt64(r.FormValue("id"), 0)
|
||||
if id <= 0 {
|
||||
redirectWithMessage(w, r, "/fields", "error", "Feld-ID ungueltig")
|
||||
return
|
||||
}
|
||||
if _, err := a.db.Exec(`DELETE FROM fields WHERE id=?`, id); err != nil {
|
||||
redirectWithMessage(w, r, "/fields", "error", "Feld nicht geloescht")
|
||||
return
|
||||
}
|
||||
redirectWithMessage(w, r, "/fields", "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, "/fields", "error", "Form ungueltig")
|
||||
return
|
||||
}
|
||||
ids, err := parseInt64List(r.Form["field_ids"])
|
||||
if err != nil || len(ids) == 0 {
|
||||
redirectWithMessage(w, r, "/fields", "error", "Mindestens ein Feld auswaehlen")
|
||||
return
|
||||
}
|
||||
fields, err := a.getFieldsByIDs(ids)
|
||||
if err != nil || len(fields) != len(ids) {
|
||||
redirectWithMessage(w, r, "/fields", "error", "Felder nicht gefunden")
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(r.FormValue("name"))
|
||||
if name == "" {
|
||||
name = autoGroupName(fields)
|
||||
}
|
||||
groupKey := fmt.Sprintf("g%d", time.Now().UnixNano())
|
||||
|
||||
tx, err := a.db.Begin()
|
||||
if err != nil {
|
||||
redirectWithMessage(w, r, "/fields", "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, name, f.ID); err != nil {
|
||||
redirectWithMessage(w, r, "/fields", "error", "Gruppierung fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
redirectWithMessage(w, r, "/fields", "error", "Gruppierung fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
redirectWithMessage(w, r, "/fields", "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, "/fields", "error", "Form ungueltig")
|
||||
return
|
||||
}
|
||||
key := strings.TrimSpace(r.FormValue("group_key"))
|
||||
if key == "" {
|
||||
redirectWithMessage(w, r, "/fields", "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, "/fields", "error", "Gruppe nicht aufgeloest")
|
||||
return
|
||||
}
|
||||
redirectWithMessage(w, r, "/fields", "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, "/crops", "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, "/crops", "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, "/crops", "error", "Feldfrucht nicht angelegt")
|
||||
return
|
||||
}
|
||||
redirectWithMessage(w, r, "/crops", "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, "/crops", "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, "/crops", "error", "Feldfrucht-ID ungueltig")
|
||||
return
|
||||
}
|
||||
if err := validateCropInput(name, start, end, grow); err != nil {
|
||||
redirectWithMessage(w, r, "/crops", "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, "/crops", "error", "Feldfrucht nicht aktualisiert")
|
||||
return
|
||||
}
|
||||
redirectWithMessage(w, r, "/crops", "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, "/crops", "error", "Form ungueltig")
|
||||
return
|
||||
}
|
||||
id := mustInt64(r.FormValue("id"), 0)
|
||||
if id <= 0 {
|
||||
redirectWithMessage(w, r, "/crops", "error", "Feldfrucht-ID ungueltig")
|
||||
return
|
||||
}
|
||||
if _, err := a.db.Exec(`DELETE FROM crops WHERE id=?`, id); err != nil {
|
||||
redirectWithMessage(w, r, "/crops", "error", "Feldfrucht nicht geloescht")
|
||||
return
|
||||
}
|
||||
redirectWithMessage(w, r, "/crops", "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, "/planning", "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, "/planning", "error", "Planungsziel oder Feldfrucht ungueltig")
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := a.getSettings()
|
||||
if err != nil {
|
||||
redirectWithMessage(w, r, "/planning", "error", "Einstellungen nicht lesbar")
|
||||
return
|
||||
}
|
||||
if startMonth < 1 || startMonth > 12 || startDay < 1 || startDay > settings.DaysPerMonth {
|
||||
redirectWithMessage(w, r, "/planning", "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, "/planning", "error", "Feldfrucht nicht gefunden")
|
||||
return
|
||||
}
|
||||
if !monthInWindow(startMonth, c.SowStartMonth, c.SowEndMonth) {
|
||||
redirectWithMessage(w, r, "/planning", "error", "Aussaat ausserhalb des Zeitfensters")
|
||||
return
|
||||
}
|
||||
|
||||
fieldID, targetName, err := a.resolvePlanningTarget(targetRef)
|
||||
if err != nil {
|
||||
redirectWithMessage(w, r, "/planning", "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, "/planning", "error", "Plan nicht gespeichert")
|
||||
return
|
||||
}
|
||||
redirectWithMessage(w, r, "/planning", "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}, fieldLabel(f), nil
|
||||
}
|
||||
if strings.HasPrefix(ref, "g:") {
|
||||
key := strings.TrimPrefix(ref, "g:")
|
||||
if key == "" {
|
||||
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`, key)
|
||||
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`, key).Scan(&groupName)
|
||||
if strings.TrimSpace(groupName) == "" {
|
||||
groupName = "Feld " + strings.Join(nums, "+")
|
||||
}
|
||||
return sql.NullInt64{}, groupName, nil
|
||||
}
|
||||
return sql.NullInt64{}, "", errors.New("Planungsziel ungueltig")
|
||||
}
|
||||
152
cmd/server/handlers_pages.go
Normal file
152
cmd/server/handlers_pages.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (a *App) renderTemplate(w http.ResponseWriter, path string, data any) {
|
||||
tmpl, err := template.ParseFiles(path)
|
||||
if err != nil {
|
||||
http.Error(w, "template parse failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
http.Error(w, "template render failed", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
settings, err := a.getSettings()
|
||||
if err != nil {
|
||||
http.Error(w, "settings read failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
plans, err := a.listPlans()
|
||||
if err != nil {
|
||||
http.Error(w, "plans read failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := DashboardPage{
|
||||
BasePage: BasePage{
|
||||
ActivePath: "/",
|
||||
Error: r.URL.Query().Get("error"),
|
||||
Info: r.URL.Query().Get("info"),
|
||||
},
|
||||
Settings: settings,
|
||||
CurrentMonth: monthNames[settings.CurrentMonth-1],
|
||||
TodayTasks: buildTasksForDay(plans, settings.CurrentMonth, settings.CurrentDay),
|
||||
Calendar: buildCalendar(plans, settings.CurrentMonth, settings.CurrentDay, settings.DaysPerMonth, 14),
|
||||
PlanningCount: len(plans),
|
||||
}
|
||||
a.renderTemplate(w, "templates/dashboard.html", data)
|
||||
}
|
||||
|
||||
func (a *App) handleFieldsPage(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
fields, err := a.listFields()
|
||||
if err != nil {
|
||||
http.Error(w, "fields read failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := FieldsPage{
|
||||
BasePage: BasePage{
|
||||
ActivePath: "/fields",
|
||||
Error: r.URL.Query().Get("error"),
|
||||
Info: r.URL.Query().Get("info"),
|
||||
},
|
||||
Fields: fields,
|
||||
Groups: buildFieldGroups(fields),
|
||||
}
|
||||
a.renderTemplate(w, "templates/fields.html", data)
|
||||
}
|
||||
|
||||
func (a *App) handleCropsPage(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
crops, err := a.listCrops()
|
||||
if err != nil {
|
||||
http.Error(w, "crops read failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := CropsPage{
|
||||
BasePage: BasePage{
|
||||
ActivePath: "/crops",
|
||||
Error: r.URL.Query().Get("error"),
|
||||
Info: r.URL.Query().Get("info"),
|
||||
},
|
||||
Crops: crops,
|
||||
}
|
||||
a.renderTemplate(w, "templates/crops.html", data)
|
||||
}
|
||||
|
||||
func (a *App) handlePlanningPage(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
settings, err := a.getSettings()
|
||||
if err != nil {
|
||||
http.Error(w, "settings read failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
crops, err := a.listCrops()
|
||||
if err != nil {
|
||||
http.Error(w, "crops read failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
fields, err := a.listFields()
|
||||
if err != nil {
|
||||
http.Error(w, "fields read failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
plans, err := a.listPlans()
|
||||
if err != nil {
|
||||
http.Error(w, "plans read failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := PlanningPage{
|
||||
BasePage: BasePage{
|
||||
ActivePath: "/planning",
|
||||
Error: r.URL.Query().Get("error"),
|
||||
Info: r.URL.Query().Get("info"),
|
||||
},
|
||||
Settings: settings,
|
||||
Months: monthOptions(),
|
||||
Crops: crops,
|
||||
Plans: plans,
|
||||
PlanningTargets: buildPlanningTargets(fields),
|
||||
}
|
||||
a.renderTemplate(w, "templates/planning.html", data)
|
||||
}
|
||||
|
||||
func (a *App) handleGeneralPage(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
settings, err := a.getSettings()
|
||||
if err != nil {
|
||||
http.Error(w, "settings read failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := GeneralPage{
|
||||
BasePage: BasePage{
|
||||
ActivePath: "/general",
|
||||
Error: r.URL.Query().Get("error"),
|
||||
Info: r.URL.Query().Get("info"),
|
||||
},
|
||||
Settings: settings,
|
||||
Months: monthOptions(),
|
||||
}
|
||||
a.renderTemplate(w, "templates/general.html", data)
|
||||
}
|
||||
49
cmd/server/helpers.go
Normal file
49
cmd/server/helpers.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func mustInt(v string, fallback int) int {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(v))
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func mustInt64(v string, fallback int64) int64 {
|
||||
n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
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 placeholders(n int) string {
|
||||
parts := make([]string, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
parts = append(parts, "?")
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
func redirectWithMessage(w http.ResponseWriter, r *http.Request, path, key, val string) {
|
||||
http.Redirect(w, r, path+"?"+key+"="+url.QueryEscape(val), http.StatusSeeOther)
|
||||
}
|
||||
@@ -3,13 +3,9 @@ package main
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -23,84 +19,6 @@ var monthNames = []string{
|
||||
|
||||
type App struct {
|
||||
db *sql.DB
|
||||
tmpl *template.Template
|
||||
}
|
||||
|
||||
type Field struct {
|
||||
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 {
|
||||
ID int64
|
||||
Name string
|
||||
SowStartMonth int
|
||||
SowEndMonth int
|
||||
GrowMonths int
|
||||
}
|
||||
|
||||
type Plan struct {
|
||||
ID int64
|
||||
FieldID sql.NullInt64
|
||||
TargetRef string
|
||||
TargetName string
|
||||
CropID int64
|
||||
CropName string
|
||||
StartMonth int
|
||||
StartDay int
|
||||
HarvestMonth int
|
||||
HarvestDay int
|
||||
Notes string
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
Type string
|
||||
Field string
|
||||
Message string
|
||||
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
|
||||
Groups []FieldGroup
|
||||
Crops []Crop
|
||||
Plans []Plan
|
||||
Tasks []Task
|
||||
PlanningTargets []PlanningTarget
|
||||
Error string
|
||||
Info string
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -120,23 +38,28 @@ func main() {
|
||||
log.Fatalf("schema setup failed: %v", err)
|
||||
}
|
||||
|
||||
tmpl, err := template.ParseFiles("templates/index.html")
|
||||
if err != nil {
|
||||
log.Fatalf("template parse failed: %v", err)
|
||||
}
|
||||
|
||||
app := &App{db: db, tmpl: tmpl}
|
||||
app := &App{db: db}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", app.handleIndex)
|
||||
mux.HandleFunc("/settings", app.handleSettings)
|
||||
|
||||
mux.HandleFunc("/", app.handleDashboard)
|
||||
mux.HandleFunc("/fields", app.handleFieldsPage)
|
||||
mux.HandleFunc("/crops", app.handleCropsPage)
|
||||
mux.HandleFunc("/planning", app.handlePlanningPage)
|
||||
mux.HandleFunc("/general", app.handleGeneralPage)
|
||||
|
||||
mux.HandleFunc("/settings/days", app.handleSetDaysPerMonth)
|
||||
mux.HandleFunc("/settings/time", app.handleSetCurrentTime)
|
||||
|
||||
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"))))
|
||||
|
||||
@@ -151,763 +74,6 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
daysPerMonth, err := a.getDaysPerMonth()
|
||||
if err != nil {
|
||||
http.Error(w, "settings read failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
nowMonth := mustInt(r.URL.Query().Get("month"), 1)
|
||||
nowDay := mustInt(r.URL.Query().Get("day"), 1)
|
||||
if nowMonth < 1 || nowMonth > 12 {
|
||||
nowMonth = 1
|
||||
}
|
||||
if nowDay < 1 || nowDay > daysPerMonth {
|
||||
nowDay = 1
|
||||
}
|
||||
|
||||
fields, err := a.listFields()
|
||||
if err != nil {
|
||||
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)
|
||||
return
|
||||
}
|
||||
plans, err := a.listPlans()
|
||||
if err != nil {
|
||||
http.Error(w, "plans read failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := PageData{
|
||||
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 {
|
||||
http.Error(w, "template render failed", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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) {
|
||||
var v int
|
||||
err := a.db.QueryRow(`SELECT days_per_month FROM settings WHERE id=1`).Scan(&v)
|
||||
return v, err
|
||||
}
|
||||
|
||||
func (a *App) listFields() ([]Field, error) {
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
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`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Crop
|
||||
for rows.Next() {
|
||||
var x Crop
|
||||
if err := rows.Scan(&x.ID, &x.Name, &x.SowStartMonth, &x.SowEndMonth, &x.GrowMonths); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, x)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (a *App) listPlans() ([]Plan, error) {
|
||||
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,'')
|
||||
FROM plans p
|
||||
JOIN crops c ON c.id=p.crop_id
|
||||
ORDER BY p.start_month,p.start_day,p.id DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Plan
|
||||
for rows.Next() {
|
||||
var x Plan
|
||||
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)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
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 '',
|
||||
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)
|
||||
}
|
||||
|
||||
func seedCrops(db *sql.DB) error {
|
||||
items := []Crop{
|
||||
{Name: "Weizen", SowStartMonth: 9, SowEndMonth: 11, GrowMonths: 10},
|
||||
{Name: "Gerste", SowStartMonth: 9, SowEndMonth: 10, GrowMonths: 9},
|
||||
{Name: "Hafer", SowStartMonth: 3, SowEndMonth: 4, GrowMonths: 4},
|
||||
{Name: "Raps", SowStartMonth: 8, SowEndMonth: 9, GrowMonths: 11},
|
||||
{Name: "Mais", SowStartMonth: 4, SowEndMonth: 5, GrowMonths: 6},
|
||||
{Name: "Sorghum", SowStartMonth: 4, SowEndMonth: 5, GrowMonths: 5},
|
||||
{Name: "Sojabohnen", SowStartMonth: 4, SowEndMonth: 5, GrowMonths: 5},
|
||||
{Name: "Sonnenblumen", SowStartMonth: 4, SowEndMonth: 5, GrowMonths: 5},
|
||||
{Name: "Kartoffeln", SowStartMonth: 3, SowEndMonth: 4, GrowMonths: 7},
|
||||
{Name: "Zuckerrueben", SowStartMonth: 3, SowEndMonth: 4, GrowMonths: 8},
|
||||
{Name: "Baumwolle", SowStartMonth: 2, SowEndMonth: 3, GrowMonths: 8},
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
}
|
||||
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 := 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})
|
||||
}
|
||||
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 {
|
||||
if tasks[i].SortOrder == tasks[j].SortOrder {
|
||||
return tasks[i].Field < tasks[j].Field
|
||||
}
|
||||
return tasks[i].SortOrder < tasks[j].SortOrder
|
||||
})
|
||||
return tasks
|
||||
}
|
||||
|
||||
func monthOptions() []MonthOption {
|
||||
out := make([]MonthOption, 0, 12)
|
||||
for i, m := range monthNames {
|
||||
out = append(out, MonthOption{Value: i + 1, Label: m})
|
||||
}
|
||||
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
|
||||
}
|
||||
return month >= start || month <= end
|
||||
}
|
||||
|
||||
func wrapMonth(v int) int {
|
||||
m := v % 12
|
||||
if m == 0 {
|
||||
return 12
|
||||
}
|
||||
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 {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func mustInt64(v string, fallback int64) int64 {
|
||||
n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func readEnv(key, fallback string) string {
|
||||
v := strings.TrimSpace(os.Getenv(key))
|
||||
if v == "" {
|
||||
|
||||
117
cmd/server/types.go
Normal file
117
cmd/server/types.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package main
|
||||
|
||||
import "database/sql"
|
||||
|
||||
type Settings struct {
|
||||
DaysPerMonth int
|
||||
CurrentMonth int
|
||||
CurrentDay int
|
||||
}
|
||||
|
||||
type Field struct {
|
||||
ID int64
|
||||
Number int
|
||||
Name string
|
||||
Owned bool
|
||||
GroupKey string
|
||||
GroupName string
|
||||
}
|
||||
|
||||
type FieldGroup struct {
|
||||
Key string
|
||||
Name string
|
||||
Numbers string
|
||||
}
|
||||
|
||||
type Crop struct {
|
||||
ID int64
|
||||
Name string
|
||||
SowStartMonth int
|
||||
SowEndMonth int
|
||||
GrowMonths int
|
||||
}
|
||||
|
||||
type Plan struct {
|
||||
ID int64
|
||||
FieldID sql.NullInt64
|
||||
TargetRef string
|
||||
TargetName string
|
||||
CropID int64
|
||||
CropName string
|
||||
StartMonth int
|
||||
StartDay int
|
||||
HarvestMonth int
|
||||
HarvestDay int
|
||||
Notes string
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
Type string
|
||||
Field string
|
||||
Message string
|
||||
SortOrder int
|
||||
}
|
||||
|
||||
type PlanningTarget struct {
|
||||
Ref string
|
||||
Label string
|
||||
}
|
||||
|
||||
type MonthOption struct {
|
||||
Value int
|
||||
Label string
|
||||
}
|
||||
|
||||
type CalendarDay struct {
|
||||
Day int
|
||||
Tasks []Task
|
||||
}
|
||||
|
||||
type CalendarMonth struct {
|
||||
Offset int
|
||||
Month int
|
||||
Label string
|
||||
YearOffset int
|
||||
Days []CalendarDay
|
||||
}
|
||||
|
||||
type BasePage struct {
|
||||
ActivePath string
|
||||
Error string
|
||||
Info string
|
||||
}
|
||||
|
||||
type DashboardPage struct {
|
||||
BasePage
|
||||
Settings Settings
|
||||
CurrentMonth string
|
||||
TodayTasks []Task
|
||||
Calendar []CalendarMonth
|
||||
PlanningCount int
|
||||
}
|
||||
|
||||
type FieldsPage struct {
|
||||
BasePage
|
||||
Fields []Field
|
||||
Groups []FieldGroup
|
||||
}
|
||||
|
||||
type CropsPage struct {
|
||||
BasePage
|
||||
Crops []Crop
|
||||
}
|
||||
|
||||
type PlanningPage struct {
|
||||
BasePage
|
||||
Settings Settings
|
||||
Months []MonthOption
|
||||
Crops []Crop
|
||||
Plans []Plan
|
||||
PlanningTargets []PlanningTarget
|
||||
}
|
||||
|
||||
type GeneralPage struct {
|
||||
BasePage
|
||||
Settings Settings
|
||||
Months []MonthOption
|
||||
}
|
||||
@@ -24,6 +24,29 @@ body {
|
||||
.top h1 { margin: 0; }
|
||||
.top p { margin: .35rem 0 0; opacity: .9; }
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
flex-wrap: wrap;
|
||||
padding: .7rem 1rem;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.tabs a {
|
||||
text-decoration: none;
|
||||
color: #234020;
|
||||
background: #e7efdf;
|
||||
border: 1px solid #cbd8c3;
|
||||
border-radius: 999px;
|
||||
padding: .35rem .75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.tabs a.active {
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
@@ -44,6 +67,7 @@ body {
|
||||
margin-top: 0;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.full-width { grid-column: 1 / -1; }
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
@@ -109,6 +133,34 @@ button:hover { background: #2d5124; }
|
||||
padding-left: 1.1rem;
|
||||
}
|
||||
.tasks li { margin: .35rem 0; }
|
||||
.task-sublist {
|
||||
margin: .3rem 0 0;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
.muted { color: var(--muted); }
|
||||
|
||||
.calendar-grid {
|
||||
display: grid;
|
||||
gap: .75rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
}
|
||||
.month-card {
|
||||
border: 1px solid #dbe6d5;
|
||||
border-radius: 10px;
|
||||
padding: .6rem;
|
||||
background: #f9fbf7;
|
||||
}
|
||||
.month-card h3 {
|
||||
margin: 0 0 .4rem;
|
||||
font-size: .98rem;
|
||||
}
|
||||
.month-days {
|
||||
margin: 0;
|
||||
padding-left: 1rem;
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
}
|
||||
.month-days li { margin: .25rem 0; }
|
||||
|
||||
.table-wrap { overflow: auto; }
|
||||
table {
|
||||
|
||||
66
templates/crops.html
Normal file
66
templates/crops.html
Normal file
@@ -0,0 +1,66 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>FarmCal - Feldfruechte</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="top"><h1>FarmCal</h1><p>Feldfruechte verwalten</p></header>
|
||||
<nav class="tabs">
|
||||
<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="/crops" class="{{if eq .ActivePath "/crops"}}active{{end}}">Feldfruechte</a>
|
||||
<a href="/fields" class="{{if eq .ActivePath "/fields"}}active{{end}}">Felder & Gruppen</a>
|
||||
<a href="/general" class="{{if eq .ActivePath "/general"}}active{{end}}">Allgemein</a>
|
||||
</nav>
|
||||
|
||||
<main class="layout">
|
||||
<section class="card">
|
||||
<h2>Neue Feldfrucht</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">Anlegen</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card full-width">
|
||||
<h2>Bestehende Feldfruechte</h2>
|
||||
<div class="table-wrap">
|
||||
<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>
|
||||
<button type="submit" formaction="/crops/delete" formnovalidate class="btn-small danger" onclick="return confirm('Feldfrucht wirklich loeschen?')">Loeschen</button>
|
||||
</td>
|
||||
</form>
|
||||
</tr>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<tr><td colspan="4">Keine Feldfruechte vorhanden.</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
{{if .Error}}<div class="toast error">{{.Error}}</div>{{end}}
|
||||
{{if .Info}}<div class="toast info">{{.Info}}</div>{{end}}
|
||||
</body>
|
||||
</html>
|
||||
96
templates/dashboard.html
Normal file
96
templates/dashboard.html
Normal file
@@ -0,0 +1,96 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>FarmCal - Dashboard</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="top">
|
||||
<h1>FarmCal</h1>
|
||||
<p>Dashboard</p>
|
||||
</header>
|
||||
|
||||
<nav class="tabs">
|
||||
<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="/crops" class="{{if eq .ActivePath "/crops"}}active{{end}}">Feldfruechte</a>
|
||||
<a href="/fields" class="{{if eq .ActivePath "/fields"}}active{{end}}">Felder & Gruppen</a>
|
||||
<a href="/general" class="{{if eq .ActivePath "/general"}}active{{end}}">Allgemein</a>
|
||||
</nav>
|
||||
|
||||
<main class="layout">
|
||||
<section class="card">
|
||||
<h2>Aktuelle Ingame-Zeit</h2>
|
||||
<p><strong>{{.CurrentMonth}} Tag {{.Settings.CurrentDay}}</strong> bei {{.Settings.DaysPerMonth}} Tagen pro Monat.</p>
|
||||
<p>{{.PlanningCount}} Plan-Eintraege insgesamt.</p>
|
||||
<form method="post" action="/settings/time" class="grid">
|
||||
<label>Monat
|
||||
<select name="current_month">
|
||||
<option value="1" {{if eq .Settings.CurrentMonth 1}}selected{{end}}>Januar</option>
|
||||
<option value="2" {{if eq .Settings.CurrentMonth 2}}selected{{end}}>Februar</option>
|
||||
<option value="3" {{if eq .Settings.CurrentMonth 3}}selected{{end}}>Maerz</option>
|
||||
<option value="4" {{if eq .Settings.CurrentMonth 4}}selected{{end}}>April</option>
|
||||
<option value="5" {{if eq .Settings.CurrentMonth 5}}selected{{end}}>Mai</option>
|
||||
<option value="6" {{if eq .Settings.CurrentMonth 6}}selected{{end}}>Juni</option>
|
||||
<option value="7" {{if eq .Settings.CurrentMonth 7}}selected{{end}}>Juli</option>
|
||||
<option value="8" {{if eq .Settings.CurrentMonth 8}}selected{{end}}>August</option>
|
||||
<option value="9" {{if eq .Settings.CurrentMonth 9}}selected{{end}}>September</option>
|
||||
<option value="10" {{if eq .Settings.CurrentMonth 10}}selected{{end}}>Oktober</option>
|
||||
<option value="11" {{if eq .Settings.CurrentMonth 11}}selected{{end}}>November</option>
|
||||
<option value="12" {{if eq .Settings.CurrentMonth 12}}selected{{end}}>Dezember</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Tag
|
||||
<input type="number" name="current_day" min="1" max="{{.Settings.DaysPerMonth}}" value="{{.Settings.CurrentDay}}">
|
||||
</label>
|
||||
<button type="submit">Zeit speichern</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Heute</h2>
|
||||
{{if .TodayTasks}}
|
||||
<ul class="tasks">
|
||||
{{range .TodayTasks}}
|
||||
<li><strong>{{.Type}}:</strong> {{.Message}}</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p>Keine Aufgaben fuer den aktuellen Ingame-Tag.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<section class="card full-width">
|
||||
<h2>Kalender ab jetzt (14 Monate)</h2>
|
||||
<div class="calendar-grid">
|
||||
{{range .Calendar}}
|
||||
<article class="month-card">
|
||||
<h3>{{.Label}}</h3>
|
||||
<ul class="month-days">
|
||||
{{range .Days}}
|
||||
<li>
|
||||
<strong>Tag {{.Day}}</strong>
|
||||
{{if .Tasks}}
|
||||
<ul class="task-sublist">
|
||||
{{range .Tasks}}
|
||||
<li>{{.Type}}: {{.Message}}</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<span class="muted">Keine Eintraege</span>
|
||||
{{end}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</article>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{{if .Error}}<div class="toast error">{{.Error}}</div>{{end}}
|
||||
{{if .Info}}<div class="toast info">{{.Info}}</div>{{end}}
|
||||
</body>
|
||||
</html>
|
||||
102
templates/fields.html
Normal file
102
templates/fields.html
Normal file
@@ -0,0 +1,102 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>FarmCal - Felder</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="top"><h1>FarmCal</h1><p>Felder und Feldgruppen</p></header>
|
||||
<nav class="tabs">
|
||||
<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="/crops" class="{{if eq .ActivePath "/crops"}}active{{end}}">Feldfruechte</a>
|
||||
<a href="/fields" class="{{if eq .ActivePath "/fields"}}active{{end}}">Felder & Gruppen</a>
|
||||
<a href="/general" class="{{if eq .ActivePath "/general"}}active{{end}}">Allgemein</a>
|
||||
</nav>
|
||||
|
||||
<main class="layout">
|
||||
<section class="card">
|
||||
<h2>Feld anlegen</h2>
|
||||
<form method="post" action="/fields/create" class="grid">
|
||||
<label>Feldnummer<input type="number" name="number" min="1" required></label>
|
||||
<label>Name (optional)<input type="text" name="name" maxlength="120"></label>
|
||||
<label class="check"><input type="checkbox" name="owned"> Im Besitz</label>
|
||||
<button type="submit">Anlegen</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card full-width">
|
||||
<h2>Felder verwalten</h2>
|
||||
<div class="table-wrap">
|
||||
<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>
|
||||
<button type="submit" formaction="/fields/delete" formnovalidate class="btn-small danger" onclick="return confirm('Feld wirklich loeschen?')">Loeschen</button>
|
||||
</td>
|
||||
</form>
|
||||
</tr>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<tr><td colspan="5">Keine Felder vorhanden.</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card full-width">
|
||||
<h2>Feldgruppen</h2>
|
||||
<form method="post" action="/field-groups/create" class="grid">
|
||||
<label class="full">Name (optional)<input type="text" name="name" maxlength="120" placeholder="z.B. Feld 1+2"></label>
|
||||
<label class="full">Felder (1 bis X)
|
||||
<select name="field_ids" multiple size="8">
|
||||
{{range .Fields}}
|
||||
<option value="{{.ID}}">Feld {{.Number}}{{if .Name}} ({{.Name}}){{end}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Gruppe speichern</button>
|
||||
</form>
|
||||
<p class="hint">Mehrfachauswahl mit Strg/Cmd.</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">
|
||||
<input type="hidden" name="group_key" value="{{.Key}}">
|
||||
<button type="submit" class="btn-small danger" onclick="return confirm('Gruppe aufloesen?')">Aufloesen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<tr><td colspan="3">Keine Gruppen vorhanden.</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
{{if .Error}}<div class="toast error">{{.Error}}</div>{{end}}
|
||||
{{if .Info}}<div class="toast info">{{.Info}}</div>{{end}}
|
||||
</body>
|
||||
</html>
|
||||
34
templates/general.html
Normal file
34
templates/general.html
Normal file
@@ -0,0 +1,34 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>FarmCal - Allgemein</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="top"><h1>FarmCal</h1><p>Allgemeine Einstellungen</p></header>
|
||||
<nav class="tabs">
|
||||
<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="/crops" class="{{if eq .ActivePath "/crops"}}active{{end}}">Feldfruechte</a>
|
||||
<a href="/fields" class="{{if eq .ActivePath "/fields"}}active{{end}}">Felder & Gruppen</a>
|
||||
<a href="/general" class="{{if eq .ActivePath "/general"}}active{{end}}">Allgemein</a>
|
||||
</nav>
|
||||
|
||||
<main class="layout">
|
||||
<section class="card">
|
||||
<h2>Tage pro Monat</h2>
|
||||
<form method="post" action="/settings/days" class="grid">
|
||||
<label>Tage pro Ingame-Monat
|
||||
<input type="number" name="days_per_month" min="1" max="31" value="{{.Settings.DaysPerMonth}}" required>
|
||||
</label>
|
||||
<button type="submit">Speichern</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{{if .Error}}<div class="toast error">{{.Error}}</div>{{end}}
|
||||
{{if .Info}}<div class="toast info">{{.Info}}</div>{{end}}
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,297 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>FarmCal</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="top">
|
||||
<h1>FarmCal</h1>
|
||||
<p>Planung fuer Felder, Feldgruppen, Aussaat und Ernte</p>
|
||||
</header>
|
||||
|
||||
<main class="layout">
|
||||
<section class="card">
|
||||
<h2>Ingame-Zeit</h2>
|
||||
<form method="get" action="/" class="grid">
|
||||
<label>Monat
|
||||
<select name="month">
|
||||
{{range .Months}}
|
||||
<option value="{{.Value}}" {{if eq $.NowMonth .Value}}selected{{end}}>{{.Label}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label>Tag
|
||||
<input type="number" name="day" min="1" max="{{.DaysPerMonth}}" value="{{.NowDay}}">
|
||||
</label>
|
||||
<button type="submit">Anzeigen</button>
|
||||
</form>
|
||||
<form method="post" action="/settings" class="grid mt">
|
||||
<label>Tage pro Monat
|
||||
<input type="number" name="days_per_month" min="1" max="31" value="{{.DaysPerMonth}}">
|
||||
</label>
|
||||
<button type="submit">Speichern</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Heutige Aufgaben</h2>
|
||||
{{if .Tasks}}
|
||||
<ul class="tasks">
|
||||
{{range .Tasks}}
|
||||
<li><strong>{{.Type}}:</strong> {{.Message}}</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p>Keine Aufgaben fuer diesen Ingame-Tag.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Felder verwalten</h2>
|
||||
<form method="post" action="/fields/create" class="grid">
|
||||
<label>Feldnummer
|
||||
<input type="number" name="number" min="1" required>
|
||||
</label>
|
||||
<label>Name (optional)
|
||||
<input type="text" name="name" maxlength="120">
|
||||
</label>
|
||||
<label class="check">
|
||||
<input type="checkbox" name="owned">
|
||||
Im Besitz
|
||||
</label>
|
||||
<button type="submit">Feld anlegen</button>
|
||||
</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 class="card">
|
||||
<h2>Feldgruppen</h2>
|
||||
<form method="post" action="/field-groups/create" class="grid">
|
||||
<label class="full">Name (optional)
|
||||
<input type="text" name="name" maxlength="120" placeholder="z.B. Feld 1+2">
|
||||
</label>
|
||||
<label class="full">Felder (1 bis X)
|
||||
<select name="field_ids" multiple size="6">
|
||||
{{range .Fields}}
|
||||
<option value="{{.ID}}">Feld {{.Number}}{{if .Name}} ({{.Name}}){{end}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</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
|
||||
<select name="crop_id" required>
|
||||
<option value="">Bitte waehlen</option>
|
||||
{{range .Crops}}
|
||||
<option value="{{.ID}}">{{.Name}} (Monat {{.SowStartMonth}}-{{.SowEndMonth}})</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label>Startmonat
|
||||
<select name="start_month" required>
|
||||
{{range .Months}}
|
||||
<option value="{{.Value}}" {{if eq $.NowMonth .Value}}selected{{end}}>{{.Label}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label>Starttag
|
||||
<input type="number" name="start_day" min="1" max="{{.DaysPerMonth}}" value="{{.NowDay}}" required>
|
||||
</label>
|
||||
<label class="full">Notiz
|
||||
<input type="text" name="notes" maxlength="255">
|
||||
</label>
|
||||
<button type="submit">Plan speichern</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Geplante Durchlaeufe</h2>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ziel</th>
|
||||
<th>Frucht</th>
|
||||
<th>Aussaat</th>
|
||||
<th>Ernte</th>
|
||||
<th>Notiz</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{if .Plans}}
|
||||
{{range .Plans}}
|
||||
<tr>
|
||||
<td>{{.TargetName}}</td>
|
||||
<td>{{.CropName}}</td>
|
||||
<td>Monat {{.StartMonth}} Tag {{.StartDay}}</td>
|
||||
<td>Monat {{.HarvestMonth}} Tag {{.HarvestDay}}</td>
|
||||
<td>{{.Notes}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<tr><td colspan="5">Noch keine Planung vorhanden.</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{{if .Error}}<div class="toast error">{{.Error}}</div>{{end}}
|
||||
{{if .Info}}<div class="toast info">{{.Info}}</div>{{end}}
|
||||
</body>
|
||||
</html>
|
||||
83
templates/planning.html
Normal file
83
templates/planning.html
Normal file
@@ -0,0 +1,83 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>FarmCal - Anbau planen</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="top"><h1>FarmCal</h1><p>Anbau planen</p></header>
|
||||
<nav class="tabs">
|
||||
<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="/crops" class="{{if eq .ActivePath "/crops"}}active{{end}}">Feldfruechte</a>
|
||||
<a href="/fields" class="{{if eq .ActivePath "/fields"}}active{{end}}">Felder & Gruppen</a>
|
||||
<a href="/general" class="{{if eq .ActivePath "/general"}}active{{end}}">Allgemein</a>
|
||||
</nav>
|
||||
|
||||
<main class="layout">
|
||||
<section class="card">
|
||||
<h2>Neuer Plan</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
|
||||
<select name="crop_id" required>
|
||||
<option value="">Bitte waehlen</option>
|
||||
{{range .Crops}}
|
||||
<option value="{{.ID}}">{{.Name}} (Monat {{.SowStartMonth}}-{{.SowEndMonth}})</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label>Startmonat
|
||||
<select name="start_month" required>
|
||||
{{range .Months}}
|
||||
<option value="{{.Value}}" {{if eq $.Settings.CurrentMonth .Value}}selected{{end}}>{{.Label}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label>Starttag
|
||||
<input type="number" name="start_day" min="1" max="{{.Settings.DaysPerMonth}}" value="{{.Settings.CurrentDay}}" required>
|
||||
</label>
|
||||
<label class="full">Notiz
|
||||
<input type="text" name="notes" maxlength="255">
|
||||
</label>
|
||||
<button type="submit">Plan speichern</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card full-width">
|
||||
<h2>Geplante Durchlaeufe</h2>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Ziel</th><th>Frucht</th><th>Aussaat</th><th>Ernte</th><th>Notiz</th></tr></thead>
|
||||
<tbody>
|
||||
{{if .Plans}}
|
||||
{{range .Plans}}
|
||||
<tr>
|
||||
<td>{{.TargetName}}</td>
|
||||
<td>{{.CropName}}</td>
|
||||
<td>Monat {{.StartMonth}} Tag {{.StartDay}}</td>
|
||||
<td>Monat {{.HarvestMonth}} Tag {{.HarvestDay}}</td>
|
||||
<td>{{.Notes}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<tr><td colspan="5">Keine Planung vorhanden.</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
{{if .Error}}<div class="toast error">{{.Error}}</div>{{end}}
|
||||
{{if .Info}}<div class="toast info">{{.Info}}</div>{{end}}
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user