Files
trading/main.go
jbergner 134e601d57
All checks were successful
release-tag / release-image (push) Successful in 2m13s
Debug-Schritte entfernt
2025-07-31 23:13:52 +02:00

1158 lines
35 KiB
Go
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"html/template"
"log"
"math"
"net/http"
"os"
"sort"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
_ "modernc.org/sqlite" // statt github.com/mattn/go-sqlite3
)
/* Quellen */
/* https://starmap.space/api/v3/oc/index.php */
func GetENV(k, d string) string {
if v := os.Getenv(k); v != "" {
return v
}
return d
}
func Enabled(k string, def bool) bool {
b, err := strconv.ParseBool(strings.ToLower(os.Getenv(k)))
if err != nil {
return def
}
return b
}
var (
username = GetENV("KT_USERNAME", "root")
password = GetENV("KT_PASSWORD", "root")
membername = GetENV("KT_MEMBER", "demo")
productive = Enabled("KT_PRODUCTIVE", false)
hasimpressum = Enabled("KT_HASIMPRESSUM", false)
impressum = GetENV("KT_IMPRESSUM", "")
hashedPassword = ""
orte = []string{}
schiffe = []string{
"", "100i", "125a", "135c", "Arrow", "Aurora CL", "Aurora ES", "Aurora LN", "Aurora LX", "Aurora MR",
"Avenger Stalker", "Avenger Titan", "Avenger Titan Renegade", "Avenger Warlock",
"Blade", "Buccaneer", "C1 Spirit", "C2 Hercules Starlifter", "M2 Hercules Starlifter", "A2 Hercules Starlifter", "C8 Pisces", "C8R Pisces Rescue",
"C8X Pisces Expedition", "Carrack", "Caterpillar", "Constellation Andromeda",
"Constellation Aquila", "Constellation Phoenix", "Constellation Taurus", "Corsair",
"Cutlass Black", "Cutlass Blue", "Cutlass Red", "Cutter", "Defender", "Eclipse",
"Freelancer", "Freelancer DUR", "Freelancer MAX", "Gladiator", "Gladius", "Glaive",
"Hammerhead", "Hawk", "Herald", "Hurricane", "Idris-P", "Idris-K", "Idris-M", "Javelin",
"Khartu-Al", "Kraken", "M50", "Merchantman", "Mercury Star Runner", "Mustang Alpha",
"Mustang Beta", "Mustang Delta", "Mustang Gamma", "Mustang Omega", "Nomad",
"Orion", "P-52 Merlin", "P-72 Archimedes", "Prospector", "Prowler", "Prowler Utility", "Raft",
"Reclaimer", "Redeemer", "Reliant Kore", "Reliant Mako", "Reliant Sen", "Reliant Tana",
"Retaliator", "Sabre Peregrine", "Starfarer Gemini", "Talon", "Talon Shrike",
"Terrapin", "Vulture", "Hull-A", "Hull-C", "Zeus ES", "Zeus CL",
// …weitere Capital- und Concept-Schiffe sind ebenfalls bekannt!
}
waren = []string{
"", "Laranite", "Titanium", "Medical Supplies", "Gold", "Agricium", "Hydrogen", "Hydrogen Fuel", "Nitrogen", "Astatine", "Processed Food", "Scrap", "Recycled Material Composite", "Agricultural Supplies",
"Iodine", "Aluminium", "Copper", "Lithium", "Silicon", "Tungsten", "Aphorite", "Beryl", "Bexalite", "Waste", "Osoian hides", "Borase", "WiDoW", "Fresh Food", "Heart of the Woods", "Pressurized Ice", "Atlasium",
"Corundum", "Diamond", "Dolivine", "Hadanite", "Hephaestanite", "Laranite", "Quartz", "Taranite", "Stims", "Carbon", "Slam", "Distilled Spirits", "Maze", "Gasping Weevil Eggs", "E'tam", "Iron", "Methane",
}
)
type POI struct {
ItemID int `json:"item_id"`
System string `json:"System"`
Planet string `json:"Planet"`
PoiName string `json:"PoiName"`
Type string `json:"Type"`
Classification string `json:"Classification"`
Latitude float64 `json:"Latitude"`
Longitude float64 `json:"Longitude"`
Longitude360 float64 `json:"Longitude360"`
Height float64 `json:"Height"`
XCoord float64 `json:"XCoord"`
YCoord float64 `json:"YCoord"`
ZCoord float64 `json:"ZCoord"`
QTMarker int `json:"QTMarker"`
NextPOI string `json:"NextPOI"`
NextQTMarker string `json:"NextQTMarker"`
Comment string `json:"Comment"`
Submitted string `json:"Submitted"` // oder time.Time, falls du umwandelst
Introduced string `json:"Introduced"` // z.B. "Unknown"
GUID string `json:"GUID"`
POISize *string `json:"POI_Size"`
POIType *string `json:"POI_Type"`
POIEntries string `json:"POI_Entries"`
POIAccessableFoot string `json:"POI_Accessable_Foot"`
POIAccessableVehicle string `json:"POI_Accessable_Vehicle"`
POIAccessableShip string `json:"POI_Accessable_Ship"`
POIDefenses string `json:"POI_Defenses"`
POILandingPads string `json:"POI_LandingPads"`
POIVehiclePads string `json:"POI_VehiclePads"`
POITerminals string `json:"POI_Terminals"`
POIMedBay string `json:"POI_MedBay"`
POIServices string `json:"POI_Services"`
POIAtmosphere *string `json:"POI_Atmosphere"`
POINPCs string `json:"POI_NPCs"`
ZoneArmistice int `json:"Zone_Armistice"`
ZoneNoFly int `json:"Zone_NoFly"`
ZoneTrespassing int `json:"Zone_Trespassing"`
ZoneBiome string `json:"Zone_Biome"`
ZoneGravitation int `json:"Zone_Gravitation"`
ZoneTemperatureMin string `json:"Zone_Temperature_Min"`
ZoneTemperatureMax string `json:"Zone_Temperature_Max"`
Minerals *string `json:"Minerals"`
SpecialLoot string `json:"SpecialLoot"`
Missions string `json:"Missions"`
}
type Entry struct {
ID int
Anfangsbestand float64
Endbestand float64
Prozentwert float64
Abgabe float64
Gesamtwert float64
Bezahlt bool
CreatedAt string
Startort string
Zielort string
Schiff string
Ware string
Zeitaufwand float64
}
type Abteilung struct {
Name string
Anteil float64 // in Prozent
Wert float64 // berechnet: Anteil * Summe / 100
WertOffen float64 // berechnet: Anteil * Summe / 100 (von offen)
Beispiel string
WertItem float64
}
type Monatsstatistik struct {
Monat string // z.B. "07.2025"
Summe float64 // bezahlte
SummeOffen float64 // noch nicht bezahlt
Prozent float64
ProzentOffen float64
Eintraege []Entry
}
var tmpl = template.Must(template.New("form").Funcs(template.FuncMap{
"formatNumber": formatNumber,
"div": func(a, b float64) float64 {
if b == 0 {
return 0
}
return math.Floor(a / b)
},
"formatDate": func(dateStr string) string {
t, err := time.Parse("2006-01-02 15:04:05", dateStr)
if err != nil {
return "?"
}
return t.Format("02.01.2006")
},
}).Parse(htmlTemplate))
// Tausendertrenner für deutsche Zahlendarstellung (z.B. 12345 → "12.345")
func formatNumber(n float64) string {
intVal := int64(n + 0.5) // runden
s := strconv.FormatInt(intVal, 10)
nStr := ""
for i, r := range reverse(s) {
if i > 0 && i%3 == 0 {
nStr = "." + nStr
}
nStr = string(r) + nStr
}
return nStr
}
func reverse(s string) string {
r := []rune(s)
for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
return string(r)
}
func isAuthenticated(r *http.Request) bool {
cookie, err := r.Cookie("session")
if err != nil {
return false
}
// Prüfen, ob der Token im sessionStore existiert
_, ok := sessionStore[cookie.Value]
return ok
}
var sessionStore = make(map[string]string) // token → username
var loginAttempts = make(map[string]int)
var loginLastAttempt = make(map[string]time.Time)
var loginBlockedUntil = make(map[string]time.Time)
var loginMutex sync.Mutex
func hashPassword(pw string) string {
hash, _ := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
return string(hash)
}
func checkPasswordHash(pw, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw))
return err == nil
}
func generateSessionToken() string {
b := make([]byte, 32)
_, err := rand.Read(b)
if err != nil {
return "" // handle error besser im echten Code
}
return hex.EncodeToString(b)
}
func main() {
var (
db *sql.DB
err error
data []byte
)
if productive {
db, err = sql.Open("sqlite", "/data/data.db")
if err != nil {
panic(err)
}
data, err = os.ReadFile("/dynamicsrc/pois.json")
if err != nil {
panic(err)
}
} else {
db, err = sql.Open("sqlite", "./data.db")
if err != nil {
panic(err)
}
data, err = os.ReadFile("./dynamicsrc/pois.json")
if err != nil {
panic(err)
}
}
hashedPassword = hashPassword(password)
var pois []POI
if err := json.Unmarshal(data, &pois); err != nil {
panic(err)
}
orte = append(orte, "")
for _, poi := range pois {
if poi.System == "Stanton" || poi.System == "Pyro" {
formatted := fmt.Sprintf("%s - %s - %s (%s)", poi.System, poi.Planet, poi.PoiName, poi.Type)
orte = append(orte, formatted)
}
}
//
if err != nil {
log.Fatal(err)
}
defer db.Close()
createTable(db)
if productive {
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("/data/static"))))
} else {
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static"))))
}
http.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
ip := strings.Split(r.RemoteAddr, ":")[0]
loginMutex.Lock()
blockUntil, blocked := loginBlockedUntil[ip]
if blocked && time.Now().Before(blockUntil) {
loginMutex.Unlock()
http.Error(w, "Zu viele Fehlversuche. Bitte versuch es später erneut.", http.StatusTooManyRequests)
return
}
loginMutex.Unlock()
if r.Method == http.MethodPost {
r.ParseForm()
user := r.FormValue("username")
pass := r.FormValue("password")
if user == username && checkPasswordHash(pass, hashedPassword) {
token := generateSessionToken()
// Speichere Session
sessionStore[token] = user
// Cookie setzen
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: token,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
// Erfolgreich -> Versuche zurücksetzen
loginMutex.Lock()
delete(loginAttempts, ip)
delete(loginLastAttempt, ip)
delete(loginBlockedUntil, ip)
loginMutex.Unlock()
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
// Fehlversuch behandeln
loginMutex.Lock()
loginAttempts[ip]++
loginLastAttempt[ip] = time.Now()
if loginAttempts[ip] >= 5 {
loginBlockedUntil[ip] = time.Now().Add(10 * time.Minute)
}
loginMutex.Unlock()
http.Error(w, "Login fehlgeschlagen", http.StatusUnauthorized)
return
}
// GET: Login-Formular
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(loginForm))
})
http.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session")
if err == nil {
token := cookie.Value
// Token aus dem serverseitigen Store löschen
delete(sessionStore, token)
// Cookie ungültig machen
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
}
http.Redirect(w, r, "/", http.StatusSeeOther)
})
http.HandleFunc("/delete", func(w http.ResponseWriter, r *http.Request) {
if !isAuthenticated(r) {
http.Error(w, "Nicht autorisiert", http.StatusUnauthorized)
return
}
id := r.URL.Query().Get("id")
if id != "" {
db.Exec("DELETE FROM eintraege WHERE id = ?", id)
}
cacheMutex.Lock()
cache.LastComputed = time.Time{} // auf null zurücksetzen
cacheMutex.Unlock()
http.Redirect(w, r, "/", http.StatusSeeOther)
})
http.HandleFunc("/reset", func(w http.ResponseWriter, r *http.Request) {
if !isAuthenticated(r) {
http.Error(w, "Nicht autorisiert", http.StatusUnauthorized)
return
}
// Tabelle leeren
db.Exec("DELETE FROM eintraege")
// Auto-Increment-Zähler zurücksetzen
db.Exec("DELETE FROM sqlite_sequence WHERE name='eintraege'")
cacheMutex.Lock()
cache.LastComputed = time.Time{} // auf null zurücksetzen
cacheMutex.Unlock()
http.Redirect(w, r, "/", http.StatusSeeOther)
})
http.HandleFunc("/markaspaid", func(w http.ResponseWriter, r *http.Request) {
if !isAuthenticated(r) {
http.Error(w, "Nicht autorisiert", http.StatusUnauthorized)
return
}
id := r.URL.Query().Get("id")
if id != "" {
db.Exec("UPDATE eintraege SET bezahlt = 1 WHERE id = ?", id)
}
cacheMutex.Lock()
cache.LastComputed = time.Time{} // auf null zurücksetzen
cacheMutex.Unlock()
http.Redirect(w, r, "/", http.StatusSeeOther)
})
http.HandleFunc("/unmarkaspaid", func(w http.ResponseWriter, r *http.Request) {
if !isAuthenticated(r) {
http.Error(w, "Nicht autorisiert", http.StatusUnauthorized)
return
}
id := r.URL.Query().Get("id")
if id != "" {
db.Exec("UPDATE eintraege SET bezahlt = 0 WHERE id = ?", id)
}
cacheMutex.Lock()
cache.LastComputed = time.Time{} // auf null zurücksetzen
cacheMutex.Unlock()
http.Redirect(w, r, "/", http.StatusSeeOther)
})
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
if !isAuthenticated(r) {
http.Error(w, "Nicht autorisiert", http.StatusUnauthorized)
return
}
r.ParseForm()
anfang, _ := strconv.ParseFloat(r.FormValue("anfangsbestand"), 64)
ende, _ := strconv.ParseFloat(r.FormValue("endbestand"), 64)
prozent, _ := strconv.ParseFloat(r.FormValue("prozentwert"), 64)
diff := ende - anfang
abgabe := (diff / 100) * prozent
startort := r.FormValue("startort")
zielort := r.FormValue("zielort")
schiff := r.FormValue("schiff")
ware := r.Form["ware"]
wareStr := strings.Join(ware, ", ")
zeitaufwand, _ := strconv.ParseFloat(r.FormValue("zeitaufwand"), 64)
_, err := db.Exec(`INSERT INTO eintraege (anfangsbestand, endbestand, prozentwert, abgabe, created_at, startort, zielort, schiff, ware, zeitaufwand) VALUES (?, ?, ?, ?, datetime('now'), ?, ?, ?, ?, ?)`, anfang, ende, prozent, abgabe, startort, zielort, schiff, wareStr, zeitaufwand)
if err != nil {
http.Error(w, "Fehler beim Einfügen", http.StatusInternalServerError)
return
}
cacheMutex.Lock()
cache.LastComputed = time.Time{} // auf null zurücksetzen
cacheMutex.Unlock()
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
cacheMutex.RLock()
validCache := time.Since(cache.LastComputed) < 6*time.Hour
cachedData := cache.Data
cacheMutex.RUnlock()
if validCache {
cachedData.LoggedIn = isAuthenticated(r)
tmpl.Execute(w, cachedData)
return
}
rows, err := db.Query(`SELECT id, anfangsbestand, endbestand, prozentwert, abgabe, bezahlt, created_at, startort, zielort, schiff, ware, zeitaufwand FROM eintraege`)
if err != nil {
http.Error(w, "Fehler beim Abrufen", http.StatusInternalServerError)
return
}
defer rows.Close()
var eintraege []Entry
var summe float64
var offeneSumme float64
for rows.Next() {
var e Entry
var bezahlt int
var createdAt sql.NullString
var startort sql.NullString
var zielort sql.NullString
var schiff sql.NullString
var ware sql.NullString
var zeitaufwand sql.NullFloat64
err := rows.Scan(&e.ID, &e.Anfangsbestand, &e.Endbestand, &e.Prozentwert, &e.Abgabe, &bezahlt, &createdAt, &startort, &zielort, &schiff, &ware, &zeitaufwand)
if err != nil {
log.Println("Fehler beim Scan:", err)
continue
}
e.Gesamtwert = e.Endbestand - e.Anfangsbestand
e.Bezahlt = bezahlt == 1
if !e.Bezahlt {
offeneSumme += e.Abgabe
} else {
summe += e.Abgabe
}
if createdAt.Valid {
e.CreatedAt = createdAt.String
} else {
e.CreatedAt = "unbekannt"
}
/**/
if startort.Valid {
e.Startort = startort.String
} else {
e.Startort = "unbekannt"
}
if zielort.Valid {
e.Zielort = zielort.String
} else {
e.Zielort = "unbekannt"
}
if schiff.Valid {
e.Schiff = schiff.String
} else {
e.Schiff = "unbekannt"
}
if ware.Valid {
e.Ware = ware.String
} else {
e.Ware = "unbekannt"
}
if zeitaufwand.Valid {
e.Zeitaufwand = zeitaufwand.Float64
} else {
e.Zeitaufwand = 0
}
eintraege = append(eintraege, e)
}
monatsMap := map[string]*Monatsstatistik{}
for _, e := range eintraege {
parsed, err := time.Parse("2006-01-02 15:04:05", e.CreatedAt)
if err != nil {
continue
}
monatKey := parsed.Format("01.2006") // z.B. "07.2025"
if _, ok := monatsMap[monatKey]; !ok {
monatsMap[monatKey] = &Monatsstatistik{Monat: monatKey}
}
monatsMap[monatKey].Eintraege = append(monatsMap[monatKey].Eintraege, e)
if e.Bezahlt {
monatsMap[monatKey].Summe += e.Abgabe
} else {
monatsMap[monatKey].SummeOffen += e.Abgabe
}
monatsMap[monatKey].Prozent = monatsMap[monatKey].Summe / (monatsMap[monatKey].Summe + monatsMap[monatKey].SummeOffen) * 100
monatsMap[monatKey].ProzentOffen = monatsMap[monatKey].SummeOffen / (monatsMap[monatKey].Summe + monatsMap[monatKey].SummeOffen) * 100
}
var monatsStat []Monatsstatistik
for _, stat := range monatsMap {
monatsStat = append(monatsStat, *stat)
}
sort.Slice(monatsStat, func(i, j int) bool {
// Nach Datum aufsteigend sortieren
ti, _ := time.Parse("01.2006", monatsStat[i].Monat)
tj, _ := time.Parse("01.2006", monatsStat[j].Monat)
return ti.Before(tj)
})
//test
// Dynamische Abteilungen frei anpassbar
abteilungen := []Abteilung{
{Name: "Raumkampf (+8)", Anteil: 23, Beispiel: "CF-337 Panther", WertItem: 36308},
{Name: "Bodenkampf", Anteil: 8, Beispiel: "P4-AR Rifle", WertItem: 5900},
{Name: "Racing", Anteil: 3, Beispiel: "LumaCore - Power Plant", WertItem: 69300},
{Name: "Medical", Anteil: 5, Beispiel: "ParaMed Medical Device", WertItem: 1250},
{Name: "Exploration", Anteil: 3, Beispiel: "Pembroke Exploration Suit", WertItem: 15000},
{Name: "Rettung", Anteil: 5, Beispiel: "GSX-HP Fuel-Pod", WertItem: 115200},
{Name: "Logistik", Anteil: 8, Beispiel: "MaxLift Tractor Beam", WertItem: 19175},
{Name: "Mining", Anteil: 3, Beispiel: "Helix II", WertItem: 108000},
{Name: "Salvaging", Anteil: 3, Beispiel: "Abrade Scraper Module", WertItem: 21250},
{Name: "Trading", Anteil: 3, Beispiel: "MaxLift Tractor Beam", WertItem: 19175},
{Name: "Basebuilding (+10)", Anteil: 0, Beispiel: "CF-337 Panther", WertItem: 36308},
{Name: "Crafting (+8)", Anteil: 0, Beispiel: "CF-337 Panther", WertItem: 36308},
{Name: "Forschung (+5)", Anteil: 0, Beispiel: "CF-337 Panther", WertItem: 36308},
{Name: "Events (-15)", Anteil: 30, Beispiel: "CF-337 Panther", WertItem: 36308},
{Name: "Roleplay", Anteil: 3, Beispiel: "Clothing", WertItem: 8400},
{Name: "Kunstflug", Anteil: 3, Beispiel: "Beacon Undersuit Crimson", WertItem: 1000},
}
for i := range abteilungen {
abteilungen[i].Wert = (abteilungen[i].Anteil / 100) * summe
abteilungen[i].WertOffen = (abteilungen[i].Anteil / 100) * offeneSumme
}
computed := TemplateData{
Entries: eintraege,
Summe: summe,
OffeneSumme: offeneSumme,
Abteilungen: abteilungen,
Monatsstatistik: monatsStat,
Member: membername,
HasImpressum: hasimpressum,
Impressum: impressum,
Orte: orte,
Schiffe: schiffe,
Waren: waren,
}
cacheMutex.Lock()
cache.Data = computed
cache.LastComputed = time.Now()
cacheMutex.Unlock()
tmpl.Execute(w, computed)
/*tmpl.Execute(w, struct {
Entries []Entry
Summe float64
OffeneSumme float64
Abteilungen []Abteilung
Monatsstatistik []Monatsstatistik
LoggedIn bool
Member string
HasImpressum bool
Impressum string
Orte []string
Schiffe []string
Waren []string
}{
Entries: eintraege,
Summe: summe,
OffeneSumme: offeneSumme,
Abteilungen: abteilungen,
Monatsstatistik: monatsStat,
LoggedIn: isAuthenticated(r),
Member: membername,
HasImpressum: hasimpressum,
Impressum: impressum,
Orte: orte,
Schiffe: schiffe,
Waren: waren,
})*/
})
log.Println("Server läuft auf http://0.0.0.0:8080")
http.ListenAndServe(":8080", nil)
}
type TemplateData struct {
Entries []Entry
Summe float64
OffeneSumme float64
Abteilungen []Abteilung
Monatsstatistik []Monatsstatistik
LoggedIn bool
Member string
HasImpressum bool
Impressum string
Orte []string
Schiffe []string
Waren []string
}
type CachedData struct {
Data TemplateData // das Struct, das du an tmpl.Execute übergibst
LastComputed time.Time
}
var cache CachedData
var cacheMutex sync.RWMutex
func createTable(db *sql.DB) {
_, err := db.Exec(`
CREATE TABLE IF NOT EXISTS eintraege (
id INTEGER PRIMARY KEY AUTOINCREMENT,
anfangsbestand REAL,
endbestand REAL,
prozentwert REAL,
abgabe REAL,
bezahlt INTEGER DEFAULT 0,
created_at TEXT,
startort TEXT,
zielort TEXT,
schiff TEXT,
ware TEXT,
zeitaufwand INTEGER
);
`)
if err != nil {
log.Fatal(err)
}
// Ergänze ALTER TABLE nur für Migration bestehender Tabellen
addColumn := func(column, colType string) {
_, err := db.Exec(`ALTER TABLE eintraege ADD COLUMN ` + column + ` ` + colType)
if err != nil && !strings.Contains(err.Error(), "duplicate column") {
log.Fatalf("Fehler beim Hinzufügen der Spalte %s: %v", column, err)
}
}
addColumn("bezahlt", "INTEGER DEFAULT 0")
addColumn("created_at", "TEXT")
addColumn("startort", "TEXT")
addColumn("zielort", "TEXT")
addColumn("schiff", "TEXT")
addColumn("ware", "TEXT")
addColumn("zeitaufwand", "INTEGER")
}
const loginForm = `
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Login</title>
<link href="/static/css/bootstrap.min.css" rel="stylesheet">
<link rel="icon" href="/static/favicon.ico" type="image/x-icon">
</head>
<body class="bg-light">
<div class="container mt-5">
<h2>Login</h2>
<form method="POST" class="card p-4 shadow-sm">
<div class="mb-3">
<label class="form-label">Benutzername</label>
<input type="text" name="username" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Passwort</label>
<input type="password" name="password" class="form-control" required>
</div>
<button type="submit" class="btn btn-primary">Login</button>
</form>
</div>
</body>
</html>
`
const htmlTemplate = `
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Abgabe-Berechnung</title>
<link href="/static/css/bootstrap.min.css" rel="stylesheet">
<link href="/static/css/tom-select.default.min.css" rel="stylesheet">
<link rel="icon" href="/static/favicon.ico" type="image/x-icon">
</head>
<body class="bg-light">
<div class="container mt-5">
<div class="d-flex justify-content-end">
{{if .LoggedIn}}
<form action="/logout" method="POST">
<button type="submit" class="btn btn-sm btn-secondary">Logout</button>
</form>
{{else}}
<a href="/login" class="btn btn-sm btn-outline-primary">Login</a>
{{end}}
</div>
<h1 class="mb-4">Beitrag zur Community vom Mitglied der Trading-Staffel ({{.Member}})</h1>
<div class="alert alert-info">
<strong>Folgender Wert wurde erwirtschaftet und wird bald zur Verfügung gestellt:</strong> {{formatNumber .OffeneSumme}} UEC
</div>
<div class="alert alert-success">
<strong>Summe aller getätigten Abgaben an die Community:</strong> {{formatNumber .Summe}} UEC
</div>
<hr />
{{if .LoggedIn}}
<form method="POST" class="card p-4 mb-4 shadow-sm">
<div class="row mb-3">
<div class="col">
<label class="form-label">Anfangsbestand</label>
<input type="number" step="1" name="anfangsbestand" class="form-control" required>
</div>
<div class="col">
<label class="form-label">Endbestand</label>
<input type="number" step="1" name="endbestand" class="form-control" required>
</div>
<div class="col">
<label class="form-label">Prozentwert</label>
<select name="prozentwert" class="form-select">
<option value="30">30% (Standard)</option>
<option value="10">10%</option>
<option value="15">15%</option>
<option value="20">20%</option>
<option value="25">25%</option>
<option value="30">30%</option>
<option value="40">40%</option>
<option value="50">50%</option>
<option value="75">75%</option>
<option value="100">100%</option>
</select>
</div>
</div>
<div class="row mb-3">
<div class="col">
<label class="form-label">Startort</label>
<select id="startort" name="startort" class="form-select">
{{range .Orte}}
<option value="{{.}}">{{.}}</option>
{{end}}
</select>
</div>
<div class="col">
<label class="form-label">Zielort</label>
<select id="zielort" name="zielort" class="form-select">
{{range .Orte}}
<option value="{{.}}">{{.}}</option>
{{end}}
</select>
</div>
</div>
<div class="row mb-3">
<div class="col">
<label class="form-label">Schiff</label>
<select id="schiff" name="schiff" class="form-select">
{{range .Schiffe}}
<option value="{{.}}">{{.}}</option>
{{end}}
</select>
</div>
<div class="col">
<label class="form-label">Ware</label>
<select id="ware" name="ware" class="form-select" multiple>
{{range .Waren}}
<option value="{{.}}">{{.}}</option>
{{end}}
</select>
</div>
<div class="col">
<label class="form-label">Zeitaufwand (min)</label>
<input type="number" name="zeitaufwand" class="form-control" min="1" required>
</div>
</div>
<button type="submit" class="btn btn-primary">Berechnen & Speichern</button>
</form>
<hr />
{{end}}
<h2 class="mb-3">Auswertungen</h2>
<ul class="nav nav-tabs" id="auswertungTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="monat-tab" data-bs-toggle="tab" data-bs-target="#monat" type="button" role="tab" aria-controls="monat" aria-selected="true">
Monatliche Übersicht
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="abteilung-tab" data-bs-toggle="tab" data-bs-target="#abteilung" type="button" role="tab" aria-controls="abteilung" aria-selected="false">
Verteilung auf Abteilungen
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="items-tab" data-bs-toggle="tab" data-bs-target="#items" type="button" role="tab" aria-controls="items" aria-selected="false">
Gegenwert in Items
</button>
</li>
</ul>
<div class="tab-content border border-top-0 p-4 bg-white" id="auswertungTabsContent">
<!-- Monatliche Übersicht -->
<div class="tab-pane fade show active" id="monat" role="tabpanel" aria-labelledby="monat-tab">
<h5 class="mb-3">Monatliche Übersicht</h5>
<table class="table table-bordered">
<thead>
<tr>
<th>Monat</th>
<th>Abgaben verteilt</th>
<th>Abgaben offen</th>
<th>Statistik</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{{range .Monatsstatistik}}
<tr>
<td>{{.Monat}}</td>
<td>{{formatNumber .Summe}} UEC</td>
<td>{{formatNumber .SummeOffen}} UEC</td>
<td>
<div class="progress">
<div class="progress-bar progress-bar-striped bg-success" role="progressbar" style="width: {{.Prozent}}%" aria-valuenow="{{.Prozent}}" aria-valuemin="0" aria-valuemax="100"></div>
<div class="progress-bar progress-bar-striped bg-danger" role="progressbar" style="width: {{.ProzentOffen}}%" aria-valuenow="{{.ProzentOffen}}" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</td>
<td>
<button class="btn btn-sm btn-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#monat-{{.Monat}}" aria-expanded="false" aria-controls="monat-{{.Monat}}">
Details
</button>
</td>
</tr>
<tr>
<td colspan="9" class="p-0 border-0">
<div class="collapse" id="monat-{{.Monat}}">
<div class="bg-light p-3 border-top">
<strong>Interne Infos (Details):</strong>
<table class="table table-striped table-bordered">
<thead>
<tr>
<th>#</th>
<th>Datum</th>
<th>UEC Anfang</th>
<th>UEC Ende</th>
<th>UEC Profit</th>
<th>Prozent</th>
<th>UEC Abgabe</th>
<th>Status</th>
{{if $.LoggedIn}}<th>Aktion</th>{{else}}<th>Erweitert</th>{{end}}
</tr>
</thead>
<tbody id="eintragsTabelle">
{{range .Eintraege}}
<tr>
<td>{{.ID}}</td>
<td>{{formatDate .CreatedAt}}</td>
<td>{{formatNumber .Anfangsbestand}}</td>
<td>{{formatNumber .Endbestand}}</td>
<td>{{formatNumber .Gesamtwert}}</td>
<td>{{formatNumber .Prozentwert}}%</td>
<td>{{formatNumber .Abgabe}}</td>
<td>
{{if .Bezahlt}}
{{if $.LoggedIn}}
<a href="/unmarkaspaid?id={{.ID}}" class="btn btn-sm btn-outline-danger">✗ stornieren</a>
{{else}}
<span class="badge bg-success">✓ Erledigt</span>
{{end}}
{{else}}
{{if $.LoggedIn}}
<a href="/markaspaid?id={{.ID}}" class="btn btn-sm btn-outline-success">✓ abgeben</a>
{{else}}
<span class="badge bg-danger">✗ Offen</span>
{{end}}
{{end}}
</td>
<td>
<button class="btn btn-sm btn-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#details-{{.ID}}" aria-expanded="false" aria-controls="details-{{.ID}}">
Details
</button>
{{if $.LoggedIn}}
<a href="/delete?id={{.ID}}" class="btn btn-sm btn-danger" onclick="return confirm('Eintrag wirklich löschen?')">Löschen</a>
{{end}}
</td>
</tr>
<tr>
<td colspan="9" class="p-0 border-0">
<div class="collapse" id="details-{{.ID}}">
<div class="bg-light p-3 border-top">
<strong>Interne Infos (Details):</strong>
<table class="table table-sm table-bordered mb-0">
<thead>
<tr>
<th>Startort</th>
<th>Zielort</th>
<th>Schiff</th>
<th>Ware</th>
<th>Zeit (min)</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{.Startort}}</td>
<td>{{.Zielort}}</td>
<td>{{.Schiff}}</td>
<td>{{.Ware}}</td>
<td>{{formatNumber .Zeitaufwand}}</td>
</tr>
</tbody>
</table>
</div>
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<!-- Verteilung auf Abteilungen -->
<div class="tab-pane fade" id="abteilung" role="tabpanel" aria-labelledby="abteilung-tab">
<div class="alert alert-info">
<strong>Die tatsächlichen Werte können abweichen.</strong> Die dargestellten Werte sind meine Vorstellung einer sinnvollen Verteilung.<br>
Die Summe wird an die Orga-Leitung entrichtet. Die endgültige Entscheidung über die Verteilung obliegt der Orga-Leitung.
</div>
<h5 class="mb-3">Verteilung auf Abteilungen:</h5>
<table class="table table-striped table-bordered">
<thead>
<tr>
<th>Abteilung</th>
<th>Verteilungsschlüssel</th>
<th>Summe verteilt</th>
<th>Summe offen</th>
</tr>
</thead>
<tbody>
{{range .Abteilungen}}
<tr>
<td>{{.Name}}</td>
<td>{{formatNumber .Anteil}}%</td>
<td>{{formatNumber .Wert}} UEC</td>
<td>{{formatNumber .WertOffen}} UEC</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<!-- Gegenwert in Items -->
<div class="tab-pane fade" id="items" role="tabpanel" aria-labelledby="items-tab">
<h5 class="mb-3">Gegenwert in Items:</h5>
<table class="table table-bordered">
<thead>
<tr>
<th>Abteilung</th>
<th>Beispiel</th>
<th>Wert pro Item</th>
<th>Summe verteilt</th>
<th>Menge</th>
</tr>
</thead>
<tbody>
{{range .Abteilungen}}
<tr>
<td>{{.Name}}</td>
<td>{{.Beispiel}}</td>
<td>{{formatNumber .WertItem}} UEC</td>
<td>{{formatNumber .Wert}} UEC</td>
<td>
{{if gt .WertItem 0.0}}
{{formatNumber (div .Wert .WertItem)}}
{{else}}
-
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{if .LoggedIn}}
<form action="/reset" method="POST" onsubmit="return confirm('Alle Einträge wirklich löschen?')">
<button type="submit" class="btn btn-outline-danger mt-3">Alle Einträge löschen</button>
</form>
{{end}}
<hr />
{{if .HasImpressum}}
<div class="alert alert-light">
<strong><a href="{{.Impressum}}">Impressum</a></strong>
</div>
{{end}}
<hr />
</div>
<style>
.max {
max-width: 100%;
}
</style>
<script src="/static/js/bootstrap.bundle.min.js"></script>
<script src="/static/js/tom-select.complete.min.js"></script>
<script>
new TomSelect("#zielort", {
create: true, // erlaubt Freitext
sortField: "text"
});
</script>
<script>
new TomSelect("#startort", {
create: true, // erlaubt Freitext
sortField: "text"
});
</script>
<script>
new TomSelect("#schiff", {
create: true, // erlaubt Freitext
sortField: "text"
});
</script>
<script>
new TomSelect("#ware", {
create: true, // erlaubt Freitext
sortField: "text",
plugins: ['remove_button'] // ← erlaubt Entfernen per „x“-Button
});
</script>
<script>
document.addEventListener("DOMContentLoaded", function () {
const tabKey = "lastActiveTab";
// Tabs initialisieren - sicherstellen, dass Bootstrap geladen ist
const triggerElList = [].slice.call(document.querySelectorAll('#auswertungTabs button[data-bs-toggle="tab"]'));
const tabList = triggerElList.map(function (triggerEl) {
return new bootstrap.Tab(triggerEl);
});
// Falls gespeicherter Tab vorhanden ist, anzeigen
const lastTabId = localStorage.getItem(tabKey);
if (lastTabId) {
const selector = '#auswertungTabs button[data-bs-target="' + lastTabId + '"]';
const lastTabTrigger = document.querySelector(selector);
if (lastTabTrigger) {
new bootstrap.Tab(lastTabTrigger).show();
}
}
// Tab-Wechsel speichern
triggerElList.forEach(function (triggerEl) {
triggerEl.addEventListener("shown.bs.tab", function (event) {
const target = event.target.getAttribute("data-bs-target");
localStorage.setItem(tabKey, target);
});
});
});
</script>
</body>
</html>
`