This commit is contained in:
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
.git
|
||||
.gitignore
|
||||
Dockerfile
|
||||
README.md
|
||||
.env
|
||||
*.zip
|
||||
mariadbdata
|
||||
/tmp
|
||||
11
.env.example
11
.env.example
@@ -1,7 +1,4 @@
|
||||
APP_ADDR=:8080
|
||||
APP_BASE_URL=http://localhost:8080
|
||||
SESSION_SECRET=change-me-32-bytes-minimum
|
||||
DATABASE_URL=trading:trading@tcp(127.0.0.1:3306)/trading?parseTime=true&multiStatements=true
|
||||
OIDC_ISSUER=https://pocketid.example.com
|
||||
OIDC_CLIENT_ID=your-client-id
|
||||
OIDC_CLIENT_SECRET=your-client-secret
|
||||
POCKET_ID_ISSUER=https://pocketid.example.com
|
||||
POCKET_ID_CLIENT_ID=trading-tool
|
||||
POCKET_ID_CLIENT_SECRET=change-me
|
||||
SESSION_SECRET=replace-with-a-long-random-secret
|
||||
|
||||
148
README.md
148
README.md
@@ -1,75 +1,111 @@
|
||||
# Trading Tool Prototype — MariaDB
|
||||
# Trading Tool
|
||||
|
||||
Go-Webapp für Trading-Anfragen, automatische Quest-Erzeugung, MariaDB und Pocket ID Login via OIDC.
|
||||
Webbasiertes Go/MariaDB-System für Trading-Anfragen, dynamische Workflows und abteilungsbezogene Quests.
|
||||
|
||||
## Webinterface
|
||||
## Enthalten
|
||||
|
||||
Ja. Enthalten sind:
|
||||
|
||||
- Login-Seite über Pocket ID / OIDC
|
||||
- Dashboard für Trading-Anfragen
|
||||
- Formular für neue Ankauf-/Verkaufs-Anfragen
|
||||
- Quest-Tabelle für Mining, Logistik, Crafting usw.
|
||||
- Auftragnehmer können Status und Notizen/Gegenvorschläge setzen
|
||||
|
||||
## Auftragnehmer beeinflussen den Workflow
|
||||
|
||||
Ja, im aktuellen Prototyp über Quest-Status und Notizen:
|
||||
|
||||
- `accepted`
|
||||
- `in_progress`
|
||||
- `done`
|
||||
- `blocked`
|
||||
- `change_requested`
|
||||
- `cancelled`
|
||||
|
||||
Alle Änderungen werden zusätzlich in `quest_updates` protokolliert. Damit kann später die Workflow-Engine z. B. bei `blocked` eine zusätzliche Beschaffungsquest oder bei `change_requested` eine Trading-Freigabe erzeugen.
|
||||
|
||||
## Pocket ID Setup
|
||||
|
||||
In Pocket ID eine OIDC-App anlegen:
|
||||
|
||||
- Callback URL: `http://localhost:8080/auth/callback`
|
||||
- Scopes: `openid profile email`
|
||||
|
||||
Pocket ID stellt Client ID, Client Secret und Issuer URL bereit. Die Issuer URL ist normalerweise die Basis-URL deiner Pocket-ID-Instanz; die Discovery URL ist `/.well-known/openid-configuration`.
|
||||
- Go Webserver mit `net/http` und `html/template`
|
||||
- Pocket ID / OIDC Login
|
||||
- MariaDB Datenbank
|
||||
- automatische Migrationen
|
||||
- Admin-Konfiguration
|
||||
- Trading-Anfragen für Ankauf und Verkauf
|
||||
- Workflow-Regeln mit `condition_json` und `actions_json`
|
||||
- Quest-Templates
|
||||
- Quest-Abhängigkeiten: nur die erste Quest ist offen, Folgequests warten
|
||||
- automatische Freischaltung der nächsten Quest nach Abschluss der vorherigen Quest
|
||||
- automatische Request-Statusberechnung
|
||||
- Abteilungsportale unter `/departments`
|
||||
- Auftragnehmer-Rückmeldungen, Blocker, Änderungswünsche und Abschlussmeldungen
|
||||
- Dockerfile und Docker Compose
|
||||
|
||||
## Start
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose up -d
|
||||
set -a; source .env; set +a
|
||||
go mod tidy
|
||||
go run ./cmd/server
|
||||
# Pocket ID Werte in .env eintragen
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Dann öffnen: http://localhost:8080
|
||||
App: http://localhost:8080
|
||||
|
||||
## Architektur
|
||||
Pocket ID Callback URL:
|
||||
|
||||
- Go-Standardbibliothek für HTTP, Templates, Cookies, OIDC-Discovery und JWT-RS256-Prüfung.
|
||||
- Einzige externe Go-Abhängigkeit: `github.com/go-sql-driver/mysql` als MariaDB/MySQL-Treiber.
|
||||
- Tabellen: `users`, `departments`, `items`, `trade_requests`, `quests`, `quest_updates`, `events`, `workflow_rules`.
|
||||
```text
|
||||
http://localhost:8080/auth/callback
|
||||
```
|
||||
|
||||
## Nächste sinnvolle Schritte
|
||||
## Wichtige Routen
|
||||
|
||||
- Workflow-Regeln aus `workflow_rules.actions_json` ausführen statt Default-Schritte im Code.
|
||||
- Rollen/Gruppen aus Pocket ID Claims auf Trading/Admin/Abteilungen mappen.
|
||||
- Lagerbestand und Stücklisten/BOM für Crafting ergänzen.
|
||||
- API-Endpunkte für externe Quest-Systeme ergänzen.
|
||||
- `/` Trading Dashboard
|
||||
- `/requests/new` neue Anfrage
|
||||
- `/departments` Abteilungsportale
|
||||
- `/departments/{id}/quests` Quest-Ansicht einer Abteilung
|
||||
- `/admin` Konfiguration
|
||||
|
||||
## Admin-Konfiguration
|
||||
## Workflow Conditions
|
||||
|
||||
Nach dem Login gibt es im Dashboard den Link **Admin-Konfiguration** (`/admin`). Dort kannst du ohne Codeänderung konfigurieren:
|
||||
`condition_json` entscheidet, wann eine Workflow-Regel greift. Beispiele:
|
||||
|
||||
- globale Einstellungen wie Währung, Standardmarge, Auto-Quest-Erzeugung und Admin-E-Mails
|
||||
- Abteilungen
|
||||
- Items und Ressourcen
|
||||
- Request- und Quest-Statuswerte
|
||||
- Quest-Templates inklusive zuständiger Abteilung, Reihenfolge und Belohnungsfaktor
|
||||
- Workflow-Regeln als JSON
|
||||
```json
|
||||
{
|
||||
"kind": "buy",
|
||||
"item_category": "weapon",
|
||||
"quantity_min": 1,
|
||||
"price_min": 1000
|
||||
}
|
||||
```
|
||||
|
||||
Standardmäßig darf jeder eingeloggte Nutzer die Admin-Seite öffnen. Setze in `/admin` bei `admin_emails` eine kommagetrennte Liste, z. B. `you@example.com,lead@example.com`, dann erhalten nur diese Pocket-ID-Nutzer Zugriff.
|
||||
Unterstützte Felder:
|
||||
|
||||
Bestehende Installationen erhalten die Admin-Tabellen über `migrations/002_admin.sql` automatisch beim nächsten Start.
|
||||
- `kind`
|
||||
- `item_category`
|
||||
- `quantity_min`
|
||||
- `quantity_max`
|
||||
- `price_min`
|
||||
- `price_max`
|
||||
|
||||
`actions_json` enthält die Quest-Template-Typen in Reihenfolge:
|
||||
|
||||
```json
|
||||
["logistics_quote", "mining_procure", "crafting_order", "delivery"]
|
||||
```
|
||||
|
||||
Die Reihenfolge erzeugt automatisch Abhängigkeiten. Quest 2 wartet auf Quest 1, Quest 3 wartet auf Quest 2 usw.
|
||||
|
||||
## Statuslogik
|
||||
|
||||
Requests:
|
||||
|
||||
- `new`
|
||||
- `in_progress`
|
||||
- `blocked`
|
||||
- `fulfilled`
|
||||
- `cancelled`
|
||||
|
||||
Quests:
|
||||
|
||||
- `waiting`
|
||||
- `open`
|
||||
- `accepted`
|
||||
- `in_progress`
|
||||
- `blocked`
|
||||
- `change_requested`
|
||||
- `done`
|
||||
- `cancelled`
|
||||
|
||||
Wenn alle Quests eines Requests `done` sind, wird der Request automatisch auf `fulfilled` gesetzt.
|
||||
Wenn eine Quest `blocked` ist, wird der Request auf `blocked` gesetzt.
|
||||
Wenn eine Quest abgeschlossen wird, werden wartende Folgequests automatisch geöffnet, sobald alle Abhängigkeiten erledigt sind.
|
||||
|
||||
## Hinweise zur Produktionsnutzung
|
||||
|
||||
Dieses Paket ist deutlich robuster als der erste Prototyp, ersetzt aber keine finale Sicherheits- und Lastprüfung für deine konkrete Umgebung. Vor echter Produktion solltest du mindestens erledigen:
|
||||
|
||||
- HTTPS und sichere Reverse-Proxy-Konfiguration
|
||||
- langes zufälliges `SESSION_SECRET`
|
||||
- restriktive `admin_emails`
|
||||
- Backups für MariaDB
|
||||
- Monitoring/Logging
|
||||
- eventuell CSRF-Schutz für schreibende Formulare
|
||||
- rollenbasierte Einschränkung pro Abteilung, falls nicht jeder eingeloggte Nutzer jede Abteilung sehen darf
|
||||
|
||||
@@ -38,6 +38,8 @@ func main() {
|
||||
mux.Handle("/", am.Require(http.HandlerFunc(app.Home)))
|
||||
mux.Handle("/requests/new", am.Require(http.HandlerFunc(app.NewRequest)))
|
||||
mux.Handle("/quests/update", am.Require(http.HandlerFunc(app.UpdateQuest)))
|
||||
mux.Handle("/departments", am.Require(http.HandlerFunc(app.Departments)))
|
||||
mux.Handle("/departments/", am.Require(http.HandlerFunc(app.DepartmentQuests)))
|
||||
mux.Handle("/admin", am.RequireAdmin(http.HandlerFunc(app.Admin)))
|
||||
mux.Handle("/admin/departments", am.RequireAdmin(http.HandlerFunc(app.AdminDepartment)))
|
||||
mux.Handle("/admin/items", am.RequireAdmin(http.HandlerFunc(app.AdminItem)))
|
||||
|
||||
2
go.mod
2
go.mod
@@ -3,5 +3,3 @@ module trading-tool
|
||||
go 1.23
|
||||
|
||||
require github.com/go-sql-driver/mysql v1.8.1
|
||||
|
||||
require filippo.io/edwards25519 v1.1.0 // indirect
|
||||
|
||||
@@ -36,8 +36,27 @@ type Manager struct {
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
type contextKey string
|
||||
|
||||
const UserIDKey contextKey = "user_id"
|
||||
|
||||
func UserID(r *http.Request) string {
|
||||
if v, ok := r.Context().Value(UserIDKey).(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstEnv(keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
func New(ctx context.Context, db *sql.DB) (*Manager, error) {
|
||||
c := Config{os.Getenv("APP_BASE_URL"), os.Getenv("OIDC_ISSUER"), os.Getenv("OIDC_CLIENT_ID"), os.Getenv("OIDC_CLIENT_SECRET"), os.Getenv("SESSION_SECRET")}
|
||||
c := Config{firstEnv("APP_BASE_URL", "PUBLIC_URL"), firstEnv("OIDC_ISSUER", "POCKET_ID_ISSUER"), firstEnv("OIDC_CLIENT_ID", "POCKET_ID_CLIENT_ID"), firstEnv("OIDC_CLIENT_SECRET", "POCKET_ID_CLIENT_SECRET"), os.Getenv("SESSION_SECRET")}
|
||||
if c.SessionSecret == "" {
|
||||
c.SessionSecret = "dev-secret-change-me"
|
||||
}
|
||||
@@ -210,7 +229,8 @@ func (m *Manager) Require(next http.Handler) http.Handler {
|
||||
http.Redirect(w, r, "/login", 302)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
ctx := context.WithValue(r.Context(), UserIDKey, uid)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
func Logout(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
@@ -14,6 +16,8 @@ func Open(ctx context.Context, url string) (*sql.DB, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.SetMaxOpenConns(25)
|
||||
d.SetMaxIdleConns(10)
|
||||
if err := d.PingContext(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -26,7 +30,11 @@ func Migrate(ctx context.Context, d *sql.DB) error {
|
||||
if _, err := d.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations(version VARCHAR(255) PRIMARY KEY, applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)`); err != nil {
|
||||
return err
|
||||
}
|
||||
files := []string{"migrations/001_init.sql", "migrations/002_admin.sql"}
|
||||
files, err := filepath.Glob("migrations/*.sql")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sort.Strings(files)
|
||||
for _, f := range files {
|
||||
var exists bool
|
||||
if err := d.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version=?)`, f).Scan(&exists); err != nil {
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"trading-tool/internal/auth"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
@@ -17,76 +23,83 @@ type Row map[string]any
|
||||
func New(db *sql.DB) *App {
|
||||
return &App{DB: db, T: template.Must(template.ParseGlob("web/templates/*.html"))}
|
||||
}
|
||||
func (a *App) Home(w http.ResponseWriter, r *http.Request) {
|
||||
reqs, _ := a.DB.Query(`select tr.id,tr.kind,i.name,tr.quantity,tr.price_limit,tr.status,tr.created_at from trade_requests tr join items i on i.id=tr.item_id order by tr.id desc limit 30`)
|
||||
defer reqs.Close()
|
||||
var rs []Row
|
||||
for reqs.Next() {
|
||||
var id, qty int
|
||||
var kind, item, status string
|
||||
var created []byte
|
||||
var price float64
|
||||
reqs.Scan(&id, &kind, &item, &qty, &price, &status, &created)
|
||||
rs = append(rs, Row{"ID": id, "Kind": kind, "Item": item, "Qty": qty, "Price": price, "Status": status, "Created": string(created)})
|
||||
}
|
||||
qs, _ := a.DB.Query(`select q.id,q.type,q.title,coalesce(d.name,''),q.status,coalesce(q.contractor_note,'') from quests q left join departments d on d.id=q.department_id order by q.id desc limit 50`)
|
||||
defer qs.Close()
|
||||
var quests []Row
|
||||
for qs.Next() {
|
||||
var id int
|
||||
var typ, title, dept, status, note string
|
||||
qs.Scan(&id, &typ, &title, &dept, &status, ¬e)
|
||||
quests = append(quests, Row{"ID": id, "Type": typ, "Title": title, "Dept": dept, "Status": status, "Note": note})
|
||||
}
|
||||
a.T.ExecuteTemplate(w, "home.html", map[string]any{"Requests": rs, "Quests": quests})
|
||||
}
|
||||
func (a *App) LoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
a.T.ExecuteTemplate(w, "login.html", nil)
|
||||
}
|
||||
|
||||
func (a *App) Home(w http.ResponseWriter, r *http.Request) {
|
||||
requests := a.requestRows(r.Context(), ``, 50)
|
||||
depts := a.departmentRows(r.Context())
|
||||
quests := a.questRows(r.Context(), 0, 80)
|
||||
a.T.ExecuteTemplate(w, "home.html", map[string]any{"Requests": requests, "Quests": quests, "Departments": depts})
|
||||
}
|
||||
|
||||
func (a *App) NewRequest(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" {
|
||||
rows, _ := a.DB.Query(`select id,name from items order by name`)
|
||||
defer rows.Close()
|
||||
var items []Row
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var name string
|
||||
rows.Scan(&id, &name)
|
||||
items = append(items, Row{"ID": id, "Name": name})
|
||||
}
|
||||
a.T.ExecuteTemplate(w, "request.html", map[string]any{"Items": items})
|
||||
a.T.ExecuteTemplate(w, "request.html", map[string]any{"Items": a.itemRows(r.Context()), "Departments": a.departmentRows(r.Context())})
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, err.Error(), 400)
|
||||
return
|
||||
}
|
||||
r.ParseForm()
|
||||
item, _ := strconv.Atoi(r.FormValue("item_id"))
|
||||
qty, _ := strconv.Atoi(r.FormValue("quantity"))
|
||||
price, _ := strconv.ParseFloat(r.FormValue("price_limit"), 64)
|
||||
source := nullInt(r.FormValue("source_department_id"))
|
||||
target := nullInt(r.FormValue("target_department_id"))
|
||||
kind := r.FormValue("kind")
|
||||
if kind != "sell" {
|
||||
kind = "buy"
|
||||
}
|
||||
res, err := a.DB.Exec(`insert into trade_requests(kind,item_id,quantity,price_limit,status) values(?,?,?,?, 'new')`, kind, item, qty, price)
|
||||
if item <= 0 || qty <= 0 {
|
||||
http.Error(w, "Item und Menge sind Pflicht", 400)
|
||||
return
|
||||
}
|
||||
tx, err := a.DB.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
res, err := tx.ExecContext(r.Context(), `insert into trade_requests(kind,item_id,quantity,price_limit,source_department_id,target_department_id,status,created_by,notes) values(?,?,?,?,?,?, 'new',?,?)`, kind, item, qty, price, source, target, nullInt(auth.UserID(r)), r.FormValue("notes"))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
a.generateQuests(r, id, kind, item, qty, price)
|
||||
http.Redirect(w, r, "/", 303)
|
||||
}
|
||||
func (a *App) generateQuests(r *http.Request, reqID int64, kind string, itemID, qty int, price float64) {
|
||||
var item string
|
||||
a.DB.QueryRowContext(r.Context(), `select name from items where id=?`, itemID).Scan(&item)
|
||||
var auto string
|
||||
a.DB.QueryRowContext(r.Context(), `select setting_value from app_settings where setting_key='auto_generate_quests'`).Scan(&auto)
|
||||
if auto == "false" || auto == "0" || auto == "no" {
|
||||
a.DB.ExecContext(r.Context(), `insert into events(type,payload) values('request.created', JSON_OBJECT('request_id',?,'kind',?,'item_id',?,'quantity',?,'price',?,'auto_generate',false))`, reqID, kind, itemID, qty, price)
|
||||
if err := a.generateQuestsTx(r.Context(), tx, id, kind, item, qty, price, source, target); err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
rows, err := a.DB.QueryContext(r.Context(), `select type,title_template,department_id,reward_percent from quest_templates where enabled=true order by sort_order,id`)
|
||||
if err != nil {
|
||||
if err := tx.Commit(); err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", 303)
|
||||
}
|
||||
|
||||
type workflowRule struct {
|
||||
ID int64
|
||||
Name, Cond, Actions string
|
||||
}
|
||||
type condition map[string]any
|
||||
|
||||
func (a *App) generateQuestsTx(ctx context.Context, tx *sql.Tx, reqID int64, kind string, itemID, qty int, price float64, source, target sql.NullInt64) error {
|
||||
var item, category string
|
||||
if err := tx.QueryRowContext(ctx, `select name,category from items where id=?`, itemID).Scan(&item, &category); err != nil {
|
||||
return err
|
||||
}
|
||||
var auto string
|
||||
_ = tx.QueryRowContext(ctx, `select setting_value from app_settings where setting_key='auto_generate_quests'`).Scan(&auto)
|
||||
if auto == "false" || auto == "0" || auto == "no" {
|
||||
return nil
|
||||
}
|
||||
rule, templateTypes := a.matchWorkflow(ctx, tx, kind, category, qty, price, source, target)
|
||||
rows, err := queryTemplates(ctx, tx, templateTypes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
var prev sql.NullInt64
|
||||
created := 0
|
||||
@@ -94,30 +107,294 @@ func (a *App) generateQuests(r *http.Request, reqID int64, kind string, itemID,
|
||||
var typ, title string
|
||||
var dept sql.NullInt64
|
||||
var percent float64
|
||||
rows.Scan(&typ, &title, &dept, &percent)
|
||||
var order int
|
||||
if err := rows.Scan(&typ, &title, &dept, &percent, &order); err != nil {
|
||||
return err
|
||||
}
|
||||
title = strings.ReplaceAll(title, "{{item}}", item)
|
||||
title = strings.ReplaceAll(title, "{{quantity}}", strconv.Itoa(qty))
|
||||
res, err := a.DB.ExecContext(r.Context(), `insert into quests(parent_request_id,depends_on_quest_id,type,title,department_id,reward,status) values(?,?,?,?,?,?,'open')`, reqID, prev, typ, title, dept, price*percent)
|
||||
if err == nil {
|
||||
qid, _ := res.LastInsertId()
|
||||
prev = sql.NullInt64{Int64: qid, Valid: true}
|
||||
created++
|
||||
status := "open"
|
||||
if created > 0 {
|
||||
status = "waiting"
|
||||
}
|
||||
res, err := tx.ExecContext(ctx, `insert into quests(parent_request_id,depends_on_quest_id,type,title,department_id,reward,status,sequence_order) values(?,?,?,?,?,?,?,?)`, reqID, prev, typ, title, dept, price*percent, status, order)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
qid, _ := res.LastInsertId()
|
||||
if prev.Valid {
|
||||
_, _ = tx.ExecContext(ctx, `insert ignore into quest_dependencies(quest_id,depends_on_quest_id) values(?,?)`, qid, prev.Int64)
|
||||
}
|
||||
prev = sql.NullInt64{Int64: qid, Valid: true}
|
||||
created++
|
||||
}
|
||||
st := "in_progress"
|
||||
if created == 0 {
|
||||
st = "fulfilled"
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `update trade_requests set status=?, workflow_rule_id=? where id=?`, st, rule, reqID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = tx.ExecContext(ctx, `insert into events(type,payload) values('request.created', JSON_OBJECT('request_id',?,'quests_created',?,'workflow_rule_id',?))`, reqID, created, rule)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) matchWorkflow(ctx context.Context, tx *sql.Tx, kind, category string, qty int, price float64, source, target sql.NullInt64) (sql.NullInt64, []string) {
|
||||
rows, err := tx.QueryContext(ctx, `select id,name,condition_json,actions_json from workflow_rules where enabled=true and trigger_name='request.created' order by id`)
|
||||
if err != nil {
|
||||
return sql.NullInt64{}, nil
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var wr workflowRule
|
||||
rows.Scan(&wr.ID, &wr.Name, &wr.Cond, &wr.Actions)
|
||||
if conditionMatches(wr.Cond, kind, category, qty, price, source, target) {
|
||||
var actions []string
|
||||
_ = json.Unmarshal([]byte(wr.Actions), &actions)
|
||||
return sql.NullInt64{Int64: wr.ID, Valid: true}, actions
|
||||
}
|
||||
}
|
||||
a.DB.ExecContext(r.Context(), `insert into events(type,payload) values('request.created', JSON_OBJECT('request_id',?,'kind',?,'item_id',?,'quantity',?,'price',?,'quests_created',?))`, reqID, kind, itemID, qty, price, created)
|
||||
return sql.NullInt64{}, nil
|
||||
}
|
||||
|
||||
func conditionMatches(raw, kind, cat string, qty int, price float64, source, target sql.NullInt64) bool {
|
||||
if strings.TrimSpace(raw) == "" || strings.TrimSpace(raw) == "{}" {
|
||||
return true
|
||||
}
|
||||
var c map[string]any
|
||||
if json.Unmarshal([]byte(raw), &c) != nil {
|
||||
return false
|
||||
}
|
||||
eqs := map[string]string{"kind": kind, "item_category": cat}
|
||||
for k, v := range eqs {
|
||||
if want, ok := c[k].(string); ok && want != "" && want != v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if v, ok := num(c["quantity_min"]); ok && float64(qty) < v {
|
||||
return false
|
||||
}
|
||||
if v, ok := num(c["quantity_max"]); ok && float64(qty) > v {
|
||||
return false
|
||||
}
|
||||
if v, ok := num(c["price_min"]); ok && price < v {
|
||||
return false
|
||||
}
|
||||
if v, ok := num(c["price_max"]); ok && price > v {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
func num(v any) (float64, bool) {
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
return t, true
|
||||
case int:
|
||||
return float64(t), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func queryTemplates(ctx context.Context, tx *sql.Tx, types []string) (*sql.Rows, error) {
|
||||
if len(types) == 0 {
|
||||
return tx.QueryContext(ctx, `select type,title_template,department_id,reward_percent,sort_order from quest_templates where enabled=true order by sort_order,id`)
|
||||
}
|
||||
qs := strings.TrimRight(strings.Repeat("?,", len(types)), ",")
|
||||
args := make([]any, len(types))
|
||||
for i, t := range types {
|
||||
args[i] = t
|
||||
}
|
||||
return tx.QueryContext(ctx, fmt.Sprintf(`select type,title_template,department_id,reward_percent,sort_order from quest_templates where enabled=true and type in (%s) order by field(type,%s)`, qs, qs), append(args, args...)...)
|
||||
}
|
||||
|
||||
func (a *App) UpdateQuest(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm()
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, err.Error(), 400)
|
||||
return
|
||||
}
|
||||
id, _ := strconv.Atoi(r.FormValue("id"))
|
||||
status := r.FormValue("status")
|
||||
note := r.FormValue("note")
|
||||
if status == "" {
|
||||
status = "open"
|
||||
if !validQuestStatus(status) {
|
||||
http.Error(w, "Ungültiger Status", 400)
|
||||
return
|
||||
}
|
||||
a.DB.Exec(`update quests set status=?, contractor_note=? where id=?`, status, note, id)
|
||||
a.DB.Exec(`insert into quest_updates(quest_id,status,note) values(?,?,?)`, id, status, note)
|
||||
tx, err := a.DB.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var old string
|
||||
var reqID sql.NullInt64
|
||||
if err := tx.QueryRowContext(r.Context(), `select status,parent_request_id from quests where id=? for update`, id).Scan(&old, &reqID); err != nil {
|
||||
http.Error(w, err.Error(), 404)
|
||||
return
|
||||
}
|
||||
if old == "waiting" && status != "cancelled" {
|
||||
http.Error(w, "Quest wartet noch auf vorherige Abhängigkeiten", 409)
|
||||
return
|
||||
}
|
||||
uid := nullInt(auth.UserID(r))
|
||||
completed := sql.NullTime{}
|
||||
if status == "done" {
|
||||
a.DB.Exec(`update quests set status='open' where depends_on_quest_id=? and status='waiting'`, id)
|
||||
completed = sql.NullTime{Time: time.Now(), Valid: true}
|
||||
}
|
||||
http.Redirect(w, r, "/", 303)
|
||||
_, err = tx.ExecContext(r.Context(), `update quests set status=?, contractor_note=?, blocked_reason=case when ?='blocked' then ? else blocked_reason end, claimed_by=coalesce(claimed_by,?), completed_at=? where id=?`, status, note, status, note, uid, completed, id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
_, _ = tx.ExecContext(r.Context(), `insert into quest_updates(quest_id,actor_user_id,status,note) values(?,?,?,?)`, id, uid, status, note)
|
||||
if status == "done" {
|
||||
_ = a.unlockDependents(r.Context(), tx, int64(id))
|
||||
}
|
||||
if reqID.Valid {
|
||||
_ = a.recalculateRequestStatus(r.Context(), tx, reqID.Int64)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
back := r.Referer()
|
||||
if back == "" {
|
||||
back = "/"
|
||||
}
|
||||
http.Redirect(w, r, back, 303)
|
||||
}
|
||||
|
||||
func validQuestStatus(s string) bool {
|
||||
switch s {
|
||||
case "open", "accepted", "in_progress", "blocked", "change_requested", "done", "cancelled":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
func (a *App) unlockDependents(ctx context.Context, tx *sql.Tx, doneID int64) error {
|
||||
rows, err := tx.QueryContext(ctx, `select q.id from quests q where q.status='waiting' and q.depends_on_quest_id=?`, doneID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var qid int64
|
||||
rows.Scan(&qid)
|
||||
var blockers int
|
||||
tx.QueryRowContext(ctx, `select count(*) from quest_dependencies d join quests q2 on q2.id=d.depends_on_quest_id where d.quest_id=? and q2.status <> 'done'`, qid).Scan(&blockers)
|
||||
if blockers == 0 {
|
||||
tx.ExecContext(ctx, `update quests set status='open' where id=?`, qid)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (a *App) recalculateRequestStatus(ctx context.Context, tx *sql.Tx, reqID int64) error {
|
||||
var total, done, active, blocked, cancelled int
|
||||
tx.QueryRowContext(ctx, `select count(*), coalesce(sum(status='done'),0), coalesce(sum(status in ('open','accepted','in_progress','change_requested','waiting')),0), coalesce(sum(status='blocked'),0), coalesce(sum(status='cancelled'),0) from quests where parent_request_id=?`, reqID).Scan(&total, &done, &active, &blocked, &cancelled)
|
||||
status := "new"
|
||||
fulfilledAt := sql.NullTime{}
|
||||
switch {
|
||||
case total > 0 && done == total:
|
||||
status = "fulfilled"
|
||||
fulfilledAt = sql.NullTime{Time: time.Now(), Valid: true}
|
||||
case blocked > 0:
|
||||
status = "blocked"
|
||||
case cancelled == total && total > 0:
|
||||
status = "cancelled"
|
||||
case active > 0 || done > 0:
|
||||
status = "in_progress"
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, `update trade_requests set status=?, fulfilled_at=? where id=?`, status, fulfilledAt, reqID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) Departments(w http.ResponseWriter, r *http.Request) {
|
||||
a.T.ExecuteTemplate(w, "departments.html", map[string]any{"Departments": a.departmentRows(r.Context())})
|
||||
}
|
||||
func (a *App) DepartmentQuests(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := strings.TrimPrefix(r.URL.Path, "/departments/")
|
||||
idStr = strings.TrimSuffix(strings.TrimSuffix(idStr, "/quests"), "/")
|
||||
id, _ := strconv.Atoi(idStr)
|
||||
var name string
|
||||
a.DB.QueryRowContext(r.Context(), `select name from departments where id=?`, id).Scan(&name)
|
||||
a.T.ExecuteTemplate(w, "department_quests.html", map[string]any{"DepartmentID": id, "DepartmentName": name, "Quests": a.questRows(r.Context(), id, 200)})
|
||||
}
|
||||
|
||||
func (a *App) requestRows(ctx context.Context, where string, limit int) []Row {
|
||||
rows, err := a.DB.QueryContext(ctx, `select tr.id,tr.kind,i.name,tr.quantity,tr.price_limit,tr.status,tr.created_at,coalesce(tr.notes,'') from trade_requests tr join items i on i.id=tr.item_id order by tr.id desc limit ?`, limit)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Row
|
||||
for rows.Next() {
|
||||
var id, qty int
|
||||
var kind, item, status, note string
|
||||
var created time.Time
|
||||
var price float64
|
||||
rows.Scan(&id, &kind, &item, &qty, &price, &status, &created, ¬e)
|
||||
out = append(out, Row{"ID": id, "Kind": kind, "Item": item, "Qty": qty, "Price": price, "Status": status, "Created": created.Format("2006-01-02 15:04"), "Note": note})
|
||||
}
|
||||
return out
|
||||
}
|
||||
func (a *App) questRows(ctx context.Context, deptID int, limit int) []Row {
|
||||
q := `select q.id,q.type,q.title,coalesce(d.name,''),q.status,coalesce(q.contractor_note,''),coalesce(q.blocked_reason,''),q.sequence_order,coalesce(tr.id,0),coalesce(i.name,'') from quests q left join departments d on d.id=q.department_id left join trade_requests tr on tr.id=q.parent_request_id left join items i on i.id=tr.item_id`
|
||||
args := []any{}
|
||||
if deptID > 0 {
|
||||
q += ` where q.department_id=?`
|
||||
args = append(args, deptID)
|
||||
}
|
||||
q += ` order by q.status='waiting', q.sequence_order, q.id desc limit ?`
|
||||
args = append(args, limit)
|
||||
rows, err := a.DB.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Row
|
||||
for rows.Next() {
|
||||
var id, order, reqID int
|
||||
var typ, title, dept, status, note, block, item string
|
||||
rows.Scan(&id, &typ, &title, &dept, &status, ¬e, &block, &order, &reqID, &item)
|
||||
out = append(out, Row{"ID": id, "Type": typ, "Title": title, "Dept": dept, "Status": status, "Note": note, "Block": block, "Order": order, "RequestID": reqID, "Item": item})
|
||||
}
|
||||
return out
|
||||
}
|
||||
func (a *App) departmentRows(ctx context.Context) []Row {
|
||||
rows, err := a.DB.QueryContext(ctx, `select id,name from departments order by name`)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Row
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var name string
|
||||
rows.Scan(&id, &name)
|
||||
out = append(out, Row{"ID": id, "Name": name})
|
||||
}
|
||||
return out
|
||||
}
|
||||
func (a *App) itemRows(ctx context.Context) []Row {
|
||||
rows, err := a.DB.QueryContext(ctx, `select id,name,category from items order by name`)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Row
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var name, cat string
|
||||
rows.Scan(&id, &name, &cat)
|
||||
out = append(out, Row{"ID": id, "Name": name, "Category": cat})
|
||||
}
|
||||
return out
|
||||
}
|
||||
func nullInt(s string) sql.NullInt64 {
|
||||
i, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
|
||||
if err != nil || i <= 0 {
|
||||
return sql.NullInt64{}
|
||||
}
|
||||
return sql.NullInt64{Int64: i, Valid: true}
|
||||
}
|
||||
|
||||
35
migrations/003_production_workflow.sql
Normal file
35
migrations/003_production_workflow.sql
Normal file
@@ -0,0 +1,35 @@
|
||||
CREATE TABLE IF NOT EXISTS user_roles (
|
||||
user_id BIGINT NOT NULL,
|
||||
role VARCHAR(60) NOT NULL,
|
||||
PRIMARY KEY(user_id, role),
|
||||
CONSTRAINT fk_user_role_user FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS department_members (
|
||||
user_id BIGINT NOT NULL,
|
||||
department_id BIGINT NOT NULL,
|
||||
role VARCHAR(60) NOT NULL DEFAULT 'member',
|
||||
PRIMARY KEY(user_id, department_id),
|
||||
CONSTRAINT fk_dept_member_user FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_dept_member_dept FOREIGN KEY(department_id) REFERENCES departments(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS quest_dependencies (
|
||||
quest_id BIGINT NOT NULL,
|
||||
depends_on_quest_id BIGINT NOT NULL,
|
||||
PRIMARY KEY(quest_id, depends_on_quest_id),
|
||||
CONSTRAINT fk_qdep_quest FOREIGN KEY(quest_id) REFERENCES quests(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_qdep_depends FOREIGN KEY(depends_on_quest_id) REFERENCES quests(id) ON DELETE CASCADE
|
||||
);
|
||||
ALTER TABLE quests ADD COLUMN IF NOT EXISTS claimed_by BIGINT NULL;
|
||||
ALTER TABLE quests ADD COLUMN IF NOT EXISTS sequence_order INT NOT NULL DEFAULT 100;
|
||||
ALTER TABLE quests ADD COLUMN IF NOT EXISTS blocked_reason TEXT NULL;
|
||||
ALTER TABLE quests ADD COLUMN IF NOT EXISTS completed_at TIMESTAMP NULL;
|
||||
ALTER TABLE trade_requests ADD COLUMN IF NOT EXISTS fulfilled_at TIMESTAMP NULL;
|
||||
ALTER TABLE trade_requests ADD COLUMN IF NOT EXISTS workflow_rule_id BIGINT NULL;
|
||||
ALTER TABLE trade_requests ADD COLUMN IF NOT EXISTS notes TEXT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_quests_dept_status ON quests(department_id,status);
|
||||
CREATE INDEX IF NOT EXISTS idx_quests_request ON quests(parent_request_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_requests_status ON trade_requests(status);
|
||||
|
||||
INSERT IGNORE INTO app_settings(setting_key,setting_value,description) VALUES
|
||||
('default_request_status_after_quest_generation','in_progress','Status einer Anfrage nach Quest-Erzeugung'),
|
||||
('unlock_next_quests_automatically','true','Folge-Quests automatisch öffnen, wenn Abhängigkeiten erledigt sind');
|
||||
1
web/templates/department_quests.html
Normal file
1
web/templates/department_quests.html
Normal file
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="de"><head><meta charset="utf-8"><title>Quest-Portal</title><style>body{font-family:system-ui;margin:0;background:#0f1115;color:#e8e8e8}header{background:#171a21;border-bottom:1px solid #2c3140;padding:1rem 2rem;display:flex;gap:1rem}main{padding:2rem}a{color:#9ecbff;text-decoration:none}.btn,button{background:#26364d;color:#fff;border:1px solid #49617f;border-radius:.5rem;padding:.45rem .7rem;display:inline-block;cursor:pointer}.card{background:#171a21;border:1px solid #2c3140;border-radius:1rem;padding:1rem;margin:1rem 0}table{border-collapse:collapse;width:100%}td,th{border-bottom:1px solid #2c3140;padding:.55rem;text-align:left;vertical-align:top}select,textarea{background:#0f1115;color:#eee;border:1px solid #394150;border-radius:.45rem;padding:.45rem}textarea{width:22rem;height:4rem}.status{border:1px solid #48556a;border-radius:999px;padding:.15rem .5rem;background:#202838}.muted{color:#9ca3af}</style></head><body><header><strong>Trading Tool</strong><a href="/">Dashboard</a><a href="/departments">Abteilungen</a><a href="/admin">Admin</a><a href="/logout">Logout</a></header><main><h1>Quest-Portal: {{.DepartmentName}}</h1><p><a class="btn" href="/departments">Zurück zu Abteilungen</a></p><section class="card"><table><tr><th>ID</th><th>Request</th><th>Typ</th><th>Titel</th><th>Status</th><th>Rückmeldung</th><th>Aktion</th></tr>{{range .Quests}}<tr><td>#{{.ID}}</td><td>#{{.RequestID}} {{.Item}}</td><td>{{.Type}}</td><td>{{.Title}}</td><td><span class="status">{{.Status}}</span></td><td>{{.Note}}{{if .Block}}<br><span class="muted">Blocker: {{.Block}}</span>{{end}}</td><td>{{if eq .Status "waiting"}}<span class="muted">Wartet auf vorherige Quest</span>{{else}}<form method="post" action="/quests/update"><input type="hidden" name="id" value="{{.ID}}"><select name="status"><option value="open">offen</option><option value="accepted">angenommen</option><option value="in_progress">in Arbeit</option><option value="blocked">blockiert</option><option value="change_requested">Änderung angefragt</option><option value="done">erledigt</option><option value="cancelled">abgebrochen</option></select><br><textarea name="note" placeholder="Rückmeldung, Blocker, Gegenvorschlag …">{{.Note}}</textarea><br><button>Aktualisieren</button></form>{{end}}</td></tr>{{end}}</table></section></main></body></html>
|
||||
1
web/templates/departments.html
Normal file
1
web/templates/departments.html
Normal file
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="de"><head><meta charset="utf-8"><title>Abteilungen</title><style>body{font-family:system-ui;margin:0;background:#0f1115;color:#e8e8e8}header{background:#171a21;border-bottom:1px solid #2c3140;padding:1rem 2rem;display:flex;gap:1rem}main{padding:2rem}a{color:#9ecbff;text-decoration:none}.card{background:#171a21;border:1px solid #2c3140;border-radius:1rem;padding:1rem;margin:1rem 0;display:block}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:1rem}.muted{color:#9ca3af}</style></head><body><header><strong>Trading Tool</strong><a href="/">Dashboard</a><a href="/departments">Abteilungen</a><a href="/admin">Admin</a><a href="/logout">Logout</a></header><main><h1>Abteilungsportale</h1><p class="muted">Jede Abteilung sieht hier ihre eigenen Quests und kann diese annehmen, blockieren, kommentieren oder abschließen.</p><div class="grid">{{range .Departments}}<a class="card" href="/departments/{{.ID}}/quests"><h2>{{.Name}}</h2><p>Quests anzeigen und bearbeiten</p></a>{{end}}</div></main></body></html>
|
||||
@@ -1 +1,5 @@
|
||||
<!doctype html><html lang="de"><head><meta charset="utf-8"><title>Trading Tool</title><style>body{font-family:system-ui;margin:2rem;background:#111;color:#eee}a{color:#9ad}table{border-collapse:collapse;width:100%;margin:1rem 0}td,th{border-bottom:1px solid #333;padding:.5rem;text-align:left;vertical-align:top}.card{background:#1b1b1b;border:1px solid #333;border-radius:.7rem;padding:1rem;margin:1rem 0}button,input,select,textarea{padding:.4rem;border-radius:.35rem}textarea{width:18rem;height:3rem}</style></head><body><p><a href="/requests/new">Neue Anfrage</a> · <a href="/admin">Admin-Konfiguration</a> · <a href="/logout">Logout</a></p><h1>Trading Dashboard</h1><div class="card"><h2>Anfragen</h2><table><tr><th>ID</th><th>Typ</th><th>Item</th><th>Menge</th><th>Preis</th><th>Status</th></tr>{{range .Requests}}<tr><td>{{.ID}}</td><td>{{.Kind}}</td><td>{{.Item}}</td><td>{{.Qty}}</td><td>{{printf "%.2f" .Price}}</td><td>{{.Status}}</td></tr>{{end}}</table></div><div class="card"><h2>Quests / Auftragnehmer-Workflow</h2><table><tr><th>ID</th><th>Typ</th><th>Titel</th><th>Abteilung</th><th>Status</th><th>Notiz</th><th>Aktion</th></tr>{{range .Quests}}<tr><td>{{.ID}}</td><td>{{.Type}}</td><td>{{.Title}}</td><td>{{.Dept}}</td><td>{{.Status}}</td><td>{{.Note}}</td><td><form method="post" action="/quests/update"><input type="hidden" name="id" value="{{.ID}}"><select name="status"><option>open</option><option>accepted</option><option>in_progress</option><option>done</option><option>blocked</option><option>change_requested</option><option>cancelled</option></select><br><textarea name="note" placeholder="Rückmeldung, Gegenvorschlag, Material fehlt …">{{.Note}}</textarea><br><button>Workflow aktualisieren</button></form></td></tr>{{end}}</table></div></body></html>
|
||||
<!doctype html><html lang="de"><head><meta charset="utf-8"><title>Trading Tool</title><style>body{font-family:system-ui;margin:0;background:#0f1115;color:#e8e8e8}header{background:#171a21;border-bottom:1px solid #2c3140;padding:1rem 2rem;display:flex;gap:1rem}main{padding:2rem}a{color:#9ecbff;text-decoration:none}.btn,button{background:#26364d;color:#fff;border:1px solid #49617f;border-radius:.5rem;padding:.45rem .7rem;display:inline-block;cursor:pointer}.actions{display:flex;gap:.75rem;flex-wrap:wrap;margin:1rem 0}.card{background:#171a21;border:1px solid #2c3140;border-radius:1rem;padding:1rem;margin:1rem 0}table{border-collapse:collapse;width:100%}td,th{border-bottom:1px solid #2c3140;padding:.55rem;text-align:left;vertical-align:top}input,select,textarea{background:#0f1115;color:#eee;border:1px solid #394150;border-radius:.45rem;padding:.45rem}textarea{width:22rem;height:4rem}.status{border:1px solid #48556a;border-radius:999px;padding:.15rem .5rem;background:#202838}.muted{color:#9ca3af}</style></head><body><header><strong>Trading Tool</strong><a href="/">Dashboard</a><a href="/departments">Abteilungen</a><a href="/admin">Admin</a><a href="/logout">Logout</a></header><main>
|
||||
<h1>Trading Dashboard</h1><div class="actions"><a class="btn" href="/requests/new">Neue Anfrage</a><a class="btn" href="/departments">Abteilungsportale</a><a class="btn" href="/admin">Admin-Konfiguration</a></div>
|
||||
<section class="card"><h2>Anfragen</h2><table><tr><th>ID</th><th>Typ</th><th>Item</th><th>Menge</th><th>Preis</th><th>Status</th><th>Notiz</th></tr>{{range .Requests}}<tr><td>#{{.ID}}</td><td>{{.Kind}}</td><td>{{.Item}}</td><td>{{.Qty}}</td><td>{{printf "%.2f" .Price}}</td><td><span class="status">{{.Status}}</span></td><td>{{.Note}}</td></tr>{{end}}</table></section>
|
||||
<section class="card"><h2>Alle Quests</h2><table><tr><th>ID</th><th>Request</th><th>Typ</th><th>Titel</th><th>Abteilung</th><th>Status</th><th>Rückmeldung</th><th>Aktion</th></tr>{{range .Quests}}<tr><td>#{{.ID}}</td><td>#{{.RequestID}} {{.Item}}</td><td>{{.Type}}</td><td>{{.Title}}</td><td>{{.Dept}}</td><td><span class="status">{{.Status}}</span></td><td>{{.Note}}{{if .Block}}<br><span class="muted">Blocker: {{.Block}}</span>{{end}}</td><td>{{if eq .Status "waiting"}}<span class="muted">Wartet auf vorherige Quest</span>{{else}}<form method="post" action="/quests/update"><input type="hidden" name="id" value="{{.ID}}"><select name="status"><option value="open">offen</option><option value="accepted">angenommen</option><option value="in_progress">in Arbeit</option><option value="blocked">blockiert</option><option value="change_requested">Änderung angefragt</option><option value="done">erledigt</option><option value="cancelled">abgebrochen</option></select><br><textarea name="note" placeholder="Rückmeldung, Blocker, Gegenvorschlag …">{{.Note}}</textarea><br><button>Aktualisieren</button></form>{{end}}</td></tr>{{end}}</table></section>
|
||||
</main></body></html>
|
||||
|
||||
@@ -1 +1 @@
|
||||
<!doctype html><html lang="de"><head><meta charset="utf-8"><title>Neue Anfrage</title><style>body{font-family:system-ui;margin:2rem;background:#111;color:#eee}label{display:block;margin:.8rem 0}input,select,button{padding:.5rem;border-radius:.4rem}</style></head><body><p><a href="/">Zurück</a></p><h1>Neue Trading-Anfrage</h1><form method="post"><label>Typ <select name="kind"><option value="buy">Ankauf</option><option value="sell">Verkauf</option></select></label><label>Item <select name="item_id">{{range .Items}}<option value="{{.ID}}">{{.Name}}</option>{{end}}</select></label><label>Menge <input name="quantity" type="number" value="1" min="1"></label><label>Preisvorstellung <input name="price_limit" type="number" step="0.01" value="1000"></label><button>Anfrage erstellen und Quests generieren</button></form></body></html>
|
||||
<!doctype html><html lang="de"><head><meta charset="utf-8"><title>Neue Anfrage</title><style>body{font-family:system-ui;margin:2rem;background:#111;color:#eee}a{color:#9ad}.card{background:#1b1b1b;border:1px solid #333;border-radius:.7rem;padding:1rem;max-width:760px}label{display:block;margin:.8rem 0}input,select,textarea,button{padding:.5rem;border-radius:.4rem}textarea{width:100%;height:5rem}</style></head><body><p><a href="/">← Dashboard</a></p><div class="card"><h1>Neue Trading-Anfrage</h1><form method="post"><label>Typ <select name="kind"><option value="buy">Ankauf/Beschaffung</option><option value="sell">Verkauf/Lieferung</option></select></label><label>Item/Ressource <select name="item_id">{{range .Items}}<option value="{{.ID}}">{{.Name}} ({{.Category}})</option>{{end}}</select></label><label>Menge <input name="quantity" type="number" min="1" value="1"></label><label>Preisvorstellung / Limit <input name="price_limit" type="number" step="0.01" value="0"></label><label>Quelle <select name="source_department_id"><option value="">keine</option>{{range .Departments}}<option value="{{.ID}}">{{.Name}}</option>{{end}}</select></label><label>Zielabteilung <select name="target_department_id"><option value="">keine</option>{{range .Departments}}<option value="{{.ID}}">{{.Name}}</option>{{end}}</select></label><label>Notizen <textarea name="notes" placeholder="Lieferort, Deadline, spezielle Bedingungen …"></textarea></label><button>Anfrage erstellen und Workflow starten</button></form></div></body></html>
|
||||
|
||||
Reference in New Issue
Block a user