Files
trading/main.go
jbergner 5bd89aa32b
All checks were successful
release-tag / release-image (push) Successful in 2m52s
fix
2025-07-25 13:15:58 +02:00

685 lines
19 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 (
"database/sql"
"html/template"
"log"
"math"
"net/http"
"os"
"sort"
"strconv"
"strings"
"time"
_ "modernc.org/sqlite" // statt github.com/mattn/go-sqlite3
)
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", "")
)
type Entry struct {
ID int
Anfangsbestand float64
Endbestand float64
Prozentwert float64
Abgabe float64
Gesamtwert float64
Bezahlt bool
CreatedAt string
}
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
}
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")
return err == nil && cookie.Value == "authenticated"
}
func main() {
var (
db *sql.DB
err error
)
if productive {
db, err = sql.Open("sqlite", "/data/data.db")
} else {
db, err = sql.Open("sqlite", "./data.db")
}
//
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) {
if r.Method == http.MethodPost {
r.ParseForm()
user := r.FormValue("username")
pass := r.FormValue("password")
if user == username && pass == password {
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: "authenticated",
Path: "/",
})
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
http.Error(w, "Login fehlgeschlagen", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(loginForm))
})
http.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: "",
Path: "/",
MaxAge: -1,
})
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)
}
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'")
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)
}
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)
}
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
_, err := db.Exec(`INSERT INTO eintraege (anfangsbestand, endbestand, prozentwert, abgabe, created_at) VALUES (?, ?, ?, ?, datetime('now'))`, anfang, ende, prozent, abgabe)
if err != nil {
http.Error(w, "Fehler beim Einfügen", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
rows, err := db.Query(`SELECT id, anfangsbestand, endbestand, prozentwert, abgabe, bezahlt, created_at 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
err := rows.Scan(&e.ID, &e.Anfangsbestand, &e.Endbestand, &e.Prozentwert, &e.Abgabe, &bezahlt, &createdAt)
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"
}
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}
}
if e.Bezahlt {
monatsMap[monatKey].Summe += e.Abgabe
} else {
monatsMap[monatKey].SummeOffen += e.Abgabe
}
}
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)
})
// Dynamische Abteilungen frei anpassbar
abteilungen := []Abteilung{
{Name: "Raumkampf", Anteil: 15, 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 (-23)", Anteil: 38, 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
}
tmpl.Execute(w, struct {
Entries []Entry
Summe float64
OffeneSumme float64
Abteilungen []Abteilung
Monatsstatistik []Monatsstatistik
LoggedIn bool
Member string
HasImpressum bool
Impressum string
}{
Entries: eintraege,
Summe: summe,
OffeneSumme: offeneSumme,
Abteilungen: abteilungen,
Monatsstatistik: monatsStat,
LoggedIn: isAuthenticated(r),
Member: membername,
HasImpressum: hasimpressum,
Impressum: impressum,
})
})
log.Println("Server läuft auf http://0.0.0.0:8080")
http.ListenAndServe(":8080", nil)
}
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
);
`)
if err != nil {
log.Fatal(err)
}
// Falls die Tabelle schon existiert, aber die Spalte "bezahlt" fehlt (z.B. nach Update)
_, err = db.Exec(`ALTER TABLE eintraege ADD COLUMN bezahlt INTEGER DEFAULT 0;`)
if err != nil && !strings.Contains(err.Error(), "duplicate column") {
log.Fatal(err)
}
_, err = db.Exec(`ALTER TABLE eintraege ADD COLUMN created_at TEXT`)
if err != nil && !strings.Contains(err.Error(), "duplicate column") {
log.Fatal(err)
}
}
const loginForm = `
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Login</title>
<link href="/static/css/bootstrap.min.css" rel="stylesheet">
</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">
</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%</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>
<button type="submit" class="btn btn-primary">Berechnen & Speichern</button>
</form>
{{end}}
<h2 class="mb-3">Gespeicherte Einträge</h2>
<table class="table table-striped table-bordered">
<thead>
<tr>
<th>#</th>
<th>Datum</th>
<th>Anfang</th>
<th>Ende</th>
<th>Profit</th>
<th>Prozent</th>
<th>Abgabe</th>
<th>Status</th>
{{if .LoggedIn}}<th>Aktion</th>{{end}}
</tr>
</thead>
<tbody>
{{range .Entries}}
<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-success">Als unverteilt markieren</a>
{{else}}
<span class="badge bg-success">✓ verteilt</span>
{{end}}
{{else}}
{{if $.LoggedIn}}
<a href="/markaspaid?id={{.ID}}" class="btn btn-sm btn-outline-success">Als verteilt markieren</a>
{{else}}
<span class="badge bg-danger">✗ nicht verteilt</span>
{{end}}
{{end}}
</td>
{{if $.LoggedIn}}
<td>
<a href="/delete?id={{.ID}}" class="btn btn-sm btn-danger" onclick="return confirm('Eintrag wirklich löschen?')">Löschen</a>
</td>
{{end}}
</tr>
{{end}}
</tbody>
</table>
<hr />
<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>
</tr>
</thead>
<tbody>
{{range .Monatsstatistik}}
<tr>
<td>{{.Monat}}</td>
<td>{{formatNumber .Summe}} UEC</td>
<td>{{formatNumber .SummeOffen}} UEC</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>
<script src="/static/js/bootstrap.bundle.min.js"></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>
`