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,192 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"boc/middleware"
|
||||
"boc/repository"
|
||||
)
|
||||
|
||||
// CustomerService innehåller affärslogik för kunder
|
||||
type CustomerService struct {
|
||||
repo *repository.CustomerRepository
|
||||
}
|
||||
|
||||
func NewCustomerService(repo *repository.CustomerRepository) *CustomerService {
|
||||
return &CustomerService{repo: repo}
|
||||
}
|
||||
|
||||
// CreateCustomer skapar en ny kund med validering
|
||||
func (s *CustomerService) CreateCustomer(ctx context.Context, req *CreateCustomerRequest) (*repository.Customer, error) {
|
||||
// Validera input
|
||||
v := middleware.NewValidator()
|
||||
v.ValidateString("name", req.Name, 2, 255, true)
|
||||
v.ValidateEmail("email", req.Email, true)
|
||||
|
||||
if req.Phone != "" {
|
||||
if !middleware.ValidatePhone(req.Phone) {
|
||||
v.AddError("phone", "invalid phone format")
|
||||
}
|
||||
}
|
||||
|
||||
if req.OrgNumber != "" {
|
||||
if !middleware.ValidateOrgNumber(req.OrgNumber) {
|
||||
v.AddError("org_number", "invalid organization number format (XXXXXX-XXXX)")
|
||||
}
|
||||
}
|
||||
|
||||
v.ValidateEnum("status", req.Status, []string{"active", "lead", "prospect", "inactive"}, false)
|
||||
|
||||
if v.HasErrors() {
|
||||
return nil, fmt.Errorf("validation failed: %v", v.Errors())
|
||||
}
|
||||
|
||||
// Skapa kund
|
||||
customer := &repository.Customer{
|
||||
TenantID: req.TenantID,
|
||||
Name: middleware.SanitizeString(req.Name),
|
||||
Email: req.Email,
|
||||
Phone: req.Phone,
|
||||
Company: middleware.SanitizeString(req.Company),
|
||||
OrgNumber: req.OrgNumber,
|
||||
Status: req.Status,
|
||||
Source: req.Source,
|
||||
Tags: req.Tags,
|
||||
AssignedTo: req.AssignedTo,
|
||||
}
|
||||
|
||||
if customer.Status == "" {
|
||||
customer.Status = "lead"
|
||||
}
|
||||
|
||||
if err := s.repo.CreateCustomer(ctx, customer); err != nil {
|
||||
return nil, fmt.Errorf("create customer: %w", err)
|
||||
}
|
||||
|
||||
return customer, nil
|
||||
}
|
||||
|
||||
// GetCustomer hämtar en kund med ID
|
||||
func (s *CustomerService) GetCustomer(ctx context.Context, id string) (*repository.Customer, error) {
|
||||
if !middleware.ValidateUUID(id) {
|
||||
return nil, fmt.Errorf("invalid customer ID")
|
||||
}
|
||||
|
||||
customer, err := s.repo.GetCustomer(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return customer, nil
|
||||
}
|
||||
|
||||
// ListCustomers hämtar kunder med pagination
|
||||
func (s *CustomerService) ListCustomers(ctx context.Context, tenantID, status string, page, pageSize int) (*ListCustomersResponse, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
customers, total, err := s.repo.ListCustomers(ctx, tenantID, status, pageSize, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ListCustomersResponse{
|
||||
Customers: customers,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
Pages: (total + pageSize - 1) / pageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdateCustomer uppdaterar en kund
|
||||
func (s *CustomerService) UpdateCustomer(ctx context.Context, id string, req *UpdateCustomerRequest) (*repository.Customer, error) {
|
||||
if !middleware.ValidateUUID(id) {
|
||||
return nil, fmt.Errorf("invalid customer ID")
|
||||
}
|
||||
|
||||
// Hämta befintlig kund
|
||||
customer, err := s.repo.GetCustomer(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Uppdatera fält
|
||||
if req.Name != "" {
|
||||
customer.Name = middleware.SanitizeString(req.Name)
|
||||
}
|
||||
if req.Email != "" {
|
||||
customer.Email = req.Email
|
||||
}
|
||||
if req.Phone != "" {
|
||||
customer.Phone = req.Phone
|
||||
}
|
||||
if req.Company != "" {
|
||||
customer.Company = middleware.SanitizeString(req.Company)
|
||||
}
|
||||
if req.Status != "" {
|
||||
customer.Status = req.Status
|
||||
}
|
||||
|
||||
if err := s.repo.UpdateCustomer(ctx, customer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return customer, nil
|
||||
}
|
||||
|
||||
// DeleteCustomer tar bort en kund (soft delete)
|
||||
func (s *CustomerService) DeleteCustomer(ctx context.Context, id string) error {
|
||||
if !middleware.ValidateUUID(id) {
|
||||
return fmt.Errorf("invalid customer ID")
|
||||
}
|
||||
|
||||
return s.repo.DeleteCustomer(ctx, id)
|
||||
}
|
||||
|
||||
// SearchCustomers söker efter kunder
|
||||
func (s *CustomerService) SearchCustomers(ctx context.Context, tenantID, query string, limit int) ([]repository.Customer, error) {
|
||||
if limit < 1 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
return s.repo.SearchCustomers(ctx, tenantID, query, limit)
|
||||
}
|
||||
|
||||
// ── Request/Response DTOs ─────────────────────────────────────────────────
|
||||
|
||||
type CreateCustomerRequest struct {
|
||||
TenantID string `json:"tenant_id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
Company string `json:"company,omitempty"`
|
||||
OrgNumber string `json:"org_number,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
AssignedTo *string `json:"assigned_to,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateCustomerRequest struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
Company string `json:"company,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
type ListCustomersResponse struct {
|
||||
Customers []repository.Customer `json:"customers"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Pages int `json:"pages"`
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"boc/repository"
|
||||
)
|
||||
|
||||
// FinanceService handles business logic for finance operations
|
||||
type FinanceService struct {
|
||||
repo *repository.FinanceRepository
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewFinanceService(db *sql.DB) *FinanceService {
|
||||
return &FinanceService{
|
||||
repo: repository.NewFinanceRepository(db),
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// ListInvoices returns all invoices
|
||||
func (s *FinanceService) ListInvoices(ctx context.Context) ([]repository.Invoice, error) {
|
||||
return s.repo.ListInvoices(ctx)
|
||||
}
|
||||
|
||||
// ListExpenses returns all expenses
|
||||
func (s *FinanceService) ListExpenses(ctx context.Context) ([]repository.Expense, error) {
|
||||
return s.repo.ListExpenses(ctx)
|
||||
}
|
||||
|
||||
// CreateExpense creates a new expense with validation
|
||||
func (s *FinanceService) CreateExpense(ctx context.Context, e *repository.Expense) error {
|
||||
if e.Category == "" {
|
||||
return fmt.Errorf("expense category is required")
|
||||
}
|
||||
if e.Amount <= 0 {
|
||||
return fmt.Errorf("expense amount must be positive")
|
||||
}
|
||||
if e.Status == "" {
|
||||
e.Status = "pending"
|
||||
}
|
||||
return s.repo.CreateExpense(ctx, e)
|
||||
}
|
||||
|
||||
// GetCashFlow returns income, outstanding, and expenses
|
||||
func (s *FinanceService) GetCashFlow(ctx context.Context) (income, outstanding, expenses float64, err error) {
|
||||
return s.repo.GetCashFlow(ctx)
|
||||
}
|
||||
|
||||
// GetAccounts returns all ledger accounts
|
||||
func (s *FinanceService) GetAccounts(ctx context.Context) ([]repository.Account, error) {
|
||||
return s.repo.GetAccounts(ctx)
|
||||
}
|
||||
|
||||
// GetBalanceSheet returns a simplified balance sheet
|
||||
func (s *FinanceService) GetBalanceSheet(ctx context.Context) (map[string]interface{}, error) {
|
||||
accounts, err := s.repo.GetAccounts(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var assets, liabilities, equity float64
|
||||
for _, a := range accounts {
|
||||
switch a.Type {
|
||||
case "asset":
|
||||
assets += a.Balance
|
||||
case "liability":
|
||||
liabilities += a.Balance
|
||||
case "equity":
|
||||
equity += a.Balance
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"assets": assets,
|
||||
"liabilities": liabilities,
|
||||
"equity": equity,
|
||||
"accounts": accounts,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"boc/repository"
|
||||
)
|
||||
|
||||
// HRService handles business logic for HR operations
|
||||
type HRService struct {
|
||||
repo *repository.HRRepository
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewHRService(db *sql.DB) *HRService {
|
||||
return &HRService{
|
||||
repo: repository.NewHRRepository(db),
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// ListEmployees returns all employees
|
||||
func (s *HRService) ListEmployees(ctx context.Context) ([]repository.Employee, error) {
|
||||
return s.repo.ListEmployees(ctx)
|
||||
}
|
||||
|
||||
// GetEmployee returns a single employee by ID
|
||||
func (s *HRService) GetEmployee(ctx context.Context, id string) (*repository.Employee, error) {
|
||||
return s.repo.GetEmployee(ctx, id)
|
||||
}
|
||||
|
||||
// CreateEmployee creates a new employee with validation
|
||||
func (s *HRService) CreateEmployee(ctx context.Context, e *repository.Employee) error {
|
||||
if e.Name == "" {
|
||||
return fmt.Errorf("employee name is required")
|
||||
}
|
||||
if e.Email == "" {
|
||||
return fmt.Errorf("employee email is required")
|
||||
}
|
||||
if e.Status == "" {
|
||||
e.Status = "active"
|
||||
}
|
||||
return s.repo.CreateEmployee(ctx, e)
|
||||
}
|
||||
|
||||
// UpdateEmployee updates an existing employee
|
||||
func (s *HRService) UpdateEmployee(ctx context.Context, id string, e *repository.Employee) error {
|
||||
if e.Name == "" {
|
||||
return fmt.Errorf("employee name is required")
|
||||
}
|
||||
return s.repo.UpdateEmployee(ctx, id, e)
|
||||
}
|
||||
|
||||
// ListLeaves returns all leave requests
|
||||
func (s *HRService) ListLeaves(ctx context.Context) ([]repository.Leave, error) {
|
||||
return s.repo.ListLeaves(ctx)
|
||||
}
|
||||
|
||||
// CreateLeave creates a new leave request with validation
|
||||
func (s *HRService) CreateLeave(ctx context.Context, l *repository.Leave) error {
|
||||
if l.EmployeeID == "" {
|
||||
return fmt.Errorf("employee ID is required")
|
||||
}
|
||||
if l.Type == "" {
|
||||
return fmt.Errorf("leave type is required")
|
||||
}
|
||||
if l.Status == "" {
|
||||
l.Status = "pending"
|
||||
}
|
||||
return s.repo.CreateLeave(ctx, l)
|
||||
}
|
||||
|
||||
// ListTimesheets returns all timesheet entries
|
||||
func (s *HRService) ListTimesheets(ctx context.Context) ([]repository.Timesheet, error) {
|
||||
return s.repo.ListTimesheets(ctx)
|
||||
}
|
||||
|
||||
// CreateTimesheet creates a new timesheet entry with validation
|
||||
func (s *HRService) CreateTimesheet(ctx context.Context, t *repository.Timesheet) error {
|
||||
if t.EmployeeID == "" {
|
||||
return fmt.Errorf("employee ID is required")
|
||||
}
|
||||
if t.Hours <= 0 {
|
||||
return fmt.Errorf("hours must be positive")
|
||||
}
|
||||
if t.Status == "" {
|
||||
t.Status = "pending"
|
||||
}
|
||||
return s.repo.CreateTimesheet(ctx, t)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"boc/repository"
|
||||
)
|
||||
|
||||
// LegalService handles business logic for legal operations
|
||||
type LegalService struct {
|
||||
repo *repository.LegalRepository
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewLegalService(db *sql.DB) *LegalService {
|
||||
return &LegalService{
|
||||
repo: repository.NewLegalRepository(db),
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// ListContracts returns all contracts
|
||||
func (s *LegalService) ListContracts(ctx context.Context) ([]repository.Contract, error) {
|
||||
return s.repo.ListContracts(ctx)
|
||||
}
|
||||
|
||||
// GetContract returns a single contract by ID
|
||||
func (s *LegalService) GetContract(ctx context.Context, id string) (*repository.Contract, error) {
|
||||
return s.repo.GetContract(ctx, id)
|
||||
}
|
||||
|
||||
// CreateContract creates a new contract with validation
|
||||
func (s *LegalService) CreateContract(ctx context.Context, c *repository.Contract) error {
|
||||
if c.Title == "" {
|
||||
return fmt.Errorf("contract title is required")
|
||||
}
|
||||
if c.Type == "" {
|
||||
return fmt.Errorf("contract type is required")
|
||||
}
|
||||
if c.CustomerID == "" {
|
||||
return fmt.Errorf("customer ID is required")
|
||||
}
|
||||
if c.Status == "" {
|
||||
c.Status = "draft"
|
||||
}
|
||||
if c.Currency == "" {
|
||||
c.Currency = "USD"
|
||||
}
|
||||
return s.repo.CreateContract(ctx, c)
|
||||
}
|
||||
|
||||
// UpdateContract updates an existing contract
|
||||
func (s *LegalService) UpdateContract(ctx context.Context, id string, c *repository.Contract) error {
|
||||
if c.Title == "" {
|
||||
return fmt.Errorf("contract title is required")
|
||||
}
|
||||
return s.repo.UpdateContract(ctx, id, c)
|
||||
}
|
||||
|
||||
// ListTemplates returns all contract templates
|
||||
func (s *LegalService) ListTemplates(ctx context.Context) ([]repository.ContractTemplate, error) {
|
||||
return s.repo.ListTemplates(ctx)
|
||||
}
|
||||
|
||||
// GetTemplate returns a single template by type
|
||||
func (s *LegalService) GetTemplate(ctx context.Context, contractType string) (*repository.ContractTemplate, error) {
|
||||
return s.repo.GetTemplate(ctx, contractType)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"boc/repository"
|
||||
)
|
||||
|
||||
// SalesService handles business logic for sales operations
|
||||
type SalesService struct {
|
||||
repo *repository.SalesRepository
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewSalesService(db *sql.DB) *SalesService {
|
||||
return &SalesService{
|
||||
repo: repository.NewSalesRepository(db),
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// ListDeals returns all deals filtered by status
|
||||
func (s *SalesService) ListDeals(ctx context.Context, status string) ([]repository.Deal, error) {
|
||||
return s.repo.ListDeals(ctx, status)
|
||||
}
|
||||
|
||||
// GetDeal returns a single deal by ID
|
||||
func (s *SalesService) GetDeal(ctx context.Context, id string) (*repository.Deal, error) {
|
||||
return s.repo.GetDeal(ctx, id)
|
||||
}
|
||||
|
||||
// CreateDeal creates a new deal with validation
|
||||
func (s *SalesService) CreateDeal(ctx context.Context, d *repository.Deal) error {
|
||||
if d.Name == "" {
|
||||
return fmt.Errorf("deal name is required")
|
||||
}
|
||||
if d.CustomerID == "" {
|
||||
return fmt.Errorf("customer ID is required")
|
||||
}
|
||||
if d.Value <= 0 {
|
||||
return fmt.Errorf("deal value must be positive")
|
||||
}
|
||||
if d.Status == "" {
|
||||
d.Status = "open"
|
||||
}
|
||||
if d.Stage == "" {
|
||||
d.Stage = "discovery"
|
||||
}
|
||||
if d.Currency == "" {
|
||||
d.Currency = "USD"
|
||||
}
|
||||
return s.repo.CreateDeal(ctx, d)
|
||||
}
|
||||
|
||||
// UpdateDeal updates an existing deal
|
||||
func (s *SalesService) UpdateDeal(ctx context.Context, id string, d *repository.Deal) error {
|
||||
if d.Name == "" {
|
||||
return fmt.Errorf("deal name is required")
|
||||
}
|
||||
return s.repo.UpdateDeal(ctx, id, d)
|
||||
}
|
||||
|
||||
// ListProducts returns all active products
|
||||
func (s *SalesService) ListProducts(ctx context.Context) ([]repository.Product, error) {
|
||||
return s.repo.ListProducts(ctx)
|
||||
}
|
||||
|
||||
// GetMRR calculates monthly recurring revenue
|
||||
func (s *SalesService) GetMRR(ctx context.Context) (float64, error) {
|
||||
return s.repo.GetMRR(ctx)
|
||||
}
|
||||
|
||||
// GetARR calculates annual recurring revenue
|
||||
func (s *SalesService) GetARR(ctx context.Context) (float64, error) {
|
||||
return s.repo.GetARR(ctx)
|
||||
}
|
||||
Reference in New Issue
Block a user