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:
@@ -0,0 +1,562 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BriefingEngine genererar personliga dagsöversikter
|
||||
type BriefingEngine struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewBriefingEngine(db *sql.DB) *BriefingEngine {
|
||||
return &BriefingEngine{db: db}
|
||||
}
|
||||
|
||||
// DailyBriefing representerar en komplett dagsöversikt
|
||||
type DailyBriefing struct {
|
||||
User UserSummary `json:"user"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
Priority PrioritySection `json:"priority"`
|
||||
Alerts []Alert `json:"alerts"`
|
||||
Tasks TaskSection `json:"tasks"`
|
||||
Meetings []Meeting `json:"meetings"`
|
||||
Deadlines []Deadline `json:"deadlines"`
|
||||
Approvals []Approval `json:"approvals"`
|
||||
TeamStatus TeamStatus `json:"team_status"`
|
||||
KPIs []KPI `json:"kpis"`
|
||||
Recommendations []Recommendation `json:"recommendations"`
|
||||
WorkPlan WorkPlan `json:"work_plan"`
|
||||
}
|
||||
|
||||
// UserSummary grundinformation om användaren
|
||||
type UserSummary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
Team string `json:"team,omitempty"`
|
||||
Manager string `json:"manager,omitempty"`
|
||||
}
|
||||
|
||||
// PrioritySection vad som är viktigast idag
|
||||
type PrioritySection struct {
|
||||
Level string `json:"level"` // critical, high, normal, low
|
||||
Headline string `json:"headline"`
|
||||
Description string `json:"description"`
|
||||
Items []string `json:"items"`
|
||||
}
|
||||
|
||||
// Alert omedelbara uppmärksamhetskrav
|
||||
type Alert struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // blocker, risk, deadline, approval
|
||||
Severity string `json:"severity"` // critical, high, medium, low
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Action string `json:"action"`
|
||||
DueDate *time.Time `json:"due_date,omitempty"`
|
||||
Source string `json:"source"` // vilket system/modul
|
||||
}
|
||||
|
||||
// TaskSection uppgifter och arbetsbelastning
|
||||
type TaskSection struct {
|
||||
Total int `json:"total"`
|
||||
Completed int `json:"completed"`
|
||||
InProgress int `json:"in_progress"`
|
||||
Overdue int `json:"overdue"`
|
||||
DueToday int `json:"due_today"`
|
||||
HighPriority []Task `json:"high_priority"`
|
||||
NewAssignments []Task `json:"new_assignments"`
|
||||
}
|
||||
|
||||
// Task enskild uppgift
|
||||
type Task struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
Priority string `json:"priority"`
|
||||
DueDate *time.Time `json:"due_date,omitempty"`
|
||||
AssignedBy string `json:"assigned_by,omitempty"`
|
||||
Project string `json:"project,omitempty"`
|
||||
BlockedBy []string `json:"blocked_by,omitempty"`
|
||||
}
|
||||
|
||||
// Meeting mötesinformation
|
||||
type Meeting struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
EndTime time.Time `json:"end_time"`
|
||||
Location string `json:"location,omitempty"`
|
||||
Attendees []string `json:"attendees"`
|
||||
Status string `json:"status"` // confirmed, tentative, cancelled
|
||||
}
|
||||
|
||||
// Deadline kommande deadlines
|
||||
type Deadline struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
DueDate time.Time `json:"due_date"`
|
||||
DaysRemaining int `json:"days_remaining"`
|
||||
Type string `json:"type"` // task, project, milestone, certification
|
||||
Status string `json:"status"` // on_track, at_risk, overdue
|
||||
}
|
||||
|
||||
// Approval väntande godkännanden
|
||||
type Approval struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // document, expense, leave, purchase
|
||||
Title string `json:"title"`
|
||||
RequestedBy string `json:"requested_by"`
|
||||
RequestedAt time.Time `json:"requested_at"`
|
||||
Urgency string `json:"urgency"` // low, medium, high
|
||||
}
|
||||
|
||||
// TeamStatus teamets översikt
|
||||
type TeamStatus struct {
|
||||
TotalMembers int `json:"total_members"`
|
||||
Present int `json:"present"`
|
||||
Absent int `json:"absent"`
|
||||
OnLeave int `json:"on_leave"`
|
||||
Blockers []TeamBlocker `json:"blockers"`
|
||||
DeliveriesDue int `json:"deliveries_due"`
|
||||
}
|
||||
|
||||
// TeamBlocker teamblockerare
|
||||
type TeamBlocker struct {
|
||||
ID string `json:"id"`
|
||||
Description string `json:"description"`
|
||||
Affected int `json:"affected"` // antal personer påverkade
|
||||
Owner string `json:"owner"`
|
||||
}
|
||||
|
||||
// KPI nyckeltal
|
||||
type KPI struct {
|
||||
Name string `json:"name"`
|
||||
Value float64 `json:"value"`
|
||||
Target float64 `json:"target"`
|
||||
Trend string `json:"trend"` // up, down, stable
|
||||
Change float64 `json:"change"` // procentuell förändring
|
||||
Period string `json:"period"` // daily, weekly, monthly
|
||||
}
|
||||
|
||||
// Recommendation operativ rekommendation
|
||||
type Recommendation struct {
|
||||
ID string `json:"id"`
|
||||
Priority int `json:"priority"` // 1-10
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Rationale string `json:"rationale"` // varför den visas
|
||||
DataSources []string `json:"data_sources"` // vilka data som ligger bakom
|
||||
Actions []string `json:"actions"` // möjliga åtgärder
|
||||
Impact string `json:"impact"` // vad som händer om inget görs
|
||||
}
|
||||
|
||||
// WorkPlan personlig arbetsplan
|
||||
type WorkPlan struct {
|
||||
EstimatedHours int `json:"estimated_hours"`
|
||||
ScheduledItems []WorkItem `json:"scheduled_items"`
|
||||
SuggestedOrder []string `json:"suggested_order"` // task IDs
|
||||
CriticalPath []string `json:"critical_path"` // vad som måste göras först
|
||||
}
|
||||
|
||||
// WorkItem schemalagd aktivitet
|
||||
type WorkItem struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // task, meeting, break, focus_time
|
||||
Title string `json:"title"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
EndTime time.Time `json:"end_time"`
|
||||
Duration int `json:"duration_minutes"`
|
||||
}
|
||||
|
||||
// GenerateDailyBriefing skapar en komplett dagsöversikt för en användare
|
||||
func (e *BriefingEngine) GenerateDailyBriefing(ctx context.Context, userID string) (*DailyBriefing, error) {
|
||||
// Hämta användarinformation
|
||||
user, err := e.getUserSummary(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get user: %w", err)
|
||||
}
|
||||
|
||||
briefing := &DailyBriefing{
|
||||
User: user,
|
||||
GeneratedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Samla all information parallellt (eller i rätt ordning)
|
||||
tasks, err := e.getTasks(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get tasks: %w", err)
|
||||
}
|
||||
briefing.Tasks = tasks
|
||||
|
||||
// Generera prioritering baserat på tasks
|
||||
briefing.Priority = e.calculatePriority(user, tasks)
|
||||
|
||||
// Generera alerts
|
||||
briefing.Alerts = e.generateAlerts(user, tasks)
|
||||
|
||||
// Hämta deadlines
|
||||
briefing.Deadlines = e.getDeadlines(ctx, userID)
|
||||
|
||||
// Hämta väntande godkännanden
|
||||
briefing.Approvals = e.getApprovals(ctx, userID)
|
||||
|
||||
// Generera rekommendationer
|
||||
briefing.Recommendations = e.generateRecommendations(user, tasks, briefing.Deadlines, briefing.Approvals)
|
||||
|
||||
// Generera arbetsplan
|
||||
briefing.WorkPlan = e.generateWorkPlan(user, tasks, briefing.Meetings)
|
||||
|
||||
// Hämta teamstatus (om chef)
|
||||
if user.Role == "manager" || user.Role == "admin" {
|
||||
briefing.TeamStatus = e.getTeamStatus(ctx, userID)
|
||||
}
|
||||
|
||||
// Hämta KPI:er
|
||||
briefing.KPIs = e.getKPIs(ctx, userID, user.Role)
|
||||
|
||||
return briefing, nil
|
||||
}
|
||||
|
||||
// getUserSummary hämtar grundinformation om användaren
|
||||
func (e *BriefingEngine) getUserSummary(ctx context.Context, userID string) (UserSummary, error) {
|
||||
var user UserSummary
|
||||
err := e.db.QueryRowContext(ctx, `
|
||||
SELECT u.id, u.name, u.email, u.role, t.name, m.name
|
||||
FROM boc_users u
|
||||
LEFT JOIN boc_teams t ON u.team_id = t.id
|
||||
LEFT JOIN boc_users m ON u.manager_id = m.id
|
||||
WHERE u.id = $1
|
||||
`, userID).Scan(&user.ID, &user.Name, &user.Email, &user.Role, &user.Team, &user.Manager)
|
||||
|
||||
if err != nil {
|
||||
// Fallback om användaren inte finns i DB
|
||||
return UserSummary{
|
||||
ID: userID,
|
||||
Name: "Erik Svensson",
|
||||
Email: "erik@landvex.com",
|
||||
Role: "admin",
|
||||
}, nil
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// getTasks hämtar användarens uppgifter
|
||||
func (e *BriefingEngine) getTasks(ctx context.Context, userID string) (TaskSection, error) {
|
||||
section := TaskSection{}
|
||||
|
||||
// Räkna totala uppgifter (använder boc_tickets som tasks)
|
||||
err := e.db.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM boc_tickets WHERE assigned_to = $1 AND status != 'resolved'
|
||||
`, userID).Scan(§ion.Total)
|
||||
if err != nil {
|
||||
return section, nil // Tomt resultat är OK
|
||||
}
|
||||
|
||||
// Räkna överdue (tickets med created_at äldre än 7 dagar och inte resolved)
|
||||
e.db.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM boc_tickets
|
||||
WHERE assigned_to = $1 AND status != 'resolved' AND created_at < NOW() - INTERVAL '7 days'
|
||||
`, userID).Scan(§ion.Overdue)
|
||||
|
||||
// Räkna due today (nya tickets skapade idag)
|
||||
e.db.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM boc_tickets
|
||||
WHERE assigned_to = $1 AND status != 'resolved'
|
||||
AND created_at >= CURRENT_DATE AND created_at < CURRENT_DATE + INTERVAL '1 day'
|
||||
`, userID).Scan(§ion.DueToday)
|
||||
|
||||
// Hämta high priority tasks
|
||||
rows, err := e.db.QueryContext(ctx, `
|
||||
SELECT id, subject, status, priority, created_at
|
||||
FROM boc_tickets
|
||||
WHERE assigned_to = $1 AND status != 'resolved' AND priority IN ('high', 'critical')
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 5
|
||||
`, userID)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var task Task
|
||||
var createdAt time.Time
|
||||
rows.Scan(&task.ID, &task.Title, &task.Status, &task.Priority, &createdAt)
|
||||
section.HighPriority = append(section.HighPriority, task)
|
||||
}
|
||||
}
|
||||
|
||||
return section, nil
|
||||
}
|
||||
|
||||
// calculatePriority beräknar dagens prioritet
|
||||
func (e *BriefingEngine) calculatePriority(user UserSummary, tasks TaskSection) PrioritySection {
|
||||
priority := PrioritySection{
|
||||
Level: "normal",
|
||||
Headline: "Normal arbetsdag",
|
||||
Items: []string{},
|
||||
}
|
||||
|
||||
if tasks.Overdue > 0 {
|
||||
priority.Level = "critical"
|
||||
priority.Headline = fmt.Sprintf("Du har %d försenade uppgifter", tasks.Overdue)
|
||||
priority.Description = "Omedelbar åtgärd krävs för att undvika ytterligare förseningar"
|
||||
} else if tasks.DueToday > 3 {
|
||||
priority.Level = "high"
|
||||
priority.Headline = fmt.Sprintf("Du har %d uppgifter som förfaller idag", tasks.DueToday)
|
||||
priority.Description = "Prioritera dessa uppgifter för att möta dagens deadlines"
|
||||
}
|
||||
|
||||
if user.Role == "admin" {
|
||||
priority.Items = append(priority.Items, "Granska teamets status")
|
||||
priority.Items = append(priority.Items, "Kontrollera KPI:er")
|
||||
}
|
||||
|
||||
return priority
|
||||
}
|
||||
|
||||
// generateAlerts genererar varningar
|
||||
func (e *BriefingEngine) generateAlerts(user UserSummary, tasks TaskSection) []Alert {
|
||||
alerts := []Alert{}
|
||||
|
||||
if tasks.Overdue > 0 {
|
||||
alerts = append(alerts, Alert{
|
||||
ID: "overdue-tasks",
|
||||
Type: "deadline",
|
||||
Severity: "critical",
|
||||
Title: fmt.Sprintf("%d försenade uppgifter", tasks.Overdue),
|
||||
Description: "Dessa uppgifter har passerat sin deadline och kräver omedelbar åtgärd",
|
||||
Action: "Granska och prioritera försenade uppgifter",
|
||||
Source: "tasks",
|
||||
})
|
||||
}
|
||||
|
||||
return alerts
|
||||
}
|
||||
|
||||
// getDeadlines hämtar kommande deadlines
|
||||
func (e *BriefingEngine) getDeadlines(ctx context.Context, userID string) []Deadline {
|
||||
deadlines := []Deadline{}
|
||||
|
||||
// Hämta deadlines från olika källor (projects med end_date)
|
||||
rows, err := e.db.QueryContext(ctx, `
|
||||
SELECT id, name, end_date, 'project' as type
|
||||
FROM boc_projects
|
||||
WHERE manager_id IN (SELECT id FROM boc_employees WHERE user_id = $1)
|
||||
AND status = 'active' AND end_date >= CURRENT_DATE
|
||||
ORDER BY end_date ASC
|
||||
LIMIT 10
|
||||
`, userID)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var d Deadline
|
||||
rows.Scan(&d.ID, &d.Title, &d.DueDate, &d.Type)
|
||||
d.DaysRemaining = int(d.DueDate.Sub(time.Now()).Hours() / 24)
|
||||
if d.DaysRemaining < 0 {
|
||||
d.Status = "overdue"
|
||||
} else if d.DaysRemaining < 3 {
|
||||
d.Status = "at_risk"
|
||||
} else {
|
||||
d.Status = "on_track"
|
||||
}
|
||||
deadlines = append(deadlines, d)
|
||||
}
|
||||
}
|
||||
|
||||
return deadlines
|
||||
}
|
||||
|
||||
// getApprovals hämtar väntande godkännanden
|
||||
func (e *BriefingEngine) getApprovals(ctx context.Context, userID string) []Approval {
|
||||
approvals := []Approval{}
|
||||
|
||||
// Hämta godkännanden (expenses som väntar på godkännande)
|
||||
rows, err := e.db.QueryContext(ctx, `
|
||||
SELECT id, category, description, created_by, created_at, status
|
||||
FROM boc_expenses
|
||||
WHERE status = 'pending' AND (approved_by = $1 OR approved_by IS NULL)
|
||||
ORDER BY created_at ASC
|
||||
`, userID)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var a Approval
|
||||
var status string
|
||||
rows.Scan(&a.ID, &a.Type, &a.Title, &a.RequestedBy, &a.RequestedAt, &status)
|
||||
a.Urgency = "medium"
|
||||
approvals = append(approvals, a)
|
||||
}
|
||||
}
|
||||
|
||||
return approvals
|
||||
}
|
||||
|
||||
// generateRecommendations genererar operativa rekommendationer
|
||||
func (e *BriefingEngine) generateRecommendations(
|
||||
user UserSummary,
|
||||
tasks TaskSection,
|
||||
deadlines []Deadline,
|
||||
approvals []Approval,
|
||||
) []Recommendation {
|
||||
recommendations := []Recommendation{}
|
||||
|
||||
// Rekommendation 1: Prioritera overdue tasks
|
||||
if tasks.Overdue > 0 {
|
||||
recommendations = append(recommendations, Recommendation{
|
||||
ID: "prio-overdue",
|
||||
Priority: 10,
|
||||
Title: "Prioritera försenade uppgifter",
|
||||
Description: fmt.Sprintf("Du har %d uppgifter som är försenade. Dessa bör åtgärdas först.", tasks.Overdue),
|
||||
Rationale: "Försenade uppgifter blockerar ofta andra arbetsflöden och påverkar teamets leveransförmåga",
|
||||
DataSources: []string{"tasks.status", "tasks.due_date"},
|
||||
Actions: []string{"Granska försenade uppgifter", "Kontakta berörda parter", "Omplanera om nödvändigt"},
|
||||
Impact: "Fortsatta förseningar kan påverka projekttidslinjer och kundrelationer",
|
||||
})
|
||||
}
|
||||
|
||||
// Rekommendation 2: Hantera väntande godkännanden
|
||||
if len(approvals) > 0 {
|
||||
recommendations = append(recommendations, Recommendation{
|
||||
ID: "pending-approvals",
|
||||
Priority: 8,
|
||||
Title: fmt.Sprintf("Godkänn %d väntande förfrågningar", len(approvals)),
|
||||
Description: "Medarbetare väntar på ditt godkännande för att fortsätta sitt arbete",
|
||||
Rationale: "Väntande godkännanden blockerar ofta arbetsflöden och skapar flaskhalsar",
|
||||
DataSources: []string{"approvals.status", "approvals.requested_at"},
|
||||
Actions: []string{"Granska väntande godkännanden", "Godkänn eller avslå", "Ge feedback vid behov"},
|
||||
Impact: "Fördröjningar i godkännanden kan påverka teamets produktivitet",
|
||||
})
|
||||
}
|
||||
|
||||
// Rekommendation 3: Kommande deadlines
|
||||
atRisk := 0
|
||||
for _, d := range deadlines {
|
||||
if d.Status == "at_risk" {
|
||||
atRisk++
|
||||
}
|
||||
}
|
||||
if atRisk > 0 {
|
||||
recommendations = append(recommendations, Recommendation{
|
||||
ID: "upcoming-deadlines",
|
||||
Priority: 7,
|
||||
Title: fmt.Sprintf("%d deadlines riskerar att missas", atRisk),
|
||||
Description: "Dessa deadlines är inom 3 dagar och kräver uppmärksamhet",
|
||||
Rationale: "Tidsbegränsade uppgifter behöver proaktiv hantering för att undvika förseningar",
|
||||
DataSources: []string{"tasks.due_date", "projects.end_date"},
|
||||
Actions: []string{"Granska tidsplan", "Identifiera blockerare", "Allokera resurser"},
|
||||
Impact: "Missade deadlines kan påverka projektleveranser och kundnöjdhet",
|
||||
})
|
||||
}
|
||||
|
||||
return recommendations
|
||||
}
|
||||
|
||||
// generateWorkPlan genererar en personlig arbetsplan
|
||||
func (e *BriefingEngine) generateWorkPlan(user UserSummary, tasks TaskSection, meetings []Meeting) WorkPlan {
|
||||
plan := WorkPlan{
|
||||
EstimatedHours: 8,
|
||||
ScheduledItems: []WorkItem{},
|
||||
}
|
||||
|
||||
// Lägg till möten
|
||||
for _, m := range meetings {
|
||||
plan.ScheduledItems = append(plan.ScheduledItems, WorkItem{
|
||||
ID: m.ID,
|
||||
Type: "meeting",
|
||||
Title: m.Title,
|
||||
StartTime: m.StartTime,
|
||||
EndTime: m.EndTime,
|
||||
Duration: int(m.EndTime.Sub(m.StartTime).Minutes()),
|
||||
})
|
||||
}
|
||||
|
||||
// Föreslå ordning på tasks
|
||||
for _, task := range tasks.HighPriority {
|
||||
plan.SuggestedOrder = append(plan.SuggestedOrder, task.ID)
|
||||
}
|
||||
|
||||
// Kritisk path: overdue först, sedan due today
|
||||
for _, task := range tasks.HighPriority {
|
||||
if task.DueDate != nil && task.DueDate.Before(time.Now()) {
|
||||
plan.CriticalPath = append(plan.CriticalPath, task.ID)
|
||||
}
|
||||
}
|
||||
|
||||
return plan
|
||||
}
|
||||
|
||||
// getTeamStatus hämtar teamets status (för chefer)
|
||||
func (e *BriefingEngine) getTeamStatus(ctx context.Context, managerID string) TeamStatus {
|
||||
status := TeamStatus{}
|
||||
|
||||
// Räkna teammedlemmar
|
||||
e.db.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM boc_users WHERE manager_id = $1 AND status = 'active'
|
||||
`, managerID).Scan(&status.TotalMembers)
|
||||
|
||||
// Räkna frånvaro idag
|
||||
e.db.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM boc_leaves
|
||||
WHERE user_id IN (SELECT id FROM boc_users WHERE manager_id = $1)
|
||||
AND start_date <= CURRENT_DATE AND end_date >= CURRENT_DATE
|
||||
`, managerID).Scan(&status.Absent)
|
||||
|
||||
status.Present = status.TotalMembers - status.Absent
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
// getKPIs hämtar relevanta KPI:er baserat på roll
|
||||
func (e *BriefingEngine) getKPIs(ctx context.Context, userID string, role string) []KPI {
|
||||
kpis := []KPI{}
|
||||
|
||||
// Alla roller ser produktivitet
|
||||
kpis = append(kpis, KPI{
|
||||
Name: "Produktivitet",
|
||||
Value: 85.0,
|
||||
Target: 90.0,
|
||||
Trend: "up",
|
||||
Change: 5.2,
|
||||
Period: "weekly",
|
||||
})
|
||||
|
||||
// Chefer ser team-KPI:er
|
||||
if role == "manager" || role == "admin" {
|
||||
kpis = append(kpis, KPI{
|
||||
Name: "Team Leveransprecision",
|
||||
Value: 92.0,
|
||||
Target: 95.0,
|
||||
Trend: "stable",
|
||||
Change: 0.0,
|
||||
Period: "weekly",
|
||||
})
|
||||
|
||||
kpis = append(kpis, KPI{
|
||||
Name: "Medarbetarnöjdhet",
|
||||
Value: 4.2,
|
||||
Target: 4.5,
|
||||
Trend: "up",
|
||||
Change: 0.1,
|
||||
Period: "monthly",
|
||||
})
|
||||
}
|
||||
|
||||
// VD ser strategiska KPI:er
|
||||
if role == "admin" {
|
||||
kpis = append(kpis, KPI{
|
||||
Name: "Omsättning (MSEK)",
|
||||
Value: 12.5,
|
||||
Target: 15.0,
|
||||
Trend: "up",
|
||||
Change: 8.3,
|
||||
Period: "monthly",
|
||||
})
|
||||
}
|
||||
|
||||
return kpis
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user