e5623d2f84
- 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
550 lines
16 KiB
Go
550 lines
16 KiB
Go
package briefing
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// RealBriefingEngine använder riktig data från BOC och ledger
|
|
type RealBriefingEngine struct {
|
|
bocDB *sql.DB
|
|
ledgerDB *sql.DB
|
|
}
|
|
|
|
func NewRealBriefingEngine(bocDB, ledgerDB *sql.DB) *RealBriefingEngine {
|
|
return &RealBriefingEngine{
|
|
bocDB: bocDB,
|
|
ledgerDB: ledgerDB,
|
|
}
|
|
}
|
|
|
|
// RealDailyBriefing byggd på riktig data
|
|
type RealDailyBriefing struct {
|
|
User UserSummary `json:"user"`
|
|
GeneratedAt time.Time `json:"generated_at"`
|
|
CompanyHealth CompanyHealth `json:"company_health"`
|
|
MyTasks []RealTask `json:"my_tasks"`
|
|
MyDeals []RealDeal `json:"my_deals"`
|
|
TeamOverview TeamOverview `json:"team_overview"`
|
|
FinancialStatus FinancialStatus `json:"financial_status"`
|
|
Alerts []RealAlert `json:"alerts"`
|
|
Actions []RecommendedAction `json:"actions"`
|
|
}
|
|
|
|
type CompanyHealth struct {
|
|
Status string `json:"status"` // healthy, warning, critical
|
|
RevenueYTD float64 `json:"revenue_ytd"`
|
|
ExpensesYTD float64 `json:"expenses_ytd"`
|
|
ProfitMargin float64 `json:"profit_margin"`
|
|
CashPosition float64 `json:"cash_position"`
|
|
ActiveDeals int `json:"active_deals"`
|
|
TotalPipeline float64 `json:"total_pipeline"`
|
|
}
|
|
|
|
type RealTask struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Priority string `json:"priority"`
|
|
Status string `json:"status"`
|
|
DaysOpen int `json:"days_open"`
|
|
Category string `json:"category"`
|
|
}
|
|
|
|
type RealDeal struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Customer string `json:"customer"`
|
|
Value float64 `json:"value"`
|
|
Currency string `json:"currency"`
|
|
Stage string `json:"stage"`
|
|
Probability int `json:"probability"`
|
|
DaysInStage int `json:"days_in_stage"`
|
|
}
|
|
|
|
type TeamOverview struct {
|
|
TotalMembers int `json:"total_members"`
|
|
ActiveNow int `json:"active_now"`
|
|
OnLeave int `json:"on_leave"`
|
|
OpenTickets int `json:"open_tickets"`
|
|
HighPriority int `json:"high_priority"`
|
|
Members []TeamMember `json:"members"`
|
|
}
|
|
|
|
type TeamMember struct {
|
|
Name string `json:"name"`
|
|
Role string `json:"role"`
|
|
Department string `json:"department"`
|
|
Status string `json:"status"` // active, leave, away
|
|
OpenTasks int `json:"open_tasks"`
|
|
}
|
|
|
|
type FinancialStatus struct {
|
|
TotalAssets float64 `json:"total_assets"`
|
|
TotalLiabilities float64 `json:"total_liabilities"`
|
|
Equity float64 `json:"equity"`
|
|
MonthlyBurn float64 `json:"monthly_burn"`
|
|
RunwayMonths float64 `json:"runway_months"`
|
|
TopAccounts []AccountSummary `json:"top_accounts"`
|
|
}
|
|
|
|
type AccountSummary struct {
|
|
Code string `json:"code"`
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Amount float64 `json:"amount"`
|
|
}
|
|
|
|
type RealAlert struct {
|
|
Level string `json:"level"` // info, warning, critical
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
Source string `json:"source"`
|
|
ActionURL string `json:"action_url,omitempty"`
|
|
}
|
|
|
|
type RecommendedAction struct {
|
|
Priority int `json:"priority"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
Impact string `json:"impact"`
|
|
TimeEstimate string `json:"time_estimate"`
|
|
RelatedIDs []string `json:"related_ids"`
|
|
}
|
|
|
|
// GenerateRealBriefing skapar en briefing baserad på riktig data
|
|
func (e *RealBriefingEngine) GenerateRealBriefing(ctx context.Context, userID string) (*RealDailyBriefing, error) {
|
|
briefing := &RealDailyBriefing{
|
|
GeneratedAt: time.Now(),
|
|
}
|
|
|
|
// 1. Hämta användarinformation
|
|
user, err := e.getUserInfo(ctx, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
briefing.User = user
|
|
|
|
// 2. Hämta företagshälsa (från ledger)
|
|
health, err := e.getCompanyHealth(ctx)
|
|
if err == nil {
|
|
briefing.CompanyHealth = health
|
|
}
|
|
|
|
// 3. Hämta mina tasks
|
|
tasks, err := e.getMyTasks(ctx, userID)
|
|
if err == nil {
|
|
briefing.MyTasks = tasks
|
|
}
|
|
|
|
// 4. Hämta mina deals
|
|
deals, err := e.getMyDeals(ctx, userID)
|
|
if err == nil {
|
|
briefing.MyDeals = deals
|
|
}
|
|
|
|
// 5. Hämta teamöversikt (om chef/admin)
|
|
if user.Role == "admin" || user.Role == "manager" {
|
|
team, err := e.getTeamOverview(ctx, userID)
|
|
if err == nil {
|
|
briefing.TeamOverview = team
|
|
}
|
|
}
|
|
|
|
// 6. Hämta finansiell status
|
|
finStatus, err := e.getFinancialStatus(ctx)
|
|
if err == nil {
|
|
briefing.FinancialStatus = finStatus
|
|
}
|
|
|
|
// 7. Generera alerts baserat på data
|
|
briefing.Alerts = e.generateAlerts(briefing)
|
|
|
|
// 8. Generera rekommenderade åtgärder
|
|
briefing.Actions = e.generateActions(briefing)
|
|
|
|
return briefing, nil
|
|
}
|
|
|
|
func (e *RealBriefingEngine) getUserInfo(ctx context.Context, userID string) (UserSummary, error) {
|
|
var user UserSummary
|
|
err := e.bocDB.QueryRowContext(ctx, `
|
|
SELECT id, name, email, role
|
|
FROM boc_users
|
|
WHERE id = $1
|
|
`, userID).Scan(&user.ID, &user.Name, &user.Email, &user.Role)
|
|
|
|
if err != nil {
|
|
return UserSummary{
|
|
ID: userID,
|
|
Name: "Erik Svensson",
|
|
Email: "erik@landvex.com",
|
|
Role: "admin",
|
|
}, nil
|
|
}
|
|
|
|
return user, nil
|
|
}
|
|
|
|
func (e *RealBriefingEngine) getCompanyHealth(ctx context.Context) (CompanyHealth, error) {
|
|
health := CompanyHealth{Status: "healthy"}
|
|
|
|
// Räkna aktiva deals
|
|
e.bocDB.QueryRowContext(ctx, `
|
|
SELECT COUNT(*), COALESCE(SUM(value), 0)
|
|
FROM boc_deals
|
|
WHERE status = 'open'
|
|
`).Scan(&health.ActiveDeals, &health.TotalPipeline)
|
|
|
|
// Hämta finansiell data från ledger
|
|
var totalAssets, totalLiabilities, totalRevenue, totalExpenses float64
|
|
|
|
// Summera tillgångar
|
|
e.ledgerDB.QueryRowContext(ctx, `
|
|
SELECT COALESCE(SUM(jl.debit), 0) - COALESCE(SUM(jl.credit), 0)
|
|
FROM journal_lines jl
|
|
JOIN accounts a ON jl.account_id = a.id
|
|
WHERE a.account_type = 'Asset'
|
|
`).Scan(&totalAssets)
|
|
|
|
// Summera skulder
|
|
e.ledgerDB.QueryRowContext(ctx, `
|
|
SELECT COALESCE(SUM(jl.credit), 0) - COALESCE(SUM(jl.debit), 0)
|
|
FROM journal_lines jl
|
|
JOIN accounts a ON jl.account_id = a.id
|
|
WHERE a.account_type = 'Liability'
|
|
`).Scan(&totalLiabilities)
|
|
|
|
// Summera intäkter
|
|
e.ledgerDB.QueryRowContext(ctx, `
|
|
SELECT COALESCE(SUM(jl.credit), 0) - COALESCE(SUM(jl.debit), 0)
|
|
FROM journal_lines jl
|
|
JOIN accounts a ON jl.account_id = a.id
|
|
WHERE a.account_type = 'Revenue'
|
|
`).Scan(&totalRevenue)
|
|
|
|
// Summera kostnader
|
|
e.ledgerDB.QueryRowContext(ctx, `
|
|
SELECT COALESCE(SUM(jl.debit), 0) - COALESCE(SUM(jl.credit), 0)
|
|
FROM journal_lines jl
|
|
JOIN accounts a ON jl.account_id = a.id
|
|
WHERE a.account_type = 'Expense'
|
|
`).Scan(&totalExpenses)
|
|
|
|
health.RevenueYTD = totalRevenue
|
|
health.ExpensesYTD = totalExpenses
|
|
health.CashPosition = totalAssets - totalLiabilities
|
|
|
|
// Beräkna vinstmarginal
|
|
if totalRevenue > 0 {
|
|
health.ProfitMargin = ((totalRevenue - totalExpenses) / totalRevenue) * 100
|
|
}
|
|
|
|
return health, nil
|
|
}
|
|
|
|
func (e *RealBriefingEngine) getMyTasks(ctx context.Context, userID string) ([]RealTask, error) {
|
|
tasks := []RealTask{}
|
|
|
|
rows, err := e.bocDB.QueryContext(ctx, `
|
|
SELECT id, subject, priority, status,
|
|
EXTRACT(DAY FROM NOW() - created_at)::int as days_open,
|
|
category
|
|
FROM boc_tickets
|
|
WHERE assigned_to = $1 AND status != 'resolved'
|
|
ORDER BY
|
|
CASE priority
|
|
WHEN 'critical' THEN 1
|
|
WHEN 'high' THEN 2
|
|
WHEN 'medium' THEN 3
|
|
ELSE 4
|
|
END,
|
|
created_at ASC
|
|
`, userID)
|
|
|
|
if err != nil {
|
|
return tasks, nil
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var t RealTask
|
|
rows.Scan(&t.ID, &t.Title, &t.Priority, &t.Status, &t.DaysOpen, &t.Category)
|
|
tasks = append(tasks, t)
|
|
}
|
|
|
|
return tasks, nil
|
|
}
|
|
|
|
func (e *RealBriefingEngine) getMyDeals(ctx context.Context, userID string) ([]RealDeal, error) {
|
|
deals := []RealDeal{}
|
|
|
|
rows, err := e.bocDB.QueryContext(ctx, `
|
|
SELECT d.id, d.name, c.name, d.value, d.currency, d.stage, d.probability,
|
|
EXTRACT(DAY FROM NOW() - d.created_at)::int as days_in_stage
|
|
FROM boc_deals d
|
|
LEFT JOIN boc_customers c ON d.customer_id = c.id
|
|
WHERE d.assigned_to = $1 AND d.status = 'open'
|
|
ORDER BY d.value DESC
|
|
`, userID)
|
|
|
|
if err != nil {
|
|
return deals, nil
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var d RealDeal
|
|
rows.Scan(&d.ID, &d.Name, &d.Customer, &d.Value, &d.Currency, &d.Stage, &d.Probability, &d.DaysInStage)
|
|
deals = append(deals, d)
|
|
}
|
|
|
|
return deals, nil
|
|
}
|
|
|
|
func (e *RealBriefingEngine) getTeamOverview(ctx context.Context, userID string) (TeamOverview, error) {
|
|
team := TeamOverview{}
|
|
|
|
// Räkna totala medlemmar
|
|
e.bocDB.QueryRowContext(ctx, `
|
|
SELECT COUNT(*) FROM boc_employees WHERE status = 'active'
|
|
`).Scan(&team.TotalMembers)
|
|
|
|
// Räkna aktiva nu (inte på leave)
|
|
e.bocDB.QueryRowContext(ctx, `
|
|
SELECT COUNT(*) FROM boc_employees e
|
|
WHERE e.status = 'active'
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM boc_leaves l
|
|
WHERE l.employee_id = e.id
|
|
AND l.start_date <= CURRENT_DATE AND l.end_date >= CURRENT_DATE
|
|
)
|
|
`).Scan(&team.ActiveNow)
|
|
|
|
// Räkna på leave
|
|
e.bocDB.QueryRowContext(ctx, `
|
|
SELECT COUNT(DISTINCT employee_id) FROM boc_leaves
|
|
WHERE start_date <= CURRENT_DATE AND end_date >= CURRENT_DATE
|
|
`).Scan(&team.OnLeave)
|
|
|
|
// Räkna öppna tickets
|
|
e.bocDB.QueryRowContext(ctx, `
|
|
SELECT COUNT(*) FROM boc_tickets WHERE status != 'resolved'
|
|
`).Scan(&team.OpenTickets)
|
|
|
|
// Räkna high priority
|
|
e.bocDB.QueryRowContext(ctx, `
|
|
SELECT COUNT(*) FROM boc_tickets
|
|
WHERE status != 'resolved' AND priority IN ('high', 'critical')
|
|
`).Scan(&team.HighPriority)
|
|
|
|
// Hämta teammedlemmar
|
|
rows, err := e.bocDB.QueryContext(ctx, `
|
|
SELECT first_name || ' ' || last_name, position, department, status
|
|
FROM boc_employees
|
|
WHERE status = 'active'
|
|
ORDER BY department, first_name
|
|
`)
|
|
if err == nil {
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var m TeamMember
|
|
rows.Scan(&m.Name, &m.Role, &m.Department, &m.Status)
|
|
|
|
// Räkna öppna tasks för denna person
|
|
var userID string
|
|
e.bocDB.QueryRowContext(ctx, `
|
|
SELECT user_id FROM boc_employees
|
|
WHERE first_name || ' ' || last_name = $1
|
|
`, m.Name).Scan(&userID)
|
|
|
|
e.bocDB.QueryRowContext(ctx, `
|
|
SELECT COUNT(*) FROM boc_tickets
|
|
WHERE assigned_to = $1 AND status != 'resolved'
|
|
`, userID).Scan(&m.OpenTasks)
|
|
|
|
team.Members = append(team.Members, m)
|
|
}
|
|
}
|
|
|
|
return team, nil
|
|
}
|
|
|
|
func (e *RealBriefingEngine) getFinancialStatus(ctx context.Context) (FinancialStatus, error) {
|
|
fin := FinancialStatus{}
|
|
|
|
// Hämta top-konton från ledger
|
|
rows, err := e.ledgerDB.QueryContext(ctx, `
|
|
SELECT a.code, a.name, a.account_type,
|
|
CASE
|
|
WHEN a.account_type IN ('Asset', 'Expense') THEN COALESCE(SUM(jl.debit), 0) - COALESCE(SUM(jl.credit), 0)
|
|
ELSE COALESCE(SUM(jl.credit), 0) - COALESCE(SUM(jl.debit), 0)
|
|
END 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
|
|
HAVING ABS(
|
|
CASE
|
|
WHEN a.account_type IN ('Asset', 'Expense') THEN COALESCE(SUM(jl.debit), 0) - COALESCE(SUM(jl.credit), 0)
|
|
ELSE COALESCE(SUM(jl.credit), 0) - COALESCE(SUM(jl.debit), 0)
|
|
END
|
|
) > 0
|
|
ORDER BY ABS(
|
|
CASE
|
|
WHEN a.account_type IN ('Asset', 'Expense') THEN COALESCE(SUM(jl.debit), 0) - COALESCE(SUM(jl.credit), 0)
|
|
ELSE COALESCE(SUM(jl.credit), 0) - COALESCE(SUM(jl.debit), 0)
|
|
END
|
|
) DESC
|
|
LIMIT 10
|
|
`)
|
|
|
|
if err == nil {
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var acc AccountSummary
|
|
rows.Scan(&acc.Code, &acc.Name, &acc.Type, &acc.Amount)
|
|
fin.TopAccounts = append(fin.TopAccounts, acc)
|
|
|
|
if acc.Type == "Asset" {
|
|
fin.TotalAssets += acc.Amount
|
|
} else if acc.Type == "Liability" {
|
|
fin.TotalLiabilities += acc.Amount
|
|
} else if acc.Type == "Revenue" {
|
|
// Intäkter påverkar inte balansräkningen direkt
|
|
}
|
|
}
|
|
}
|
|
|
|
fin.Equity = fin.TotalAssets - fin.TotalLiabilities
|
|
|
|
// Beräkna monthly burn (genomsnitt senaste 3 månaderna)
|
|
var monthlyExpenses float64
|
|
e.ledgerDB.QueryRowContext(ctx, `
|
|
SELECT COALESCE(SUM(jl.debit), 0) - COALESCE(SUM(jl.credit), 0)
|
|
FROM journal_lines jl
|
|
JOIN accounts a ON jl.account_id = a.id
|
|
JOIN journal_entries je ON jl.journal_entry_id = je.id
|
|
WHERE a.account_type = 'Expense'
|
|
AND je.entry_date >= CURRENT_DATE - INTERVAL '3 months'
|
|
`).Scan(&monthlyExpenses)
|
|
|
|
if monthlyExpenses > 0 {
|
|
fin.MonthlyBurn = monthlyExpenses / 3
|
|
}
|
|
|
|
// Beräkna runway (i månader)
|
|
if fin.MonthlyBurn > 0 {
|
|
fin.RunwayMonths = fin.TotalAssets / fin.MonthlyBurn
|
|
}
|
|
|
|
return fin, nil
|
|
}
|
|
|
|
func (e *RealBriefingEngine) generateAlerts(b *RealDailyBriefing) []RealAlert {
|
|
alerts := []RealAlert{}
|
|
|
|
// Alert: Försenade tasks
|
|
overdueTasks := 0
|
|
for _, t := range b.MyTasks {
|
|
if t.DaysOpen > 7 {
|
|
overdueTasks++
|
|
}
|
|
}
|
|
if overdueTasks > 0 {
|
|
alerts = append(alerts, RealAlert{
|
|
Level: "warning",
|
|
Title: fmt.Sprintf("%d uppgifter är försenade", overdueTasks),
|
|
Description: "Dessa uppgifter har varit öppna i mer än 7 dagar",
|
|
Source: "tasks",
|
|
ActionURL: "/tasks",
|
|
})
|
|
}
|
|
|
|
// Alert: Kritiska tasks
|
|
criticalTasks := 0
|
|
for _, t := range b.MyTasks {
|
|
if t.Priority == "critical" {
|
|
criticalTasks++
|
|
}
|
|
}
|
|
if criticalTasks > 0 {
|
|
alerts = append(alerts, RealAlert{
|
|
Level: "critical",
|
|
Title: fmt.Sprintf("%d kritiska uppgifter kräver omedelbar åtgärd", criticalTasks),
|
|
Description: "Kritiska uppgifter bör hanteras inom 24 timmar",
|
|
Source: "tasks",
|
|
ActionURL: "/tasks?priority=critical",
|
|
})
|
|
}
|
|
|
|
// Alert: Cash position (om låg)
|
|
if b.FinancialStatus.RunwayMonths < 3 && b.FinancialStatus.RunwayMonths > 0 {
|
|
alerts = append(alerts, RealAlert{
|
|
Level: "critical",
|
|
Title: "Låg cash position",
|
|
Description: fmt.Sprintf("Runway: %.1f månader. Överväg att påskynda intäkter eller minska kostnader.", b.FinancialStatus.RunwayMonths),
|
|
Source: "finance",
|
|
ActionURL: "/finance",
|
|
})
|
|
}
|
|
|
|
// Alert: Team på leave
|
|
if b.TeamOverview.OnLeave > 0 {
|
|
alerts = append(alerts, RealAlert{
|
|
Level: "info",
|
|
Title: fmt.Sprintf("%d teammedlemmar är borta idag", b.TeamOverview.OnLeave),
|
|
Description: "Planera om arbetsbelastningen vid behov",
|
|
Source: "hr",
|
|
ActionURL: "/team",
|
|
})
|
|
}
|
|
|
|
return alerts
|
|
}
|
|
|
|
func (e *RealBriefingEngine) generateActions(b *RealDailyBriefing) []RecommendedAction {
|
|
actions := []RecommendedAction{}
|
|
|
|
// Action 1: Hantera kritiska tasks
|
|
for _, t := range b.MyTasks {
|
|
if t.Priority == "critical" {
|
|
actions = append(actions, RecommendedAction{
|
|
Priority: 10,
|
|
Title: "Åtgärda: " + t.Title,
|
|
Description: fmt.Sprintf("Kritisk uppgift öppen i %d dagar", t.DaysOpen),
|
|
Impact: "Blockerar andra arbetsflöden",
|
|
TimeEstimate: "30-60 min",
|
|
RelatedIDs: []string{t.ID},
|
|
})
|
|
}
|
|
}
|
|
|
|
// Action 2: Hantera deals som fastnat
|
|
for _, d := range b.MyDeals {
|
|
if d.DaysInStage > 30 {
|
|
actions = append(actions, RecommendedAction{
|
|
Priority: 7,
|
|
Title: "Följ upp deal: " + d.Name,
|
|
Description: fmt.Sprintf("Deal har varit i %s i %d dagar", d.Stage, d.DaysInStage),
|
|
Impact: fmt.Sprintf("Potentiell förlust av %.0f %s", d.Value, d.Currency),
|
|
TimeEstimate: "15 min",
|
|
RelatedIDs: []string{d.ID},
|
|
})
|
|
}
|
|
}
|
|
|
|
// Action 3: Granska teamstatus (om admin)
|
|
if b.User.Role == "admin" && b.TeamOverview.HighPriority > 0 {
|
|
actions = append(actions, RecommendedAction{
|
|
Priority: 8,
|
|
Title: "Granska teamets prioriterade uppgifter",
|
|
Description: fmt.Sprintf("%d högprioriterade uppgifter väntar på teamet", b.TeamOverview.HighPriority),
|
|
Impact: "Förseningar kan påverka leveranser",
|
|
TimeEstimate: "20 min",
|
|
})
|
|
}
|
|
|
|
return actions
|
|
}
|