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,214 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// CustomerRepository hanterar alla customer-relaterade databasoperationer
|
||||
type CustomerRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewCustomerRepository(db *sql.DB) *CustomerRepository {
|
||||
return &CustomerRepository{db: db}
|
||||
}
|
||||
|
||||
// Customer representerar en kund
|
||||
type Customer struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
Company string `json:"company"`
|
||||
OrgNumber string `json:"org_number"`
|
||||
Status string `json:"status"`
|
||||
Source string `json:"source"`
|
||||
Tags []string `json:"tags"`
|
||||
AssignedTo *string `json:"assigned_to"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CreateCustomer skapar en ny kund
|
||||
func (r *CustomerRepository) CreateCustomer(ctx context.Context, c *Customer) error {
|
||||
query := `
|
||||
INSERT INTO boc_customers (tenant_id, name, email, phone, company, org_number, status, source, tags, assigned_to)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id, created_at, updated_at
|
||||
`
|
||||
|
||||
err := r.db.QueryRowContext(ctx, query,
|
||||
c.TenantID, c.Name, c.Email, c.Phone, c.Company, c.OrgNumber,
|
||||
c.Status, c.Source, pq.Array(c.Tags), c.AssignedTo,
|
||||
).Scan(&c.ID, &c.CreatedAt, &c.UpdatedAt)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("create customer: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCustomer hämtar en kund med ID
|
||||
func (r *CustomerRepository) GetCustomer(ctx context.Context, id string) (*Customer, error) {
|
||||
query := `
|
||||
SELECT id, tenant_id, name, email, phone, company, org_number, status, source, tags, assigned_to, created_at, updated_at
|
||||
FROM boc_customers
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
var c Customer
|
||||
var tags pq.StringArray
|
||||
|
||||
err := r.db.QueryRowContext(ctx, query, id).Scan(
|
||||
&c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company,
|
||||
&c.OrgNumber, &c.Status, &c.Source, &tags, &c.AssignedTo,
|
||||
&c.CreatedAt, &c.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("customer not found")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get customer: %w", err)
|
||||
}
|
||||
|
||||
c.Tags = []string(tags)
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// ListCustomers hämtar kunder med pagination
|
||||
func (r *CustomerRepository) ListCustomers(ctx context.Context, tenantID, status string, limit, offset int) ([]Customer, int, error) {
|
||||
// Hämta total count
|
||||
var total int
|
||||
countQuery := `SELECT COUNT(*) FROM boc_customers WHERE tenant_id = $1 AND status = $2`
|
||||
if err := r.db.QueryRowContext(ctx, countQuery, tenantID, status).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count customers: %w", err)
|
||||
}
|
||||
|
||||
// Hämta kunder
|
||||
query := `
|
||||
SELECT id, tenant_id, name, email, phone, company, org_number, status, source, tags, assigned_to, created_at, updated_at
|
||||
FROM boc_customers
|
||||
WHERE tenant_id = $1 AND status = $2
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $3 OFFSET $4
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, query, tenantID, status, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("list customers: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var customers []Customer
|
||||
for rows.Next() {
|
||||
var c Customer
|
||||
var tags pq.StringArray
|
||||
|
||||
if err := rows.Scan(
|
||||
&c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company,
|
||||
&c.OrgNumber, &c.Status, &c.Source, &tags, &c.AssignedTo,
|
||||
&c.CreatedAt, &c.UpdatedAt,
|
||||
); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
c.Tags = []string(tags)
|
||||
customers = append(customers, c)
|
||||
}
|
||||
|
||||
return customers, total, rows.Err()
|
||||
}
|
||||
|
||||
// UpdateCustomer uppdaterar en kund
|
||||
func (r *CustomerRepository) UpdateCustomer(ctx context.Context, c *Customer) error {
|
||||
query := `
|
||||
UPDATE boc_customers
|
||||
SET name = $1, email = $2, phone = $3, company = $4, org_number = $5,
|
||||
status = $6, source = $7, tags = $8, assigned_to = $9, updated_at = NOW()
|
||||
WHERE id = $10
|
||||
RETURNING updated_at
|
||||
`
|
||||
|
||||
err := r.db.QueryRowContext(ctx, query,
|
||||
c.Name, c.Email, c.Phone, c.Company, c.OrgNumber,
|
||||
c.Status, c.Source, pq.Array(c.Tags), c.AssignedTo, c.ID,
|
||||
).Scan(&c.UpdatedAt)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return fmt.Errorf("customer not found")
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("update customer: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteCustomer markerar en kund som borttagen (soft delete)
|
||||
func (r *CustomerRepository) DeleteCustomer(ctx context.Context, id string) error {
|
||||
query := `
|
||||
UPDATE boc_customers
|
||||
SET status = 'deleted', updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
result, err := r.db.ExecContext(ctx, query, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete customer: %w", err)
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("customer not found")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SearchCustomers söker efter kunder
|
||||
func (r *CustomerRepository) SearchCustomers(ctx context.Context, tenantID, query string, limit int) ([]Customer, error) {
|
||||
sql := `
|
||||
SELECT id, tenant_id, name, email, phone, company, org_number, status, source, tags, assigned_to, created_at, updated_at
|
||||
FROM boc_customers
|
||||
WHERE tenant_id = $1
|
||||
AND status != 'deleted'
|
||||
AND (name ILIKE $2 OR email ILIKE $2 OR company ILIKE $2)
|
||||
ORDER BY name
|
||||
LIMIT $3
|
||||
`
|
||||
|
||||
searchTerm := "%" + query + "%"
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, sql, tenantID, searchTerm, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search customers: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var customers []Customer
|
||||
for rows.Next() {
|
||||
var c Customer
|
||||
var tags pq.StringArray
|
||||
|
||||
if err := rows.Scan(
|
||||
&c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company,
|
||||
&c.OrgNumber, &c.Status, &c.Source, &tags, &c.AssignedTo,
|
||||
&c.CreatedAt, &c.UpdatedAt,
|
||||
); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
c.Tags = []string(tags)
|
||||
customers = append(customers, c)
|
||||
}
|
||||
|
||||
return customers, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Invoice represents a financial invoice
|
||||
type Invoice struct {
|
||||
ID string `json:"id"`
|
||||
CustomerID string `json:"customer_id"`
|
||||
Amount float64 `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
Status string `json:"status"`
|
||||
DueDate *time.Time `json:"due_date"`
|
||||
PaidAt *time.Time `json:"paid_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Expense represents a business expense
|
||||
type Expense struct {
|
||||
ID string `json:"id"`
|
||||
Category string `json:"category"`
|
||||
Description string `json:"description"`
|
||||
Amount float64 `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
Date time.Time `json:"date"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Account represents a ledger account
|
||||
type Account struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Balance float64 `json:"balance"`
|
||||
}
|
||||
|
||||
// FinanceRepository handles all finance-related database operations
|
||||
type FinanceRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewFinanceRepository(db *sql.DB) *FinanceRepository {
|
||||
return &FinanceRepository{db: db}
|
||||
}
|
||||
|
||||
// ListInvoices returns all invoices
|
||||
func (r *FinanceRepository) ListInvoices(ctx context.Context) ([]Invoice, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, customer_id, amount, currency, status, due_date, paid_at, created_at
|
||||
FROM boc_invoices
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var invoices []Invoice
|
||||
for rows.Next() {
|
||||
var i Invoice
|
||||
if err := rows.Scan(&i.ID, &i.CustomerID, &i.Amount, &i.Currency, &i.Status, &i.DueDate, &i.PaidAt, &i.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
invoices = append(invoices, i)
|
||||
}
|
||||
return invoices, rows.Err()
|
||||
}
|
||||
|
||||
// ListExpenses returns all expenses
|
||||
func (r *FinanceRepository) ListExpenses(ctx context.Context) ([]Expense, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, category, description, amount, currency, date, status, created_at
|
||||
FROM boc_expenses
|
||||
ORDER BY date DESC
|
||||
LIMIT 100
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var expenses []Expense
|
||||
for rows.Next() {
|
||||
var e Expense
|
||||
if err := rows.Scan(&e.ID, &e.Category, &e.Description, &e.Amount, &e.Currency, &e.Date, &e.Status, &e.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
expenses = append(expenses, e)
|
||||
}
|
||||
return expenses, rows.Err()
|
||||
}
|
||||
|
||||
// CreateExpense creates a new expense
|
||||
func (r *FinanceRepository) CreateExpense(ctx context.Context, e *Expense) error {
|
||||
return r.db.QueryRowContext(ctx, `
|
||||
INSERT INTO boc_expenses (category, description, amount, currency, date, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, created_at
|
||||
`, e.Category, e.Description, e.Amount, e.Currency, e.Date, e.Status).Scan(&e.ID, &e.CreatedAt)
|
||||
}
|
||||
|
||||
// GetCashFlow returns income, outstanding, and expenses for the last month
|
||||
func (r *FinanceRepository) GetCashFlow(ctx context.Context) (income, outstanding, expenses float64, err error) {
|
||||
err = r.db.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(SUM(amount), 0)
|
||||
FROM boc_invoices
|
||||
WHERE status = 'paid' AND paid_at >= NOW() - INTERVAL '1 month'
|
||||
`).Scan(&income)
|
||||
if err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
|
||||
err = r.db.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(SUM(amount), 0)
|
||||
FROM boc_invoices
|
||||
WHERE status = 'sent'
|
||||
`).Scan(&outstanding)
|
||||
if err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
|
||||
err = r.db.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(SUM(amount), 0)
|
||||
FROM boc_expenses
|
||||
WHERE status = 'approved' AND created_at >= NOW() - INTERVAL '1 month'
|
||||
`).Scan(&expenses)
|
||||
if err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
|
||||
return income, outstanding, expenses, nil
|
||||
}
|
||||
|
||||
// GetAccounts returns all ledger accounts
|
||||
func (r *FinanceRepository) GetAccounts(ctx context.Context) ([]Account, error) {
|
||||
// This should ideally query aamos-ledger, but for now return from local cache
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT code, name, type, COALESCE(balance, 0)
|
||||
FROM boc_accounts
|
||||
ORDER BY code
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var accounts []Account
|
||||
for rows.Next() {
|
||||
var a Account
|
||||
if err := rows.Scan(&a.Code, &a.Name, &a.Type, &a.Balance); err != nil {
|
||||
continue
|
||||
}
|
||||
accounts = append(accounts, a)
|
||||
}
|
||||
return accounts, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Employee represents a company employee
|
||||
type Employee struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
Department string `json:"department"`
|
||||
Position string `json:"position"`
|
||||
StartDate time.Time `json:"start_date"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Leave represents a leave request
|
||||
type Leave struct {
|
||||
ID string `json:"id"`
|
||||
EmployeeID string `json:"employee_id"`
|
||||
Type string `json:"type"`
|
||||
StartDate time.Time `json:"start_date"`
|
||||
EndDate time.Time `json:"end_date"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Timesheet represents a time entry
|
||||
type Timesheet struct {
|
||||
ID string `json:"id"`
|
||||
EmployeeID string `json:"employee_id"`
|
||||
ProjectID *string `json:"project_id"`
|
||||
Date time.Time `json:"date"`
|
||||
Hours float64 `json:"hours"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// HRRepository handles all HR-related database operations
|
||||
type HRRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewHRRepository(db *sql.DB) *HRRepository {
|
||||
return &HRRepository{db: db}
|
||||
}
|
||||
|
||||
// ListEmployees returns all employees
|
||||
func (r *HRRepository) ListEmployees(ctx context.Context) ([]Employee, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, name, email, phone, department, position, start_date, status, created_at, updated_at
|
||||
FROM boc_employees
|
||||
ORDER BY name
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var employees []Employee
|
||||
for rows.Next() {
|
||||
var e Employee
|
||||
if err := rows.Scan(&e.ID, &e.Name, &e.Email, &e.Phone, &e.Department, &e.Position, &e.StartDate, &e.Status, &e.CreatedAt, &e.UpdatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
employees = append(employees, e)
|
||||
}
|
||||
return employees, rows.Err()
|
||||
}
|
||||
|
||||
// GetEmployee returns a single employee by ID
|
||||
func (r *HRRepository) GetEmployee(ctx context.Context, id string) (*Employee, error) {
|
||||
var e Employee
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, name, email, phone, department, position, start_date, status, created_at, updated_at
|
||||
FROM boc_employees
|
||||
WHERE id = $1
|
||||
`, id).Scan(&e.ID, &e.Name, &e.Email, &e.Phone, &e.Department, &e.Position, &e.StartDate, &e.Status, &e.CreatedAt, &e.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
// CreateEmployee creates a new employee
|
||||
func (r *HRRepository) CreateEmployee(ctx context.Context, e *Employee) error {
|
||||
return r.db.QueryRowContext(ctx, `
|
||||
INSERT INTO boc_employees (name, email, phone, department, position, start_date, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, created_at, updated_at
|
||||
`, e.Name, e.Email, e.Phone, e.Department, e.Position, e.StartDate, e.Status).Scan(&e.ID, &e.CreatedAt, &e.UpdatedAt)
|
||||
}
|
||||
|
||||
// UpdateEmployee updates an existing employee
|
||||
func (r *HRRepository) UpdateEmployee(ctx context.Context, id string, e *Employee) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
UPDATE boc_employees
|
||||
SET name = $1, email = $2, phone = $3, department = $4, position = $5, start_date = $6, status = $7, updated_at = NOW()
|
||||
WHERE id = $8
|
||||
`, e.Name, e.Email, e.Phone, e.Department, e.Position, e.StartDate, e.Status, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListLeaves returns all leave requests
|
||||
func (r *HRRepository) ListLeaves(ctx context.Context) ([]Leave, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, employee_id, type, start_date, end_date, status, created_at
|
||||
FROM boc_leaves
|
||||
ORDER BY start_date DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var leaves []Leave
|
||||
for rows.Next() {
|
||||
var l Leave
|
||||
if err := rows.Scan(&l.ID, &l.EmployeeID, &l.Type, &l.StartDate, &l.EndDate, &l.Status, &l.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
leaves = append(leaves, l)
|
||||
}
|
||||
return leaves, rows.Err()
|
||||
}
|
||||
|
||||
// CreateLeave creates a new leave request
|
||||
func (r *HRRepository) CreateLeave(ctx context.Context, l *Leave) error {
|
||||
return r.db.QueryRowContext(ctx, `
|
||||
INSERT INTO boc_leaves (employee_id, type, start_date, end_date, status)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, created_at
|
||||
`, l.EmployeeID, l.Type, l.StartDate, l.EndDate, l.Status).Scan(&l.ID, &l.CreatedAt)
|
||||
}
|
||||
|
||||
// ListTimesheets returns all timesheet entries
|
||||
func (r *HRRepository) ListTimesheets(ctx context.Context) ([]Timesheet, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, employee_id, project_id, date, hours, description, status, created_at
|
||||
FROM boc_timesheets
|
||||
ORDER BY date DESC
|
||||
LIMIT 100
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var timesheets []Timesheet
|
||||
for rows.Next() {
|
||||
var t Timesheet
|
||||
if err := rows.Scan(&t.ID, &t.EmployeeID, &t.ProjectID, &t.Date, &t.Hours, &t.Description, &t.Status, &t.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
timesheets = append(timesheets, t)
|
||||
}
|
||||
return timesheets, rows.Err()
|
||||
}
|
||||
|
||||
// CreateTimesheet creates a new timesheet entry
|
||||
func (r *HRRepository) CreateTimesheet(ctx context.Context, t *Timesheet) error {
|
||||
return r.db.QueryRowContext(ctx, `
|
||||
INSERT INTO boc_timesheets (employee_id, project_id, date, hours, description, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, created_at
|
||||
`, t.EmployeeID, t.ProjectID, t.Date, t.Hours, t.Description, t.Status).Scan(&t.ID, &t.CreatedAt)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Contract represents a legal contract
|
||||
type Contract struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
CustomerID string `json:"customer_id"`
|
||||
Value float64 `json:"value"`
|
||||
Currency string `json:"currency"`
|
||||
StartDate time.Time `json:"start_date"`
|
||||
EndDate time.Time `json:"end_date"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ContractTemplate represents a reusable contract template
|
||||
type ContractTemplate struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content"`
|
||||
Language string `json:"language"`
|
||||
}
|
||||
|
||||
// LegalRepository handles all legal-related database operations
|
||||
type LegalRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewLegalRepository(db *sql.DB) *LegalRepository {
|
||||
return &LegalRepository{db: db}
|
||||
}
|
||||
|
||||
// ListContracts returns all contracts
|
||||
func (r *LegalRepository) ListContracts(ctx context.Context) ([]Contract, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, title, type, customer_id, value, currency, start_date, end_date, status, created_at, updated_at
|
||||
FROM boc_contracts
|
||||
ORDER BY created_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var contracts []Contract
|
||||
for rows.Next() {
|
||||
var c Contract
|
||||
if err := rows.Scan(&c.ID, &c.Title, &c.Type, &c.CustomerID, &c.Value, &c.Currency, &c.StartDate, &c.EndDate, &c.Status, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
contracts = append(contracts, c)
|
||||
}
|
||||
return contracts, rows.Err()
|
||||
}
|
||||
|
||||
// GetContract returns a single contract by ID
|
||||
func (r *LegalRepository) GetContract(ctx context.Context, id string) (*Contract, error) {
|
||||
var c Contract
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, title, type, customer_id, value, currency, start_date, end_date, status, created_at, updated_at
|
||||
FROM boc_contracts
|
||||
WHERE id = $1
|
||||
`, id).Scan(&c.ID, &c.Title, &c.Type, &c.CustomerID, &c.Value, &c.Currency, &c.StartDate, &c.EndDate, &c.Status, &c.CreatedAt, &c.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// CreateContract creates a new contract
|
||||
func (r *LegalRepository) CreateContract(ctx context.Context, c *Contract) error {
|
||||
return r.db.QueryRowContext(ctx, `
|
||||
INSERT INTO boc_contracts (title, type, customer_id, value, currency, start_date, end_date, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, created_at, updated_at
|
||||
`, c.Title, c.Type, c.CustomerID, c.Value, c.Currency, c.StartDate, c.EndDate, c.Status).Scan(&c.ID, &c.CreatedAt, &c.UpdatedAt)
|
||||
}
|
||||
|
||||
// UpdateContract updates an existing contract
|
||||
func (r *LegalRepository) UpdateContract(ctx context.Context, id string, c *Contract) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
UPDATE boc_contracts
|
||||
SET title = $1, type = $2, customer_id = $3, value = $4, currency = $5, start_date = $6, end_date = $7, status = $8, updated_at = NOW()
|
||||
WHERE id = $9
|
||||
`, c.Title, c.Type, c.CustomerID, c.Value, c.Currency, c.StartDate, c.EndDate, c.Status, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListTemplates returns all contract templates
|
||||
func (r *LegalRepository) ListTemplates(ctx context.Context) ([]ContractTemplate, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, name, type, content, language
|
||||
FROM boc_contract_templates
|
||||
ORDER BY name
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var templates []ContractTemplate
|
||||
for rows.Next() {
|
||||
var t ContractTemplate
|
||||
if err := rows.Scan(&t.ID, &t.Name, &t.Type, &t.Content, &t.Language); err != nil {
|
||||
continue
|
||||
}
|
||||
templates = append(templates, t)
|
||||
}
|
||||
return templates, rows.Err()
|
||||
}
|
||||
|
||||
// GetTemplate returns a single template by type
|
||||
func (r *LegalRepository) GetTemplate(ctx context.Context, contractType string) (*ContractTemplate, error) {
|
||||
var t ContractTemplate
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, name, type, content, language
|
||||
FROM boc_contract_templates
|
||||
WHERE type = $1
|
||||
LIMIT 1
|
||||
`, contractType).Scan(&t.ID, &t.Name, &t.Type, &t.Content, &t.Language)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Deal represents a sales opportunity
|
||||
type Deal struct {
|
||||
ID string `json:"id"`
|
||||
CustomerID string `json:"customer_id"`
|
||||
ContactID *string `json:"contact_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Value float64 `json:"value"`
|
||||
Currency string `json:"currency"`
|
||||
Status string `json:"status"`
|
||||
Stage string `json:"stage"`
|
||||
Probability int `json:"probability"`
|
||||
ExpectedClose *time.Time `json:"expected_close"`
|
||||
ActualClose *time.Time `json:"actual_close"`
|
||||
AssignedTo *string `json:"assigned_to"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Product represents a sellable product/service
|
||||
type Product struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
SKU string `json:"sku"`
|
||||
Price float64 `json:"price"`
|
||||
Currency string `json:"currency"`
|
||||
Unit string `json:"unit"`
|
||||
IsRecurring bool `json:"is_recurring"`
|
||||
BillingPeriod string `json:"billing_period"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// SalesRepository handles all sales-related database operations
|
||||
type SalesRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewSalesRepository(db *sql.DB) *SalesRepository {
|
||||
return &SalesRepository{db: db}
|
||||
}
|
||||
|
||||
// ListDeals returns all deals filtered by status
|
||||
func (r *SalesRepository) ListDeals(ctx context.Context, status string) ([]Deal, error) {
|
||||
if status == "" {
|
||||
status = "active"
|
||||
}
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, customer_id, contact_id, name, description, value, currency, status, stage, probability, expected_close, actual_close, assigned_to, created_at, updated_at
|
||||
FROM boc_deals
|
||||
WHERE status = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100
|
||||
`, status)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var deals []Deal
|
||||
for rows.Next() {
|
||||
var d Deal
|
||||
if err := rows.Scan(&d.ID, &d.CustomerID, &d.ContactID, &d.Name, &d.Description, &d.Value,
|
||||
&d.Currency, &d.Status, &d.Stage, &d.Probability, &d.ExpectedClose, &d.ActualClose,
|
||||
&d.AssignedTo, &d.CreatedAt, &d.UpdatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
deals = append(deals, d)
|
||||
}
|
||||
return deals, rows.Err()
|
||||
}
|
||||
|
||||
// GetDeal returns a single deal by ID
|
||||
func (r *SalesRepository) GetDeal(ctx context.Context, id string) (*Deal, error) {
|
||||
var d Deal
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, customer_id, contact_id, name, description, value, currency, status, stage, probability, expected_close, actual_close, assigned_to, created_at, updated_at
|
||||
FROM boc_deals
|
||||
WHERE id = $1
|
||||
`, id).Scan(&d.ID, &d.CustomerID, &d.ContactID, &d.Name, &d.Description, &d.Value,
|
||||
&d.Currency, &d.Status, &d.Stage, &d.Probability, &d.ExpectedClose, &d.ActualClose,
|
||||
&d.AssignedTo, &d.CreatedAt, &d.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// CreateDeal creates a new deal
|
||||
func (r *SalesRepository) CreateDeal(ctx context.Context, d *Deal) error {
|
||||
return r.db.QueryRowContext(ctx, `
|
||||
INSERT INTO boc_deals (customer_id, contact_id, name, description, value, currency, status, stage, probability, expected_close, assigned_to)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING id, created_at, updated_at
|
||||
`, d.CustomerID, d.ContactID, d.Name, d.Description, d.Value, d.Currency, d.Status, d.Stage, d.Probability, d.ExpectedClose, d.AssignedTo).Scan(&d.ID, &d.CreatedAt, &d.UpdatedAt)
|
||||
}
|
||||
|
||||
// UpdateDeal updates an existing deal
|
||||
func (r *SalesRepository) UpdateDeal(ctx context.Context, id string, d *Deal) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
UPDATE boc_deals
|
||||
SET customer_id = $1, contact_id = $2, name = $3, description = $4, value = $5, currency = $6, status = $7, stage = $8, probability = $9, expected_close = $10, assigned_to = $11, updated_at = NOW()
|
||||
WHERE id = $12
|
||||
`, d.CustomerID, d.ContactID, d.Name, d.Description, d.Value, d.Currency, d.Status, d.Stage, d.Probability, d.ExpectedClose, d.AssignedTo, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListProducts returns all active products
|
||||
func (r *SalesRepository) ListProducts(ctx context.Context) ([]Product, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, name, description, sku, price, currency, unit, is_recurring, billing_period, status
|
||||
FROM boc_products
|
||||
WHERE status = 'active'
|
||||
ORDER BY name
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var products []Product
|
||||
for rows.Next() {
|
||||
var p Product
|
||||
if err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.SKU, &p.Price, &p.Currency, &p.Unit, &p.IsRecurring, &p.BillingPeriod, &p.Status); err != nil {
|
||||
continue
|
||||
}
|
||||
products = append(products, p)
|
||||
}
|
||||
return products, rows.Err()
|
||||
}
|
||||
|
||||
// GetMRR calculates monthly recurring revenue
|
||||
func (r *SalesRepository) GetMRR(ctx context.Context) (float64, error) {
|
||||
var mrr float64
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(SUM(value * probability / 100.0), 0)
|
||||
FROM boc_deals
|
||||
WHERE status = 'open' AND stage IN ('negotiation', 'proposal')
|
||||
`).Scan(&mrr)
|
||||
return mrr, err
|
||||
}
|
||||
|
||||
// GetARR calculates annual recurring revenue
|
||||
func (r *SalesRepository) GetARR(ctx context.Context) (float64, error) {
|
||||
var arr float64
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(SUM(value * probability / 100.0) * 12, 0)
|
||||
FROM boc_deals
|
||||
WHERE status = 'open' AND stage IN ('negotiation', 'proposal')
|
||||
`).Scan(&arr)
|
||||
return arr, err
|
||||
}
|
||||
Reference in New Issue
Block a user