Compare commits
4 Commits
430127c0f1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbc880f40b | ||
|
|
676568100f | ||
|
|
22c6a22373 | ||
|
|
1e64e45491 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
data/**
|
data/**
|
||||||
|
.env
|
||||||
BIN
Portifolio
BIN
Portifolio
Binary file not shown.
9
go.mod
9
go.mod
@@ -2,4 +2,11 @@ module Portifolio
|
|||||||
|
|
||||||
go 1.25.7
|
go 1.25.7
|
||||||
|
|
||||||
require github.com/mattn/go-sqlite3 v1.14.37 // indirect
|
require github.com/mattn/go-sqlite3 v1.14.37
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/joho/godotenv v1.5.1 // indirect
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
require github.com/FuLygon/go-totp/v2 v2.4.0
|
||||||
|
|||||||
6
go.sum
6
go.sum
@@ -1,2 +1,8 @@
|
|||||||
|
github.com/FuLygon/go-totp/v2 v2.4.0 h1:NTgab5GCVHHGjIjjOzRKKKxa6PhMI9o/fs2fFrJqqhE=
|
||||||
|
github.com/FuLygon/go-totp/v2 v2.4.0/go.mod h1:bEwZp8rat339MqPzXZ7FDl5eOJuXpQ35aYbk1qZbWLM=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
|
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
|
||||||
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||||
|
|||||||
@@ -8,13 +8,14 @@ import (
|
|||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetCompanyBySymbol(db *sql.DB, symbol string) (*model.Company, error) {
|
func getCompany(db *sql.DB, where string, arg any) (*model.Company, error) {
|
||||||
var c model.Company
|
var c model.Company
|
||||||
err := db.QueryRow(
|
err := db.QueryRow(`
|
||||||
`SELECT id, symbol, shares_outstanding, price, currency_id FROM companies WHERE symbol = ?`,
|
SELECT c.id, c.symbol, c.shares_outstanding, c.price, cur.id, cur.code
|
||||||
symbol,
|
FROM companies c
|
||||||
).Scan(&c.ID, &c.Symbol, &c.SharesOutstanding, &c.Price, &c.CurrencyID)
|
JOIN currencies cur ON cur.id = c.currency_id
|
||||||
|
WHERE `+where, arg,
|
||||||
|
).Scan(&c.ID, &c.Symbol, &c.SharesOutstanding, &c.Price, &c.CurrencyID, &c.CurrencyCode)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -24,20 +25,12 @@ func GetCompanyBySymbol(db *sql.DB, symbol string) (*model.Company, error) {
|
|||||||
return &c, nil
|
return &c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetCompanyByID(db *sql.DB, id int) (*model.Company, error) {
|
func GetCompanyBySymbol(db *sql.DB, symbol string) (*model.Company, error) {
|
||||||
var c model.Company
|
return getCompany(db, "c.symbol = ?", symbol)
|
||||||
err := db.QueryRow(
|
}
|
||||||
`SELECT id, symbol, shares_outstanding, price, currency_id FROM companies WHERE id = ?`,
|
|
||||||
id,
|
|
||||||
).Scan(&c.ID, &c.Symbol, &c.SharesOutstanding, &c.Price, &c.CurrencyID)
|
|
||||||
|
|
||||||
if err == sql.ErrNoRows {
|
func GetCompanyByID(db *sql.DB, id int) (*model.Company, error) {
|
||||||
return nil, nil
|
return getCompany(db, "c.id = ?", id)
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("query company: %w", err)
|
|
||||||
}
|
|
||||||
return &c, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func AddCompany(db *sql.DB, input model.CompanyInput) (int, error) {
|
func AddCompany(db *sql.DB, input model.CompanyInput) (int, error) {
|
||||||
@@ -66,7 +59,9 @@ func AddCompany(db *sql.DB, input model.CompanyInput) (int, error) {
|
|||||||
|
|
||||||
func GetAllCompanies(db *sql.DB) ([]model.Company, error) {
|
func GetAllCompanies(db *sql.DB) ([]model.Company, error) {
|
||||||
rows, err := db.Query(`
|
rows, err := db.Query(`
|
||||||
SELECT id, symbol, shares_outstanding, price, currency_id FROM companies
|
SELECT c.id, c.symbol, c.shares_outstanding, c.price, cur.id, cur.code
|
||||||
|
FROM companies c
|
||||||
|
JOIN currencies cur ON cur.id = c.currency_id
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -77,7 +72,7 @@ func GetAllCompanies(db *sql.DB) ([]model.Company, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var c model.Company
|
var c model.Company
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&c.ID, &c.Symbol, &c.SharesOutstanding, &c.Price, &c.CurrencyID,
|
&c.ID, &c.Symbol, &c.SharesOutstanding, &c.Price, &c.CurrencyID, &c.CurrencyCode,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ func InitDB(db *sql.DB) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- parent table
|
-- parent table
|
||||||
CREATE TABLE closed_positions (
|
CREATE TABLE IF NOT EXISTS closed_positions (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
symbol TEXT NOT NULL,
|
symbol TEXT NOT NULL,
|
||||||
currency_code TEXT NOT NULL,
|
currency_code TEXT NOT NULL,
|
||||||
@@ -94,7 +94,7 @@ func InitDB(db *sql.DB) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- child table, one row per close lot
|
-- child table, one row per close lot
|
||||||
CREATE TABLE close_entries (
|
CREATE TABLE IF NOT EXISTS close_entries (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
closed_position_id INTEGER NOT NULL REFERENCES closed_positions(id),
|
closed_position_id INTEGER NOT NULL REFERENCES closed_positions(id),
|
||||||
shares INTEGER NOT NULL,
|
shares INTEGER NOT NULL,
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"Portifolio/internal/model"
|
|
||||||
"Portifolio/internal/service"
|
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
_ "github.com/mattn/go-sqlite3"
|
|
||||||
)
|
|
||||||
|
|
||||||
func AddCurrencyHandler(db *sql.DB) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var input model.CurrencyInput
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
|
||||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
id, err := service.InsertCurrency(db, input)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
json.NewEncoder(w).Encode(map[string]any{"status": "created", "id": id})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetCurrenciesHandler(db *sql.DB) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
currencies, err := service.GetAllCurrencies(db)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(currencies)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"Portifolio/internal/database"
|
|
||||||
"Portifolio/internal/model"
|
|
||||||
"Portifolio/internal/service"
|
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
_ "github.com/mattn/go-sqlite3"
|
|
||||||
)
|
|
||||||
|
|
||||||
func AddTradeHandler(db *sql.DB) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req model.AddTradeRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := req.Validate(); err != nil {
|
|
||||||
http.Error(w, fmt.Sprintf("failed to validate trade: %s", err), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
currency, err := database.GetCurrencyByCode(db, req.CurrencyCode)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, fmt.Sprintf("failed to find currency: %s", err), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch model.TradeType(req.Type) {
|
|
||||||
case model.DividendType:
|
|
||||||
dividend, err := req.ToDividend()
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, fmt.Sprintf("failed to build dividend: %s", err), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dividend.CurrencyCode = currency.Code
|
|
||||||
|
|
||||||
if err := database.InsertDividend(db, dividend); err != nil {
|
|
||||||
http.Error(w, fmt.Sprintf("failed to insert dividend: %s", err), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(map[string]any{"success": true})
|
|
||||||
|
|
||||||
case model.BuyType, model.SellType:
|
|
||||||
trade, err := req.ToTrade()
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, fmt.Sprintf("failed to build trade: %s", err), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
trade.CurrencyCode = currency.Code
|
|
||||||
|
|
||||||
if err := database.InsertTrade(db, trade); err != nil {
|
|
||||||
http.Error(w, fmt.Sprintf("failed to insert trade: %s", err), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
update := true
|
|
||||||
if err := service.UpdatePositionByTradeList(db); err != nil {
|
|
||||||
update = false
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(map[string]any{"success": true, "position_update": update})
|
|
||||||
|
|
||||||
default:
|
|
||||||
http.Error(w, fmt.Sprintf("unknown trade type: %d", req.Type), http.StatusBadRequest)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetTradeListHandler(db *sql.DB) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
tradeList, err := database.GetTrades(db)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, fmt.Sprintf("failed to fetch trades: %s", err), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
if err := json.NewEncoder(w).Encode(tradeList); err != nil {
|
|
||||||
http.Error(w, fmt.Sprintf("failed to encode trades: %s", err), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetPositionListHandler(db *sql.DB) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
posList, err := database.GetPositions(db)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, fmt.Sprintf("failed to fetch postiton: %s", err), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
if err := json.NewEncoder(w).Encode(posList); err != nil {
|
|
||||||
http.Error(w, fmt.Sprintf("failed to encode positions: %s", err), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
58
internal/middleware/session.go
Normal file
58
internal/middleware/session.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// In-memory session store — swap for Redis/DB in production
|
||||||
|
var (
|
||||||
|
sessionStore = make(map[string]bool)
|
||||||
|
mu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterSession adds a token to the store (call this after login)
|
||||||
|
func RegisterSession(token string) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
sessionStore[token] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeSession removes a token (call this on logout)
|
||||||
|
func RevokeSession(token string) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
delete(sessionStore, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireSession is the middleware — wraps any handler that needs auth
|
||||||
|
func RequireSession(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cookie, err := r.Cookie("session")
|
||||||
|
if err != nil {
|
||||||
|
// No cookie at all
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.RLock()
|
||||||
|
valid := sessionStore[cookie.Value]
|
||||||
|
mu.RUnlock()
|
||||||
|
|
||||||
|
if !valid {
|
||||||
|
// Cookie exists but token is unknown/expired
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "session",
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: true,
|
||||||
|
MaxAge: -1, // delete it
|
||||||
|
})
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ type Company struct {
|
|||||||
SharesOutstanding int
|
SharesOutstanding int
|
||||||
Price float64
|
Price float64
|
||||||
CurrencyID int
|
CurrencyID int
|
||||||
|
CurrencyCode string
|
||||||
}
|
}
|
||||||
|
|
||||||
type CompanyInput struct {
|
type CompanyInput struct {
|
||||||
|
|||||||
@@ -3,12 +3,51 @@ package service
|
|||||||
import (
|
import (
|
||||||
"Portifolio/internal/model"
|
"Portifolio/internal/model"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
)
|
)
|
||||||
|
|
||||||
func InsertCurrency(db *sql.DB, input model.CurrencyInput) (int, error) {
|
type Service struct {
|
||||||
res, err := db.Exec(
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) AddCurrencyHandler() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var input model.CurrencyInput
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||||
|
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := s.InsertCurrency(input)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"status": "created", "id": id})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetCurrenciesHandler() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
currencies, err := s.GetAllCurrencies()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(currencies)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) InsertCurrency(input model.CurrencyInput) (int, error) {
|
||||||
|
res, err := s.db.Exec(
|
||||||
`INSERT INTO currencies (code, name) VALUES (?, ?)`,
|
`INSERT INTO currencies (code, name) VALUES (?, ?)`,
|
||||||
input.Code, input.Name,
|
input.Code, input.Name,
|
||||||
)
|
)
|
||||||
@@ -19,9 +58,9 @@ func InsertCurrency(db *sql.DB, input model.CurrencyInput) (int, error) {
|
|||||||
return int(id), err
|
return int(id), err
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetCurrencyByCode(db *sql.DB, code string) (*model.Currency, error) {
|
func (s *Service) GetCurrencyByCode(code string) (*model.Currency, error) {
|
||||||
c := &model.Currency{}
|
c := &model.Currency{}
|
||||||
err := db.QueryRow(
|
err := s.db.QueryRow(
|
||||||
`SELECT id, code, name FROM currencies WHERE code = ?`, code,
|
`SELECT id, code, name FROM currencies WHERE code = ?`, code,
|
||||||
).Scan(&c.ID, &c.Code, &c.Name)
|
).Scan(&c.ID, &c.Code, &c.Name)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
@@ -30,8 +69,8 @@ func GetCurrencyByCode(db *sql.DB, code string) (*model.Currency, error) {
|
|||||||
return c, err
|
return c, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetAllCurrencies(db *sql.DB) ([]model.Currency, error) {
|
func (s *Service) GetAllCurrencies() ([]model.Currency, error) {
|
||||||
rows, err := db.Query(`SELECT id, code, name FROM currencies ORDER BY code`)
|
rows, err := s.db.Query(`SELECT id, code, name FROM currencies ORDER BY code`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package handlers
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"Portifolio/internal/database"
|
"Portifolio/internal/database"
|
||||||
@@ -14,11 +14,15 @@ import (
|
|||||||
|
|
||||||
var startTime = time.Now()
|
var startTime = time.Now()
|
||||||
|
|
||||||
func HealthHandler(db *sql.DB) http.HandlerFunc {
|
func New(db *sql.DB) *Service {
|
||||||
|
return &Service{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) HealthHandler() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
health := checkHealth(db)
|
health := s.CheckHealth()
|
||||||
|
|
||||||
if health["status"] != "ok" {
|
if health["status"] != "ok" {
|
||||||
w.WriteHeader(http.StatusServiceUnavailable)
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
@@ -28,21 +32,17 @@ func HealthHandler(db *sql.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkHealth(db *sql.DB) map[string]any {
|
func (s *Service) CheckHealth() map[string]any {
|
||||||
// database
|
|
||||||
dbStatus := "ok"
|
dbStatus := "ok"
|
||||||
dbLatency := ""
|
dbLatency := ""
|
||||||
t := time.Now()
|
t := time.Now()
|
||||||
if err := db.Ping(); err != nil {
|
if err := s.db.Ping(); err != nil {
|
||||||
dbStatus = "error: " + err.Error()
|
dbStatus = "error: " + err.Error()
|
||||||
} else {
|
} else {
|
||||||
dbLatency = time.Since(t).String()
|
dbLatency = time.Since(t).String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// db pool stats
|
stats := s.db.Stats()
|
||||||
stats := db.Stats()
|
|
||||||
|
|
||||||
// overall status
|
|
||||||
status := "ok"
|
status := "ok"
|
||||||
if dbStatus != "ok" {
|
if dbStatus != "ok" {
|
||||||
status = "degraded"
|
status = "degraded"
|
||||||
@@ -75,7 +75,7 @@ func bToMb(b uint64) float64 {
|
|||||||
return float64(b) / 1024 / 1024
|
return float64(b) / 1024 / 1024
|
||||||
}
|
}
|
||||||
|
|
||||||
func AddCompanyHandler(db *sql.DB) http.HandlerFunc {
|
func (s *Service) AddCompanyHandler() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
var input model.CompanyInput
|
var input model.CompanyInput
|
||||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||||
@@ -83,7 +83,7 @@ func AddCompanyHandler(db *sql.DB) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
id, err := database.AddCompany(db, input)
|
id, err := database.AddCompany(s.db, input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -95,9 +95,14 @@ func AddCompanyHandler(db *sql.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetCompaniesHandler(db *sql.DB) http.HandlerFunc {
|
func (s *Service) GetAllCompanies() ([]model.Company, error) {
|
||||||
|
companies, err := database.GetAllCompanies(s.db)
|
||||||
|
return companies, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetCompaniesHandler() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
companies, err := database.GetAllCompanies(db)
|
companies, err := database.GetAllCompanies(s.db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -3,15 +3,16 @@ package service
|
|||||||
import (
|
import (
|
||||||
"Portifolio/internal/database"
|
"Portifolio/internal/database"
|
||||||
"Portifolio/internal/model"
|
"Portifolio/internal/model"
|
||||||
"database/sql"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
)
|
)
|
||||||
|
|
||||||
func UpdatePositionByTradeList(db *sql.DB) error {
|
func (s *Service) UpdatePositionByTradeList() error {
|
||||||
|
|
||||||
trades, err := database.GetTrades(db)
|
trades, err := database.GetTrades(s.db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Failed to get the trades from db: %s", err)
|
fmt.Printf("Failed to get the trades from db: %s", err)
|
||||||
}
|
}
|
||||||
@@ -42,10 +43,115 @@ func UpdatePositionByTradeList(db *sql.DB) error {
|
|||||||
NewPositinos = append(NewPositinos, pos)
|
NewPositinos = append(NewPositinos, pos)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = database.UpdatePositions(db, NewPositinos)
|
err = database.UpdatePositions(s.db, NewPositinos)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("Failed to insert the new postions number into db: %s", err)
|
return fmt.Errorf("Failed to insert the new postions number into db: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) AddTradeHandler() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req model.AddTradeRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := req.Validate(); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("failed to validate trade: %s", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currency, err := database.GetCurrencyByCode(s.db, req.CurrencyCode)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("failed to find currency: %s", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch model.TradeType(req.Type) {
|
||||||
|
case model.DividendType:
|
||||||
|
dividend, err := req.ToDividend()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("failed to build dividend: %s", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dividend.CurrencyCode = currency.Code
|
||||||
|
|
||||||
|
if err := database.InsertDividend(s.db, dividend); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("failed to insert dividend: %s", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||||
|
|
||||||
|
case model.BuyType, model.SellType:
|
||||||
|
trade, err := req.ToTrade()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("failed to build trade: %s", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
trade.CurrencyCode = currency.Code
|
||||||
|
|
||||||
|
if err := database.InsertTrade(s.db, trade); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("failed to insert trade: %s", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
update := true
|
||||||
|
if err := s.UpdatePositionByTradeList(); err != nil {
|
||||||
|
update = false
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"success": true, "position_update": update})
|
||||||
|
|
||||||
|
default:
|
||||||
|
http.Error(w, fmt.Sprintf("unknown trade type: %d", req.Type), http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetTrades() ([]model.Trade, error) {
|
||||||
|
TradeList, err := database.GetTrades(s.db)
|
||||||
|
return TradeList, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetTradeListHandler() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tradeList, err := database.GetTrades(s.db)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("failed to fetch trades: %s", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(tradeList); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("failed to encode trades: %s", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetPositions() ([]model.Position, error) {
|
||||||
|
posList, err := database.GetPositions(s.db)
|
||||||
|
return posList, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetPositionListHandler() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
posList, err := database.GetPositions(s.db)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("failed to fetch postiton: %s", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(posList); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("failed to encode positions: %s", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
package handlers
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"Portifolio/internal/database"
|
"Portifolio/internal/database"
|
||||||
"Portifolio/internal/model"
|
"Portifolio/internal/model"
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -11,7 +10,7 @@ import (
|
|||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
)
|
)
|
||||||
|
|
||||||
func AddRevenueEntryHandler(db *sql.DB) http.HandlerFunc {
|
func (s *Service) AddRevenueEntryHandler() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
var input struct {
|
var input struct {
|
||||||
CompanyID int `json:"company_id"`
|
CompanyID int `json:"company_id"`
|
||||||
@@ -48,7 +47,7 @@ func AddRevenueEntryHandler(db *sql.DB) http.HandlerFunc {
|
|||||||
Value: input.Value,
|
Value: input.Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
err := database.InsertRevenue(db, rev)
|
err := database.InsertRevenue(s.db, rev)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -60,27 +59,7 @@ func AddRevenueEntryHandler(db *sql.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
func (s *Service) GetCompanyRevenueCategories() http.HandlerFunc {
|
||||||
func GetRevenueReportHandler(db *sql.DB) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
companyID, _ := strconv.Atoi(r.URL.Query().Get("company_id"))
|
|
||||||
periodType := r.URL.Query().Get("period_type")
|
|
||||||
year, _ := strconv.Atoi(r.URL.Query().Get("year"))
|
|
||||||
idx, _ := strconv.Atoi(r.URL.Query().Get("index"))
|
|
||||||
|
|
||||||
entries, err := database.GetRevenueByPeriod(db, companyID, )
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(entries)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
func GetCompanyRevenueCategories(db *sql.DB) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
var input struct {
|
var input struct {
|
||||||
CompanyID int `json:"company_id"`
|
CompanyID int `json:"company_id"`
|
||||||
@@ -90,7 +69,7 @@ func GetCompanyRevenueCategories(db *sql.DB) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
catlist, err := database.GetCategoriesByCompanyID(db, input.CompanyID)
|
catlist, err := database.GetCategoriesByCompanyID(s.db, input.CompanyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, fmt.Sprintf("Could not find categories by that id:%s", err), http.StatusBadRequest)
|
http.Error(w, fmt.Sprintf("Could not find categories by that id:%s", err), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
package shell
|
|
||||||
|
|
||||||
import (
|
|
||||||
"Portifolio/internal/database"
|
|
||||||
"Portifolio/internal/model"
|
|
||||||
"bufio"
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
_ "github.com/mattn/go-sqlite3"
|
|
||||||
)
|
|
||||||
|
|
||||||
func AddCompany(scanner *bufio.Scanner, db *sql.DB) {
|
|
||||||
input := model.CompanyInput{}
|
|
||||||
|
|
||||||
fmt.Print(" symbol: ")
|
|
||||||
scanner.Scan()
|
|
||||||
input.Symbol = strings.TrimSpace(scanner.Text())
|
|
||||||
|
|
||||||
fmt.Print(" Shares outstanding: ")
|
|
||||||
scanner.Scan()
|
|
||||||
shares, err := strconv.Atoi(strings.TrimSpace(scanner.Text()))
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println(" Invalid number for shares.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
input.SharesOutstanding = shares
|
|
||||||
|
|
||||||
fmt.Print(" Price: ")
|
|
||||||
scanner.Scan()
|
|
||||||
price, err := strconv.ParseFloat(strings.TrimSpace(scanner.Text()), 64)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println(" Invalid number for price.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
input.Price = price
|
|
||||||
|
|
||||||
fmt.Print(" Currency Code: ")
|
|
||||||
scanner.Scan()
|
|
||||||
input.CurrencyCode = strings.TrimSpace(scanner.Text())
|
|
||||||
|
|
||||||
if _, err := database.AddCompany(db, input); err != nil {
|
|
||||||
fmt.Println(" Error:", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Printf(" ✓ Company '%s' added.\n", input.Symbol)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ListCompanies(db *sql.DB) {
|
|
||||||
companies, err := database.GetAllCompanies(db)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println(" ✗ Error:", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(companies) == 0 {
|
|
||||||
fmt.Println(" No companies found.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("\n %-5s %-20s %-10s %-15s %s\n", "ID", "NAME", "CURRENCY", "PRICE", "SHARES")
|
|
||||||
fmt.Println(" " + strings.Repeat("-", 60))
|
|
||||||
for _, c := range companies {
|
|
||||||
|
|
||||||
currency, err := database.GetCurrencyByID(db, c.CurrencyID)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println("No currency by id.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf(" %-5d %-20s %-10s %-15.2f %d\n",
|
|
||||||
c.ID, c.Symbol, currency.Code, c.Price, c.SharesOutstanding)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
package shell
|
|
||||||
|
|
||||||
import (
|
|
||||||
"Portifolio/internal/model"
|
|
||||||
"Portifolio/internal/service"
|
|
||||||
"bufio"
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
_ "github.com/mattn/go-sqlite3"
|
|
||||||
)
|
|
||||||
|
|
||||||
func AddCurrency(scanner *bufio.Scanner, db *sql.DB) {
|
|
||||||
input := model.CurrencyInput{}
|
|
||||||
|
|
||||||
fmt.Print(" Code (e.g. DKK): ")
|
|
||||||
scanner.Scan()
|
|
||||||
input.Code = strings.ToUpper(strings.TrimSpace(scanner.Text()))
|
|
||||||
|
|
||||||
fmt.Print(" Name (e.g. Danish Krone): ")
|
|
||||||
scanner.Scan()
|
|
||||||
input.Name = strings.TrimSpace(scanner.Text())
|
|
||||||
|
|
||||||
id, err := service.InsertCurrency(db, input)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println(" ✗ Error:", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Printf(" ✓ Currency '%s' (%s) added with ID %d\n", input.Name, input.Code, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ListCurrencies(db *sql.DB) {
|
|
||||||
currencies, err := service.GetAllCurrencies(db)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println(" ✗ Error:", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Printf(" %-5s %-6s %s\n", "ID", "CODE", "NAME")
|
|
||||||
fmt.Println(" " + strings.Repeat("-", 30))
|
|
||||||
for _, c := range currencies {
|
|
||||||
fmt.Printf(" %-5d %-6s %s\n", c.ID, c.Code, c.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
package shell
|
|
||||||
|
|
||||||
import (
|
|
||||||
"Portifolio/internal/database"
|
|
||||||
"Portifolio/internal/model"
|
|
||||||
"bufio"
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func promptInt(scanner *bufio.Scanner, label string) (int, error) {
|
|
||||||
fmt.Print(label)
|
|
||||||
scanner.Scan()
|
|
||||||
v, err := strconv.Atoi(strings.TrimSpace(scanner.Text()))
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("invalid %s", label)
|
|
||||||
}
|
|
||||||
return v, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func promptFloat(scanner *bufio.Scanner, label string) (float64, error) {
|
|
||||||
fmt.Print(label)
|
|
||||||
scanner.Scan()
|
|
||||||
v, err := strconv.ParseFloat(strings.TrimSpace(scanner.Text()), 64)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("invalid %s", label)
|
|
||||||
}
|
|
||||||
return v, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func promptString(scanner *bufio.Scanner, label string) string {
|
|
||||||
fmt.Print(label)
|
|
||||||
scanner.Scan()
|
|
||||||
return strings.TrimSpace(scanner.Text())
|
|
||||||
}
|
|
||||||
|
|
||||||
func promptPeriod(scanner *bufio.Scanner) (model.Period, error) {
|
|
||||||
periodType := strings.ToUpper(promptString(scanner, " Period type (Q/H/Y): "))
|
|
||||||
year, err := promptInt(scanner, " Year: ")
|
|
||||||
if err != nil {
|
|
||||||
return model.Period{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
switch periodType {
|
|
||||||
case "Y":
|
|
||||||
return model.FullYearPeriod(year), nil
|
|
||||||
case "Q", "H":
|
|
||||||
label := map[string]string{"Q": " Index (1-4): ", "H": " Index (1-2): "}[periodType]
|
|
||||||
idx, err := promptInt(scanner, label)
|
|
||||||
if err != nil {
|
|
||||||
return model.Period{}, err
|
|
||||||
}
|
|
||||||
if periodType == "Q" {
|
|
||||||
return model.QuarterPeriod(year, idx), nil
|
|
||||||
}
|
|
||||||
return model.HalfYearPeriod(year, idx), nil
|
|
||||||
default:
|
|
||||||
return model.Period{}, fmt.Errorf("invalid period type: %s", periodType)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func AddRevenue(scanner *bufio.Scanner, db *sql.DB) {
|
|
||||||
companyID, err := promptInt(scanner, " Company ID: ")
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println(" ✗", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// checking if company exits
|
|
||||||
_, err = database.GetCompanyByID(db, companyID)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println("No company by that id:", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
currencyID, err := promptInt(scanner, " Currency ID: ")
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println(" ✗", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
period, err := promptPeriod(scanner)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println(" ✗", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
category := promptString(scanner, " Category name: ")
|
|
||||||
|
|
||||||
var parentID *int
|
|
||||||
parentStr := promptString(scanner, " Parent category ID (leave blank for root): ")
|
|
||||||
if parentStr != "" {
|
|
||||||
pid, err := strconv.Atoi(parentStr)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println(" ✗ Invalid parent ID")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
parentID = &pid
|
|
||||||
}
|
|
||||||
|
|
||||||
value, err := promptFloat(scanner, " Value: ")
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println(" ✗", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rev := model.RevenueInsert{
|
|
||||||
CompanyID: companyID,
|
|
||||||
CurrencyID: currencyID,
|
|
||||||
CategoryName: category,
|
|
||||||
ParentID: *parentID,
|
|
||||||
Period: period,
|
|
||||||
Value: value,
|
|
||||||
}
|
|
||||||
|
|
||||||
err = database.InsertRevenue(db, rev)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println(" ✗ Error:", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Printf(" ✓ Revenue added: %s = %.2f (%s)\n", category, value, period.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
func ListRevenue(scanner *bufio.Scanner, db *sql.DB) {
|
|
||||||
companyID, err := promptInt(scanner, " Company ID: ")
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println(" ✗", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
period, err := promptPeriod(scanner)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println(" ✗", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
entries, err := database.GetRevenueByPeriod(db, companyID, period.ID)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println(" ✗ Error:", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("\n %-20s %12s\n", "CATEGORY", "VALUE")
|
|
||||||
fmt.Println(" " + strings.Repeat("-", 34))
|
|
||||||
for _, e := range entries {
|
|
||||||
fmt.Printf(" %-20s %12.2f\n", e.Category, e.Value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
368
internal/website/main.go
Normal file
368
internal/website/main.go
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
package website
|
||||||
|
|
||||||
|
import (
|
||||||
|
"Portifolio/internal/middleware"
|
||||||
|
"Portifolio/internal/model"
|
||||||
|
"Portifolio/internal/service"
|
||||||
|
"crypto/rand"
|
||||||
|
_ "embed"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/FuLygon/go-totp/v2"
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed static/index.html
|
||||||
|
var indexHTML []byte
|
||||||
|
|
||||||
|
func Getsite() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Write(indexHTML)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:embed static/login.html
|
||||||
|
var loginxHTML []byte
|
||||||
|
|
||||||
|
func GetAdminLogin() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Write(loginxHTML)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const ContextKeyRequestTime contextKey = "requestTime"
|
||||||
|
|
||||||
|
func LoginHandler() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// checked once when the handler is registered
|
||||||
|
envUsername := os.Getenv("username")
|
||||||
|
envPassword := os.Getenv("password")
|
||||||
|
envTOTPSecret := os.Getenv("AUTH_TOTP_SECRET")
|
||||||
|
if envUsername == "" || envPassword == "" || envTOTPSecret == "" {
|
||||||
|
http.Error(w, "env var missing", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
ts, ok := r.Context().Value(ContextKeyRequestTime).(time.Time)
|
||||||
|
if !ok {
|
||||||
|
ts = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "invalid form data", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
password := r.FormValue("password")
|
||||||
|
username := r.FormValue("username")
|
||||||
|
code := r.FormValue("auth_code")
|
||||||
|
fmt.Println(username, password, code)
|
||||||
|
|
||||||
|
if password == "" || username == "" || code == "" {
|
||||||
|
http.Error(w, "form value is empty", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if password != envPassword || username != envUsername {
|
||||||
|
http.Error(w, "username or password is not valid", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
v := totp.Validator{
|
||||||
|
Algorithm: totp.AlgorithmSHA1,
|
||||||
|
Digits: 6,
|
||||||
|
Period: 30,
|
||||||
|
Secret: envTOTPSecret,
|
||||||
|
}
|
||||||
|
|
||||||
|
valid, err := v.ValidateWithTimestamp(code, ts.Unix())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("error validating TOTP code: %s", err), http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
http.Error(w, "invalid auth code", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// In your login POST handler, after setting the cookie:
|
||||||
|
token := generateSessionToken()
|
||||||
|
middleware.RegisterSession(token) // <-- register before writing cookie
|
||||||
|
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "session",
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: 86400,
|
||||||
|
})
|
||||||
|
w.Header().Set("HX-Redirect", "/admin")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple session token generator
|
||||||
|
func generateSessionToken() string {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return base64.URLEncoding.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:embed static/admin.html
|
||||||
|
var adminHTML []byte
|
||||||
|
|
||||||
|
func GetAdmin() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Write(adminHTML)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:embed static/styles.css
|
||||||
|
var styleCSSmain []byte
|
||||||
|
|
||||||
|
func GetstylesheetMain() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||||
|
w.Write(styleCSSmain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:embed static/login.css
|
||||||
|
var styleCSSlogin []byte
|
||||||
|
|
||||||
|
func GetstylesheetLogin() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||||
|
w.Write(styleCSSlogin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:embed static/admin.css
|
||||||
|
var styleCSSadmin []byte
|
||||||
|
|
||||||
|
func GetstylesheetAdmin() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||||
|
w.Write(styleCSSadmin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HealthFragment(svc *service.Service) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h := svc.CheckHealth()
|
||||||
|
|
||||||
|
status := h["status"].(string)
|
||||||
|
uptime := h["uptime"].(string)
|
||||||
|
|
||||||
|
dotClass := "ok"
|
||||||
|
statusText := "backend ok"
|
||||||
|
if status != "ok" {
|
||||||
|
dotClass = "error"
|
||||||
|
statusText = "backend " + status
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable) // ← triggers htmx:responseError
|
||||||
|
}
|
||||||
|
|
||||||
|
dbInfo := h["database"].(map[string]any)
|
||||||
|
dbStatus := dbInfo["status"].(string)
|
||||||
|
dbLatency := dbInfo["latency"].(string)
|
||||||
|
dbText := "db " + dbStatus
|
||||||
|
if dbLatency != "" {
|
||||||
|
dbText += " · " + dbLatency
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(w, `
|
||||||
|
<span class="status-dot %s"></span>
|
||||||
|
<span>%s</span>
|
||||||
|
<span class="status-sep">·</span>
|
||||||
|
<span style="color:var(--muted)">up %s</span>
|
||||||
|
<span class="status-sep">·</span>
|
||||||
|
<span style="color:var(--muted)">%s</span>
|
||||||
|
<span class="status-spacer"></span>
|
||||||
|
<button class="refresh-btn"
|
||||||
|
hx-get="/health/fragment"
|
||||||
|
hx-target="#statusBar"
|
||||||
|
hx-swap="innerHTML">↺ refresh</button>
|
||||||
|
`, dotClass, statusText, uptime, dbText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /positions/fragment — returns <tr> rows for positions tbody
|
||||||
|
func PositionsFragment(svc *service.Service) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
positions, err := svc.GetPositions()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(w, `<tr class="state-row"><td colspan="5">load failed: %s</td></tr>`, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(positions) == 0 {
|
||||||
|
fmt.Fprint(w, `<tr class="state-row"><td colspan="5">no positions</td></tr>`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var total float64
|
||||||
|
for _, p := range positions {
|
||||||
|
total += p.CostBasis
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range positions {
|
||||||
|
weight := 0.0 // ← renamed from w to avoid shadowing http.ResponseWriter
|
||||||
|
if total > 0 {
|
||||||
|
weight = p.CostBasis / total * 100
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, `<tr>
|
||||||
|
<td><span class="ticker">%s</span></td>
|
||||||
|
<td><span class="currency-badge">%s</span></td>
|
||||||
|
<td>%s</td>
|
||||||
|
<td>%s</td>
|
||||||
|
<td><div class="weight-cell">%.1f%%
|
||||||
|
<div class="weight-bar-track">
|
||||||
|
<div class="weight-bar-fill" style="width:%.0f%%"></div>
|
||||||
|
</div>
|
||||||
|
</div></td>
|
||||||
|
</tr>`, p.Symbol, p.CurrencyCode, fmtInt(p.Shares), fmtNum(p.CostBasis), weight, weight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /positions/fragment — returns <tr> rows for positions tbody
|
||||||
|
func CompanyFragment(svc *service.Service) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
companies, err := svc.GetAllCompanies()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(w, `<tr class="state-row"><td colspan="5">load failed: %s</td></tr>`, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(companies) == 0 {
|
||||||
|
fmt.Fprint(w, `<tr class="state-row"><td colspan="5">no positions</td></tr>`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range companies {
|
||||||
|
fmt.Fprintf(w, `<tr>
|
||||||
|
<td><span class="ticker">%s</span></td>
|
||||||
|
<td>%s</td>
|
||||||
|
<td>%.2f</td>
|
||||||
|
<td>%d</td>
|
||||||
|
<td>%.2f</td>
|
||||||
|
</tr>`, c.Symbol, c.CurrencyCode, c.Price, c.SharesOutstanding, c.Price*float64(c.SharesOutstanding))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /positions/fragment — returns <tr> rows for positions tbody
|
||||||
|
func SummaryFragment(svc *service.Service) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
positions, err := svc.GetPositions()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(w, `<tr class="state-row"><td colspan="5">load failed: %s</td></tr>`, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(positions) == 0 {
|
||||||
|
fmt.Fprint(w, `<tr class="state-row"><td colspan="5">no positions</td></tr>`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var total float64
|
||||||
|
for _, p := range positions {
|
||||||
|
total += p.CostBasis
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range positions {
|
||||||
|
weight := 0.0 // ← renamed from w to avoid shadowing http.ResponseWriter
|
||||||
|
if total > 0 {
|
||||||
|
weight = p.CostBasis / total * 100
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, `<tr>
|
||||||
|
<td><span class="ticker">%s</span></td>
|
||||||
|
<td><span class="currency-badge">%s</span></td>
|
||||||
|
<td>%s</td>
|
||||||
|
<td>%s</td>
|
||||||
|
<td><div class="weight-cell">%.1f%%
|
||||||
|
<div class="weight-bar-track">
|
||||||
|
<div class="weight-bar-fill" style="width:%.0f%%"></div>
|
||||||
|
</div>
|
||||||
|
</div></td>
|
||||||
|
</tr>`, p.Symbol, p.CurrencyCode, fmtInt(p.Shares), fmtNum(p.CostBasis), weight, weight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fmtNum(v float64) string {
|
||||||
|
if v == 0 {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
return strconv.FormatFloat(v, 'f', 2, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fmtInt(v int) string {
|
||||||
|
return strconv.Itoa(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
var productLabels = map[model.TradeProduct]string{
|
||||||
|
model.StockTrade: "Stock",
|
||||||
|
model.OptionCallTrade: "Call Option",
|
||||||
|
model.OptionPutTrade: "Put Option",
|
||||||
|
model.CurrencyTrade: "Currency",
|
||||||
|
model.BondTrade: "Bond",
|
||||||
|
}
|
||||||
|
|
||||||
|
func TradeFragment(svc *service.Service) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
trades, err := svc.GetTrades()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(w, `<tr class="state-row"><td colspan="7">load failed: %s</td></tr>`, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(trades) == 0 {
|
||||||
|
fmt.Fprint(w, `<tr class="state-row"><td colspan="7">no trades</td></tr>`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, t := range trades {
|
||||||
|
dir, cls := "SELL", "dir-sell"
|
||||||
|
if t.Type == model.BuyType {
|
||||||
|
dir, cls = "BUY", "dir-buy"
|
||||||
|
}
|
||||||
|
label := productLabels[t.Product]
|
||||||
|
fmt.Fprintf(w, `<tr>
|
||||||
|
<td><span class="trade-ticker">%s</span></td>
|
||||||
|
<td>%s</td>
|
||||||
|
<td>%s</td>
|
||||||
|
<td>%s</td>
|
||||||
|
<td class="%s">%s</td>
|
||||||
|
<td>%s</td>
|
||||||
|
<td>%s</td>
|
||||||
|
</tr>`, t.Symbol, label, t.CurrencyCode,
|
||||||
|
t.Date.Format("2006-01-02"), cls, dir,
|
||||||
|
fmtNum(float64(t.Shares)), fmtNum(t.Price))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TradeCount(svc *service.Service) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
trades, err := svc.GetTrades()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprint(w, "—")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
count := len(trades)
|
||||||
|
if count == 1 {
|
||||||
|
fmt.Fprint(w, "1 trade")
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(w, "%d trades", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
444
internal/website/static/admin.css
Normal file
444
internal/website/static/admin.css
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Space+Mono:ital,wght@0,400;0,700;1,400&family=Fraunces:ital,wght@0,300;0,400;0,600;1,300;1,400&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #f5efe7;;
|
||||||
|
--surface: #efefef;
|
||||||
|
--surface2:#e0e0e0;
|
||||||
|
--border: #8c8c8c;
|
||||||
|
--border2: #acacac;
|
||||||
|
--accent: #c8a96e;
|
||||||
|
--accent2: #f0d060;
|
||||||
|
--muted: #5a5a68;
|
||||||
|
--muted2: #3d3d4a;
|
||||||
|
--text: #000000;
|
||||||
|
--text-dim: #9e9a92;
|
||||||
|
--red: #f06060;
|
||||||
|
--blue: #60c0f0;
|
||||||
|
--radius: 2px;
|
||||||
|
--mono: 'Space Mono', monospace;
|
||||||
|
--serif: 'Fraunces', Georgia, serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
html { font-size: 14px; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--mono);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* GRID LAYOUT */
|
||||||
|
.app {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 220px 1fr;
|
||||||
|
grid-template-rows: 56px 1fr;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TOP NAV */
|
||||||
|
.topbar {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 0 24px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.topbar-logo {
|
||||||
|
font-family: var(--serif);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 300;
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.topbar-logo span { color: var(--accent); }
|
||||||
|
.topbar-tag {
|
||||||
|
font-size: 0.71rem;
|
||||||
|
color: var(--muted2);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.topbar-spacer { flex: 1; }
|
||||||
|
.status-pill {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
font-size: 0.71rem;
|
||||||
|
color: var(--muted2);
|
||||||
|
}
|
||||||
|
.dot {
|
||||||
|
width: 7px; height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
animation: pulse 2.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.3; }
|
||||||
|
}
|
||||||
|
.dot.err { background: var(--red); animation: none; }
|
||||||
|
.dot.loading { background: var(--muted); animation: pulse 1s linear infinite; }
|
||||||
|
|
||||||
|
/* SIDEBAR */
|
||||||
|
.sidebar {
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 20px 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.nav-section-label {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
color: var(--muted);
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 10px 18px 4px;
|
||||||
|
}
|
||||||
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 9px 18px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted2);
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.nav-item:hover { color: var(--text); background: var(--surface2); }
|
||||||
|
.nav-item.active { color: var(--accent); border-left-color: var(--accent); background: rgba(200,240,96,0.05); }
|
||||||
|
.nav-icon { width: 14px; opacity: 0.7; font-style: normal; }
|
||||||
|
|
||||||
|
/* MAIN */
|
||||||
|
.main {
|
||||||
|
padding: 28px 32px;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: calc(100vh - 56px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* PAGE HEADER */
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.page-title {
|
||||||
|
font-family: var(--serif);
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 300;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.page-title span { font-style: italic; color: var(--accent); }
|
||||||
|
.page-subtitle {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--muted2);
|
||||||
|
padding-bottom: 4px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SECTION */
|
||||||
|
.section { margin-bottom: 32px; }
|
||||||
|
.section-label {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--muted2);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.section-label::after {
|
||||||
|
content: '';
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
.section-label span { color: var(--accent); }
|
||||||
|
|
||||||
|
/* SUMMARY CARDS */
|
||||||
|
.cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 16px 18px;
|
||||||
|
}
|
||||||
|
.card-label {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--muted2);
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.card-value {
|
||||||
|
font-family: var(--serif);
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 300;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.card-value.accent { color: var(--accent); }
|
||||||
|
.card-value.dim { color: var(--muted); }
|
||||||
|
|
||||||
|
/* PANEL / FORM */
|
||||||
|
.panel {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.panel-header {
|
||||||
|
padding: 14px 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.panel-title {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.panel-title span { color: var(--accent); }
|
||||||
|
.panel-body { padding: 20px; }
|
||||||
|
|
||||||
|
/* FORM GRID */
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.form-grid.two { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.form-group.full { grid-column: 1 / -1; }
|
||||||
|
.form-label {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--muted2);
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.form-label .required { color: var(--red); margin-left: 3px; }
|
||||||
|
|
||||||
|
input, select, textarea {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border2);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
outline: none;
|
||||||
|
width: 100%;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
input:focus, select:focus, textarea:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 2px rgba(200,240,96,0.1);
|
||||||
|
}
|
||||||
|
input::placeholder { color: var(--muted); }
|
||||||
|
select option { background: var(--surface2); }
|
||||||
|
|
||||||
|
.type-toggle {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 0;
|
||||||
|
border: 1px solid var(--border2);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.type-btn {
|
||||||
|
padding: 9px 8px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--muted2);
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
border-right: 1px solid var(--border2);
|
||||||
|
}
|
||||||
|
.type-btn:last-child { border-right: none; }
|
||||||
|
.type-btn.active-buy { background: rgba(96,192,240,0.12); color: var(--blue); }
|
||||||
|
.type-btn.active-sell { background: rgba(240,96,96,0.12); color: var(--red); }
|
||||||
|
.type-btn.active-div { background: rgba(200,240,96,0.12); color: var(--accent); }
|
||||||
|
|
||||||
|
/* DIVIDEND FIELDS */
|
||||||
|
.dividend-fields {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: none;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.dividend-fields.visible { display: grid; }
|
||||||
|
.div-label {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
font-size: 0.63rem;
|
||||||
|
color: var(--accent);
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* FORM ACTIONS */
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin-top: 4px;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 9px 20px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
transition: background 0.15s, color 0.15s, transform 0.1s;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.btn:active { transform: scale(0.98); }
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #0d0d0d;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { background: #d8ff70; }
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted2);
|
||||||
|
border-color: var(--border2);
|
||||||
|
}
|
||||||
|
.btn-ghost:hover { color: var(--text); border-color: var(--muted); }
|
||||||
|
|
||||||
|
/* FEEDBACK */
|
||||||
|
.feedback {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.feedback.success { display: inline; color: var(--accent); }
|
||||||
|
.feedback.error { display: inline; color: var(--red); }
|
||||||
|
|
||||||
|
/* TABLE */
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
thead {
|
||||||
|
background: var(--surface2);
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
padding: 10px 14px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--muted2);
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
tr:last-child td { border-bottom: none; }
|
||||||
|
tr:hover td { background: rgba(255,255,255,0.025); }
|
||||||
|
.td-sym {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
.td-muted { color: var(--muted2); }
|
||||||
|
.td-buy { color: var(--blue); }
|
||||||
|
.td-sell { color: var(--red); }
|
||||||
|
.td-div { color: var(--accent); }
|
||||||
|
.td-right { text-align: right; }
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 20px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.badge-buy { background: rgba(96,192,240,0.12); color: var(--blue); }
|
||||||
|
.badge-sell { background: rgba(240,96,96,0.12); color: var(--red); }
|
||||||
|
.badge-div { background: rgba(200,240,96,0.12); color: var(--accent); }
|
||||||
|
|
||||||
|
/* TABS */
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: -1px;
|
||||||
|
}
|
||||||
|
.tab {
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--muted2);
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font-family: var(--mono);
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
transition: color 0.15s;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
.tab:hover { color: var(--text); }
|
||||||
|
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||||
|
|
||||||
|
/* PILL TAG */
|
||||||
|
.tag-pill {
|
||||||
|
font-size: 0.60rem;
|
||||||
|
padding: 2px 7px;
|
||||||
|
background: var(--surface2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--muted2);
|
||||||
|
border-radius: 20px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* PAGES */
|
||||||
|
.page { display: none; }
|
||||||
|
.page.active { display: block; }
|
||||||
|
|
||||||
|
/* RESPONSIVE NOTE */
|
||||||
|
@media(max-width:900px) {
|
||||||
|
.app { grid-template-columns: 1fr; }
|
||||||
|
.sidebar { display: none; }
|
||||||
|
.cards { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.form-grid { grid-template-columns: 1fr; }
|
||||||
|
.dividend-fields { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
582
internal/website/static/admin.html
Normal file
582
internal/website/static/admin.html
Normal file
@@ -0,0 +1,582 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Portfolio Admin — Samantha Friis</title>
|
||||||
|
<link rel="stylesheet" href="/styles/admin">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="app">
|
||||||
|
|
||||||
|
<!-- TOP BAR -->
|
||||||
|
<header class="topbar">
|
||||||
|
<a href="/" class="topbar-logo">Samantha <span>Friis</span></a>
|
||||||
|
<span class="topbar-tag">// portfolio admin</span>
|
||||||
|
<div class="topbar-spacer"></div>
|
||||||
|
<div class="status-pill">
|
||||||
|
<span class="dot" id="status-dot"></span>
|
||||||
|
<span id="status-text">live</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- SIDEBAR -->
|
||||||
|
<nav class="sidebar">
|
||||||
|
<span class="nav-section-label">overview</span>
|
||||||
|
<button class="nav-item active" onclick="navigate('dashboard', this)">
|
||||||
|
<i class="nav-icon">◈</i> Dashboard
|
||||||
|
</button>
|
||||||
|
<button class="nav-item" onclick="navigate('positions', this)">
|
||||||
|
<i class="nav-icon">▤</i> Positions
|
||||||
|
</button>
|
||||||
|
<button class="nav-item" onclick="navigate('history', this)">
|
||||||
|
<i class="nav-icon">◷</i> Trade History
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span class="nav-section-label" style="margin-top:12px">add data</span>
|
||||||
|
<button class="nav-item" onclick="navigate('add-trade', this)">
|
||||||
|
<i class="nav-icon">⊕</i> Add Trade
|
||||||
|
</button>
|
||||||
|
<button class="nav-item" onclick="navigate('add-company', this)">
|
||||||
|
<i class="nav-icon">⊕</i> Add Company
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- MAIN CONTENT -->
|
||||||
|
<main class="main">
|
||||||
|
|
||||||
|
<!-- ═══ DASHBOARD ═══ -->
|
||||||
|
<div class="page active" id="page-dashboard">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">Investment <span>Portfolio</span></h1>
|
||||||
|
<span class="page-subtitle">Equity positions · Personal research</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cards">
|
||||||
|
<div class="card">
|
||||||
|
<p class="card-label">Positions</p>
|
||||||
|
<p class="card-value accent" id="stat-positions">—</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<p class="card-label">Total Shares</p>
|
||||||
|
<p class="card-value" id="stat-shares">—</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<p class="card-label">Total Trades</p>
|
||||||
|
<p class="card-value" id="stat-trades">—</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<p class="card-label">Currencies</p>
|
||||||
|
<p class="card-value" id="stat-currencies">—</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="section-label">// <span>positions</span></p>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Symbol</th><th>Currency</th><th>Shares</th>
|
||||||
|
<th>Cost Basis</th><th>Weight</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody hx-get="/positions/fragment"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<tr class="state-row"><td colspan="5">loading…</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="section-label" style="margin-top:28px">// <span>companies</span> — tracked universe</p>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Symbol</th><th>Currency</th><th>Price</th>
|
||||||
|
<th>Shares Outstanding</th><th>Market Cap</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="companies-body">
|
||||||
|
<tr><td colspan="5" class="td-muted">loading…</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ POSITIONS ═══ -->
|
||||||
|
<div class="page" id="page-positions">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">Open <span>Positions</span></h1>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Symbol</th><th>Currency</th><th>Shares</th>
|
||||||
|
<th>Cost Basis</th><th>Weight</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody hx-get="/positions/fragment"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<tr class="state-row"><td colspan="5">loading…</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ HISTORY ═══ -->
|
||||||
|
<div class="page" id="page-history">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">Trade <span>History</span></h1>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th><th>Symbol</th><th>Type</th>
|
||||||
|
<th>Shares</th><th>Price</th><th>Currency</th><th>Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="history-body">
|
||||||
|
<tr><td colspan="7" class="td-muted">loading…</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ ADD TRADE ═══ -->
|
||||||
|
<div class="page" id="page-add-trade">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">Add <span>Trade</span></h1>
|
||||||
|
<span class="page-subtitle">Record a buy, sell, or dividend</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="panel-title">// <span>new trade</span></span>
|
||||||
|
<span class="tag-pill">POST /trades</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="form-grid" id="trade-form">
|
||||||
|
|
||||||
|
<!-- Type toggle -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Trade type <span class="required">*</span></label>
|
||||||
|
<div class="type-toggle">
|
||||||
|
<button class="type-btn active-buy" data-val="0" onclick="setTradeType(0, this)">BUY</button>
|
||||||
|
<button class="type-btn" data-val="1" onclick="setTradeType(1, this)">SELL</button>
|
||||||
|
<button class="type-btn" data-val="2" onclick="setTradeType(2, this)">DIVIDEND</button>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="trade-type" value="0"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Symbol -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="trade-symbol">Symbol <span class="required">*</span></label>
|
||||||
|
<input type="text" id="trade-symbol" placeholder="AAPL" style="text-transform:uppercase"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Date -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="trade-date">Date <span class="required">*</span></label>
|
||||||
|
<input type="date" id="trade-date"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Shares -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="trade-shares">Shares <span class="required">*</span></label>
|
||||||
|
<input type="number" id="trade-shares" placeholder="100" min="0" step="1"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Price -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="trade-price">Price per share <span class="required">*</span></label>
|
||||||
|
<input type="number" id="trade-price" placeholder="0.00" min="0" step="0.01"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Currency -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="trade-currency">Currency <span class="required">*</span></label>
|
||||||
|
<select id="trade-currency">
|
||||||
|
<option value="USD">USD — US Dollar</option>
|
||||||
|
<option value="EUR">EUR — Euro</option>
|
||||||
|
<option value="GBP">GBP — British Pound</option>
|
||||||
|
<option value="DKK">DKK — Danish Krone</option>
|
||||||
|
<option value="SEK">SEK — Swedish Krona</option>
|
||||||
|
<option value="NOK">NOK — Norwegian Krone</option>
|
||||||
|
<option value="JPY">JPY — Japanese Yen</option>
|
||||||
|
<option value="CHF">CHF — Swiss Franc</option>
|
||||||
|
<option value="CAD">CAD — Canadian Dollar</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Product ID -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="trade-product">Product ID</label>
|
||||||
|
<input type="number" id="trade-product" placeholder="0" min="0" step="1"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DIVIDEND-ONLY FIELDS -->
|
||||||
|
<div class="dividend-fields" id="dividend-fields">
|
||||||
|
<span class="div-label">dividend fields</span>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="div-net">Net value</label>
|
||||||
|
<input type="number" id="div-net" placeholder="0.00" step="0.01"/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="div-tax-amount">Tax amount</label>
|
||||||
|
<input type="number" id="div-tax-amount" placeholder="0.00" step="0.01"/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="div-tax-rate">Tax rate</label>
|
||||||
|
<input type="number" id="div-tax-rate" placeholder="0.00" step="0.0001" min="0" max="1"/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="div-payment-date">Payment date</label>
|
||||||
|
<input type="date" id="div-payment-date"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn btn-primary" onclick="submitTrade()">Submit trade →</button>
|
||||||
|
<button class="btn btn-ghost" onclick="resetTradeForm()">Clear</button>
|
||||||
|
<span class="feedback" id="trade-feedback"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live preview -->
|
||||||
|
<div class="panel" style="margin-top:16px">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="panel-title">// <span>payload preview</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<pre id="trade-preview" style="font-size:0.72rem;color:var(--muted2);line-height:1.7;white-space:pre-wrap">{}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ ADD COMPANY ═══ -->
|
||||||
|
<div class="page" id="page-add-company">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">Add <span>Company</span></h1>
|
||||||
|
<span class="page-subtitle">Track a new company in the universe</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="panel-title">// <span>new company</span></span>
|
||||||
|
<span class="tag-pill">POST /company</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="form-grid two">
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="co-symbol">Symbol <span class="required">*</span></label>
|
||||||
|
<input type="text" id="co-symbol" placeholder="AAPL" style="text-transform:uppercase"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="co-currency-code">Currency code <span class="required">*</span></label>
|
||||||
|
<select id="co-currency-code">
|
||||||
|
<option value="USD">USD</option>
|
||||||
|
<option value="EUR">EUR</option>
|
||||||
|
<option value="GBP">GBP</option>
|
||||||
|
<option value="DKK">DKK</option>
|
||||||
|
<option value="SEK">SEK</option>
|
||||||
|
<option value="NOK">NOK</option>
|
||||||
|
<option value="JPY">JPY</option>
|
||||||
|
<option value="CHF">CHF</option>
|
||||||
|
<option value="CAD">CAD</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="co-currency-id">Currency ID <span class="required">*</span></label>
|
||||||
|
<input type="number" id="co-currency-id" placeholder="1" min="1" step="1"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="co-price">Current price <span class="required">*</span></label>
|
||||||
|
<input type="number" id="co-price" placeholder="0.00" step="0.01"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group full">
|
||||||
|
<label class="form-label" for="co-shares-out">Shares outstanding <span class="required">*</span></label>
|
||||||
|
<input type="number" id="co-shares-out" placeholder="1000000000" min="0" step="1"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn btn-primary" onclick="submitCompany()">Add company →</button>
|
||||||
|
<button class="btn btn-ghost" onclick="resetCompanyForm()">Clear</button>
|
||||||
|
<span class="feedback" id="company-feedback"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel" style="margin-top:16px">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="panel-title">// <span>payload preview</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<pre id="company-preview" style="font-size:0.72rem;color:var(--muted2);line-height:1.7;white-space:pre-wrap">{}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ── MOCK DATA ────────────────────────────────────────────
|
||||||
|
const MOCK_POSITIONS = [
|
||||||
|
{ symbol:'AAPL', currency:'USD', shares:150, costBasis:22050, weight:'34.2%' },
|
||||||
|
{ symbol:'MSFT', currency:'USD', shares:80, costBasis:28800, weight:'27.1%' },
|
||||||
|
{ symbol:'NOVO B', currency:'DKK', shares:200, costBasis:18000, weight:'25.5%' },
|
||||||
|
{ symbol:'ASML', currency:'EUR', shares:15, costBasis:11250, weight:'13.2%' },
|
||||||
|
];
|
||||||
|
const MOCK_COMPANIES = [
|
||||||
|
{ symbol:'AAPL', currency:'USD', price:182.50, sharesOut:'15.4B', mktCap:'2.81T' },
|
||||||
|
{ symbol:'MSFT', currency:'USD', price:360.00, sharesOut:'7.4B', mktCap:'2.66T' },
|
||||||
|
{ symbol:'NOVO B', currency:'DKK', price:635.00, sharesOut:'2.2B', mktCap:'1.40T' },
|
||||||
|
{ symbol:'ASML', currency:'EUR', price:750.00, sharesOut:'406M', mktCap:'304B' },
|
||||||
|
];
|
||||||
|
const MOCK_TRADES = [
|
||||||
|
{ date:'2026-04-15', symbol:'AAPL', type:0, shares:50, price:175.20, currency:'USD', total:8760.00 },
|
||||||
|
{ date:'2026-03-22', symbol:'MSFT', type:0, shares:20, price:355.00, currency:'USD', total:7100.00 },
|
||||||
|
{ date:'2026-03-01', symbol:'NOVO B', type:2, shares:200, price:18.50, currency:'DKK', total:3700.00 },
|
||||||
|
{ date:'2026-02-10', symbol:'AAPL', type:1, shares:10, price:180.00, currency:'USD', total:1800.00 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── NAV ──────────────────────────────────────────────────
|
||||||
|
function navigate(page, el) {
|
||||||
|
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
||||||
|
document.getElementById('page-' + page).classList.add('active');
|
||||||
|
if (el) el.classList.add('active');
|
||||||
|
if (page === 'dashboard' || page === 'positions') loadPositions();
|
||||||
|
if (page === 'history') loadHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── RENDER TABLES ────────────────────────────────────────
|
||||||
|
function loadPositions() {
|
||||||
|
const total = MOCK_POSITIONS.reduce((s,p)=>s+p.costBasis,0);
|
||||||
|
const tbody1 = document.getElementById('positions-body');
|
||||||
|
const tbody2 = document.getElementById('positions-body-2');
|
||||||
|
const rows = MOCK_POSITIONS.map(p => `
|
||||||
|
<tr>
|
||||||
|
<td class="td-sym">${p.symbol}</td>
|
||||||
|
<td class="td-muted">${p.currency}</td>
|
||||||
|
<td>${p.shares.toLocaleString()}</td>
|
||||||
|
<td>${p.costBasis.toLocaleString(undefined,{minimumFractionDigits:2})}</td>
|
||||||
|
<td class="td-muted">${p.weight}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
if (tbody1) tbody1.innerHTML = rows;
|
||||||
|
if (tbody2) tbody2.innerHTML = rows;
|
||||||
|
|
||||||
|
// summary stats
|
||||||
|
document.getElementById('stat-positions').textContent = MOCK_POSITIONS.length;
|
||||||
|
document.getElementById('stat-shares').textContent = MOCK_POSITIONS.reduce((s,p)=>s+p.shares,0).toLocaleString();
|
||||||
|
document.getElementById('stat-trades').textContent = MOCK_TRADES.length;
|
||||||
|
document.getElementById('stat-currencies').textContent = [...new Set(MOCK_POSITIONS.map(p=>p.currency))].length;
|
||||||
|
|
||||||
|
const tbody3 = document.getElementById('companies-body');
|
||||||
|
if (tbody3) tbody3.innerHTML = MOCK_COMPANIES.map(c=>`
|
||||||
|
<tr>
|
||||||
|
<td class="td-sym">${c.symbol}</td>
|
||||||
|
<td class="td-muted">${c.currency}</td>
|
||||||
|
<td>${c.price.toLocaleString(undefined,{minimumFractionDigits:2})}</td>
|
||||||
|
<td class="td-muted">${c.sharesOut}</td>
|
||||||
|
<td>${c.mktCap}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadHistory() {
|
||||||
|
const typeMap = ['BUY','SELL','DIVIDEND'];
|
||||||
|
const classMap = ['td-buy','td-sell','td-div'];
|
||||||
|
const badgeMap = ['badge-buy','badge-sell','badge-div'];
|
||||||
|
document.getElementById('history-body').innerHTML = MOCK_TRADES.map(t=>`
|
||||||
|
<tr>
|
||||||
|
<td class="td-muted">${t.date}</td>
|
||||||
|
<td class="td-sym">${t.symbol}</td>
|
||||||
|
<td><span class="badge ${badgeMap[t.type]}">${typeMap[t.type]}</span></td>
|
||||||
|
<td>${t.shares}</td>
|
||||||
|
<td>${t.price.toFixed(2)}</td>
|
||||||
|
<td class="td-muted">${t.currency}</td>
|
||||||
|
<td>${t.total.toLocaleString(undefined,{minimumFractionDigits:2})}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TRADE TYPE TOGGLE ─────────────────────────────────────
|
||||||
|
function setTradeType(val, btn) {
|
||||||
|
document.querySelectorAll('.type-btn').forEach(b => {
|
||||||
|
b.classList.remove('active-buy','active-sell','active-div');
|
||||||
|
});
|
||||||
|
const cls = ['active-buy','active-sell','active-div'][val];
|
||||||
|
btn.classList.add(cls);
|
||||||
|
document.getElementById('trade-type').value = val;
|
||||||
|
const df = document.getElementById('dividend-fields');
|
||||||
|
df.classList.toggle('visible', val === 2);
|
||||||
|
updateTradePreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PAYLOAD PREVIEW ──────────────────────────────────────
|
||||||
|
function tradePayload() {
|
||||||
|
const type = parseInt(document.getElementById('trade-type').value);
|
||||||
|
const p = {
|
||||||
|
symbol: document.getElementById('trade-symbol').value.toUpperCase() || '',
|
||||||
|
shares: parseInt(document.getElementById('trade-shares').value) || 0,
|
||||||
|
product: parseInt(document.getElementById('trade-product').value) || 0,
|
||||||
|
type,
|
||||||
|
price: parseFloat(document.getElementById('trade-price').value) || 0,
|
||||||
|
currency_code: document.getElementById('trade-currency').value,
|
||||||
|
date: document.getElementById('trade-date').value || new Date().toISOString().split('T')[0],
|
||||||
|
};
|
||||||
|
if (type === 2) {
|
||||||
|
p.net_value = parseFloat(document.getElementById('div-net').value) || 0;
|
||||||
|
p.tax_amount = parseFloat(document.getElementById('div-tax-amount').value) || 0;
|
||||||
|
p.tax_rate = parseFloat(document.getElementById('div-tax-rate').value) || 0;
|
||||||
|
p.payment_date = document.getElementById('div-payment-date').value || '';
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTradePreview() {
|
||||||
|
document.getElementById('trade-preview').textContent =
|
||||||
|
JSON.stringify(tradePayload(), null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCompanyPreview() {
|
||||||
|
const p = {
|
||||||
|
symbol: document.getElementById('co-symbol').value.toUpperCase() || '',
|
||||||
|
shares_outstanding: parseInt(document.getElementById('co-shares-out').value) || 0,
|
||||||
|
price: parseFloat(document.getElementById('co-price').value) || 0,
|
||||||
|
currency_id: parseInt(document.getElementById('co-currency-id').value) || 0,
|
||||||
|
currency_code: document.getElementById('co-currency-code').value,
|
||||||
|
};
|
||||||
|
document.getElementById('company-preview').textContent =
|
||||||
|
JSON.stringify(p, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook preview update to all trade inputs
|
||||||
|
document.querySelectorAll('#trade-form input, #trade-form select').forEach(el => {
|
||||||
|
el.addEventListener('input', updateTradePreview);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── SUBMIT TRADE ─────────────────────────────────────────
|
||||||
|
async function submitTrade() {
|
||||||
|
const payload = tradePayload();
|
||||||
|
const fb = document.getElementById('trade-feedback');
|
||||||
|
fb.className = 'feedback';
|
||||||
|
|
||||||
|
if (!payload.symbol || !payload.price || !payload.shares) {
|
||||||
|
fb.textContent = '✗ fill in required fields';
|
||||||
|
fb.className = 'feedback error';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/trades', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||||
|
fb.textContent = '✓ trade recorded';
|
||||||
|
fb.className = 'feedback success';
|
||||||
|
// Add to mock history for demo
|
||||||
|
MOCK_TRADES.unshift({
|
||||||
|
date: payload.date, symbol: payload.symbol, type: payload.type,
|
||||||
|
shares: payload.shares, price: payload.price,
|
||||||
|
currency: payload.currency_code,
|
||||||
|
total: payload.shares * payload.price,
|
||||||
|
});
|
||||||
|
setTimeout(() => { fb.className = 'feedback'; }, 3000);
|
||||||
|
} catch(e) {
|
||||||
|
// For demo: show success anyway (backend not connected)
|
||||||
|
fb.textContent = '✓ trade recorded (demo)';
|
||||||
|
fb.className = 'feedback success';
|
||||||
|
setTimeout(() => { fb.className = 'feedback'; }, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCompany() {
|
||||||
|
const fb = document.getElementById('company-feedback');
|
||||||
|
fb.className = 'feedback';
|
||||||
|
const payload = {
|
||||||
|
symbol: document.getElementById('co-symbol').value.toUpperCase(),
|
||||||
|
shares_outstanding: parseInt(document.getElementById('co-shares-out').value) || 0,
|
||||||
|
price: parseFloat(document.getElementById('co-price').value) || 0,
|
||||||
|
currency_id: parseInt(document.getElementById('co-currency-id').value) || 0,
|
||||||
|
currency_code: document.getElementById('co-currency-code').value,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!payload.symbol || !payload.price) {
|
||||||
|
fb.textContent = '✗ fill in required fields';
|
||||||
|
fb.className = 'feedback error';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/company', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`${res.status}`);
|
||||||
|
fb.textContent = '✓ company added';
|
||||||
|
fb.className = 'feedback success';
|
||||||
|
} catch {
|
||||||
|
fb.textContent = '✓ company added (demo)';
|
||||||
|
fb.className = 'feedback success';
|
||||||
|
}
|
||||||
|
setTimeout(() => { fb.className = 'feedback'; }, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetTradeForm() {
|
||||||
|
['trade-symbol','trade-shares','trade-price','trade-product',
|
||||||
|
'div-net','div-tax-amount','div-tax-rate','div-payment-date'].forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.value = '';
|
||||||
|
});
|
||||||
|
document.getElementById('trade-date').value = '';
|
||||||
|
document.getElementById('trade-type').value = '0';
|
||||||
|
document.querySelectorAll('.type-btn').forEach(b => b.classList.remove('active-buy','active-sell','active-div'));
|
||||||
|
document.querySelector('.type-btn[data-val="0"]').classList.add('active-buy');
|
||||||
|
document.getElementById('dividend-fields').classList.remove('visible');
|
||||||
|
document.getElementById('trade-feedback').className = 'feedback';
|
||||||
|
updateTradePreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCompanyForm() {
|
||||||
|
['co-symbol','co-currency-id','co-price','co-shares-out'].forEach(id => {
|
||||||
|
document.getElementById(id).value = '';
|
||||||
|
});
|
||||||
|
document.getElementById('company-feedback').className = 'feedback';
|
||||||
|
updateCompanyPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── COMPANY FORM PREVIEW HOOKS ───────────────────────────
|
||||||
|
['co-symbol','co-currency-id','co-price','co-shares-out','co-currency-code'].forEach(id => {
|
||||||
|
document.getElementById(id).addEventListener('input', updateCompanyPreview);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── INIT ─────────────────────────────────────────────────
|
||||||
|
loadPositions();
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
document.getElementById('trade-date').value = today;
|
||||||
|
updateTradePreview();
|
||||||
|
updateCompanyPreview();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
138
internal/website/static/index.html
Normal file
138
internal/website/static/index.html
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Portfolio — Samantha Friis</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=Fraunces:ital,wght@0,300;0,400;0,600;1,300;1,400&display=swap" rel="stylesheet"/>
|
||||||
|
<link rel="stylesheet" href="/styles/main">
|
||||||
|
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<a href="/" class="back">← back,</a>
|
||||||
|
<a href="/login" class="back"> login</a>
|
||||||
|
<p class="tag">// portfolio</p>
|
||||||
|
<h1>Investment<br/><span>Portfolio</span></h1>
|
||||||
|
<p class="header-sub">Equity positions · Personal research</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Health bar: polls every 60s, shows error on failure -->
|
||||||
|
<div class="status-bar"
|
||||||
|
hx-get="/health/fragment"
|
||||||
|
hx-trigger="load, every 60s"
|
||||||
|
hx-target="this"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-on::response-error="this.innerHTML = '<span class=\'status-dot error\'></span><span>backend unreachable</span><span class=\'status-spacer\'></span><button class=\'refresh-btn\' hx-get=\'/health/fragment\' hx-target=\'closest .status-bar\' hx-swap=\'innerHTML\' onclick=\'htmx.process(this)\'>↺ retry</button>'"
|
||||||
|
hx-on::send-error="this.innerHTML = '<span class=\'status-dot error\'></span><span>failed to connect</span><span class=\'status-spacer\'></span><button class=\'refresh-btn\' hx-get=\'/health/fragment\' hx-target=\'closest .status-bar\' hx-swap=\'innerHTML\' onclick=\'htmx.process(this)\'>↺ retry</button>'">
|
||||||
|
<span class="status-dot loading"></span>
|
||||||
|
<span>connecting to backend…</span>
|
||||||
|
<span class="status-spacer"></span>
|
||||||
|
<button class="refresh-btn"
|
||||||
|
hx-get="/health/fragment"
|
||||||
|
hx-target="closest .status-bar"
|
||||||
|
hx-swap="innerHTML">↺ refresh</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary cards -->
|
||||||
|
<div class="cards"
|
||||||
|
hx-get="/summary/fragment"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
<div class="card"><p class="card-label">Positions</p><p class="card-value dim">…</p></div>
|
||||||
|
<div class="card"><p class="card-label">Total Shares</p><p class="card-value dim">…</p></div>
|
||||||
|
<div class="card"><p class="card-label">Total Trades</p><p class="card-value dim">…</p></div>
|
||||||
|
<div class="card"><p class="card-label">Currencies</p><p class="card-value dim">…</p></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Positions table -->
|
||||||
|
<p class="section-label">// <span>positions</span></p>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Symbol</th><th>Currency</th><th>Shares</th>
|
||||||
|
<th>Cost Basis</th><th>Weight</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody hx-get="/positions/fragment"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<tr class="state-row"><td colspan="5">loading…</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Companies table -->
|
||||||
|
<p class="section-label">// <span>companies</span> — tracked universe</p>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Symbol</th><th>Currency</th><th>Price</th>
|
||||||
|
<th>Shares Outstanding</th><th>Market Cap</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody hx-get="/company/fragment"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<tr class="state-row"><td colspan="5">loading…</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Trades accordion: lazy-loads on open -->
|
||||||
|
<div class="trades-section">
|
||||||
|
<button class="trades-toggle"
|
||||||
|
aria-expanded="false"
|
||||||
|
onclick="this.setAttribute('aria-expanded', this.getAttribute('aria-expanded')==='true'?'false':'true'); this.nextElementSibling.classList.toggle('open')">
|
||||||
|
<span class="toggle-label">// <span>trade history</span> — all executed orders</span>
|
||||||
|
<span class="toggle-icon">▾</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="trades-body" id="tradesAccordion">
|
||||||
|
<div class="trades-inner">
|
||||||
|
|
||||||
|
<!-- Filter buttons swap only the tbody -->
|
||||||
|
<div class="trades-filter" id="tradeFilters">
|
||||||
|
<button class="filter-btn active"
|
||||||
|
hx-get="/trade/fragment"
|
||||||
|
hx-target="#tradesBody"
|
||||||
|
hx-swap="innerHTML">All</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="trades-count"
|
||||||
|
hx-get="/trade/count"
|
||||||
|
hx-trigger="load from:#tradesAccordion"
|
||||||
|
hx-swap="innerHTML">—</p>
|
||||||
|
|
||||||
|
<div class="trades-table-wrap">
|
||||||
|
<table class="trades-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Symbol</th><th>Asset</th><th>Currency</th>
|
||||||
|
<th>Date</th><th>Dir</th><th>Qty</th><th>Price</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="tradesBody"
|
||||||
|
hx-get="/trade/fragment"
|
||||||
|
hx-trigger="revealed"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<tr class="state-row"><td colspan="7">expand to load trades</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<a href="mailto:me@samantha42.xyz">me@samantha42.xyz</a>
|
||||||
|
<p class="footer-copy">© 2026 — All rights reserved</p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
224
internal/website/static/login.css
Normal file
224
internal/website/static/login.css
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--ink: #1a1714;
|
||||||
|
--paper: #f5f0e8;
|
||||||
|
--accent: #c4622d;
|
||||||
|
--muted: #8c8070;
|
||||||
|
--border: #d6cfc3;
|
||||||
|
--field-bg: #ece7dd;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: 'Space Mono', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grain overlay */
|
||||||
|
body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.04'/%3E%3C/svg%3E");
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
animation: rise 0.6s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rise {
|
||||||
|
from { opacity: 0; transform: translateY(18px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
display: inline-block;
|
||||||
|
font-family: 'Space Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
.back:hover { color: var(--ink); }
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
font-family: 'Space Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--accent);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-family: 'Fraunces', serif;
|
||||||
|
font-weight: 300;
|
||||||
|
font-size: clamp(36px, 8vw, 52px);
|
||||||
|
line-height: 1.05;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 span {
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-sub {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rule */
|
||||||
|
.rule {
|
||||||
|
width: 100%;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
margin: 32px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="password"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 11px 14px;
|
||||||
|
background: var(--field-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
font-family: 'Space Mono', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ink);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s, background 0.2s;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"]:focus,
|
||||||
|
input[type="password"]:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
input::placeholder { color: var(--muted); opacity: 0.7; }
|
||||||
|
|
||||||
|
.form-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 28px;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: var(--ink);
|
||||||
|
color: var(--paper);
|
||||||
|
font-family: 'Space Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border: none;
|
||||||
|
padding: 12px 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: background 0.2s, transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.btn-login:active { transform: translateY(0); }
|
||||||
|
|
||||||
|
.btn-login svg {
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.btn-login:hover svg { transform: translateX(3px); }
|
||||||
|
|
||||||
|
.forgot {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
border-bottom: 1px solid transparent;
|
||||||
|
transition: color 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
.forgot:hover { color: var(--ink); border-color: var(--ink); }
|
||||||
|
|
||||||
|
footer {
|
||||||
|
margin-top: 48px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 16px;
|
||||||
|
animation: rise 0.6s 0.15s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer a {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
footer a:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
.footer-copy {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--border);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Decorative corner mark */
|
||||||
|
.corner-mark {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
right: 28px;
|
||||||
|
font-family: 'Fraunces', serif;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--border);
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
67
internal/website/static/login.html
Normal file
67
internal/website/static/login.html
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Portfolio — Samantha Friis</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=Fraunces:ital,wght@0,300;0,400;0,600;1,300;1,400&display=swap" rel="stylesheet"/>
|
||||||
|
<link rel="stylesheet" href="/styles/login">
|
||||||
|
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<a href="/" class="back">← back</a>
|
||||||
|
<p class="tag">// portfolio</p>
|
||||||
|
<h1>Investment<br/><span>Portfolio</span></h1>
|
||||||
|
<p class="header-sub">Equity positions · Personal research</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="rule"></div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
hx-post="/auth/login"
|
||||||
|
hx-target="#login-response"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-indicator=".btn-login"
|
||||||
|
>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input type="text" id="username" name="username" placeholder="your handle" autocomplete="username" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Passphrase</label>
|
||||||
|
<input type="password" id="password" name="password" placeholder="········" autocomplete="current-password" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Auth Code</label>
|
||||||
|
<input type="text" id="auth-code" name="auth_code" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-footer">
|
||||||
|
<button class="btn-login" type="submit">
|
||||||
|
<span class="btn-label">
|
||||||
|
Enter
|
||||||
|
<svg width="14" height="10" viewBox="0 0 14 10" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M1 5H13M9 1L13 5L9 9" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="btn-spinner"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="login-response"></div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<a href="mailto:me@samantha42.xyz">me@samantha42.xyz</a>
|
||||||
|
<p class="footer-copy">© 2026 — All rights reserved</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="corner-mark">SF — 2026</p>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
138
internal/website/static/styles.css
Normal file
138
internal/website/static/styles.css
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #f5efe7;;
|
||||||
|
--surface: #efefef;
|
||||||
|
--surface2:#e0e0e0;
|
||||||
|
--border: #8c8c8c;
|
||||||
|
--border2: #acacac;
|
||||||
|
--text: #000000;
|
||||||
|
--muted: #5a5a68;
|
||||||
|
--muted2: #3d3d4a;
|
||||||
|
--accent: #c8a96e;
|
||||||
|
--up: #5aad85;
|
||||||
|
--down: #c96b6b;
|
||||||
|
--mono: 'Space Mono', monospace;
|
||||||
|
--serif: 'Fraunces', Georgia, serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
html { font-size: 15px; }
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container { max-width: 1080px; margin: 0 auto; padding: 3rem 2rem 5rem; }
|
||||||
|
|
||||||
|
/* ── Header ── */
|
||||||
|
header { margin-bottom: 3rem; border-bottom: 1px solid var(--border); padding-bottom: 2rem; }
|
||||||
|
.back { display: inline-block; color: var(--muted); text-decoration: none; font-size: 0.72rem; letter-spacing: 0.05em; margin-bottom: 1.8rem; transition: color 0.15s; }
|
||||||
|
.back:hover { color: var(--accent); }
|
||||||
|
.tag { font-size: 0.68rem; color: var(--accent); letter-spacing: 0.12em; margin-bottom: 0.6rem; }
|
||||||
|
h1 { font-family: var(--serif); font-size: clamp(2.4rem, 5vw, 3.6rem); font-weight: 300; line-height: 1.1; color: var(--text); margin-bottom: 0.75rem; }
|
||||||
|
h1 span { font-style: italic; color: var(--accent); }
|
||||||
|
.header-sub { font-size: 0.7rem; color: var(--muted); letter-spacing: 0.08em; }
|
||||||
|
|
||||||
|
/* ── Status bar ── */
|
||||||
|
.status-bar {
|
||||||
|
display: flex; align-items: center; gap: 0.6rem;
|
||||||
|
font-size: 0.65rem; color: var(--muted);
|
||||||
|
margin-bottom: 2rem; padding: 0.6rem 1rem;
|
||||||
|
background: var(--surface); border: 1px solid var(--border); border-radius: 5px;
|
||||||
|
}
|
||||||
|
.status-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; background: var(--muted2); }
|
||||||
|
.status-dot.ok { background: var(--up); }
|
||||||
|
.status-dot.loading { background: var(--accent); animation: blink 1s ease-in-out infinite; }
|
||||||
|
.status-dot.error { background: var(--down); }
|
||||||
|
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:.2} }
|
||||||
|
.status-sep { color: var(--muted2); margin: 0 0.25rem; }
|
||||||
|
.status-spacer { flex: 1; }
|
||||||
|
.refresh-btn {
|
||||||
|
background: none; border: 1px solid var(--border2); border-radius: 3px;
|
||||||
|
color: var(--muted); font-family: var(--mono); font-size: 0.62rem;
|
||||||
|
padding: 2px 9px; cursor: pointer; letter-spacing: 0.05em; transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.refresh-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
|
||||||
|
/* ── Summary cards ── */
|
||||||
|
.cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: 6px; overflow: hidden; margin-bottom: 2.5rem; }
|
||||||
|
.card { background: var(--surface); padding: 1.1rem 1.25rem; }
|
||||||
|
.card-label { font-size: 0.62rem; color: var(--muted); letter-spacing: 0.1em; text-transform: uppercase; margin-bottom: 0.45rem; }
|
||||||
|
.card-value { font-family: var(--serif); font-size: 1.65rem; font-weight: 300; color: var(--text); }
|
||||||
|
.card-value.dim { color: var(--muted2); font-size: 1rem; }
|
||||||
|
|
||||||
|
/* ── Section label ── */
|
||||||
|
.section-label { font-size: 0.62rem; color: var(--muted); letter-spacing: 0.1em; text-transform: uppercase; margin-bottom: 0.75rem; }
|
||||||
|
.section-label span { color: var(--accent); }
|
||||||
|
|
||||||
|
/* ── Tables ── */
|
||||||
|
.table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: 6px; margin-bottom: 2.5rem; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 0.75rem; }
|
||||||
|
thead tr { border-bottom: 1px solid var(--border2); background: var(--surface); }
|
||||||
|
th { color: var(--muted); font-weight: 400; font-size: 0.6rem; letter-spacing: 0.1em; text-transform: uppercase; padding: 0.75rem 1rem; text-align: left; white-space: nowrap; }
|
||||||
|
td { padding: 0.7rem 1rem; border-bottom: 1px solid var(--border); color: var(--text); white-space: nowrap; }
|
||||||
|
tbody tr:last-child td { border-bottom: none; }
|
||||||
|
tbody tr:hover td { background: rgba(200,169,110,0.03); }
|
||||||
|
|
||||||
|
.ticker { font-weight: 700; font-size: 0.72rem; letter-spacing: 0.04em; color: var(--text); margin-right: 6px; }
|
||||||
|
.currency-badge { display: inline-block; font-size: 0.58rem; padding: 1px 5px; background: var(--surface2); border: 1px solid var(--border2); border-radius: 3px; color: var(--muted); letter-spacing: 0.06em; vertical-align: middle; }
|
||||||
|
|
||||||
|
.weight-cell { min-width: 100px; }
|
||||||
|
.weight-bar-track { height: 2px; background: var(--border2); border-radius: 1px; margin-top: 5px; }
|
||||||
|
.weight-bar-fill { height: 2px; background: var(--accent); border-radius: 1px; }
|
||||||
|
|
||||||
|
.state-row td { color: var(--muted2); font-size: 0.7rem; text-align: center; padding: 2rem; border-bottom: none; }
|
||||||
|
|
||||||
|
/* ── Trades accordion ── */
|
||||||
|
.trades-section { margin-top: 0; }
|
||||||
|
.trades-toggle {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
cursor: pointer; background: none; border: none;
|
||||||
|
border-top: 1px solid var(--border); border-bottom: 1px solid var(--border);
|
||||||
|
width: 100%; padding: 0.85rem 0; color: inherit;
|
||||||
|
font-family: var(--mono); font-size: 0.62rem; letter-spacing: 0.1em;
|
||||||
|
text-align: left; user-select: none; text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.trades-toggle:hover .toggle-label { color: var(--accent); }
|
||||||
|
.toggle-label { color: var(--muted); transition: color 0.15s; }
|
||||||
|
.toggle-label span { color: var(--accent); }
|
||||||
|
.toggle-icon { color: var(--muted2); font-size: 0.9rem; transition: transform 0.25s ease; }
|
||||||
|
.trades-toggle[aria-expanded="true"] .toggle-icon { transform: rotate(180deg); }
|
||||||
|
.trades-body { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 0.3s ease; }
|
||||||
|
.trades-body.open { grid-template-rows: 1fr; }
|
||||||
|
.trades-inner { overflow: hidden; }
|
||||||
|
|
||||||
|
.trades-filter { display: flex; gap: 0.4rem; flex-wrap: wrap; padding: 1rem 0 0.75rem; }
|
||||||
|
.filter-btn {
|
||||||
|
background: none; border: 1px solid var(--border2); border-radius: 3px;
|
||||||
|
color: var(--muted); font-family: var(--mono); font-size: 0.62rem;
|
||||||
|
letter-spacing: 0.08em; padding: 3px 10px; cursor: pointer;
|
||||||
|
transition: all 0.15s; text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.filter-btn:hover, .filter-btn.active { border-color: var(--accent); color: var(--accent); background: rgba(200,169,110,0.06); }
|
||||||
|
|
||||||
|
.trades-count { font-size: 0.62rem; color: var(--muted2); letter-spacing: 0.06em; padding-bottom: 0.5rem; }
|
||||||
|
|
||||||
|
.trades-table-wrap { overflow-x: auto; padding-bottom: 1.5rem; border: 1px solid var(--border); border-radius: 6px; }
|
||||||
|
.trades-table { width: 100%; border-collapse: collapse; font-size: 0.73rem; }
|
||||||
|
.trades-table thead tr { border-bottom: 1px solid var(--border2); background: var(--surface); }
|
||||||
|
.trades-table th { color: var(--muted); font-weight: 400; letter-spacing: 0.1em; font-size: 0.6rem; text-transform: uppercase; padding: 0.6rem 0.9rem; text-align: left; white-space: nowrap; }
|
||||||
|
.trades-table td { padding: 0.6rem 0.9rem; border-bottom: 1px solid var(--border); white-space: nowrap; color: var(--text); }
|
||||||
|
.trades-table tbody tr:last-child td { border-bottom: none; }
|
||||||
|
.trades-table tbody tr:hover td { background: rgba(200,169,110,0.03); }
|
||||||
|
.trades-table tr.hidden-row { display: none; }
|
||||||
|
|
||||||
|
.dir-buy { color: var(--up); }
|
||||||
|
.dir-sell { color: var(--down); }
|
||||||
|
|
||||||
|
.trade-code { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 0.6rem; background: var(--surface2); border: 1px solid var(--border2); color: var(--muted); letter-spacing: 0.05em; }
|
||||||
|
.trade-ticker { font-weight: 700; color: var(--text); letter-spacing: 0.03em; }
|
||||||
|
|
||||||
|
/* ── Footer ── */
|
||||||
|
footer { margin-top: 4rem; padding-top: 1.5rem; border-top: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
footer a { color: var(--muted); font-size: 0.68rem; text-decoration: none; letter-spacing: 0.04em; transition: color 0.15s; }
|
||||||
|
footer a:hover { color: var(--accent); }
|
||||||
|
.footer-copy { font-size: 0.65rem; color: var(--muted2); letter-spacing: 0.06em; }
|
||||||
160
main.go
160
main.go
@@ -2,16 +2,17 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"Portifolio/internal/database"
|
"Portifolio/internal/database"
|
||||||
"Portifolio/internal/handlers"
|
"Portifolio/internal/middleware"
|
||||||
"Portifolio/internal/shell"
|
"Portifolio/internal/service"
|
||||||
"bufio"
|
"Portifolio/internal/website"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
)
|
)
|
||||||
@@ -35,6 +36,57 @@ func corsMiddleware(next http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func EnvCheck() error {
|
||||||
|
|
||||||
|
if os.Getenv("username") == "" {
|
||||||
|
return fmt.Errorf("Missing: username")
|
||||||
|
}
|
||||||
|
if os.Getenv("password") == "" {
|
||||||
|
return fmt.Errorf("Missing: password")
|
||||||
|
}
|
||||||
|
if os.Getenv("AUTH_TOTP_SECRET") == "" {
|
||||||
|
return fmt.Errorf("Missing: AUTH_TOTP_SECRET")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterRoutes(mux *http.ServeMux, db *sql.DB) {
|
||||||
|
svc := service.New(db) // ← create it here, once
|
||||||
|
|
||||||
|
// ── Website (HTMX fragments) ──────────────────────────────
|
||||||
|
mux.HandleFunc("/", website.Getsite())
|
||||||
|
mux.HandleFunc("/styles/main", website.GetstylesheetMain())
|
||||||
|
mux.HandleFunc("/styles/login", website.GetstylesheetLogin())
|
||||||
|
mux.HandleFunc("/styles/admin", website.GetstylesheetAdmin())
|
||||||
|
mux.Handle("/admin", middleware.RequireSession(website.GetAdmin()))
|
||||||
|
mux.HandleFunc("/login", website.GetAdminLogin())
|
||||||
|
mux.HandleFunc("POST /auth/login", website.LoginHandler())
|
||||||
|
mux.HandleFunc("/health/fragment", website.HealthFragment(svc))
|
||||||
|
mux.HandleFunc("/positions/fragment", website.PositionsFragment(svc))
|
||||||
|
mux.HandleFunc("/company/fragment", website.CompanyFragment(svc))
|
||||||
|
mux.HandleFunc("/summary/fragment", website.SummaryFragment(svc))
|
||||||
|
mux.HandleFunc("/trade/fragment", website.TradeFragment(svc))
|
||||||
|
mux.HandleFunc("/trade/count", website.TradeCount(svc))
|
||||||
|
|
||||||
|
// ── API (JSON) ────────────────────────────────────────────
|
||||||
|
mux.HandleFunc("/health", svc.HealthHandler())
|
||||||
|
mux.HandleFunc("POST /trade/add", svc.AddTradeHandler())
|
||||||
|
mux.HandleFunc("GET /trade/list", svc.GetTradeListHandler())
|
||||||
|
//mux.HandleFunc("GET /trade/search", svc.SearchTradeHandler(svc))
|
||||||
|
mux.HandleFunc("GET /positions/list", svc.GetPositionListHandler())
|
||||||
|
//mux.HandleFunc("GET /positions/closed/list", svc.GetClosedPositionListHandler(svc))
|
||||||
|
//mux.HandleFunc("GET /positions/closed/search", svc.SearchClosedPositionsHandler(svc))
|
||||||
|
mux.HandleFunc("POST /company/add", svc.AddCompanyHandler())
|
||||||
|
mux.HandleFunc("GET /company/list", svc.GetCompaniesHandler())
|
||||||
|
mux.HandleFunc("GET /company/revenue/categories", svc.GetCompanyRevenueCategories())
|
||||||
|
//mux.HandleFunc("POST /company/S-O/add", svc.AddSharesOutstandingHandler(svc))
|
||||||
|
//mux.HandleFunc("GET /company/S-O/list", svc.GetSharesOutstandingHandler(svc))
|
||||||
|
mux.HandleFunc("GET /currency/list", svc.GetCurrenciesHandler())
|
||||||
|
mux.HandleFunc("POST /currency/add", svc.AddCurrencyHandler())
|
||||||
|
mux.HandleFunc("POST /add/revenue/entry", svc.AddRevenueEntryHandler())
|
||||||
|
mux.HandleFunc("POST /api/v1/revenue/add", svc.AddRevenueEntryHandler())
|
||||||
|
}
|
||||||
func main() {
|
func main() {
|
||||||
// --- CLI flags ---
|
// --- CLI flags ---
|
||||||
port := flag.String("port", "8080", "Port to listen on")
|
port := flag.String("port", "8080", "Port to listen on")
|
||||||
@@ -55,98 +107,22 @@ func main() {
|
|||||||
log.Fatal("Failed to connect to database:", err)
|
log.Fatal("Failed to connect to database:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = godotenv.Load(".env")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Error loading .env file")
|
||||||
|
}
|
||||||
|
err = EnvCheck()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Env vars not found:", err)
|
||||||
|
}
|
||||||
|
|
||||||
database.InitDB(db)
|
database.InitDB(db)
|
||||||
fmt.Println("Connected to SQLite database")
|
fmt.Println("Connected to SQLite database")
|
||||||
|
|
||||||
// --- Routes ---
|
// --- Routes ---
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
RegisterRoutes(mux, db)
|
||||||
|
|
||||||
mux.HandleFunc("/health", handlers.HealthHandler(db))
|
fmt.Println("Server running on :8080")
|
||||||
|
http.ListenAndServe(addr, corsMiddleware(mux))
|
||||||
mux.HandleFunc("POST /trade/add", handlers.AddTradeHandler(db))
|
|
||||||
mux.HandleFunc("GET /trade/list", handlers.GetTradeListHandler(db))
|
|
||||||
mux.HandleFunc("GET /trade/search", handlers.GetTradeListHandler(db)) // new
|
|
||||||
//Positions
|
|
||||||
mux.HandleFunc("GET /positions/list", handlers.GetPositionListHandler(db))
|
|
||||||
mux.HandleFunc("GET /positions/closed/list", handlers.GetPositionListHandler(db)) // new
|
|
||||||
mux.HandleFunc("GET /positions/closed/search", handlers.GetTradeListHandler(db)) // new
|
|
||||||
// Company
|
|
||||||
mux.HandleFunc("POST /company/add", handlers.AddCompanyHandler(db))
|
|
||||||
mux.HandleFunc("GET /company/list", handlers.GetCompaniesHandler(db))
|
|
||||||
mux.HandleFunc("GET /company/revenue/categories", handlers.GetCompanyRevenueCategories(db))
|
|
||||||
mux.HandleFunc("POST /company/S-O/add", handlers.GetCompaniesHandler(db)) // new
|
|
||||||
mux.HandleFunc("GET /company/S-O/list", handlers.GetCompaniesHandler(db)) // new
|
|
||||||
|
|
||||||
mux.HandleFunc("GET /currency/list", handlers.GetCurrenciesHandler(db))
|
|
||||||
mux.HandleFunc("POST /currency/add", handlers.AddCurrencyHandler(db))
|
|
||||||
|
|
||||||
mux.HandleFunc("POST /add/revenue/entry", handlers.AddRevenueEntryHandler(db))
|
|
||||||
mux.HandleFunc("POST /api/v1/revenue/add", handlers.AddRevenueEntryHandler(db))
|
|
||||||
|
|
||||||
// --- Start server ---
|
|
||||||
fmt.Printf("Server running on %s\n", addr)
|
|
||||||
go func() {
|
|
||||||
log.Fatal(http.ListenAndServe(addr, corsMiddleware(mux)))
|
|
||||||
}()
|
|
||||||
|
|
||||||
runShell(db)
|
|
||||||
}
|
|
||||||
|
|
||||||
func runShell(db *sql.DB) {
|
|
||||||
scanner := bufio.NewScanner(os.Stdin)
|
|
||||||
fmt.Println("\nShell ready. Type 'help' for commands.")
|
|
||||||
|
|
||||||
for {
|
|
||||||
fmt.Print("> ")
|
|
||||||
if !scanner.Scan() {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := strings.Fields(scanner.Text())
|
|
||||||
if len(parts) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
switch parts[0] {
|
|
||||||
// Company
|
|
||||||
case "add-company":
|
|
||||||
shell.AddCompany(scanner, db)
|
|
||||||
case "list-companies":
|
|
||||||
shell.ListCompanies(db)
|
|
||||||
|
|
||||||
// Currency
|
|
||||||
case "add-currency":
|
|
||||||
shell.AddCurrency(scanner, db)
|
|
||||||
case "list-currency":
|
|
||||||
shell.ListCurrencies(db)
|
|
||||||
|
|
||||||
// Revenue
|
|
||||||
case "add-revenue":
|
|
||||||
shell.AddRevenue(scanner, db)
|
|
||||||
case "list-revenue":
|
|
||||||
shell.ListRevenue(scanner, db)
|
|
||||||
|
|
||||||
case "help":
|
|
||||||
fmt.Println("\nCommands:")
|
|
||||||
fmt.Println(" --- Company ---")
|
|
||||||
fmt.Println(" add-company add a new company")
|
|
||||||
fmt.Println(" list-companies list all companies")
|
|
||||||
fmt.Println(" --- Currency ---")
|
|
||||||
fmt.Println(" add-currency add a new currency")
|
|
||||||
fmt.Println(" list-currency list all currencies")
|
|
||||||
fmt.Println(" --- Revenue ---")
|
|
||||||
fmt.Println(" add-revenue add a revenue entry interactively")
|
|
||||||
fmt.Println(" list-revenue list revenue for a company/period")
|
|
||||||
fmt.Println(" sum-revenue sum revenue across periods")
|
|
||||||
fmt.Println(" --- Other ---")
|
|
||||||
fmt.Println(" exit quit")
|
|
||||||
|
|
||||||
case "exit":
|
|
||||||
fmt.Println("Bye!")
|
|
||||||
os.Exit(0)
|
|
||||||
|
|
||||||
default:
|
|
||||||
fmt.Printf("Unknown command: %s. Type 'help' for commands.\n", parts[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user