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
+360
View File
@@ -0,0 +1,360 @@
package handlers
import (
"database/sql"
"encoding/json"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
)
// JournalHandler hanterar totaljournal och transaktionsdetaljer
type JournalHandler struct {
ledgerDB *sql.DB
}
func NewJournalHandler(ledgerDB *sql.DB) *JournalHandler {
return &JournalHandler{ledgerDB: ledgerDB}
}
// JournalEntry representerar en verifikation
type JournalEntry struct {
ID string `json:"id"`
EntryNumber int `json:"entry_number"`
Description string `json:"description"`
EntryDate time.Time `json:"entry_date"`
CreatedAt time.Time `json:"created_at"`
CreatedBy string `json:"created_by"`
Period string `json:"period"`
FiscalYear int `json:"fiscal_year"`
Status string `json:"status"`
Lines []JournalLine `json:"lines,omitempty"`
}
// JournalLine representerar en bokföringsrad
type JournalLine struct {
ID string `json:"id"`
AccountID string `json:"account_id"`
AccountCode string `json:"account_code"`
AccountName string `json:"account_name"`
Description string `json:"description"`
Debit float64 `json:"debit"`
Credit float64 `json:"credit"`
}
// AccountTransactions representerar alla transaktioner för ett konto
type AccountTransactions struct {
AccountCode string `json:"account_code"`
AccountName string `json:"account_name"`
AccountType string `json:"account_type"`
OpeningBalance float64 `json:"opening_balance"`
Transactions []JournalEntry `json:"transactions"`
ClosingBalance float64 `json:"closing_balance"`
TotalDebit float64 `json:"total_debit"`
TotalCredit float64 `json:"total_credit"`
}
// GetJournalEntries returnerar alla verifikationer med pagination
func (h *JournalHandler) GetJournalEntries(w http.ResponseWriter, r *http.Request) {
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit < 1 || limit > 100 {
limit = 50
}
offset := (page - 1) * limit
accountFilter := r.URL.Query().Get("account")
periodFilter := r.URL.Query().Get("period")
query := `
SELECT id, entry_number, description, entry_date, created_at, created_by, period, fiscal_year, status
FROM journal_entries
WHERE 1=1
`
args := []interface{}{}
argCount := 0
if accountFilter != "" {
argCount++
query += ` AND EXISTS (
SELECT 1 FROM journal_lines jl
JOIN accounts a ON jl.account_id = a.id
WHERE jl.journal_entry_id = journal_entries.id AND a.code = $` + strconv.Itoa(argCount) + `
)`
args = append(args, accountFilter)
}
if periodFilter != "" {
argCount++
query += ` AND period = $` + strconv.Itoa(argCount)
args = append(args, periodFilter)
}
query += ` ORDER BY entry_date DESC, entry_number DESC LIMIT $` + strconv.Itoa(argCount+1) + ` OFFSET $` + strconv.Itoa(argCount+2)
args = append(args, limit, offset)
rows, err := h.ledgerDB.Query(query, args...)
if err != nil {
http.Error(w, `{"error":"failed to fetch journal entries"}`, http.StatusInternalServerError)
return
}
defer rows.Close()
entries := []JournalEntry{}
for rows.Next() {
var e JournalEntry
rows.Scan(&e.ID, &e.EntryNumber, &e.Description, &e.EntryDate, &e.CreatedAt, &e.CreatedBy, &e.Period, &e.FiscalYear, &e.Status)
entries = append(entries, e)
}
// Hämta total count
var total int
countQuery := `SELECT COUNT(*) FROM journal_entries WHERE 1=1`
if accountFilter != "" {
countQuery += ` AND EXISTS (
SELECT 1 FROM journal_lines jl
JOIN accounts a ON jl.account_id = a.id
WHERE jl.journal_entry_id = journal_entries.id AND a.code = '` + accountFilter + `'
)`
}
if periodFilter != "" {
countQuery += ` AND period = '` + periodFilter + `'`
}
h.ledgerDB.QueryRow(countQuery).Scan(&total)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"entries": entries,
"total": total,
"page": page,
"limit": limit,
"pages": (total + limit - 1) / limit,
})
}
// GetJournalEntry returnerar en specifik verifikation med alla rader
func (h *JournalHandler) GetJournalEntry(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var entry JournalEntry
err := h.ledgerDB.QueryRow(`
SELECT id, entry_number, description, entry_date, created_at, created_by, period, fiscal_year, status
FROM journal_entries WHERE id = $1
`, id).Scan(&entry.ID, &entry.EntryNumber, &entry.Description, &entry.EntryDate, &entry.CreatedAt, &entry.CreatedBy, &entry.Period, &entry.FiscalYear, &entry.Status)
if err != nil {
if err == sql.ErrNoRows {
http.Error(w, `{"error":"journal entry not found"}`, http.StatusNotFound)
return
}
http.Error(w, `{"error":"failed to fetch journal entry"}`, http.StatusInternalServerError)
return
}
// Hämta alla rader
rows, err := h.ledgerDB.Query(`
SELECT jl.id, jl.account_id, a.code, a.name, jl.description, jl.debit, jl.credit
FROM journal_lines jl
JOIN accounts a ON jl.account_id = a.id
WHERE jl.journal_entry_id = $1
ORDER BY jl.debit DESC, jl.credit DESC
`, id)
if err != nil {
http.Error(w, `{"error":"failed to fetch journal lines"}`, http.StatusInternalServerError)
return
}
defer rows.Close()
for rows.Next() {
var line JournalLine
rows.Scan(&line.ID, &line.AccountID, &line.AccountCode, &line.AccountName, &line.Description, &line.Debit, &line.Credit)
entry.Lines = append(entry.Lines, line)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(entry)
}
// GetAccountTransactions returnerar alla transaktioner för ett specifikt konto
func (h *JournalHandler) GetAccountTransactions(w http.ResponseWriter, r *http.Request) {
accountCode := chi.URLParam(r, "code")
// Hämta kontoinformation
var accountID, accountName, accountType string
err := h.ledgerDB.QueryRow(`
SELECT id, name, account_type FROM accounts WHERE code = $1
`, accountCode).Scan(&accountID, &accountName, &accountType)
if err != nil {
if err == sql.ErrNoRows {
http.Error(w, `{"error":"account not found"}`, http.StatusNotFound)
return
}
http.Error(w, `{"error":"failed to fetch account"}`, http.StatusInternalServerError)
return
}
// Beräkna balans
var totalDebit, totalCredit float64
h.ledgerDB.QueryRow(`
SELECT COALESCE(SUM(debit), 0), COALESCE(SUM(credit), 0)
FROM journal_lines WHERE account_id = $1
`, accountID).Scan(&totalDebit, &totalCredit)
balance := totalDebit - totalCredit
if accountType != "Asset" && accountType != "Expense" {
balance = totalCredit - totalDebit
}
// Hämta alla transaktioner för detta konto
rows, err := h.ledgerDB.Query(`
SELECT je.id, je.entry_number, je.description, je.entry_date, je.period, je.status,
jl.description as line_description, jl.debit, jl.credit
FROM journal_lines jl
JOIN journal_entries je ON jl.journal_entry_id = je.id
WHERE jl.account_id = $1
ORDER BY je.entry_date DESC, je.entry_number DESC
LIMIT 100
`, accountID)
if err != nil {
http.Error(w, `{"error":"failed to fetch transactions"}`, http.StatusInternalServerError)
return
}
defer rows.Close()
type Transaction struct {
JournalEntryID string `json:"journal_entry_id"`
EntryNumber int `json:"entry_number"`
Description string `json:"description"`
EntryDate time.Time `json:"entry_date"`
Period string `json:"period"`
Status string `json:"status"`
LineDescription string `json:"line_description"`
Debit float64 `json:"debit"`
Credit float64 `json:"credit"`
}
transactions := []Transaction{}
for rows.Next() {
var t Transaction
rows.Scan(&t.JournalEntryID, &t.EntryNumber, &t.Description, &t.EntryDate, &t.Period, &t.Status, &t.LineDescription, &t.Debit, &t.Credit)
transactions = append(transactions, t)
}
result := AccountTransactions{
AccountCode: accountCode,
AccountName: accountName,
AccountType: accountType,
ClosingBalance: balance,
TotalDebit: totalDebit,
TotalCredit: totalCredit,
}
// Konvertera till JournalEntry-format för frontend
for _, t := range transactions {
entry := JournalEntry{
ID: t.JournalEntryID,
EntryNumber: t.EntryNumber,
Description: t.Description,
EntryDate: t.EntryDate,
Period: t.Period,
Status: t.Status,
Lines: []JournalLine{{
Description: t.LineDescription,
Debit: t.Debit,
Credit: t.Credit,
}},
}
result.Transactions = append(result.Transactions, entry)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
}
// DrillDownRequest används för att söka i totaljournalen
type DrillDownRequest struct {
AccountCode string `json:"account_code,omitempty"`
Period string `json:"period,omitempty"`
DateFrom string `json:"date_from,omitempty"`
DateTo string `json:"date_to,omitempty"`
MinAmount float64 `json:"min_amount,omitempty"`
MaxAmount float64 `json:"max_amount,omitempty"`
}
// PostDrillDown hanterar avancerade sökningar i totaljournalen
func (h *JournalHandler) PostDrillDown(w http.ResponseWriter, r *http.Request) {
var req DrillDownRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
query := `
SELECT DISTINCT je.id, je.entry_number, je.description, je.entry_date, je.created_at, je.created_by, je.period, je.fiscal_year, je.status
FROM journal_entries je
JOIN journal_lines jl ON je.id = jl.journal_entry_id
JOIN accounts a ON jl.account_id = a.id
WHERE 1=1
`
args := []interface{}{}
argCount := 0
if req.AccountCode != "" {
argCount++
query += ` AND a.code = $` + strconv.Itoa(argCount)
args = append(args, req.AccountCode)
}
if req.Period != "" {
argCount++
query += ` AND je.period = $` + strconv.Itoa(argCount)
args = append(args, req.Period)
}
if req.DateFrom != "" {
argCount++
query += ` AND je.entry_date >= $` + strconv.Itoa(argCount)
args = append(args, req.DateFrom)
}
if req.DateTo != "" {
argCount++
query += ` AND je.entry_date <= $` + strconv.Itoa(argCount)
args = append(args, req.DateTo)
}
if req.MinAmount > 0 {
argCount++
query += ` AND (jl.debit >= $` + strconv.Itoa(argCount) + ` OR jl.credit >= $` + strconv.Itoa(argCount) + `)`
args = append(args, req.MinAmount)
}
query += ` ORDER BY je.entry_date DESC LIMIT 100`
rows, err := h.ledgerDB.Query(query, args...)
if err != nil {
http.Error(w, `{"error":"failed to search journal"}`, http.StatusInternalServerError)
return
}
defer rows.Close()
entries := []JournalEntry{}
for rows.Next() {
var e JournalEntry
rows.Scan(&e.ID, &e.EntryNumber, &e.Description, &e.EntryDate, &e.CreatedAt, &e.CreatedBy, &e.Period, &e.FiscalYear, &e.Status)
entries = append(entries, e)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"entries": entries,
"total": len(entries),
"filters": req,
})
}