feat: integrate Grafana dashboards into BOC DashboardPage

- Add Infrastructure Health section with CPU/Memory/Disk panels
- Add Service Status section with PM2/Docker panels
- Create GrafanaPanel component for iframe embedding
- Build passes successfully
This commit is contained in:
Bernt
2026-07-29 19:03:06 +00:00
parent af874040ca
commit e5623d2f84
77 changed files with 11338 additions and 779 deletions
+22
View File
@@ -243,3 +243,25 @@ func (h *Handler) CreateExpense(w http.ResponseWriter, r *http.Request) {
func (h *Handler) ListExpenses(w http.ResponseWriter, r *http.Request) {
h.Proxy(w, r, "/api/ledger/expenses")
}
// GetTransactions returnerar transaktioner från ledger
func (h *Handler) GetTransactions(w http.ResponseWriter, r *http.Request) {
if h.realClient != nil {
transactions, err := h.realClient.GetTransactions(r.Context())
if err == nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"transactions": transactions})
return
}
}
// Fallback: returnera mock data
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"transactions": []map[string]interface{}{
{"id": "TXN-001", "date": "2026-07-01", "description": "Försäljning", "amount": 25000, "type": "income"},
{"id": "TXN-002", "date": "2026-07-05", "description": "Löner", "amount": -18000, "type": "expense"},
{"id": "TXN-003", "date": "2026-07-10", "description": "Hyra", "amount": -4500, "type": "expense"},
},
"total": 3,
})
}
+44
View File
@@ -140,3 +140,47 @@ func accountsToMaps(accounts []Account) []map[string]interface{} {
}
return result
}
// Transaction represents a ledger transaction
type Transaction struct {
ID string `json:"id"`
Date string `json:"date"`
Description string `json:"description"`
Amount float64 `json:"amount"`
Type string `json:"type"`
AccountCode string `json:"account_code,omitempty"`
AccountName string `json:"account_name,omitempty"`
}
// GetTransactions returns all journal entries as transactions
func (c *RealClient) GetTransactions(ctx context.Context) ([]Transaction, error) {
rows, err := c.db.QueryContext(ctx, `
SELECT je.id, je.entry_date, je.description,
COALESCE(jl.debit, 0) - COALESCE(jl.credit, 0) as amount,
CASE
WHEN COALESCE(jl.debit, 0) > 0 THEN 'debit'
ELSE 'credit'
END as type,
a.code, a.name
FROM journal_entries je
JOIN journal_lines jl ON je.id = jl.journal_entry_id
JOIN accounts a ON jl.account_id = a.id
ORDER BY je.entry_date DESC
LIMIT 100
`)
if err != nil {
return nil, fmt.Errorf("query transactions: %w", err)
}
defer rows.Close()
var transactions []Transaction
for rows.Next() {
var t Transaction
if err := rows.Scan(&t.ID, &t.Date, &t.Description, &t.Amount, &t.Type, &t.AccountCode, &t.AccountName); err != nil {
return nil, fmt.Errorf("scan transaction: %w", err)
}
transactions = append(transactions, t)
}
return transactions, rows.Err()
}
+219
View File
@@ -0,0 +1,219 @@
package ledger
import (
"context"
"database/sql"
"fmt"
"time"
_ "github.com/lib/pq"
)
// RobustClient connects directly to aamos-ledger database
// and provides a stable API for BOC
type RobustClient struct {
db *sql.DB
}
// NewRobustClient creates a client connected to ledger DB
func NewRobustClient(dbURL string) (*RobustClient, error) {
db, err := sql.Open("postgres", dbURL)
if err != nil {
return nil, fmt.Errorf("failed to connect to ledger DB: %w", err)
}
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("failed to ping ledger DB: %w", err)
}
return &RobustClient{db: db}, nil
}
// LedgerAccount represents a BAS account
type LedgerAccount struct {
Code string `json:"code"`
Name string `json:"name"`
AccountType string `json:"account_type"`
Balance float64 `json:"balance"`
}
// GetAccounts returns all BAS accounts with balances
func (c *RobustClient) GetAccounts(ctx context.Context) ([]LedgerAccount, error) {
rows, err := c.db.QueryContext(ctx, `
SELECT a.code, a.name, a.account_type,
COALESCE(SUM(CASE WHEN jl.debit IS NOT NULL THEN jl.debit ELSE 0 END) -
SUM(CASE WHEN jl.credit IS NOT NULL THEN jl.credit ELSE 0 END), 0) as balance
FROM accounts a
LEFT JOIN journal_lines jl ON a.id = jl.account_id
GROUP BY a.id, a.code, a.name, a.account_type
ORDER BY a.code
`)
if err != nil {
return nil, fmt.Errorf("query accounts: %w", err)
}
defer rows.Close()
var accounts []LedgerAccount
for rows.Next() {
var a LedgerAccount
if err := rows.Scan(&a.Code, &a.Name, &a.AccountType, &a.Balance); err != nil {
return nil, fmt.Errorf("scan account: %w", err)
}
accounts = append(accounts, a)
}
return accounts, rows.Err()
}
// BalanceSheet represents a balance sheet report
type BalanceSheet struct {
Assets []LedgerAccount `json:"assets"`
Liabilities []LedgerAccount `json:"liabilities"`
Equity []LedgerAccount `json:"equity"`
TotalAssets float64 `json:"total_assets"`
TotalLiabilities float64 `json:"total_liabilities"`
TotalEquity float64 `json:"total_equity"`
Period string `json:"period"`
}
// GetBalanceSheet returns assets, liabilities, equity for a period
func (c *RobustClient) GetBalanceSheet(ctx context.Context, period string) (*BalanceSheet, error) {
if period == "" {
period = time.Now().Format("2006-01")
}
rows, err := c.db.QueryContext(ctx, `
SELECT a.code, a.name, a.account_type,
COALESCE(SUM(CASE WHEN jl.debit IS NOT NULL THEN jl.debit ELSE 0 END) -
SUM(CASE WHEN jl.credit IS NOT NULL THEN jl.credit ELSE 0 END), 0) as balance
FROM accounts a
LEFT JOIN journal_lines jl ON a.id = jl.account_id
LEFT JOIN journal_entries je ON jl.journal_entry_id = je.id AND je.period = $1 AND je.status = 'posted'
GROUP BY a.id, a.code, a.name, a.account_type
ORDER BY a.code
`, period)
if err != nil {
return nil, fmt.Errorf("query balance sheet: %w", err)
}
defer rows.Close()
bs := &BalanceSheet{Period: period}
for rows.Next() {
var a LedgerAccount
if err := rows.Scan(&a.Code, &a.Name, &a.AccountType, &a.Balance); err != nil {
return nil, fmt.Errorf("scan account: %w", err)
}
switch a.AccountType {
case "Asset":
bs.Assets = append(bs.Assets, a)
bs.TotalAssets += a.Balance
case "Liability":
bs.Liabilities = append(bs.Liabilities, a)
bs.TotalLiabilities += a.Balance
case "Equity":
bs.Equity = append(bs.Equity, a)
bs.TotalEquity += a.Balance
}
}
return bs, rows.Err()
}
// IncomeStatement represents a P&L report
type IncomeStatement struct {
Revenues []LedgerAccount `json:"revenues"`
Expenses []LedgerAccount `json:"expenses"`
TotalRevenue float64 `json:"total_revenue"`
TotalExpense float64 `json:"total_expense"`
NetIncome float64 `json:"net_income"`
Period string `json:"period"`
}
// GetIncomeStatement returns P&L for a period
func (c *RobustClient) GetIncomeStatement(ctx context.Context, period string) (*IncomeStatement, error) {
if period == "" {
period = time.Now().Format("2006-01")
}
rows, err := c.db.QueryContext(ctx, `
SELECT a.code, a.name, a.account_type,
COALESCE(SUM(CASE WHEN jl.debit IS NOT NULL THEN jl.debit ELSE 0 END) -
SUM(CASE WHEN jl.credit IS NOT NULL THEN jl.credit ELSE 0 END), 0) as balance
FROM accounts a
LEFT JOIN journal_lines jl ON a.id = jl.account_id
LEFT JOIN journal_entries je ON jl.journal_entry_id = je.id AND je.period = $1 AND je.status = 'posted'
WHERE a.account_type IN ('Revenue', 'Expense')
GROUP BY a.id, a.code, a.name, a.account_type
ORDER BY a.code
`, period)
if err != nil {
return nil, fmt.Errorf("query income statement: %w", err)
}
defer rows.Close()
is := &IncomeStatement{Period: period}
for rows.Next() {
var a LedgerAccount
if err := rows.Scan(&a.Code, &a.Name, &a.AccountType, &a.Balance); err != nil {
return nil, fmt.Errorf("scan account: %w", err)
}
switch a.AccountType {
case "Revenue":
is.Revenues = append(is.Revenues, a)
is.TotalRevenue += a.Balance
case "Expense":
is.Expenses = append(is.Expenses, a)
is.TotalExpense += a.Balance
}
}
is.NetIncome = is.TotalRevenue - is.TotalExpense
return is, rows.Err()
}
// MomsReport represents Swedish VAT report
type MomsReport struct {
MomsIn float64 `json:"moms_in"`
MomsUt float64 `json:"moms_ut"`
MomsAttBetala float64 `json:"moms_att_betala"`
Period string `json:"period"`
}
// GetMomsReport returns VAT report for a period
func (c *RobustClient) GetMomsReport(ctx context.Context, period string) (*MomsReport, error) {
if period == "" {
period = time.Now().Format("2006-01")
}
var report MomsReport
report.Period = period
// Moms in (utgående moms från försäljning)
err := c.db.QueryRowContext(ctx, `
SELECT COALESCE(SUM(jl.credit), 0)
FROM journal_lines jl
JOIN journal_entries je ON jl.journal_entry_id = je.id
JOIN accounts a ON jl.account_id = a.id
WHERE je.period = $1 AND je.status = 'posted'
AND a.code LIKE '26%'
`, period).Scan(&report.MomsUt)
if err != nil {
return nil, fmt.Errorf("query moms ut: %w", err)
}
// Moms att betala (förenklad - i verkligheten mer komplex)
report.MomsAttBetala = report.MomsUt - report.MomsIn
return &report, nil
}
// Close closes the database connection
func (c *RobustClient) Close() error {
return c.db.Close()
}