Compare commits
1 Commits
v0.2.5
...
feature/ph
| Author | SHA1 | Date | |
|---|---|---|---|
| 20056f3593 |
21
assets_embed.go
Normal file
21
assets_embed.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package quoteforge
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
// TemplatesFS contains HTML templates embedded into the binary.
|
||||
//
|
||||
//go:embed web/templates/*.html web/templates/partials/*.html
|
||||
var TemplatesFS embed.FS
|
||||
|
||||
// StaticFiles contains static assets (CSS, JS, etc.) embedded into the binary.
|
||||
//
|
||||
//go:embed web/static/*
|
||||
var StaticFiles embed.FS
|
||||
|
||||
// StaticFS returns a filesystem rooted at web/static for serving static assets.
|
||||
func StaticFS() (fs.FS, error) {
|
||||
return fs.Sub(StaticFiles, "web/static")
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
qfassets "git.mchus.pro/mchus/quoteforge"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/db"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/handlers"
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services/pricelist"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
@@ -56,9 +57,14 @@ func main() {
|
||||
// Load config for server settings (optional)
|
||||
cfg, err := config.Load(*configPath)
|
||||
if err != nil {
|
||||
// Use defaults if config file doesn't exist
|
||||
slog.Info("config file not found, using defaults", "path", *configPath)
|
||||
cfg = &config.Config{}
|
||||
if os.IsNotExist(err) {
|
||||
// Use defaults if config file doesn't exist
|
||||
slog.Info("config file not found, using defaults", "path", *configPath)
|
||||
cfg = &config.Config{}
|
||||
} else {
|
||||
slog.Error("failed to load config", "path", *configPath, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
setConfigDefaults(cfg)
|
||||
|
||||
@@ -211,7 +217,11 @@ func runSetupMode(local *localdb.LocalDB) {
|
||||
router := gin.New()
|
||||
router.Use(gin.Recovery())
|
||||
|
||||
router.Static("/static", "web/static")
|
||||
if stat, err := os.Stat("web/static"); err == nil && stat.IsDir() {
|
||||
router.Static("/static", "web/static")
|
||||
} else if staticFS, err := qfassets.StaticFS(); err == nil {
|
||||
router.StaticFS("/static", http.FS(staticFS))
|
||||
}
|
||||
|
||||
// Setup routes only
|
||||
router.GET("/", func(c *gin.Context) {
|
||||
@@ -404,7 +414,11 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
router.Use(middleware.OfflineDetector(connMgr, local))
|
||||
|
||||
// Static files
|
||||
router.Static("/static", "web/static")
|
||||
if stat, err := os.Stat("web/static"); err == nil && stat.IsDir() {
|
||||
router.Static("/static", "web/static")
|
||||
} else if staticFS, err := qfassets.StaticFS(); err == nil {
|
||||
router.StaticFS("/static", http.FS(staticFS))
|
||||
}
|
||||
|
||||
// Health check
|
||||
router.GET("/health", func(c *gin.Context) {
|
||||
|
||||
@@ -4,10 +4,10 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ComponentHandler struct {
|
||||
@@ -40,7 +40,13 @@ func (h *ComponentHandler) List(c *gin.Context) {
|
||||
}
|
||||
|
||||
// If offline mode (empty result), fallback to local components
|
||||
if result.Total == 0 && h.localDB != nil {
|
||||
isOffline := false
|
||||
if v, ok := c.Get("is_offline"); ok {
|
||||
if b, ok := v.(bool); ok {
|
||||
isOffline = b
|
||||
}
|
||||
}
|
||||
if isOffline && result.Total == 0 && h.localDB != nil {
|
||||
localFilter := localdb.ComponentFilter{
|
||||
Category: filter.Category,
|
||||
Search: filter.Search,
|
||||
|
||||
@@ -5,13 +5,15 @@ import (
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
qfassets "git.mchus.pro/mchus/quoteforge"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/db"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
@@ -34,7 +36,13 @@ func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, te
|
||||
|
||||
// Load setup template (standalone, no base needed)
|
||||
setupPath := filepath.Join(templatesPath, "setup.html")
|
||||
tmpl, err := template.New("").Funcs(funcMap).ParseFiles(setupPath)
|
||||
var tmpl *template.Template
|
||||
var err error
|
||||
if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() {
|
||||
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(setupPath)
|
||||
} else {
|
||||
tmpl, err = template.New("").Funcs(funcMap).ParseFS(qfassets.TemplatesFS, "web/templates/setup.html")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing setup template: %w", err)
|
||||
}
|
||||
@@ -196,8 +204,8 @@ func (h *SetupHandler) SaveConnection(c *gin.Context) {
|
||||
|
||||
// Always restart to properly initialize all services with the new connection
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Settings saved. Please restart the application to apply changes.",
|
||||
"success": true,
|
||||
"message": "Settings saved. Please restart the application to apply changes.",
|
||||
"restart_required": true,
|
||||
})
|
||||
|
||||
|
||||
@@ -4,13 +4,15 @@ import (
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
qfassets "git.mchus.pro/mchus/quoteforge"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/db"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SyncHandler handles sync API endpoints
|
||||
@@ -25,7 +27,13 @@ type SyncHandler struct {
|
||||
func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr *db.ConnectionManager, templatesPath string) (*SyncHandler, error) {
|
||||
// Load sync_status partial template
|
||||
partialPath := filepath.Join(templatesPath, "partials", "sync_status.html")
|
||||
tmpl, err := template.ParseFiles(partialPath)
|
||||
var tmpl *template.Template
|
||||
var err error
|
||||
if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() {
|
||||
tmpl, err = template.ParseFiles(partialPath)
|
||||
} else {
|
||||
tmpl, err = template.ParseFS(qfassets.TemplatesFS, "web/templates/partials/sync_status.html")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -40,14 +48,14 @@ func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr
|
||||
|
||||
// SyncStatusResponse represents the sync status
|
||||
type SyncStatusResponse struct {
|
||||
LastComponentSync *time.Time `json:"last_component_sync"`
|
||||
LastPricelistSync *time.Time `json:"last_pricelist_sync"`
|
||||
IsOnline bool `json:"is_online"`
|
||||
ComponentsCount int64 `json:"components_count"`
|
||||
PricelistsCount int64 `json:"pricelists_count"`
|
||||
ServerPricelists int `json:"server_pricelists"`
|
||||
NeedComponentSync bool `json:"need_component_sync"`
|
||||
NeedPricelistSync bool `json:"need_pricelist_sync"`
|
||||
LastComponentSync *time.Time `json:"last_component_sync"`
|
||||
LastPricelistSync *time.Time `json:"last_pricelist_sync"`
|
||||
IsOnline bool `json:"is_online"`
|
||||
ComponentsCount int64 `json:"components_count"`
|
||||
PricelistsCount int64 `json:"pricelists_count"`
|
||||
ServerPricelists int `json:"server_pricelists"`
|
||||
NeedComponentSync bool `json:"need_component_sync"`
|
||||
NeedPricelistSync bool `json:"need_pricelist_sync"`
|
||||
}
|
||||
|
||||
// GetStatus returns current sync status
|
||||
@@ -79,14 +87,14 @@ func (h *SyncHandler) GetStatus(c *gin.Context) {
|
||||
needComponentSync := h.localDB.NeedComponentSync(24)
|
||||
|
||||
c.JSON(http.StatusOK, SyncStatusResponse{
|
||||
LastComponentSync: lastComponentSync,
|
||||
LastPricelistSync: lastPricelistSync,
|
||||
IsOnline: isOnline,
|
||||
ComponentsCount: componentsCount,
|
||||
PricelistsCount: pricelistsCount,
|
||||
ServerPricelists: serverPricelists,
|
||||
NeedComponentSync: needComponentSync,
|
||||
NeedPricelistSync: needPricelistSync,
|
||||
LastComponentSync: lastComponentSync,
|
||||
LastPricelistSync: lastPricelistSync,
|
||||
IsOnline: isOnline,
|
||||
ComponentsCount: componentsCount,
|
||||
PricelistsCount: pricelistsCount,
|
||||
ServerPricelists: serverPricelists,
|
||||
NeedComponentSync: needComponentSync,
|
||||
NeedPricelistSync: needPricelistSync,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -169,11 +177,11 @@ func (h *SyncHandler) SyncPricelists(c *gin.Context) {
|
||||
|
||||
// SyncAllResponse represents result of full sync
|
||||
type SyncAllResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
ComponentsSynced int `json:"components_synced"`
|
||||
PricelistsSynced int `json:"pricelists_synced"`
|
||||
Duration string `json:"duration"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
ComponentsSynced int `json:"components_synced"`
|
||||
PricelistsSynced int `json:"pricelists_synced"`
|
||||
Duration string `json:"duration"`
|
||||
}
|
||||
|
||||
// SyncAll syncs both components and pricelists
|
||||
@@ -216,8 +224,8 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
|
||||
if err != nil {
|
||||
slog.Error("pricelist sync failed during full sync", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": "Pricelist sync failed: " + err.Error(),
|
||||
"success": false,
|
||||
"error": "Pricelist sync failed: " + err.Error(),
|
||||
"components_synced": componentsSynced,
|
||||
})
|
||||
return
|
||||
@@ -294,9 +302,9 @@ func (h *SyncHandler) GetPendingChanges(c *gin.Context) {
|
||||
|
||||
// SyncInfoResponse represents sync information
|
||||
type SyncInfoResponse struct {
|
||||
LastSyncAt *time.Time `json:"last_sync_at"`
|
||||
IsOnline bool `json:"is_online"`
|
||||
ErrorCount int `json:"error_count"`
|
||||
LastSyncAt *time.Time `json:"last_sync_at"`
|
||||
IsOnline bool `json:"is_online"`
|
||||
ErrorCount int `json:"error_count"`
|
||||
Errors []SyncError `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,14 @@ package handlers
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
qfassets "git.mchus.pro/mchus/quoteforge"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type WebHandler struct {
|
||||
@@ -59,12 +61,26 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
|
||||
|
||||
templates := make(map[string]*template.Template)
|
||||
basePath := filepath.Join(templatesPath, "base.html")
|
||||
useDisk := false
|
||||
if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() {
|
||||
useDisk = true
|
||||
}
|
||||
|
||||
// Load each page template with base
|
||||
simplePages := []string{"login.html", "configs.html", "admin_pricing.html", "pricelists.html", "pricelist_detail.html"}
|
||||
for _, page := range simplePages {
|
||||
pagePath := filepath.Join(templatesPath, page)
|
||||
tmpl, err := template.New("").Funcs(funcMap).ParseFiles(basePath, pagePath)
|
||||
var tmpl *template.Template
|
||||
var err error
|
||||
if useDisk {
|
||||
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(basePath, pagePath)
|
||||
} else {
|
||||
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
||||
qfassets.TemplatesFS,
|
||||
"web/templates/base.html",
|
||||
"web/templates/"+page,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -74,7 +90,18 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
|
||||
// Index page needs components_list.html as well
|
||||
indexPath := filepath.Join(templatesPath, "index.html")
|
||||
componentsListPath := filepath.Join(templatesPath, "components_list.html")
|
||||
indexTmpl, err := template.New("").Funcs(funcMap).ParseFiles(basePath, indexPath, componentsListPath)
|
||||
var indexTmpl *template.Template
|
||||
var err error
|
||||
if useDisk {
|
||||
indexTmpl, err = template.New("").Funcs(funcMap).ParseFiles(basePath, indexPath, componentsListPath)
|
||||
} else {
|
||||
indexTmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
||||
qfassets.TemplatesFS,
|
||||
"web/templates/base.html",
|
||||
"web/templates/index.html",
|
||||
"web/templates/components_list.html",
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -84,7 +111,16 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
|
||||
partials := []string{"components_list.html"}
|
||||
for _, partial := range partials {
|
||||
partialPath := filepath.Join(templatesPath, partial)
|
||||
tmpl, err := template.New("").Funcs(funcMap).ParseFiles(partialPath)
|
||||
var tmpl *template.Template
|
||||
var err error
|
||||
if useDisk {
|
||||
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(partialPath)
|
||||
} else {
|
||||
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
||||
qfassets.TemplatesFS,
|
||||
"web/templates/"+partial,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ func (s *Service) CalculatePrice(lotName string, method models.PriceMethod, peri
|
||||
case models.PriceMethodAverage:
|
||||
return CalculateAverage(prices), nil
|
||||
case models.PriceMethodWeightedMedian:
|
||||
return CalculateWeightedMedian(points, s.config.DefaultPeriodDays), nil
|
||||
return CalculateWeightedMedian(points, periodDays), nil
|
||||
case models.PriceMethodMedian:
|
||||
fallthrough
|
||||
default:
|
||||
@@ -149,17 +149,17 @@ func (s *Service) GetPriceStats(lotName string, periodDays int) (*PriceStats, er
|
||||
}
|
||||
|
||||
return &PriceStats{
|
||||
QuoteCount: len(points),
|
||||
MinPrice: CalculatePercentile(prices, 0),
|
||||
MaxPrice: CalculatePercentile(prices, 100),
|
||||
MedianPrice: CalculateMedian(prices),
|
||||
AveragePrice: CalculateAverage(prices),
|
||||
StdDeviation: CalculateStdDev(prices),
|
||||
LatestPrice: points[0].Price,
|
||||
LatestDate: points[0].Date,
|
||||
OldestDate: points[len(points)-1].Date,
|
||||
Percentile25: CalculatePercentile(prices, 25),
|
||||
Percentile75: CalculatePercentile(prices, 75),
|
||||
QuoteCount: len(points),
|
||||
MinPrice: CalculatePercentile(prices, 0),
|
||||
MaxPrice: CalculatePercentile(prices, 100),
|
||||
MedianPrice: CalculateMedian(prices),
|
||||
AveragePrice: CalculateAverage(prices),
|
||||
StdDeviation: CalculateStdDev(prices),
|
||||
LatestPrice: points[0].Price,
|
||||
LatestDate: points[0].Date,
|
||||
OldestDate: points[len(points)-1].Date,
|
||||
Percentile25: CalculatePercentile(prices, 25),
|
||||
Percentile75: CalculatePercentile(prices, 75),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -9,14 +9,14 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrEmptyQuote = errors.New("quote cannot be empty")
|
||||
ErrEmptyQuote = errors.New("quote cannot be empty")
|
||||
ErrComponentNotFound = errors.New("component not found")
|
||||
ErrNoPriceAvailable = errors.New("no price available for component")
|
||||
)
|
||||
|
||||
type QuoteService struct {
|
||||
componentRepo *repository.ComponentRepository
|
||||
statsRepo *repository.StatsRepository
|
||||
componentRepo *repository.ComponentRepository
|
||||
statsRepo *repository.StatsRepository
|
||||
pricingService *pricing.Service
|
||||
}
|
||||
|
||||
@@ -43,11 +43,11 @@ type QuoteItem struct {
|
||||
}
|
||||
|
||||
type QuoteValidationResult struct {
|
||||
Valid bool `json:"valid"`
|
||||
Items []QuoteItem `json:"items"`
|
||||
Errors []string `json:"errors"`
|
||||
Warnings []string `json:"warnings"`
|
||||
Total float64 `json:"total"`
|
||||
Valid bool `json:"valid"`
|
||||
Items []QuoteItem `json:"items"`
|
||||
Errors []string `json:"errors"`
|
||||
Warnings []string `json:"warnings"`
|
||||
Total float64 `json:"total"`
|
||||
}
|
||||
|
||||
type QuoteRequest struct {
|
||||
@@ -61,6 +61,9 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation
|
||||
if len(req.Items) == 0 {
|
||||
return nil, ErrEmptyQuote
|
||||
}
|
||||
if s.componentRepo == nil || s.pricingService == nil {
|
||||
return nil, errors.New("offline mode: quote calculation not available")
|
||||
}
|
||||
|
||||
result := &QuoteValidationResult{
|
||||
Valid: true,
|
||||
|
||||
@@ -134,12 +134,16 @@ func (s *Service) SyncPricelists() (int, error) {
|
||||
|
||||
synced := 0
|
||||
var latestLocalID uint
|
||||
var latestServerID uint
|
||||
for _, pl := range serverPricelists {
|
||||
// Check if pricelist already exists locally
|
||||
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
|
||||
if existing != nil {
|
||||
// Already synced, track latest
|
||||
latestLocalID = existing.ID
|
||||
// Already synced, track latest by server ID
|
||||
if pl.ID > latestServerID {
|
||||
latestServerID = pl.ID
|
||||
latestLocalID = existing.ID
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -167,7 +171,10 @@ func (s *Service) SyncPricelists() (int, error) {
|
||||
slog.Debug("synced pricelist with items", "version", pl.Version, "items", itemCount)
|
||||
}
|
||||
|
||||
latestLocalID = localPL.ID
|
||||
if pl.ID > latestServerID {
|
||||
latestServerID = pl.ID
|
||||
latestLocalID = localPL.ID
|
||||
}
|
||||
synced++
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user