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
+675
View File
@@ -0,0 +1,675 @@
package employee
import (
"database/sql"
"encoding/json"
"net/http"
"time"
"github.com/go-chi/chi/v5"
)
// Handler hanterar Employee Lifecycle API
type Handler struct {
DB *sql.DB
}
func NewHandler(db *sql.DB) *Handler {
return &Handler{DB: db}
}
// ==========================================
// EMPLOYEE CRUD
// ==========================================
// Employee representerar en medarbetare
type Employee struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email"`
Phone *string `json:"phone,omitempty"`
PersonalNumber *string `json:"personal_number,omitempty"`
BirthDate *time.Time `json:"birth_date,omitempty"`
AvatarURL *string `json:"avatar_url,omitempty"`
Bio *string `json:"bio,omitempty"`
EmploymentType string `json:"employment_type"`
EmploymentStatus string `json:"employment_status"`
StartDate *time.Time `json:"start_date,omitempty"`
EndDate *time.Time `json:"end_date,omitempty"`
ProbationEndDate *time.Time `json:"probation_end_date,omitempty"`
NoticePeriodDays int `json:"notice_period_days"`
DepartmentID *string `json:"department_id,omitempty"`
TeamID *string `json:"team_id,omitempty"`
ManagerID *string `json:"manager_id,omitempty"`
RoleID *string `json:"role_id,omitempty"`
Salary *float64 `json:"salary,omitempty"`
SalaryCurrency string `json:"salary_currency"`
BankAccount *string `json:"bank_account,omitempty"`
BankClearing *string `json:"bank_clearing,omitempty"`
Address *string `json:"address,omitempty"`
PostalCode *string `json:"postal_code,omitempty"`
City *string `json:"city,omitempty"`
Country string `json:"country"`
EmergencyContactName *string `json:"emergency_contact_name,omitempty"`
EmergencyContactPhone *string `json:"emergency_contact_phone,omitempty"`
EmergencyContactRelation *string `json:"emergency_contact_relation,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ListEmployees returnerar alla medarbetare
func (h *Handler) ListEmployees(w http.ResponseWriter, r *http.Request) {
rows, err := h.DB.Query(`
SELECT id, tenant_id, first_name, last_name, email, phone, employment_type, employment_status,
start_date, department_id, team_id, manager_id, role_id, created_at
FROM employees
WHERE employment_status != 'terminated'
ORDER BY created_at DESC
`)
if err != nil {
http.Error(w, `{"error":"database error"}`, http.StatusInternalServerError)
return
}
defer rows.Close()
var employees []Employee
for rows.Next() {
var e Employee
var phone sql.NullString
var startDate sql.NullTime
var deptID, teamID, managerID, roleID sql.NullString
rows.Scan(&e.ID, &e.TenantID, &e.FirstName, &e.LastName, &e.Email, &phone,
&e.EmploymentType, &e.EmploymentStatus, &startDate,
&deptID, &teamID, &managerID, &roleID, &e.CreatedAt)
if phone.Valid { e.Phone = &phone.String }
if startDate.Valid { e.StartDate = &startDate.Time }
if deptID.Valid { e.DepartmentID = &deptID.String }
if teamID.Valid { e.TeamID = &teamID.String }
if managerID.Valid { e.ManagerID = &managerID.String }
if roleID.Valid { e.RoleID = &roleID.String }
employees = append(employees, e)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"employees": employees,
"total": len(employees),
})
}
// GetEmployee returnerar en specifik medarbetare
func (h *Handler) GetEmployee(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var e Employee
var phone, personalNumber, avatarURL, bio, bankAccount, bankClearing sql.NullString
var birthDate, startDate, endDate, probationEnd sql.NullTime
var deptID, teamID, managerID, roleID sql.NullString
var salary sql.NullFloat64
var address, postalCode, city, emergencyName, emergencyPhone, emergencyRelation sql.NullString
err := h.DB.QueryRow(`
SELECT id, tenant_id, first_name, last_name, email, phone, personal_number, birth_date,
avatar_url, bio, employment_type, employment_status, start_date, end_date, probation_end_date,
notice_period_days, department_id, team_id, manager_id, role_id,
salary, salary_currency, bank_account, bank_clearing,
address, postal_code, city, country,
emergency_contact_name, emergency_contact_phone, emergency_contact_relation,
created_at, updated_at
FROM employees WHERE id = $1
`, id).Scan(
&e.ID, &e.TenantID, &e.FirstName, &e.LastName, &e.Email, &phone, &personalNumber, &birthDate,
&avatarURL, &bio, &e.EmploymentType, &e.EmploymentStatus, &startDate, &endDate, &probationEnd,
&e.NoticePeriodDays, &deptID, &teamID, &managerID, &roleID,
&salary, &e.SalaryCurrency, &bankAccount, &bankClearing,
&address, &postalCode, &city, &e.Country,
&emergencyName, &emergencyPhone, &emergencyRelation,
&e.CreatedAt, &e.UpdatedAt,
)
if err == sql.ErrNoRows {
http.Error(w, `{"error":"employee not found"}`, http.StatusNotFound)
return
}
if err != nil {
http.Error(w, `{"error":"database error"}`, http.StatusInternalServerError)
return
}
// Sätt nullable fält
if phone.Valid { e.Phone = &phone.String }
if personalNumber.Valid { e.PersonalNumber = &personalNumber.String }
if birthDate.Valid { e.BirthDate = &birthDate.Time }
if avatarURL.Valid { e.AvatarURL = &avatarURL.String }
if bio.Valid { e.Bio = &bio.String }
if startDate.Valid { e.StartDate = &startDate.Time }
if endDate.Valid { e.EndDate = &endDate.Time }
if probationEnd.Valid { e.ProbationEndDate = &probationEnd.Time }
if deptID.Valid { e.DepartmentID = &deptID.String }
if teamID.Valid { e.TeamID = &teamID.String }
if managerID.Valid { e.ManagerID = &managerID.String }
if roleID.Valid { e.RoleID = &roleID.String }
if salary.Valid { e.Salary = &salary.Float64 }
if bankAccount.Valid { e.BankAccount = &bankAccount.String }
if bankClearing.Valid { e.BankClearing = &bankClearing.String }
if address.Valid { e.Address = &address.String }
if postalCode.Valid { e.PostalCode = &postalCode.String }
if city.Valid { e.City = &city.String }
if emergencyName.Valid { e.EmergencyContactName = &emergencyName.String }
if emergencyPhone.Valid { e.EmergencyContactPhone = &emergencyPhone.String }
if emergencyRelation.Valid { e.EmergencyContactRelation = &emergencyRelation.String }
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(e)
}
// CreateEmployee skapar en ny medarbetare
func (h *Handler) CreateEmployee(w http.ResponseWriter, r *http.Request) {
var req Employee
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
var id string
err := h.DB.QueryRow(`
INSERT INTO employees (tenant_id, first_name, last_name, email, phone, personal_number,
employment_type, employment_status, start_date, department_id, team_id, manager_id, role_id,
salary, salary_currency, bank_account, bank_clearing,
address, postal_code, city, country)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)
RETURNING id
`, req.TenantID, req.FirstName, req.LastName, req.Email, req.Phone, req.PersonalNumber,
req.EmploymentType, req.EmploymentStatus, req.StartDate, req.DepartmentID, req.TeamID, req.ManagerID, req.RoleID,
req.Salary, req.SalaryCurrency, req.BankAccount, req.BankClearing,
req.Address, req.PostalCode, req.City, req.Country).Scan(&id)
if err != nil {
http.Error(w, `{"error":"failed to create employee"}`, http.StatusInternalServerError)
return
}
// Skapa timeline event
h.DB.Exec(`
INSERT INTO employee_timeline_events (employee_id, event_type, event_date, title, description)
VALUES ($1, 'hired', $2, 'Anställd', 'Anställning påbörjad')
`, id, time.Now())
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]interface{}{
"id": id,
"message": "Employee created",
})
}
// UpdateEmployee uppdaterar en medarbetare
func (h *Handler) UpdateEmployee(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var req Employee
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
_, err := h.DB.Exec(`
UPDATE employees
SET first_name = COALESCE($1, first_name),
last_name = COALESCE($2, last_name),
email = COALESCE($3, email),
phone = COALESCE($4, phone),
employment_type = COALESCE($5, employment_type),
employment_status = COALESCE($6, employment_status),
department_id = COALESCE($7, department_id),
team_id = COALESCE($8, team_id),
manager_id = COALESCE($9, manager_id),
role_id = COALESCE($10, role_id),
salary = COALESCE($11, salary),
updated_at = NOW()
WHERE id = $12
`, req.FirstName, req.LastName, req.Email, req.Phone,
req.EmploymentType, req.EmploymentStatus,
req.DepartmentID, req.TeamID, req.ManagerID, req.RoleID,
req.Salary, id)
if err != nil {
http.Error(w, `{"error":"failed to update employee"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Employee updated",
})
}
// ==========================================
// TIMELINE
// ==========================================
// TimelineEvent representerar en händelse i tidslinjen
type TimelineEvent struct {
ID string `json:"id"`
EmployeeID string `json:"employee_id"`
EventType string `json:"event_type"`
EventDate time.Time `json:"event_date"`
Title string `json:"title"`
Description *string `json:"description,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// GetTimeline returnerar tidslinje för en medarbetare
func (h *Handler) GetTimeline(w http.ResponseWriter, r *http.Request) {
employeeID := chi.URLParam(r, "id")
rows, err := h.DB.Query(`
SELECT id, event_type, event_date, title, description, metadata, created_at
FROM employee_timeline_events
WHERE employee_id = $1
ORDER BY event_date DESC, created_at DESC
`, employeeID)
if err != nil {
http.Error(w, `{"error":"database error"}`, http.StatusInternalServerError)
return
}
defer rows.Close()
var events []TimelineEvent
for rows.Next() {
var e TimelineEvent
var desc sql.NullString
var meta []byte
rows.Scan(&e.ID, &e.EventType, &e.EventDate, &e.Title, &desc, &meta, &e.CreatedAt)
if desc.Valid { e.Description = &desc.String }
if len(meta) > 0 {
json.Unmarshal(meta, &e.Metadata)
}
e.EmployeeID = employeeID
events = append(events, e)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"events": events,
"total": len(events),
})
}
// AddTimelineEvent lägger till en händelse
func (h *Handler) AddTimelineEvent(w http.ResponseWriter, r *http.Request) {
employeeID := chi.URLParam(r, "id")
var req struct {
EventType string `json:"event_type"`
EventDate time.Time `json:"event_date"`
Title string `json:"title"`
Description string `json:"description"`
Metadata map[string]interface{} `json:"metadata"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
metaJSON, _ := json.Marshal(req.Metadata)
var id string
err := h.DB.QueryRow(`
INSERT INTO employee_timeline_events (employee_id, event_type, event_date, title, description, metadata)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id
`, employeeID, req.EventType, req.EventDate, req.Title, req.Description, metaJSON).Scan(&id)
if err != nil {
http.Error(w, `{"error":"failed to add event"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]interface{}{
"id": id,
"message": "Timeline event added",
})
}
// ==========================================
// COMPETENCES
// ==========================================
// Competence representerar en kompetens
type Competence struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"`
Name string `json:"name"`
Category string `json:"category"`
Description string `json:"description"`
}
// EmployeeCompetence representerar en medarbetares kompetens
type EmployeeCompetence struct {
ID string `json:"id"`
EmployeeID string `json:"employee_id"`
CompetenceID string `json:"competence_id"`
Competence Competence `json:"competence"`
Level int `json:"level"`
AcquiredDate *time.Time `json:"acquired_date,omitempty"`
ValidatedBy *string `json:"validated_by,omitempty"`
ValidationDate *time.Time `json:"validation_date,omitempty"`
Notes string `json:"notes"`
}
// GetCompetences returnerar kompetenser för en medarbetare
func (h *Handler) GetCompetences(w http.ResponseWriter, r *http.Request) {
employeeID := chi.URLParam(r, "id")
rows, err := h.DB.Query(`
SELECT ec.id, ec.competence_id, c.name, c.category, c.description,
ec.level, ec.acquired_date, ec.validated_by, ec.validation_date, ec.notes
FROM employee_competences ec
JOIN competences c ON ec.competence_id = c.id
WHERE ec.employee_id = $1
ORDER BY c.category, c.name
`, employeeID)
if err != nil {
http.Error(w, `{"error":"database error"}`, http.StatusInternalServerError)
return
}
defer rows.Close()
var competences []EmployeeCompetence
for rows.Next() {
var ec EmployeeCompetence
var acquired, validation sql.NullTime
var validatedBy sql.NullString
rows.Scan(&ec.ID, &ec.CompetenceID, &ec.Competence.Name, &ec.Competence.Category, &ec.Competence.Description,
&ec.Level, &acquired, &validatedBy, &validation, &ec.Notes)
if acquired.Valid { ec.AcquiredDate = &acquired.Time }
if validatedBy.Valid { ec.ValidatedBy = &validatedBy.String }
if validation.Valid { ec.ValidationDate = &validation.Time }
competences = append(competences, ec)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"competences": competences,
"total": len(competences),
})
}
// AddCompetence lägger till en kompetens
func (h *Handler) AddCompetence(w http.ResponseWriter, r *http.Request) {
employeeID := chi.URLParam(r, "id")
var req struct {
CompetenceID string `json:"competence_id"`
Level int `json:"level"`
AcquiredDate *time.Time `json:"acquired_date"`
Notes string `json:"notes"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
var id string
err := h.DB.QueryRow(`
INSERT INTO employee_competences (employee_id, competence_id, level, acquired_date, notes)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
`, employeeID, req.CompetenceID, req.Level, req.AcquiredDate, req.Notes).Scan(&id)
if err != nil {
http.Error(w, `{"error":"failed to add competence"}`, http.StatusInternalServerError)
return
}
// Lägg till timeline event
h.DB.Exec(`
INSERT INTO employee_timeline_events (employee_id, event_type, event_date, title, description)
VALUES ($1, 'competence_added', NOW(), 'Kompetens tillagd', $2)
`, employeeID, req.Notes)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]interface{}{
"id": id,
"message": "Competence added",
})
}
// ==========================================
// DOCUMENTS
// ==========================================
// EmployeeDocument representerar ett dokument
type EmployeeDocument struct {
ID string `json:"id"`
EmployeeID string `json:"employee_id"`
TemplateID *string `json:"template_id,omitempty"`
DocumentType string `json:"document_type"`
Title string `json:"title"`
SignatureStatus string `json:"signature_status"`
SignedByEmployeeAt *time.Time `json:"signed_by_employee_at,omitempty"`
SignedByCompanyAt *time.Time `json:"signed_by_company_at,omitempty"`
Version int `json:"version"`
CreatedAt time.Time `json:"created_at"`
}
// GetDocuments returnerar dokument för en medarbetare
func (h *Handler) GetDocuments(w http.ResponseWriter, r *http.Request) {
employeeID := chi.URLParam(r, "id")
rows, err := h.DB.Query(`
SELECT id, template_id, document_type, title, signature_status,
signed_by_employee_at, signed_by_company_at, version, created_at
FROM employee_documents
WHERE employee_id = $1
ORDER BY created_at DESC
`, employeeID)
if err != nil {
http.Error(w, `{"error":"database error"}`, http.StatusInternalServerError)
return
}
defer rows.Close()
var documents []EmployeeDocument
for rows.Next() {
var d EmployeeDocument
var templateID sql.NullString
var signedEmp, signedComp sql.NullTime
rows.Scan(&d.ID, &templateID, &d.DocumentType, &d.Title, &d.SignatureStatus,
&signedEmp, &signedComp, &d.Version, &d.CreatedAt)
if templateID.Valid { d.TemplateID = &templateID.String }
if signedEmp.Valid { d.SignedByEmployeeAt = &signedEmp.Time }
if signedComp.Valid { d.SignedByCompanyAt = &signedComp.Time }
documents = append(documents, d)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"documents": documents,
"total": len(documents),
})
}
// ==========================================
// TRAININGS
// ==========================================
// EmployeeTraining representerar en medarbetares utbildning
type EmployeeTraining struct {
ID string `json:"id"`
TrainingID string `json:"training_id"`
TrainingTitle string `json:"training_title"`
Status string `json:"status"`
ProgressPercent int `json:"progress_percent"`
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
Score *int `json:"score,omitempty"`
CertificateURL *string `json:"certificate_url,omitempty"`
}
// GetTrainings returnerar utbildningar för en medarbetare
func (h *Handler) GetTrainings(w http.ResponseWriter, r *http.Request) {
employeeID := chi.URLParam(r, "id")
rows, err := h.DB.Query(`
SELECT et.id, et.training_id, t.title, et.status, et.progress_percent,
et.started_at, et.completed_at, et.expires_at, et.score, et.certificate_url
FROM employee_trainings et
JOIN trainings t ON et.training_id = t.id
WHERE et.employee_id = $1
ORDER BY et.created_at DESC
`, employeeID)
if err != nil {
http.Error(w, `{"error":"database error"}`, http.StatusInternalServerError)
return
}
defer rows.Close()
var trainings []EmployeeTraining
for rows.Next() {
var et EmployeeTraining
var started, completed, expires sql.NullTime
var score sql.NullInt64
var certURL sql.NullString
rows.Scan(&et.ID, &et.TrainingID, &et.TrainingTitle, &et.Status, &et.ProgressPercent,
&started, &completed, &expires, &score, &certURL)
if started.Valid { et.StartedAt = &started.Time }
if completed.Valid { et.CompletedAt = &completed.Time }
if expires.Valid { et.ExpiresAt = &expires.Time }
if score.Valid { s := int(score.Int64); et.Score = &s }
if certURL.Valid { et.CertificateURL = &certURL.String }
trainings = append(trainings, et)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"trainings": trainings,
"total": len(trainings),
})
}
// ==========================================
// TASKS
// ==========================================
// EmployeeTask representerar en uppgift
type EmployeeTask struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
DueDate *time.Time `json:"due_date,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Source string `json:"source"`
AssignedAt time.Time `json:"assigned_at"`
}
// GetTasks returnerar uppgifter för en medarbetare
func (h *Handler) GetTasks(w http.ResponseWriter, r *http.Request) {
employeeID := chi.URLParam(r, "id")
rows, err := h.DB.Query(`
SELECT id, title, description, status, priority, due_date, completed_at, source, assigned_at
FROM employee_tasks
WHERE employee_id = $1
ORDER BY
CASE status WHEN 'pending' THEN 1 WHEN 'in_progress' THEN 2 ELSE 3 END,
CASE priority WHEN 'critical' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 ELSE 4 END,
due_date ASC
`, employeeID)
if err != nil {
http.Error(w, `{"error":"database error"}`, http.StatusInternalServerError)
return
}
defer rows.Close()
var tasks []EmployeeTask
for rows.Next() {
var t EmployeeTask
var dueDate, completed sql.NullTime
rows.Scan(&t.ID, &t.Title, &t.Description, &t.Status, &t.Priority, &dueDate, &completed, &t.Source, &t.AssignedAt)
if dueDate.Valid { t.DueDate = &dueDate.Time }
if completed.Valid { t.CompletedAt = &completed.Time }
tasks = append(tasks, t)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"tasks": tasks,
"total": len(tasks),
})
}
// ==========================================
// PERFORMANCE
// ==========================================
// PerformanceReview representerar en prestationsbedömning
type PerformanceReview struct {
ID string `json:"id"`
ReviewerID string `json:"reviewer_id"`
ReviewPeriodStart time.Time `json:"review_period_start"`
ReviewPeriodEnd time.Time `json:"review_period_end"`
ReviewType string `json:"review_type"`
OverallRating *int `json:"overall_rating,omitempty"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
// GetPerformance returnerar prestationsbedömningar
func (h *Handler) GetPerformance(w http.ResponseWriter, r *http.Request) {
employeeID := chi.URLParam(r, "id")
rows, err := h.DB.Query(`
SELECT id, reviewer_id, review_period_start, review_period_end, review_type, overall_rating, status, created_at
FROM performance_reviews
WHERE employee_id = $1
ORDER BY review_period_end DESC
`, employeeID)
if err != nil {
http.Error(w, `{"error":"database error"}`, http.StatusInternalServerError)
return
}
defer rows.Close()
var reviews []PerformanceReview
for rows.Next() {
var pr PerformanceReview
var rating sql.NullInt64
rows.Scan(&pr.ID, &pr.ReviewerID, &pr.ReviewPeriodStart, &pr.ReviewPeriodEnd,
&pr.ReviewType, &rating, &pr.Status, &pr.CreatedAt)
if rating.Valid { r := int(rating.Int64); pr.OverallRating = &r }
reviews = append(reviews, pr)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"reviews": reviews,
"total": len(reviews),
})
}