diff --git a/cmd/server/calendar.go b/cmd/server/calendar.go index 101577e..6a05969 100644 --- a/cmd/server/calendar.go +++ b/cmd/server/calendar.go @@ -6,48 +6,30 @@ import ( "strings" ) -func buildTasksForDay(plans []Plan, month, day int) []Task { +func buildTasksForDay(plans []Plan, stepMap map[int64][]CropStep, 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}) - } + tasks = append(tasks, expandPlanDayTasks(p, stepMap[p.CropID], month, day)...) } - 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 - }) + sortTasks(tasks) return tasks } -func buildCalendar(plans []Plan, startMonth, startDay, daysPerMonth, spanMonths int) []CalendarMonth { +func buildCalendar(plans []Plan, stepMap map[int64][]CropStep, startMonth, startDay, daysPerMonth, spanMonths int) []CalendarMonth { tasksByKey := make(map[string][]Task) for _, p := range plans { - field := p.TargetName - if strings.TrimSpace(field) == "" { - field = "Unbekanntes Feld" + for m := 1; m <= 12; m++ { + for d := 1; d <= daysPerMonth; d++ { + dayTasks := expandPlanDayTasks(p, stepMap[p.CropID], m, d) + if len(dayTasks) > 0 { + key := fmt.Sprintf("%d-%d", m, d) + tasksByKey[key] = append(tasksByKey[key], dayTasks...) + } + } } - tasksByKey[fmt.Sprintf("%d-%d", p.StartMonth, p.StartDay)] = append(tasksByKey[fmt.Sprintf("%d-%d", p.StartMonth, p.StartDay)], Task{ - Type: "Aussaat", - Field: field, - Message: fmt.Sprintf("Aussaat %s", p.CropName), - SortOrder: 1, - }) - tasksByKey[fmt.Sprintf("%d-%d", p.HarvestMonth, p.HarvestDay)] = append(tasksByKey[fmt.Sprintf("%d-%d", p.HarvestMonth, p.HarvestDay)], Task{ - Type: "Ernte", - Field: field, - Message: fmt.Sprintf("Ernte %s", p.CropName), - SortOrder: 2, - }) + } + for k := range tasksByKey { + sortTasks(tasksByKey[k]) } var out []CalendarMonth @@ -64,16 +46,10 @@ func buildCalendar(plans []Plan, startMonth, startDay, daysPerMonth, spanMonths fromDay = startDay } var days []CalendarDay - for day := fromDay; day <= daysPerMonth; day++ { - key := fmt.Sprintf("%d-%d", month, day) + for d := fromDay; d <= daysPerMonth; d++ { + key := fmt.Sprintf("%d-%d", month, d) 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}) + days = append(days, CalendarDay{Day: d, Tasks: items}) } out = append(out, CalendarMonth{ Offset: offset, @@ -85,3 +61,111 @@ func buildCalendar(plans []Plan, startMonth, startDay, daysPerMonth, spanMonths } return out } + +func expandPlanDayTasks(p Plan, steps []CropStep, month, day int) []Task { + field := p.TargetName + if strings.TrimSpace(field) == "" { + field = "Unbekanntes Feld" + } + var out []Task + baseHarvest := p.HarvestMonth + + if p.StartMonth == month && p.StartDay == day { + out = append(out, Task{ + Type: "Aussaat", + Field: field, + Message: withOptionalNote(fmt.Sprintf("Aussaat %s", p.CropName), p.Notes), + SortOrder: 10, + }) + } + + harvestMonths := []int{baseHarvest} + if p.RegrowEnabled { + maxExtra := p.RegrowCycles + if maxExtra == 0 { + maxExtra = 24 + } + for i := 1; i <= maxExtra; i++ { + harvestMonths = append(harvestMonths, wrapMonth(baseHarvest+(i*p.HarvestDistance()))) + } + } + harvestMonths = uniqueMonths(harvestMonths) + for _, hm := range harvestMonths { + if hm == month && p.HarvestDay == day { + out = append(out, Task{ + Type: "Ernte", + Field: field, + Message: withOptionalNote(fmt.Sprintf("Ernte %s", p.CropName), p.Notes), + SortOrder: 20, + }) + } + } + + for _, s := range steps { + switch s.Phase { + case "pre": + taskMonth := wrapMonth(p.StartMonth - s.MonthOffset) + if taskMonth == month && p.StartDay == day { + out = append(out, Task{ + Type: "Vorbereitung", + Field: field, + Message: s.Title, + SortOrder: 5, + }) + } + case "post": + for _, hm := range harvestMonths { + taskMonth := wrapMonth(hm + s.MonthOffset) + if taskMonth == month && p.HarvestDay == day { + out = append(out, Task{ + Type: "Nachbereitung", + Field: field, + Message: s.Title, + SortOrder: 30, + }) + } + } + } + } + + return out +} + +func (p Plan) HarvestDistance() int { + if p.GrowMonths <= 0 { + return 1 + } + return p.GrowMonths +} + +func withOptionalNote(base, note string) string { + n := strings.TrimSpace(note) + if n == "" { + return base + } + return fmt.Sprintf("%s (Notiz: %s)", base, n) +} + +func sortTasks(tasks []Task) { + sort.Slice(tasks, func(i, j int) bool { + if tasks[i].SortOrder == tasks[j].SortOrder { + if tasks[i].Field == tasks[j].Field { + return tasks[i].Message < tasks[j].Message + } + return tasks[i].Field < tasks[j].Field + } + return tasks[i].SortOrder < tasks[j].SortOrder + }) +} + +func uniqueMonths(values []int) []int { + seen := make(map[int]bool) + var out []int + for _, v := range values { + if !seen[v] { + seen[v] = true + out = append(out, v) + } + } + return out +} diff --git a/cmd/server/db.go b/cmd/server/db.go index 3eb0a66..cee252c 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -37,7 +37,21 @@ func ensureSchema(db *sql.DB) error { name VARCHAR(80) NOT NULL UNIQUE, sow_start_month TINYINT NOT NULL, sow_end_month TINYINT NOT NULL, - grow_months TINYINT NOT NULL + grow_months TINYINT NOT NULL, + regrow_enabled TINYINT(1) NOT NULL DEFAULT 0, + regrow_cycles INT NOT NULL DEFAULT 0 + )`, + `ALTER TABLE crops ADD COLUMN IF NOT EXISTS regrow_enabled TINYINT(1) NOT NULL DEFAULT 0`, + `ALTER TABLE crops ADD COLUMN IF NOT EXISTS regrow_cycles INT NOT NULL DEFAULT 0`, + `UPDATE crops SET regrow_cycles=0 WHERE regrow_cycles < 0`, + `CREATE TABLE IF NOT EXISTS crop_steps( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + crop_id BIGINT NOT NULL, + phase ENUM('pre','post') NOT NULL, + month_offset INT NOT NULL DEFAULT 0, + title VARCHAR(140) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_crop_steps_crop FOREIGN KEY(crop_id) REFERENCES crops(id) ON DELETE CASCADE )`, `CREATE TABLE IF NOT EXISTS plans( @@ -114,7 +128,7 @@ func (a *App) getFieldsByIDs(ids []int64) ([]Field, error) { } func (a *App) listCrops() ([]Crop, error) { - rows, err := a.db.Query(`SELECT id,name,sow_start_month,sow_end_month,grow_months FROM crops ORDER BY name`) + rows, err := a.db.Query(`SELECT id,name,sow_start_month,sow_end_month,grow_months,regrow_enabled,regrow_cycles FROM crops ORDER BY name`) if err != nil { return nil, err } @@ -122,7 +136,7 @@ func (a *App) listCrops() ([]Crop, error) { 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 { + if err := rows.Scan(&c.ID, &c.Name, &c.SowStartMonth, &c.SowEndMonth, &c.GrowMonths, &c.RegrowEnabled, &c.RegrowCycles); err != nil { return nil, err } out = append(out, c) @@ -132,7 +146,7 @@ func (a *App) listCrops() ([]Crop, error) { 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,'') + SELECT p.id,p.field_id,COALESCE(p.target_ref,''),COALESCE(p.target_name,''),p.crop_id,COALESCE(c.name,''),COALESCE(c.grow_months,1),p.start_month,p.start_day,p.harvest_month,p.harvest_day,COALESCE(p.notes,''),COALESCE(c.regrow_enabled,0),COALESCE(c.regrow_cycles,0) FROM plans p JOIN crops c ON c.id=p.crop_id ORDER BY p.start_month,p.start_day,p.id DESC`) @@ -143,7 +157,7 @@ func (a *App) listPlans() ([]Plan, error) { 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 { + if err := rows.Scan(&p.ID, &p.FieldID, &p.TargetRef, &p.TargetName, &p.CropID, &p.CropName, &p.GrowMonths, &p.StartMonth, &p.StartDay, &p.HarvestMonth, &p.HarvestDay, &p.Notes, &p.RegrowEnabled, &p.RegrowCycles); err != nil { return nil, err } out = append(out, p) @@ -151,6 +165,40 @@ func (a *App) listPlans() ([]Plan, error) { return out, rows.Err() } +func (a *App) listCropSteps() ([]CropStep, error) { + rows, err := a.db.Query(` + SELECT s.id,s.crop_id,COALESCE(c.name,''),s.phase,s.month_offset,s.title + FROM crop_steps s + JOIN crops c ON c.id=s.crop_id + ORDER BY c.name, s.phase, s.month_offset, s.id`) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []CropStep + for rows.Next() { + var s CropStep + if err := rows.Scan(&s.ID, &s.CropID, &s.CropName, &s.Phase, &s.MonthOffset, &s.Title); err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() +} + +func (a *App) listCropStepsMap() (map[int64][]CropStep, error) { + all, err := a.listCropSteps() + if err != nil { + return nil, err + } + m := make(map[int64][]CropStep) + for _, s := range all { + m[s.CropID] = append(m[s.CropID], s) + } + return m, nil +} + func seedCrops(db *sql.DB) error { items := []Crop{ {Name: "Weizen", SowStartMonth: 9, SowEndMonth: 11, GrowMonths: 10}, @@ -166,7 +214,7 @@ func seedCrops(db *sql.DB) error { {Name: "Baumwolle", SowStartMonth: 2, SowEndMonth: 3, GrowMonths: 8}, } for _, c := range items { - if _, err := db.Exec(`INSERT INTO crops(name,sow_start_month,sow_end_month,grow_months) VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE sow_start_month=VALUES(sow_start_month),sow_end_month=VALUES(sow_end_month),grow_months=VALUES(grow_months)`, c.Name, c.SowStartMonth, c.SowEndMonth, c.GrowMonths); err != nil { + if _, err := db.Exec(`INSERT INTO crops(name,sow_start_month,sow_end_month,grow_months,regrow_enabled,regrow_cycles) VALUES (?,?,?,?,0,0) ON DUPLICATE KEY UPDATE sow_start_month=VALUES(sow_start_month),sow_end_month=VALUES(sow_end_month),grow_months=VALUES(grow_months)`, c.Name, c.SowStartMonth, c.SowEndMonth, c.GrowMonths); err != nil { return err } } diff --git a/cmd/server/domain.go b/cmd/server/domain.go index 4c246d6..2fab1cd 100644 --- a/cmd/server/domain.go +++ b/cmd/server/domain.go @@ -105,7 +105,7 @@ func wrapMonth(v int) int { return m } -func validateCropInput(name string, start, end, grow int) error { +func validateCropInput(name string, start, end, grow, regrowCycles int) error { if name == "" { return errors.New("Name der Feldfrucht fehlt") } @@ -115,5 +115,8 @@ func validateCropInput(name string, start, end, grow int) error { if grow < 1 || grow > 24 { return errors.New("Wachstumsdauer muss 1-24 sein") } + if regrowCycles < 0 || regrowCycles > 120 { + return errors.New("Regrow-Zyklen muessen 0-120 sein") + } return nil } diff --git a/cmd/server/handlers_actions.go b/cmd/server/handlers_actions.go index d151962..965e7ed 100644 --- a/cmd/server/handlers_actions.go +++ b/cmd/server/handlers_actions.go @@ -210,11 +210,16 @@ func (a *App) handleCreateCrop(w http.ResponseWriter, r *http.Request) { 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 { + regrowEnabled := r.FormValue("regrow_enabled") == "on" + regrowCycles := mustInt(r.FormValue("regrow_cycles"), 0) + if err := validateCropInput(name, start, end, grow, regrowCycles); err != nil { redirectWithMessage(w, r, "/crops", "error", err.Error()) return } - if _, err := a.db.Exec(`INSERT INTO crops(name,sow_start_month,sow_end_month,grow_months) VALUES (?,?,?,?)`, name, start, end, grow); err != nil { + if !regrowEnabled { + regrowCycles = 0 + } + if _, err := a.db.Exec(`INSERT INTO crops(name,sow_start_month,sow_end_month,grow_months,regrow_enabled,regrow_cycles) VALUES (?,?,?,?,?,?)`, name, start, end, grow, regrowEnabled, regrowCycles); err != nil { redirectWithMessage(w, r, "/crops", "error", "Feldfrucht nicht angelegt") return } @@ -235,15 +240,20 @@ func (a *App) handleUpdateCrop(w http.ResponseWriter, r *http.Request) { start := mustInt(r.FormValue("sow_start_month"), 0) end := mustInt(r.FormValue("sow_end_month"), 0) grow := mustInt(r.FormValue("grow_months"), 0) + regrowEnabled := r.FormValue("regrow_enabled") == "on" + regrowCycles := mustInt(r.FormValue("regrow_cycles"), 0) if id <= 0 { redirectWithMessage(w, r, "/crops", "error", "Feldfrucht-ID ungueltig") return } - if err := validateCropInput(name, start, end, grow); err != nil { + if err := validateCropInput(name, start, end, grow, regrowCycles); err != nil { redirectWithMessage(w, r, "/crops", "error", err.Error()) return } - if _, err := a.db.Exec(`UPDATE crops SET name=?,sow_start_month=?,sow_end_month=?,grow_months=? WHERE id=?`, name, start, end, grow, id); err != nil { + if !regrowEnabled { + regrowCycles = 0 + } + if _, err := a.db.Exec(`UPDATE crops SET name=?,sow_start_month=?,sow_end_month=?,grow_months=?,regrow_enabled=?,regrow_cycles=? WHERE id=?`, name, start, end, grow, regrowEnabled, regrowCycles, id); err != nil { redirectWithMessage(w, r, "/crops", "error", "Feldfrucht nicht aktualisiert") return } @@ -331,6 +341,72 @@ func (a *App) handleCreatePlan(w http.ResponseWriter, r *http.Request) { redirectWithMessage(w, r, "/planning", "info", "Plan gespeichert") } +func (a *App) handleDeletePlan(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if err := r.ParseForm(); err != nil { + redirectWithMessage(w, r, "/planning", "error", "Form ungueltig") + return + } + id := mustInt64(r.FormValue("id"), 0) + if id <= 0 { + redirectWithMessage(w, r, "/planning", "error", "Plan-ID ungueltig") + return + } + if _, err := a.db.Exec(`DELETE FROM plans WHERE id=?`, id); err != nil { + redirectWithMessage(w, r, "/planning", "error", "Plan nicht geloescht") + return + } + redirectWithMessage(w, r, "/planning", "info", "Plan geloescht") +} + +func (a *App) handleCreateCropStep(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if err := r.ParseForm(); err != nil { + redirectWithMessage(w, r, "/crops", "error", "Form ungueltig") + return + } + cropID := mustInt64(r.FormValue("crop_id"), 0) + phase := strings.TrimSpace(r.FormValue("phase")) + offset := mustInt(r.FormValue("month_offset"), -1) + title := strings.TrimSpace(r.FormValue("title")) + if cropID <= 0 || (phase != "pre" && phase != "post") || offset < 0 || offset > 24 || title == "" { + redirectWithMessage(w, r, "/crops", "error", "Vor/Nachbereitung ungueltig") + return + } + if _, err := a.db.Exec(`INSERT INTO crop_steps(crop_id,phase,month_offset,title) VALUES (?,?,?,?)`, cropID, phase, offset, title); err != nil { + redirectWithMessage(w, r, "/crops", "error", "Schritt nicht gespeichert") + return + } + redirectWithMessage(w, r, "/crops", "info", "Schritt gespeichert") +} + +func (a *App) handleDeleteCropStep(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if err := r.ParseForm(); err != nil { + redirectWithMessage(w, r, "/crops", "error", "Form ungueltig") + return + } + id := mustInt64(r.FormValue("id"), 0) + if id <= 0 { + redirectWithMessage(w, r, "/crops", "error", "Schritt-ID ungueltig") + return + } + if _, err := a.db.Exec(`DELETE FROM crop_steps WHERE id=?`, id); err != nil { + redirectWithMessage(w, r, "/crops", "error", "Schritt nicht geloescht") + return + } + redirectWithMessage(w, r, "/crops", "info", "Schritt geloescht") +} + func (a *App) resolvePlanningTarget(ref string) (sql.NullInt64, string, error) { if strings.HasPrefix(ref, "f:") { id := mustInt64(strings.TrimPrefix(ref, "f:"), 0) diff --git a/cmd/server/handlers_pages.go b/cmd/server/handlers_pages.go index 042538b..252c33d 100644 --- a/cmd/server/handlers_pages.go +++ b/cmd/server/handlers_pages.go @@ -31,6 +31,11 @@ func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) { http.Error(w, "plans read failed", http.StatusInternalServerError) return } + stepMap, err := a.listCropStepsMap() + if err != nil { + http.Error(w, "steps read failed", http.StatusInternalServerError) + return + } data := DashboardPage{ BasePage: BasePage{ ActivePath: "/", @@ -39,8 +44,8 @@ func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) { }, Settings: settings, CurrentMonth: monthNames[settings.CurrentMonth-1], - TodayTasks: buildTasksForDay(plans, settings.CurrentMonth, settings.CurrentDay), - Calendar: buildCalendar(plans, settings.CurrentMonth, settings.CurrentDay, settings.DaysPerMonth, 14), + TodayTasks: buildTasksForDay(plans, stepMap, settings.CurrentMonth, settings.CurrentDay), + Calendar: buildCalendar(plans, stepMap, settings.CurrentMonth, settings.CurrentDay, settings.DaysPerMonth, 14), PlanningCount: len(plans), } a.renderTemplate(w, "templates/dashboard.html", data) @@ -78,13 +83,19 @@ func (a *App) handleCropsPage(w http.ResponseWriter, r *http.Request) { http.Error(w, "crops read failed", http.StatusInternalServerError) return } + steps, err := a.listCropSteps() + if err != nil { + http.Error(w, "steps 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, + Crops: crops, + CropSteps: steps, } a.renderTemplate(w, "templates/crops.html", data) } diff --git a/cmd/server/main.go b/cmd/server/main.go index 2b4837b..da90b12 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -59,8 +59,11 @@ func main() { mux.HandleFunc("/crops/create", app.handleCreateCrop) mux.HandleFunc("/crops/update", app.handleUpdateCrop) mux.HandleFunc("/crops/delete", app.handleDeleteCrop) + mux.HandleFunc("/crop-steps/create", app.handleCreateCropStep) + mux.HandleFunc("/crop-steps/delete", app.handleDeleteCropStep) mux.HandleFunc("/plans/create", app.handleCreatePlan) + mux.HandleFunc("/plans/delete", app.handleDeletePlan) mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) srv := &http.Server{ diff --git a/cmd/server/types.go b/cmd/server/types.go index fcc1b17..43ee23d 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -29,6 +29,8 @@ type Crop struct { SowStartMonth int SowEndMonth int GrowMonths int + RegrowEnabled bool + RegrowCycles int } type Plan struct { @@ -38,11 +40,23 @@ type Plan struct { TargetName string CropID int64 CropName string + GrowMonths int StartMonth int StartDay int HarvestMonth int HarvestDay int Notes string + RegrowEnabled bool + RegrowCycles int +} + +type CropStep struct { + ID int64 + CropID int64 + CropName string + Phase string + MonthOffset int + Title string } type Task struct { @@ -98,7 +112,8 @@ type FieldsPage struct { type CropsPage struct { BasePage - Crops []Crop + Crops []Crop + CropSteps []CropStep } type PlanningPage struct { diff --git a/static/farmcal-icon.svg b/static/farmcal-icon.svg new file mode 100644 index 0000000..4b3a29d --- /dev/null +++ b/static/farmcal-icon.svg @@ -0,0 +1,24 @@ + diff --git a/static/styles.css b/static/styles.css index 9dcb391..4b2bceb 100644 --- a/static/styles.css +++ b/static/styles.css @@ -1,50 +1,86 @@ :root { - --bg: #f2f5ef; - --card: #ffffff; - --text: #1f2a1d; - --muted: #5d6a5a; - --accent: #365f2c; - --accent-2: #93b877; - --danger: #9b2f2f; + --sky-1: #59b8df; + --sky-2: #f3d49a; + --field-1: #87b52a; + --field-2: #3f6620; + --soil: #6f4e2d; + --card: #ffffffee; + --text: #1c2328; + --muted: #55606a; + --accent: #7eb61a; + --accent-dark: #537b0f; + --danger: #ac2f2f; } * { box-sizing: border-box; } + body { margin: 0; - font-family: "Segoe UI", Tahoma, sans-serif; - background: radial-gradient(circle at top, #e8f1df, var(--bg)); + font-family: "Bahnschrift", "Trebuchet MS", sans-serif; color: var(--text); + background: + radial-gradient(1100px 500px at 78% 14%, #fff6d6aa 0%, transparent 55%), + linear-gradient(180deg, var(--sky-1) 0%, var(--sky-2) 35%, #f2e4c0 55%, #d4c17f 68%, #8ea347 77%, var(--field-1) 100%); + min-height: 100vh; } .top { - padding: 1.2rem 1rem; - background: linear-gradient(90deg, #2f4f2a, #4d7a40); + position: relative; + padding: 1.1rem 1rem; color: #fff; + text-shadow: 0 2px 10px #1d2b33aa; + background: + linear-gradient(130deg, #2f6e89cc, #6ca741cc), + repeating-linear-gradient(130deg, #00000011 0 10px, #ffffff10 10px 20px); + border-bottom: 2px solid #ffffff44; +} + +.top h1 { + margin: 0; + display: inline-flex; + align-items: center; + gap: .55rem; + font-size: clamp(1.5rem, 2.8vw, 2.1rem); + letter-spacing: .02em; +} + +.brand-icon { + width: 2rem; + height: 2rem; + border-radius: .4rem; + box-shadow: 0 4px 14px #00000055; +} + +.top p { + margin: .25rem 0 0; + font-weight: 600; + opacity: .96; } -.top h1 { margin: 0; } -.top p { margin: .35rem 0 0; opacity: .9; } .tabs { display: flex; gap: .5rem; flex-wrap: wrap; - padding: .7rem 1rem; - max-width: 1280px; + padding: .75rem 1rem; + max-width: 1320px; margin: 0 auto; } + .tabs a { text-decoration: none; - color: #234020; - background: #e7efdf; - border: 1px solid #cbd8c3; + color: #21331b; + background: #f2f8e7d9; + border: 1px solid #99bd5f; border-radius: 999px; - padding: .35rem .75rem; - font-weight: 600; + padding: .36rem .85rem; + font-weight: 700; + box-shadow: 0 2px 8px #1b331111; } + .tabs a.active { color: #fff; - background: var(--accent); - border-color: var(--accent); + border-color: var(--accent-dark); + background: linear-gradient(180deg, #8fcf22, #6d9f14); } .layout { @@ -52,61 +88,85 @@ body { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 1rem; padding: 1rem; - max-width: 1280px; + max-width: 1320px; margin: 0 auto; } .card { background: var(--card); - border: 1px solid #dbe6d5; - border-radius: 12px; + border: 1px solid #ffffffc4; + border-radius: 14px; padding: 1rem; - box-shadow: 0 8px 20px rgba(0,0,0,0.05); + box-shadow: 0 10px 28px #2b2d181e; + backdrop-filter: blur(2px); } + .card h2 { - margin-top: 0; - font-size: 1.05rem; + margin: 0 0 .7rem; + font-size: 1.06rem; } + .full-width { grid-column: 1 / -1; } .grid { display: grid; gap: .8rem; - grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); } + .full { grid-column: 1 / -1; } .mt { margin-top: .8rem; } +.mt-xs { margin-top: .3rem; } label { display: grid; gap: .35rem; - font-weight: 600; color: var(--muted); -} -input, select, button { - font: inherit; - padding: .55rem .65rem; - border-radius: 8px; - border: 1px solid #c9d8c2; -} -button { - background: var(--accent); - color: #fff; - border: none; - cursor: pointer; font-weight: 700; + min-width: 0; } -button:hover { background: #2d5124; } -.btn-small { padding: .4rem .6rem; font-size: .9rem; } -.danger { background: #a13939; } -.danger:hover { background: #8f2f2f; } + +input, select, textarea { + font: inherit; + width: 100%; + max-width: 100%; + min-width: 0; + padding: .52rem .65rem; + border-radius: 9px; + border: 1px solid #b7c7a2; + background: #fff; + color: var(--text); +} + +button { + font: inherit; + border: none; + border-radius: 9px; + padding: .55rem .85rem; + background: linear-gradient(180deg, #8fcf22, #6d9f14); + color: #fff; + cursor: pointer; + font-weight: 800; + width: auto; + max-width: 100%; +} + +button:hover { background: linear-gradient(180deg, #7cbc19, #5e8a12); } + +.btn-small { padding: .35rem .58rem; font-size: .9rem; } +.danger { background: linear-gradient(180deg, #cc4747, #9a2e2e); } +.danger:hover { background: linear-gradient(180deg, #b43d3d, #842626); } .check { display: inline-flex; align-items: center; gap: .45rem; } -.check input { width: 1rem; height: 1rem; } + +.check input { + width: 1.05rem; + height: 1.05rem; +} .hint { color: var(--muted); @@ -117,65 +177,91 @@ button:hover { background: #2d5124; } display: flex; gap: .45rem; align-items: center; + flex-wrap: wrap; } .inline-inputs { display: inline-flex; gap: .35rem; align-items: center; + flex-wrap: wrap; } + .inline-inputs input { - width: 5rem; + width: 5.2rem; } .tasks { margin: 0; padding-left: 1.1rem; } -.tasks li { margin: .35rem 0; } + +.tasks li { margin: .3rem 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)); + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); } + .month-card { - border: 1px solid #dbe6d5; - border-radius: 10px; - padding: .6rem; - background: #f9fbf7; + border: 1px solid #cad5ad; + border-radius: 11px; + padding: .65rem; + background: linear-gradient(180deg, #fbfff5, #f0f7de); } + .month-card h3 { - margin: 0 0 .4rem; + margin: 0 0 .45rem; 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; } +.month-days li { margin: .24rem 0; } + +.table-wrap { + overflow-x: auto; + max-width: 100%; +} + table { width: 100%; border-collapse: collapse; + min-width: 680px; } + th, td { text-align: left; padding: .5rem; - border-bottom: 1px solid #e2eadf; - white-space: nowrap; + border-bottom: 1px solid #d7decb; + vertical-align: top; } + th { - background: #eef5e8; - color: #384534; + background: #edf5de; + color: #2f3a24; +} + +td form { + min-width: 0; +} + +td input, +td select { + max-width: 100%; } .toast { @@ -186,10 +272,13 @@ th { border-radius: 8px; color: #fff; max-width: 380px; + box-shadow: 0 8px 18px #0000003f; } + .toast.error { background: var(--danger); } .toast.info { background: #2f6d7a; } -@media (max-width: 700px) { +@media (max-width: 760px) { .layout { grid-template-columns: 1fr; } + table { min-width: 540px; } } diff --git a/templates/crops.html b/templates/crops.html index 4d4e31e..e24e536 100644 --- a/templates/crops.html +++ b/templates/crops.html @@ -4,10 +4,11 @@
Feldfruechte verwalten
Feldfruechte verwalten