Add Phase 2: Local SQLite database with sync functionality

Implements complete offline-first architecture with SQLite caching and MariaDB synchronization.

Key features:
- Local SQLite database for offline operation (data/quoteforge.db)
- Connection settings with encrypted credentials
- Component and pricelist caching with auto-sync
- Sync API endpoints (/api/sync/status, /components, /pricelists, /all)
- Real-time sync status indicator in UI with auto-refresh
- Offline mode detection middleware
- Migration tool for database initialization
- Setup wizard for initial configuration

New components:
- internal/localdb: SQLite repository layer (components, pricelists, sync)
- internal/services/sync: Synchronization service
- internal/handlers/sync: Sync API handlers
- internal/handlers/setup: Setup wizard handlers
- internal/middleware/offline: Offline detection
- cmd/migrate: Database migration tool

UI improvements:
- Setup page for database configuration
- Sync status indicator with online/offline detection
- Warning icons for pending synchronization
- Auto-refresh every 30 seconds

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-01 11:00:32 +03:00
parent 8b8d2f18f9
commit 143d217397
30 changed files with 3697 additions and 887 deletions

View File

@@ -194,6 +194,149 @@ func (s *ConfigurationService) ListByUser(userID uint, page, perPage int) ([]mod
return s.configRepo.ListByUser(userID, offset, perPage)
}
// ListAll returns all configurations without user filter (for use when auth is disabled)
func (s *ConfigurationService) ListAll(page, perPage int) ([]models.Configuration, int64, error) {
if page < 1 {
page = 1
}
if perPage < 1 || perPage > 100 {
perPage = 20
}
offset := (page - 1) * perPage
return s.configRepo.ListAll(offset, perPage)
}
// GetByUUIDNoAuth returns configuration without ownership check (for use when auth is disabled)
func (s *ConfigurationService) GetByUUIDNoAuth(uuid string) (*models.Configuration, error) {
config, err := s.configRepo.GetByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
return config, nil
}
// UpdateNoAuth updates configuration without ownership check
func (s *ConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigRequest) (*models.Configuration, error) {
config, err := s.configRepo.GetByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
total := req.Items.Total()
if req.ServerCount > 1 {
total *= float64(req.ServerCount)
}
config.Name = req.Name
config.Items = req.Items
config.TotalPrice = &total
config.CustomPrice = req.CustomPrice
config.Notes = req.Notes
config.IsTemplate = req.IsTemplate
config.ServerCount = req.ServerCount
if err := s.configRepo.Update(config); err != nil {
return nil, err
}
return config, nil
}
// DeleteNoAuth deletes configuration without ownership check
func (s *ConfigurationService) DeleteNoAuth(uuid string) error {
config, err := s.configRepo.GetByUUID(uuid)
if err != nil {
return ErrConfigNotFound
}
return s.configRepo.Delete(config.ID)
}
// RenameNoAuth renames configuration without ownership check
func (s *ConfigurationService) RenameNoAuth(uuid string, newName string) (*models.Configuration, error) {
config, err := s.configRepo.GetByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
config.Name = newName
if err := s.configRepo.Update(config); err != nil {
return nil, err
}
return config, nil
}
// CloneNoAuth clones configuration without ownership check
func (s *ConfigurationService) CloneNoAuth(configUUID string, newName string, userID uint) (*models.Configuration, error) {
original, err := s.configRepo.GetByUUID(configUUID)
if err != nil {
return nil, ErrConfigNotFound
}
total := original.Items.Total()
if original.ServerCount > 1 {
total *= float64(original.ServerCount)
}
clone := &models.Configuration{
UUID: uuid.New().String(),
UserID: userID, // Use provided user ID
Name: newName,
Items: original.Items,
TotalPrice: &total,
CustomPrice: original.CustomPrice,
Notes: original.Notes,
IsTemplate: false,
ServerCount: original.ServerCount,
}
if err := s.configRepo.Create(clone); err != nil {
return nil, err
}
return clone, nil
}
// RefreshPricesNoAuth refreshes prices without ownership check
func (s *ConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configuration, error) {
config, err := s.configRepo.GetByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
updatedItems := make(models.ConfigItems, len(config.Items))
for i, item := range config.Items {
metadata, err := s.componentRepo.GetByLotName(item.LotName)
if err != nil || metadata.CurrentPrice == nil {
updatedItems[i] = item
continue
}
updatedItems[i] = models.ConfigItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: *metadata.CurrentPrice,
}
}
config.Items = updatedItems
total := updatedItems.Total()
if config.ServerCount > 1 {
total *= float64(config.ServerCount)
}
config.TotalPrice = &total
now := time.Now()
config.PriceUpdatedAt = &now
if err := s.configRepo.Update(config); err != nil {
return nil, err
}
return config, nil
}
func (s *ConfigurationService) ListTemplates(page, perPage int) ([]models.Configuration, int64, error) {
if page < 1 {
page = 1