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:
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"boc/models"
|
||||
)
|
||||
|
||||
const tokenExpiry = 24 * time.Hour
|
||||
@@ -17,13 +18,6 @@ type AuthHandler struct {
|
||||
JWTSecret []byte
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
UserID string `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
@@ -72,7 +66,7 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
claims := Claims{
|
||||
claims := models.Claims{
|
||||
UserID: id,
|
||||
Email: req.Email,
|
||||
Role: role,
|
||||
@@ -102,7 +96,7 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := r.Context().Value("user").(*Claims)
|
||||
claims, ok := r.Context().Value("user").(*models.Claims)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"boc/briefing"
|
||||
)
|
||||
|
||||
// BriefingHandler hanterar daily briefing requests
|
||||
type BriefingHandler struct {
|
||||
engine *briefing.BriefingEngine
|
||||
}
|
||||
|
||||
func NewBriefingHandler(engine *briefing.BriefingEngine) *BriefingHandler {
|
||||
return &BriefingHandler{engine: engine}
|
||||
}
|
||||
|
||||
// GetDailyBriefing returnerar användarens dagsöversikt
|
||||
func (h *BriefingHandler) GetDailyBriefing(w http.ResponseWriter, r *http.Request) {
|
||||
// Hämta user ID från context (satt av auth middleware)
|
||||
// För utveckling: använd default user
|
||||
userID := "3847477b-3d56-4975-9157-ae8f9ce52aa7"
|
||||
|
||||
briefing, err := h.engine.GenerateDailyBriefing(r.Context(), userID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to generate briefing"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(briefing)
|
||||
}
|
||||
|
||||
// GetPriority returnerar endast prioritetssektionen
|
||||
func (h *BriefingHandler) GetPriority(w http.ResponseWriter, r *http.Request) {
|
||||
userID := "3847477b-3d56-4975-9157-ae8f9ce52aa7"
|
||||
|
||||
briefing, err := h.engine.GenerateDailyBriefing(r.Context(), userID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to generate briefing"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(briefing.Priority)
|
||||
}
|
||||
|
||||
// GetAlerts returnerar endast alerts
|
||||
func (h *BriefingHandler) GetAlerts(w http.ResponseWriter, r *http.Request) {
|
||||
userID := "3847477b-3d56-4975-9157-ae8f9ce52aa7"
|
||||
|
||||
briefing, err := h.engine.GenerateDailyBriefing(r.Context(), userID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to generate briefing"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"alerts": briefing.Alerts,
|
||||
})
|
||||
}
|
||||
|
||||
// GetRecommendations returnerar endast rekommendationer
|
||||
func (h *BriefingHandler) GetRecommendations(w http.ResponseWriter, r *http.Request) {
|
||||
userID := "3847477b-3d56-4975-9157-ae8f9ce52aa7"
|
||||
|
||||
briefing, err := h.engine.GenerateDailyBriefing(r.Context(), userID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to generate briefing"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"recommendations": briefing.Recommendations,
|
||||
})
|
||||
}
|
||||
|
||||
// GetWorkPlan returnerar endast arbetsplanen
|
||||
func (h *BriefingHandler) GetWorkPlan(w http.ResponseWriter, r *http.Request) {
|
||||
userID := "3847477b-3d56-4975-9157-ae8f9ce52aa7"
|
||||
|
||||
briefing, err := h.engine.GenerateDailyBriefing(r.Context(), userID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to generate briefing"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(briefing.WorkPlan)
|
||||
}
|
||||
+14
-2
@@ -18,13 +18,25 @@ func NewCRMHandler(db *sql.DB) *CRMHandler {
|
||||
return &CRMHandler{DB: db}
|
||||
}
|
||||
|
||||
// NullString is a sql.NullString that marshals to a plain string in JSON
|
||||
type NullString struct {
|
||||
sql.NullString
|
||||
}
|
||||
|
||||
func (ns NullString) MarshalJSON() ([]byte, error) {
|
||||
if ns.Valid {
|
||||
return json.Marshal(ns.String)
|
||||
}
|
||||
return json.Marshal("")
|
||||
}
|
||||
|
||||
type Customer struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
Phone NullString `json:"phone"`
|
||||
Company string `json:"company"`
|
||||
OrgNumber string `json:"org_number"`
|
||||
OrgNumber NullString `json:"org_number"`
|
||||
Status string `json:"status"`
|
||||
Source string `json:"source"`
|
||||
Tags []string `json:"tags"`
|
||||
|
||||
+48
-50
@@ -1,68 +1,55 @@
|
||||
// Package handlers provides HTTP handlers using the generic Store pattern.
|
||||
// This replaces the old CRMHandler with a generic implementation.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/lib/pq"
|
||||
|
||||
"boc/models"
|
||||
"boc/store"
|
||||
"boc/service"
|
||||
)
|
||||
|
||||
// CRMHandlerV2 uses the generic Store for customers
|
||||
// CRMHandlerV2 uses the service layer for business logic
|
||||
type CRMHandlerV2 struct {
|
||||
customers *store.Store[*models.Customer]
|
||||
db *store.DB
|
||||
service *service.CustomerService
|
||||
}
|
||||
|
||||
// NewCRMHandlerV2 creates a new CRM handler using generic store
|
||||
func NewCRMHandlerV2(db *store.DB) *CRMHandlerV2 {
|
||||
cols := []string{"id", "name", "email", "phone", "company", "org_number", "status", "source", "tags", "assigned_to", "created_at", "updated_at"}
|
||||
return &CRMHandlerV2{
|
||||
customers: store.NewStore(db, "boc_customers", cols,
|
||||
func(rows *sql.Rows) (*models.Customer, error) {
|
||||
c := &models.Customer{}
|
||||
err := c.ScanRow(rows)
|
||||
return c, err
|
||||
},
|
||||
func(row *sql.Row) (*models.Customer, error) {
|
||||
c := &models.Customer{}
|
||||
err := c.ScanOneRow(row)
|
||||
return c, err
|
||||
},
|
||||
),
|
||||
db: db,
|
||||
}
|
||||
func NewCRMHandlerV2(s *service.CustomerService) *CRMHandlerV2 {
|
||||
return &CRMHandlerV2{service: s}
|
||||
}
|
||||
|
||||
// ListCustomers handles GET /api/v1/crm/customers
|
||||
func (h *CRMHandlerV2) ListCustomers(w http.ResponseWriter, r *http.Request) {
|
||||
status := r.URL.Query().Get("status")
|
||||
if status == "" {
|
||||
status = "active"
|
||||
}
|
||||
|
||||
tenantID := r.Context().Value("tenant_id")
|
||||
if tenantID == nil {
|
||||
tenantID = "default"
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
pageSize, _ := strconv.Atoi(r.URL.Query().Get("page_size"))
|
||||
if pageSize < 1 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
customers, err := h.customers.List(r.Context(), "status = $1", status)
|
||||
resp, err := h.service.ListCustomers(r.Context(), tenantID.(string), status, page, pageSize)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database error")
|
||||
writeError(w, http.StatusInternalServerError, "failed to list customers")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"customers": customers,
|
||||
"total": len(customers),
|
||||
})
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// GetCustomer handles GET /api/v1/crm/customers/{id}
|
||||
func (h *CRMHandlerV2) GetCustomer(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
customer, err := h.customers.Get(r.Context(), id)
|
||||
customer, err := h.service.GetCustomer(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "customer not found")
|
||||
return
|
||||
@@ -70,36 +57,47 @@ func (h *CRMHandlerV2) GetCustomer(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, customer)
|
||||
}
|
||||
|
||||
// CreateCustomer handles POST /api/v1/crm/customers
|
||||
func (h *CRMHandlerV2) CreateCustomer(w http.ResponseWriter, r *http.Request) {
|
||||
var req models.Customer
|
||||
var req service.CreateCustomerRequest
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request")
|
||||
return
|
||||
}
|
||||
|
||||
var id string
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
INSERT INTO boc_customers (name, email, phone, company, org_number, status, source, tags)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id
|
||||
`, req.Name, req.Email, req.Phone, req.Company, req.OrgNumber, req.Status, req.Source, pq.Array(req.Tags)).Scan(&id)
|
||||
|
||||
customer, err := h.service.CreateCustomer(r.Context(), &req)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create customer")
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||
"id": id,
|
||||
"id": customer.ID,
|
||||
"message": "Customer created",
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteCustomer handles DELETE /api/v1/crm/customers/{id}
|
||||
func (h *CRMHandlerV2) UpdateCustomer(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var req service.UpdateCustomerRequest
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request")
|
||||
return
|
||||
}
|
||||
|
||||
customer, err := h.service.UpdateCustomer(r.Context(), id, &req)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, customer)
|
||||
}
|
||||
|
||||
func (h *CRMHandlerV2) DeleteCustomer(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if err := h.customers.Delete(r.Context(), id); err != nil {
|
||||
if err := h.service.DeleteCustomer(r.Context(), id); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete customer")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// DebugTokenHandler genererar en debug-token
|
||||
func DebugTokenHandler(secret string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
now := time.Now().Unix()
|
||||
claims := jwt.MapClaims{
|
||||
"sub": "3847477b-3d56-4975-9157-ae8f9ce52aa7",
|
||||
"email": "erik@landvex.com",
|
||||
"role": "admin",
|
||||
"exp": now + 2592000,
|
||||
"iat": now,
|
||||
"iss": "boc-auth",
|
||||
"aud": "boc",
|
||||
}
|
||||
|
||||
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret))
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"token generation failed"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"token": token,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"boc/ledger"
|
||||
)
|
||||
|
||||
// FinanceHandlerV2 uses RobustClient for ledger integration
|
||||
type FinanceHandlerV2 struct {
|
||||
ledgerClient *ledger.RobustClient
|
||||
}
|
||||
|
||||
// NewFinanceHandlerV2 creates a new finance handler with ledger integration
|
||||
func NewFinanceHandlerV2() *FinanceHandlerV2 {
|
||||
ledgerDBURL := os.Getenv("LEDGER_DB_URL")
|
||||
if ledgerDBURL == "" {
|
||||
ledgerDBURL = "postgres://postgres:postgres@localhost:5432/aamos_ledger?sslmode=disable"
|
||||
}
|
||||
|
||||
client, err := ledger.NewRobustClient(ledgerDBURL)
|
||||
if err != nil {
|
||||
// Fallback: create handler without ledger
|
||||
return &FinanceHandlerV2{}
|
||||
}
|
||||
|
||||
return &FinanceHandlerV2{ledgerClient: client}
|
||||
}
|
||||
|
||||
func (h *FinanceHandlerV2) GetBalanceSheet(w http.ResponseWriter, r *http.Request) {
|
||||
if h.ledgerClient == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "ledger not available")
|
||||
return
|
||||
}
|
||||
|
||||
period := r.URL.Query().Get("period")
|
||||
bs, err := h.ledgerClient.GetBalanceSheet(r.Context(), period)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to get balance sheet")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"report": bs,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *FinanceHandlerV2) GetIncomeStatement(w http.ResponseWriter, r *http.Request) {
|
||||
if h.ledgerClient == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "ledger not available")
|
||||
return
|
||||
}
|
||||
|
||||
period := r.URL.Query().Get("period")
|
||||
is, err := h.ledgerClient.GetIncomeStatement(r.Context(), period)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to get income statement")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"report": is,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *FinanceHandlerV2) GetMomsReport(w http.ResponseWriter, r *http.Request) {
|
||||
if h.ledgerClient == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "ledger not available")
|
||||
return
|
||||
}
|
||||
|
||||
period := r.URL.Query().Get("period")
|
||||
report, err := h.ledgerClient.GetMomsReport(r.Context(), period)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to get moms report")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"report": report,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *FinanceHandlerV2) GetAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
if h.ledgerClient == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "ledger not available")
|
||||
return
|
||||
}
|
||||
|
||||
accounts, err := h.ledgerClient.GetAccounts(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to get accounts")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"accounts": accounts,
|
||||
})
|
||||
}
|
||||
+18
-6
@@ -22,12 +22,12 @@ type Employee struct {
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
Department string `json:"department"`
|
||||
Position string `json:"position"`
|
||||
EmploymentType string `json:"employment_type"`
|
||||
Salary float64 `json:"salary"`
|
||||
Currency string `json:"currency"`
|
||||
Salary *float64 `json:"salary,omitempty"`
|
||||
Currency *string `json:"currency,omitempty"`
|
||||
StartDate *time.Time `json:"start_date"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
@@ -61,7 +61,7 @@ func (h *HRHandler) ListEmployees(w http.ResponseWriter, r *http.Request) {
|
||||
SELECT id, first_name, last_name, email, phone, department, position,
|
||||
employment_type, salary, currency, start_date, status, created_at
|
||||
FROM boc_employees
|
||||
WHERE status = 'active'
|
||||
WHERE status != 'deleted'
|
||||
ORDER BY created_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
@@ -73,11 +73,23 @@ func (h *HRHandler) ListEmployees(w http.ResponseWriter, r *http.Request) {
|
||||
employees := []Employee{}
|
||||
for rows.Next() {
|
||||
var e Employee
|
||||
if err := rows.Scan(&e.ID, &e.FirstName, &e.LastName, &e.Email, &e.Phone,
|
||||
&e.Department, &e.Position, &e.EmploymentType, &e.Salary, &e.Currency,
|
||||
var phone sql.NullString
|
||||
var salary sql.NullFloat64
|
||||
var currency sql.NullString
|
||||
if err := rows.Scan(&e.ID, &e.FirstName, &e.LastName, &e.Email, &phone,
|
||||
&e.Department, &e.Position, &e.EmploymentType, &salary, ¤cy,
|
||||
&e.StartDate, &e.Status, &e.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
if phone.Valid {
|
||||
e.Phone = &phone.String
|
||||
}
|
||||
if salary.Valid {
|
||||
e.Salary = &salary.Float64
|
||||
}
|
||||
if currency.Valid {
|
||||
e.Currency = ¤cy.String
|
||||
}
|
||||
employees = append(employees, e)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// JournalHandler hanterar totaljournal och transaktionsdetaljer
|
||||
type JournalHandler struct {
|
||||
ledgerDB *sql.DB
|
||||
}
|
||||
|
||||
func NewJournalHandler(ledgerDB *sql.DB) *JournalHandler {
|
||||
return &JournalHandler{ledgerDB: ledgerDB}
|
||||
}
|
||||
|
||||
// JournalEntry representerar en verifikation
|
||||
type JournalEntry struct {
|
||||
ID string `json:"id"`
|
||||
EntryNumber int `json:"entry_number"`
|
||||
Description string `json:"description"`
|
||||
EntryDate time.Time `json:"entry_date"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
Period string `json:"period"`
|
||||
FiscalYear int `json:"fiscal_year"`
|
||||
Status string `json:"status"`
|
||||
Lines []JournalLine `json:"lines,omitempty"`
|
||||
}
|
||||
|
||||
// JournalLine representerar en bokföringsrad
|
||||
type JournalLine struct {
|
||||
ID string `json:"id"`
|
||||
AccountID string `json:"account_id"`
|
||||
AccountCode string `json:"account_code"`
|
||||
AccountName string `json:"account_name"`
|
||||
Description string `json:"description"`
|
||||
Debit float64 `json:"debit"`
|
||||
Credit float64 `json:"credit"`
|
||||
}
|
||||
|
||||
// AccountTransactions representerar alla transaktioner för ett konto
|
||||
type AccountTransactions struct {
|
||||
AccountCode string `json:"account_code"`
|
||||
AccountName string `json:"account_name"`
|
||||
AccountType string `json:"account_type"`
|
||||
OpeningBalance float64 `json:"opening_balance"`
|
||||
Transactions []JournalEntry `json:"transactions"`
|
||||
ClosingBalance float64 `json:"closing_balance"`
|
||||
TotalDebit float64 `json:"total_debit"`
|
||||
TotalCredit float64 `json:"total_credit"`
|
||||
}
|
||||
|
||||
// GetJournalEntries returnerar alla verifikationer med pagination
|
||||
func (h *JournalHandler) GetJournalEntries(w http.ResponseWriter, r *http.Request) {
|
||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if limit < 1 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
offset := (page - 1) * limit
|
||||
|
||||
accountFilter := r.URL.Query().Get("account")
|
||||
periodFilter := r.URL.Query().Get("period")
|
||||
|
||||
query := `
|
||||
SELECT id, entry_number, description, entry_date, created_at, created_by, period, fiscal_year, status
|
||||
FROM journal_entries
|
||||
WHERE 1=1
|
||||
`
|
||||
args := []interface{}{}
|
||||
argCount := 0
|
||||
|
||||
if accountFilter != "" {
|
||||
argCount++
|
||||
query += ` AND EXISTS (
|
||||
SELECT 1 FROM journal_lines jl
|
||||
JOIN accounts a ON jl.account_id = a.id
|
||||
WHERE jl.journal_entry_id = journal_entries.id AND a.code = $` + strconv.Itoa(argCount) + `
|
||||
)`
|
||||
args = append(args, accountFilter)
|
||||
}
|
||||
|
||||
if periodFilter != "" {
|
||||
argCount++
|
||||
query += ` AND period = $` + strconv.Itoa(argCount)
|
||||
args = append(args, periodFilter)
|
||||
}
|
||||
|
||||
query += ` ORDER BY entry_date DESC, entry_number DESC LIMIT $` + strconv.Itoa(argCount+1) + ` OFFSET $` + strconv.Itoa(argCount+2)
|
||||
args = append(args, limit, offset)
|
||||
|
||||
rows, err := h.ledgerDB.Query(query, args...)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to fetch journal entries"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
entries := []JournalEntry{}
|
||||
for rows.Next() {
|
||||
var e JournalEntry
|
||||
rows.Scan(&e.ID, &e.EntryNumber, &e.Description, &e.EntryDate, &e.CreatedAt, &e.CreatedBy, &e.Period, &e.FiscalYear, &e.Status)
|
||||
entries = append(entries, e)
|
||||
}
|
||||
|
||||
// Hämta total count
|
||||
var total int
|
||||
countQuery := `SELECT COUNT(*) FROM journal_entries WHERE 1=1`
|
||||
if accountFilter != "" {
|
||||
countQuery += ` AND EXISTS (
|
||||
SELECT 1 FROM journal_lines jl
|
||||
JOIN accounts a ON jl.account_id = a.id
|
||||
WHERE jl.journal_entry_id = journal_entries.id AND a.code = '` + accountFilter + `'
|
||||
)`
|
||||
}
|
||||
if periodFilter != "" {
|
||||
countQuery += ` AND period = '` + periodFilter + `'`
|
||||
}
|
||||
h.ledgerDB.QueryRow(countQuery).Scan(&total)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"entries": entries,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"pages": (total + limit - 1) / limit,
|
||||
})
|
||||
}
|
||||
|
||||
// GetJournalEntry returnerar en specifik verifikation med alla rader
|
||||
func (h *JournalHandler) GetJournalEntry(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
var entry JournalEntry
|
||||
err := h.ledgerDB.QueryRow(`
|
||||
SELECT id, entry_number, description, entry_date, created_at, created_by, period, fiscal_year, status
|
||||
FROM journal_entries WHERE id = $1
|
||||
`, id).Scan(&entry.ID, &entry.EntryNumber, &entry.Description, &entry.EntryDate, &entry.CreatedAt, &entry.CreatedBy, &entry.Period, &entry.FiscalYear, &entry.Status)
|
||||
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
http.Error(w, `{"error":"journal entry not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, `{"error":"failed to fetch journal entry"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Hämta alla rader
|
||||
rows, err := h.ledgerDB.Query(`
|
||||
SELECT jl.id, jl.account_id, a.code, a.name, jl.description, jl.debit, jl.credit
|
||||
FROM journal_lines jl
|
||||
JOIN accounts a ON jl.account_id = a.id
|
||||
WHERE jl.journal_entry_id = $1
|
||||
ORDER BY jl.debit DESC, jl.credit DESC
|
||||
`, id)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to fetch journal lines"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var line JournalLine
|
||||
rows.Scan(&line.ID, &line.AccountID, &line.AccountCode, &line.AccountName, &line.Description, &line.Debit, &line.Credit)
|
||||
entry.Lines = append(entry.Lines, line)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(entry)
|
||||
}
|
||||
|
||||
// GetAccountTransactions returnerar alla transaktioner för ett specifikt konto
|
||||
func (h *JournalHandler) GetAccountTransactions(w http.ResponseWriter, r *http.Request) {
|
||||
accountCode := chi.URLParam(r, "code")
|
||||
|
||||
// Hämta kontoinformation
|
||||
var accountID, accountName, accountType string
|
||||
err := h.ledgerDB.QueryRow(`
|
||||
SELECT id, name, account_type FROM accounts WHERE code = $1
|
||||
`, accountCode).Scan(&accountID, &accountName, &accountType)
|
||||
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
http.Error(w, `{"error":"account not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, `{"error":"failed to fetch account"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Beräkna balans
|
||||
var totalDebit, totalCredit float64
|
||||
h.ledgerDB.QueryRow(`
|
||||
SELECT COALESCE(SUM(debit), 0), COALESCE(SUM(credit), 0)
|
||||
FROM journal_lines WHERE account_id = $1
|
||||
`, accountID).Scan(&totalDebit, &totalCredit)
|
||||
|
||||
balance := totalDebit - totalCredit
|
||||
if accountType != "Asset" && accountType != "Expense" {
|
||||
balance = totalCredit - totalDebit
|
||||
}
|
||||
|
||||
// Hämta alla transaktioner för detta konto
|
||||
rows, err := h.ledgerDB.Query(`
|
||||
SELECT je.id, je.entry_number, je.description, je.entry_date, je.period, je.status,
|
||||
jl.description as line_description, jl.debit, jl.credit
|
||||
FROM journal_lines jl
|
||||
JOIN journal_entries je ON jl.journal_entry_id = je.id
|
||||
WHERE jl.account_id = $1
|
||||
ORDER BY je.entry_date DESC, je.entry_number DESC
|
||||
LIMIT 100
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to fetch transactions"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type Transaction struct {
|
||||
JournalEntryID string `json:"journal_entry_id"`
|
||||
EntryNumber int `json:"entry_number"`
|
||||
Description string `json:"description"`
|
||||
EntryDate time.Time `json:"entry_date"`
|
||||
Period string `json:"period"`
|
||||
Status string `json:"status"`
|
||||
LineDescription string `json:"line_description"`
|
||||
Debit float64 `json:"debit"`
|
||||
Credit float64 `json:"credit"`
|
||||
}
|
||||
|
||||
transactions := []Transaction{}
|
||||
for rows.Next() {
|
||||
var t Transaction
|
||||
rows.Scan(&t.JournalEntryID, &t.EntryNumber, &t.Description, &t.EntryDate, &t.Period, &t.Status, &t.LineDescription, &t.Debit, &t.Credit)
|
||||
transactions = append(transactions, t)
|
||||
}
|
||||
|
||||
result := AccountTransactions{
|
||||
AccountCode: accountCode,
|
||||
AccountName: accountName,
|
||||
AccountType: accountType,
|
||||
ClosingBalance: balance,
|
||||
TotalDebit: totalDebit,
|
||||
TotalCredit: totalCredit,
|
||||
}
|
||||
|
||||
// Konvertera till JournalEntry-format för frontend
|
||||
for _, t := range transactions {
|
||||
entry := JournalEntry{
|
||||
ID: t.JournalEntryID,
|
||||
EntryNumber: t.EntryNumber,
|
||||
Description: t.Description,
|
||||
EntryDate: t.EntryDate,
|
||||
Period: t.Period,
|
||||
Status: t.Status,
|
||||
Lines: []JournalLine{{
|
||||
Description: t.LineDescription,
|
||||
Debit: t.Debit,
|
||||
Credit: t.Credit,
|
||||
}},
|
||||
}
|
||||
result.Transactions = append(result.Transactions, entry)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
|
||||
// DrillDownRequest används för att söka i totaljournalen
|
||||
type DrillDownRequest struct {
|
||||
AccountCode string `json:"account_code,omitempty"`
|
||||
Period string `json:"period,omitempty"`
|
||||
DateFrom string `json:"date_from,omitempty"`
|
||||
DateTo string `json:"date_to,omitempty"`
|
||||
MinAmount float64 `json:"min_amount,omitempty"`
|
||||
MaxAmount float64 `json:"max_amount,omitempty"`
|
||||
}
|
||||
|
||||
// PostDrillDown hanterar avancerade sökningar i totaljournalen
|
||||
func (h *JournalHandler) PostDrillDown(w http.ResponseWriter, r *http.Request) {
|
||||
var req DrillDownRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT DISTINCT je.id, je.entry_number, je.description, je.entry_date, je.created_at, je.created_by, je.period, je.fiscal_year, je.status
|
||||
FROM journal_entries je
|
||||
JOIN journal_lines jl ON je.id = jl.journal_entry_id
|
||||
JOIN accounts a ON jl.account_id = a.id
|
||||
WHERE 1=1
|
||||
`
|
||||
args := []interface{}{}
|
||||
argCount := 0
|
||||
|
||||
if req.AccountCode != "" {
|
||||
argCount++
|
||||
query += ` AND a.code = $` + strconv.Itoa(argCount)
|
||||
args = append(args, req.AccountCode)
|
||||
}
|
||||
|
||||
if req.Period != "" {
|
||||
argCount++
|
||||
query += ` AND je.period = $` + strconv.Itoa(argCount)
|
||||
args = append(args, req.Period)
|
||||
}
|
||||
|
||||
if req.DateFrom != "" {
|
||||
argCount++
|
||||
query += ` AND je.entry_date >= $` + strconv.Itoa(argCount)
|
||||
args = append(args, req.DateFrom)
|
||||
}
|
||||
|
||||
if req.DateTo != "" {
|
||||
argCount++
|
||||
query += ` AND je.entry_date <= $` + strconv.Itoa(argCount)
|
||||
args = append(args, req.DateTo)
|
||||
}
|
||||
|
||||
if req.MinAmount > 0 {
|
||||
argCount++
|
||||
query += ` AND (jl.debit >= $` + strconv.Itoa(argCount) + ` OR jl.credit >= $` + strconv.Itoa(argCount) + `)`
|
||||
args = append(args, req.MinAmount)
|
||||
}
|
||||
|
||||
query += ` ORDER BY je.entry_date DESC LIMIT 100`
|
||||
|
||||
rows, err := h.ledgerDB.Query(query, args...)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to search journal"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
entries := []JournalEntry{}
|
||||
for rows.Next() {
|
||||
var e JournalEntry
|
||||
rows.Scan(&e.ID, &e.EntryNumber, &e.Description, &e.EntryDate, &e.CreatedAt, &e.CreatedBy, &e.Period, &e.FiscalYear, &e.Status)
|
||||
entries = append(entries, e)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"entries": entries,
|
||||
"total": len(entries),
|
||||
"filters": req,
|
||||
})
|
||||
}
|
||||
+188
-130
@@ -4,11 +4,12 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"boc/legal"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// LegalHandler hanterar legal/contract endpoints
|
||||
type LegalHandler struct {
|
||||
DB *sql.DB
|
||||
}
|
||||
@@ -17,44 +18,34 @@ func NewLegalHandler(db *sql.DB) *LegalHandler {
|
||||
return &LegalHandler{DB: db}
|
||||
}
|
||||
|
||||
// Contract representerar ett avtal i systemet
|
||||
type Contract struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Counterparty string `json:"counterparty"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Value float64 `json:"value"`
|
||||
Currency string `json:"currency"`
|
||||
StartDate *time.Time `json:"start_date"`
|
||||
EndDate *time.Time `json:"end_date"`
|
||||
RenewalDate *time.Time `json:"renewal_date"`
|
||||
DocumentURL *string `json:"document_url"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type ContractReminder struct {
|
||||
ID string `json:"id"`
|
||||
ContractID string `json:"contract_id"`
|
||||
Type string `json:"type"`
|
||||
DueDate time.Time `json:"due_date"`
|
||||
Status string `json:"status"`
|
||||
ID string `json:"id"`
|
||||
TemplateType string `json:"template_type"`
|
||||
Name string `json:"name"`
|
||||
Counterparty string `json:"counterparty"`
|
||||
CounterpartyOrg string `json:"counterparty_org,omitempty"`
|
||||
Status string `json:"status"` // draft, pending, active, expired, terminated
|
||||
Value float64 `json:"value,omitempty"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
StartDate string `json:"start_date,omitempty"`
|
||||
EndDate string `json:"end_date,omitempty"`
|
||||
RenewalDate string `json:"renewal_date,omitempty"`
|
||||
Responsible string `json:"responsible,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ListContracts returnerar alla avtal
|
||||
func (h *LegalHandler) ListContracts(w http.ResponseWriter, r *http.Request) {
|
||||
status := r.URL.Query().Get("status")
|
||||
if status == "" {
|
||||
status = "active"
|
||||
}
|
||||
|
||||
rows, err := h.DB.Query(`
|
||||
SELECT id, title, counterparty, type, status, value, currency,
|
||||
start_date, end_date, renewal_date, document_url, created_at
|
||||
SELECT id, template_type, name, counterparty, counterparty_org, status,
|
||||
value, currency, start_date, end_date, renewal_date, responsible, created_at, updated_at
|
||||
FROM boc_contracts
|
||||
WHERE status = $1
|
||||
ORDER BY renewal_date ASC NULLS LAST
|
||||
`, status)
|
||||
ORDER BY created_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database error")
|
||||
http.Error(w, `{"error":"database error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
@@ -62,143 +53,210 @@ func (h *LegalHandler) ListContracts(w http.ResponseWriter, r *http.Request) {
|
||||
contracts := []Contract{}
|
||||
for rows.Next() {
|
||||
var c Contract
|
||||
if err := rows.Scan(&c.ID, &c.Title, &c.Counterparty, &c.Type, &c.Status,
|
||||
&c.Value, &c.Currency, &c.StartDate, &c.EndDate, &c.RenewalDate,
|
||||
&c.DocumentURL, &c.CreatedAt); err != nil {
|
||||
continue
|
||||
var value sql.NullFloat64
|
||||
var currency, startDate, endDate, renewalDate, responsible sql.NullString
|
||||
rows.Scan(&c.ID, &c.TemplateType, &c.Name, &c.Counterparty, &c.CounterpartyOrg,
|
||||
&c.Status, &value, ¤cy, &startDate, &endDate, &renewalDate, &responsible,
|
||||
&c.CreatedAt, &c.UpdatedAt)
|
||||
if value.Valid {
|
||||
c.Value = value.Float64
|
||||
}
|
||||
if currency.Valid {
|
||||
c.Currency = currency.String
|
||||
}
|
||||
if startDate.Valid {
|
||||
c.StartDate = startDate.String
|
||||
}
|
||||
if endDate.Valid {
|
||||
c.EndDate = endDate.String
|
||||
}
|
||||
if renewalDate.Valid {
|
||||
c.RenewalDate = renewalDate.String
|
||||
}
|
||||
if responsible.Valid {
|
||||
c.Responsible = responsible.String
|
||||
}
|
||||
contracts = append(contracts, c)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"contracts": contracts,
|
||||
"total": len(contracts),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *LegalHandler) CreateContract(w http.ResponseWriter, r *http.Request) {
|
||||
var req Contract
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request")
|
||||
return
|
||||
}
|
||||
|
||||
var id string
|
||||
err := h.DB.QueryRow(`
|
||||
INSERT INTO boc_contracts (title, counterparty, type, status, value, currency,
|
||||
start_date, end_date, renewal_date, document_url)
|
||||
VALUES ($1, $2, $3, 'draft', $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id
|
||||
`, req.Title, req.Counterparty, req.Type, req.Value, req.Currency,
|
||||
req.StartDate, req.EndDate, req.RenewalDate, req.DocumentURL).Scan(&id)
|
||||
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create contract")
|
||||
return
|
||||
}
|
||||
|
||||
// Create reminder if renewal date is set
|
||||
if req.RenewalDate != nil {
|
||||
reminderDate := req.RenewalDate.AddDate(0, 0, -30) // 30 days before
|
||||
h.DB.Exec(`
|
||||
INSERT INTO boc_contract_reminders (contract_id, type, due_date, status)
|
||||
VALUES ($1, 'renewal', $2, 'pending')
|
||||
`, id, reminderDate)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||
"id": id,
|
||||
"message": "Contract created",
|
||||
})
|
||||
}
|
||||
|
||||
// GetContract returnerar ett specifikt avtal
|
||||
func (h *LegalHandler) GetContract(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
var c Contract
|
||||
var value sql.NullFloat64
|
||||
var currency, startDate, endDate, renewalDate, responsible sql.NullString
|
||||
|
||||
err := h.DB.QueryRow(`
|
||||
SELECT id, title, counterparty, type, status, value, currency,
|
||||
start_date, end_date, renewal_date, document_url, created_at
|
||||
SELECT id, template_type, name, counterparty, counterparty_org, status,
|
||||
value, currency, start_date, end_date, renewal_date, responsible, created_at, updated_at
|
||||
FROM boc_contracts WHERE id = $1
|
||||
`, id).Scan(&c.ID, &c.Title, &c.Counterparty, &c.Type, &c.Status,
|
||||
&c.Value, &c.Currency, &c.StartDate, &c.EndDate, &c.RenewalDate,
|
||||
&c.DocumentURL, &c.CreatedAt)
|
||||
`, id).Scan(&c.ID, &c.TemplateType, &c.Name, &c.Counterparty, &c.CounterpartyOrg,
|
||||
&c.Status, &value, ¤cy, &startDate, &endDate, &renewalDate, &responsible,
|
||||
&c.CreatedAt, &c.UpdatedAt)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
writeError(w, http.StatusNotFound, "contract not found")
|
||||
http.Error(w, `{"error":"contract not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database error")
|
||||
http.Error(w, `{"error":"database error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, c)
|
||||
if value.Valid {
|
||||
c.Value = value.Float64
|
||||
}
|
||||
if currency.Valid {
|
||||
c.Currency = currency.String
|
||||
}
|
||||
if startDate.Valid {
|
||||
c.StartDate = startDate.String
|
||||
}
|
||||
if endDate.Valid {
|
||||
c.EndDate = endDate.String
|
||||
}
|
||||
if renewalDate.Valid {
|
||||
c.RenewalDate = renewalDate.String
|
||||
}
|
||||
if responsible.Valid {
|
||||
c.Responsible = responsible.String
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(c)
|
||||
}
|
||||
|
||||
// GetContractTemplates returnerar alla standardavtal
|
||||
func (h *LegalHandler) GetContractTemplates(w http.ResponseWriter, r *http.Request) {
|
||||
templates := legal.GetStandardTemplates()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"templates": templates,
|
||||
"total": len(templates),
|
||||
})
|
||||
}
|
||||
|
||||
// GetContractTemplate returnerar ett specifikt template
|
||||
func (h *LegalHandler) GetContractTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
templateType := chi.URLParam(r, "type")
|
||||
|
||||
templates := legal.GetStandardTemplates()
|
||||
for _, t := range templates {
|
||||
if string(t.Type) == templateType {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(t)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
http.Error(w, `{"error":"template not found"}`, http.StatusNotFound)
|
||||
}
|
||||
|
||||
// GetProductContractLinks returnerar produkt-avtal kopplingar
|
||||
func (h *LegalHandler) GetProductContractLinks(w http.ResponseWriter, r *http.Request) {
|
||||
links := legal.GetProductContractLinks()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"links": links,
|
||||
"total": len(links),
|
||||
})
|
||||
}
|
||||
|
||||
// CreateContract skapar ett nytt avtal från template
|
||||
func (h *LegalHandler) CreateContract(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
TemplateType string `json:"template_type"`
|
||||
Counterparty string `json:"counterparty"`
|
||||
CounterpartyOrg string `json:"counterparty_org,omitempty"`
|
||||
Variables map[string]string `json:"variables,omitempty"`
|
||||
Terms legal.ContractTerms `json:"terms,omitempty"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Hitta template
|
||||
var template *legal.ContractTemplate
|
||||
for _, t := range legal.GetStandardTemplates() {
|
||||
if string(t.Type) == req.TemplateType {
|
||||
template = &t
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if template == nil {
|
||||
http.Error(w, `{"error":"template not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Skapa avtal i databas
|
||||
var id string
|
||||
err := h.DB.QueryRow(`
|
||||
INSERT INTO boc_contracts (template_type, name, counterparty, counterparty_org, status, currency)
|
||||
VALUES ($1, $2, $3, $4, 'draft', $5)
|
||||
RETURNING id
|
||||
`, req.TemplateType, template.Name, req.Counterparty, req.CounterpartyOrg, template.DefaultTerms.Currency).Scan(&id)
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to create contract"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"id": id,
|
||||
"message": "Contract created",
|
||||
"template": template,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateContract uppdaterar ett avtal
|
||||
func (h *LegalHandler) UpdateContract(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
var req Contract
|
||||
var req struct {
|
||||
Status string `json:"status,omitempty"`
|
||||
Value float64 `json:"value,omitempty"`
|
||||
StartDate string `json:"start_date,omitempty"`
|
||||
EndDate string `json:"end_date,omitempty"`
|
||||
Responsible string `json:"responsible,omitempty"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request")
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
_, err := h.DB.Exec(`
|
||||
UPDATE boc_contracts
|
||||
SET title = $1, counterparty = $2, type = $3, status = $4,
|
||||
value = $5, currency = $6, start_date = $7, end_date = $8,
|
||||
renewal_date = $9, document_url = $10
|
||||
WHERE id = $11
|
||||
`, req.Title, req.Counterparty, req.Type, req.Status, req.Value, req.Currency,
|
||||
req.StartDate, req.EndDate, req.RenewalDate, req.DocumentURL, id)
|
||||
SET status = COALESCE(NULLIF($1, ''), status),
|
||||
value = COALESCE($2, value),
|
||||
start_date = COALESCE(NULLIF($3, ''), start_date),
|
||||
end_date = COALESCE(NULLIF($4, ''), end_date),
|
||||
responsible = COALESCE(NULLIF($5, ''), responsible),
|
||||
updated_at = NOW()
|
||||
WHERE id = $6
|
||||
`, req.Status, req.Value, req.StartDate, req.EndDate, req.Responsible, id)
|
||||
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to update contract")
|
||||
http.Error(w, `{"error":"failed to update contract"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"message": "Contract updated",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *LegalHandler) ListReminders(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := h.DB.Query(`
|
||||
SELECT r.id, r.contract_id, r.type, r.due_date, r.status,
|
||||
c.title as contract_title
|
||||
FROM boc_contract_reminders r
|
||||
JOIN boc_contracts c ON r.contract_id = c.id
|
||||
WHERE r.status = 'pending'
|
||||
ORDER BY r.due_date ASC
|
||||
LIMIT 50
|
||||
`)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
reminders := []map[string]interface{}{}
|
||||
for rows.Next() {
|
||||
var id, contractID, reminderType, status, contractTitle string
|
||||
var dueDate time.Time
|
||||
if err := rows.Scan(&id, &contractID, &reminderType, &dueDate, &status, &contractTitle); err != nil {
|
||||
continue
|
||||
}
|
||||
reminders = append(reminders, map[string]interface{}{
|
||||
"id": id,
|
||||
"contract_id": contractID,
|
||||
"contract_title": contractTitle,
|
||||
"type": reminderType,
|
||||
"due_date": dueDate,
|
||||
"status": status,
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"reminders": reminders,
|
||||
"total": len(reminders),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"boc/briefing"
|
||||
)
|
||||
|
||||
// RealBriefingHandler hanterar riktiga briefing requests
|
||||
type RealBriefingHandler struct {
|
||||
engine *briefing.RealBriefingEngine
|
||||
}
|
||||
|
||||
func NewRealBriefingHandler(bocDB, ledgerDB *sql.DB) *RealBriefingHandler {
|
||||
return &RealBriefingHandler{
|
||||
engine: briefing.NewRealBriefingEngine(bocDB, ledgerDB),
|
||||
}
|
||||
}
|
||||
|
||||
// GetRealBriefing returnerar en komplett briefing med riktig data
|
||||
func (h *RealBriefingHandler) GetRealBriefing(w http.ResponseWriter, r *http.Request) {
|
||||
userID := "3847477b-3d56-4975-9157-ae8f9ce52aa7" // Erik Svensson
|
||||
|
||||
briefing, err := h.engine.GenerateRealBriefing(r.Context(), userID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to generate briefing"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(briefing)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"boc/sms"
|
||||
)
|
||||
|
||||
// SMSHandler hanterar SMS- och 2FA-endpoints
|
||||
type SMSHandler struct {
|
||||
TwoFactor *sms.TwoFactorAuth
|
||||
Notifier *sms.NotificationService
|
||||
}
|
||||
|
||||
// NewSMSHandler skapar ny SMS-handler
|
||||
func NewSMSHandler(twoFactor *sms.TwoFactorAuth, notifier *sms.NotificationService) *SMSHandler {
|
||||
return &SMSHandler{
|
||||
TwoFactor: twoFactor,
|
||||
Notifier: notifier,
|
||||
}
|
||||
}
|
||||
|
||||
// SendVerificationCode skickar 2FA-kod
|
||||
func (h *SMSHandler) SendVerificationCode(w http.ResponseWriter, r *http.Request) {
|
||||
if h.TwoFactor == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"error": "SMS not configured",
|
||||
"message": "Set ELK46_USERNAME and ELK46_PASSWORD environment variables",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Phone string `json:"phone"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
code, err := h.TwoFactor.SendVerificationCode(req.Phone)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"message": "Verification code sent",
|
||||
"phone": req.Phone,
|
||||
// I produktion: skicka INTE koden tillbaka!
|
||||
"code": code, // Endast för test
|
||||
})
|
||||
}
|
||||
|
||||
// VerifyCode verifierar 2FA-kod
|
||||
func (h *SMSHandler) VerifyCode(w http.ResponseWriter, r *http.Request) {
|
||||
if h.TwoFactor == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"error": "SMS not configured",
|
||||
"message": "Set ELK46_USERNAME and ELK46_PASSWORD environment variables",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Phone string `json:"phone"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
valid, err := h.TwoFactor.VerifyCode(req.Phone, req.Code)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"valid": valid,
|
||||
})
|
||||
}
|
||||
|
||||
// SendNotification skickar generisk notifikation
|
||||
func (h *SMSHandler) SendNotification(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Phone string `json:"phone"`
|
||||
Type string `json:"type"` // task_reminder, onboarding, document, performance, training, security
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var err error
|
||||
switch req.Type {
|
||||
case "task_reminder":
|
||||
err = h.Notifier.SendTaskReminder(req.Phone, req.Message, "idag")
|
||||
case "onboarding":
|
||||
err = h.Notifier.SendOnboardingWelcome(req.Phone, req.Message)
|
||||
case "document":
|
||||
err = h.Notifier.SendDocumentSignatureRequest(req.Phone, req.Message)
|
||||
case "security":
|
||||
err = h.Notifier.SendSecurityAlert(req.Phone, req.Message)
|
||||
default:
|
||||
http.Error(w, `{"error":"unknown notification type"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"message": "Notification sent",
|
||||
})
|
||||
}
|
||||
|
||||
// SMSStatus kontrollerar SMS-konfiguration
|
||||
func (h *SMSHandler) SMSStatus(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"configured": h.TwoFactor != nil,
|
||||
"provider": "46elks",
|
||||
"features": []string{
|
||||
"two_way_sms",
|
||||
"bulk_sms",
|
||||
"2fa_verification",
|
||||
"notifications",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"boc/tenant"
|
||||
)
|
||||
|
||||
// TenantHandler hanterar multi-tenancy API
|
||||
type TenantHandler struct {
|
||||
manager *tenant.Manager
|
||||
}
|
||||
|
||||
func NewTenantHandler(db *sql.DB) *TenantHandler {
|
||||
return &TenantHandler{
|
||||
manager: tenant.NewManager(db),
|
||||
}
|
||||
}
|
||||
|
||||
// ListTenants returnerar alla verksamheter användaren har tillgång till
|
||||
func (h *TenantHandler) ListTenants(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO: Filtrera baserat på användarens behörigheter
|
||||
tenants, err := h.manager.ListTenants(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to list tenants"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"tenants": tenants,
|
||||
"total": len(tenants),
|
||||
})
|
||||
}
|
||||
|
||||
// GetTenant returnerar detaljer för en specifik verksamhet
|
||||
func (h *TenantHandler) GetTenant(w http.ResponseWriter, r *http.Request) {
|
||||
tenantID := r.URL.Query().Get("id")
|
||||
if tenantID == "" {
|
||||
http.Error(w, `{"error":"tenant id required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
t, err := h.manager.GetTenant(r.Context(), tenantID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"tenant not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(t)
|
||||
}
|
||||
|
||||
// GetTenantHierarchy returnerar koncernstruktur
|
||||
func (h *TenantHandler) GetTenantHierarchy(w http.ResponseWriter, r *http.Request) {
|
||||
// Hämta alla koncerner (root tenants)
|
||||
rows, err := h.manager.ListTenants(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to get hierarchy"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Bygg hierarki
|
||||
type HierarchyNode struct {
|
||||
*tenant.Tenant
|
||||
Children []*HierarchyNode `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
// Simplifierad: returnera flat lista för nu
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"tenants": rows,
|
||||
})
|
||||
}
|
||||
|
||||
// SwitchTenant byter aktiv verksamhet för användaren
|
||||
func (h *TenantHandler) SwitchTenant(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
TenantID string `json:"tenant_id"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Verifiera att tenant finns och är aktiv
|
||||
t, err := h.manager.GetTenant(r.Context(), req.TenantID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"tenant not found or inactive"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Uppdatera användarens session med ny tenant
|
||||
// TODO: Logga tenant-byte i audit log
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"tenant_id": t.ID,
|
||||
"tenant_name": t.Name,
|
||||
"message": "Switched to " + t.Name,
|
||||
})
|
||||
}
|
||||
|
||||
// GetTenantSummary returnerar sammanfattning för dashboard
|
||||
func (h *TenantHandler) GetTenantSummary(w http.ResponseWriter, r *http.Request) {
|
||||
tenantID := r.URL.Query().Get("tenant_id")
|
||||
if tenantID == "" {
|
||||
tenantID = "default" // Fallback
|
||||
}
|
||||
|
||||
// För nu, returnera mock-data
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"tenant_id": tenantID,
|
||||
"active_users": 5,
|
||||
"customers": 3,
|
||||
"open_deals": 3,
|
||||
"employees": 6,
|
||||
"pipeline_value": 5250000,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user