Compare commits
45 Commits
ec3c16f3fc
...
v0.2.11-3-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4f92dea66 | ||
|
|
f42b850734 | ||
|
|
d094d39427 | ||
|
|
4509e93864 | ||
|
|
e2800b06f9 | ||
|
|
7c606af2bb | ||
|
|
fabd30650d | ||
|
|
40ade651b0 | ||
|
|
1b87c53609 | ||
| a3dc264efd | |||
| 20056f3593 | |||
|
|
8a37542929 | ||
|
|
0eb6730a55 | ||
|
|
e2d056e7cb | ||
|
|
1bce8086d6 | ||
|
|
0bdd163728 | ||
|
|
fa0f5e321d | ||
|
|
502832ac9a | ||
|
|
8d84484412 | ||
| 2510d9e36e | |||
| d7285fc730 | |||
| e33a3f2c88 | |||
| 4735e2b9bb | |||
| cdf5cef2cf | |||
| 7f030e7db7 | |||
| 3d222b7f14 | |||
| c024b96de7 | |||
| 2c75a7ccb8 | |||
|
|
f25477a25e | ||
|
|
0bde12a39d | ||
|
|
e0404186ad | ||
|
|
eda0e7cb47 | ||
|
|
693c1d05d7 | ||
|
|
7fb9dd0267 | ||
|
|
61646bea46 | ||
|
|
9495f929aa | ||
|
|
b80bde7dac | ||
|
|
e307a2765d | ||
|
|
6f1feb942a | ||
|
|
236e37376e | ||
|
|
ded6e09b5e | ||
|
|
96bbe0a510 | ||
|
|
b672cbf27d | ||
|
|
e206531364 | ||
|
|
9bd2acd4f7 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -13,6 +13,9 @@ config.yaml
|
||||
/cron
|
||||
/bin/
|
||||
|
||||
# Local Go build cache used in sandboxed runs
|
||||
.gocache/
|
||||
|
||||
# ---> macOS
|
||||
# General
|
||||
.DS_Store
|
||||
@@ -41,3 +44,4 @@ Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
releases/
|
||||
|
||||
69
CLAUDE.md
69
CLAUDE.md
@@ -29,12 +29,49 @@
|
||||
- ✅ Add routes for new sync endpoints (`/api/sync/push`, `/pending/count`, `/pending`)
|
||||
- ✅ ConfigurationGetter interface for handler compatibility
|
||||
- ✅ Background sync worker: auto-sync every 5min (push + pull) - `internal/services/sync/worker.go`
|
||||
- ✅ UI: sync status indicator (pending badge + sync button + offline/online dot) - `web/templates/partials/sync_status.html`
|
||||
- ✅ RefreshPrices for local mode:
|
||||
- `RefreshPrices()` / `RefreshPricesNoAuth()` в `local_configuration.go`
|
||||
- Берёт цены из `local_components.current_price`
|
||||
- Graceful degradation при отсутствии компонента
|
||||
- Добавлено поле `price_updated_at` в `LocalConfiguration` (models.go:72)
|
||||
- Обновлены converters для PriceUpdatedAt
|
||||
- UI кнопка "Пересчитать цену" работает offline/online
|
||||
- ✅ Fixed sync bugs:
|
||||
- Duplicate entry error при update конфигураций (`sync/service.go:334-365`)
|
||||
- pushConfigurationUpdate теперь проверяет наличие server_id перед update
|
||||
- Если нет ID → получает из LocalConfiguration.ServerID или ищет на сервере
|
||||
- Fixed setup.go: `settings.Password` → `settings.PasswordEncrypted`
|
||||
|
||||
**TODO:**
|
||||
- ❌ UI: sync status partial (pending badge + sync button + offline indicator)
|
||||
- ❌ RefreshPrices for local mode (via local_components)
|
||||
- ❌ Conflict resolution (Phase 4, last-write-wins default)
|
||||
|
||||
### UI Improvements ✅ MOSTLY DONE
|
||||
|
||||
**1. Sync UI + pricelist badge: ✅ DONE**
|
||||
- ✅ `sync_status.html`: SVG иконки Online/Offline (кликабельные → открывают модал)
|
||||
- ✅ Кнопка sync → иконка circular arrows (только full sync)
|
||||
- ✅ Модальное окно "Статус системы" в `base.html` (info о БД, ошибки синхронизации)
|
||||
- ✅ `configs.html`: badge с версией активного прайслиста
|
||||
- ✅ Загрузка через `/api/pricelists/latest` при DOMContentLoaded
|
||||
- ✅ Удалён dropdown с Push changes (упрощение UI)
|
||||
|
||||
**2. Прайслисты → вкладка в "Администратор цен": ✅ DONE**
|
||||
- ✅ `base.html`: убрана ссылка "Прайслисты" из навигации
|
||||
- ✅ `admin_pricing.html`: добавлена вкладка "Прайслисты"
|
||||
- ✅ Логика перенесена из `pricelists.html` (table, create modal, CRUD)
|
||||
- ✅ Route `/pricelists` → редирект на `/admin/pricing?tab=pricelists`
|
||||
- ✅ Поддержка URL param `?tab=pricelists`
|
||||
|
||||
**3. Модал "Настройка цены" - кол-во котировок с учётом периода: ❌ TODO**
|
||||
- Текущее: показывает только общее кол-во котировок
|
||||
- Новое: показывать `N (всего: M)` где N - за выбранный период, M - всего
|
||||
- ❌ `admin_pricing.html`: обновить `#modal-quote-count`
|
||||
- ❌ `admin_pricing_handler.go`: в `/api/admin/pricing/preview` возвращать `quote_count_period` + `quote_count_total`
|
||||
|
||||
**4. Страница настроек: ❌ ОТЛОЖЕНО**
|
||||
- Перенесено в Phase 3 (после основных UI улучшений)
|
||||
|
||||
### Phase 3: Projects and Specifications
|
||||
- qt_projects, qt_specifications tables (MariaDB)
|
||||
- Replace qt_configurations → Project/Specification hierarchy
|
||||
@@ -65,12 +102,12 @@ Go 1.22+ | Gin | GORM | MariaDB 11 | SQLite (glebarez/sqlite) | htmx + Tailwind
|
||||
- `qt_specifications` - project_id, pricelist_id, variant, rev, qty, items JSON (Phase 3)
|
||||
|
||||
### SQLite (data/quoteforge.db)
|
||||
- `connection_settings` - encrypted DB credentials
|
||||
- `connection_settings` - encrypted DB credentials (PasswordEncrypted field)
|
||||
- `local_pricelists/items` - cached from server
|
||||
- `local_components` - lot cache for offline search
|
||||
- `local_configurations` - with sync_status (pending/synced/conflict)
|
||||
- `local_components` - lot cache for offline search (with current_price)
|
||||
- `local_configurations` - UUID, items, price_updated_at, sync_status (pending/synced/conflict), server_id
|
||||
- `local_projects/specifications` - Phase 3
|
||||
- `pending_changes` - sync queue (entity_type, uuid, op, payload, created_at)
|
||||
- `pending_changes` - sync queue (entity_type, uuid, op, payload, created_at, attempts, last_error)
|
||||
|
||||
## Business Logic
|
||||
|
||||
@@ -91,14 +128,28 @@ Go 1.22+ | Gin | GORM | MariaDB 11 | SQLite (glebarez/sqlite) | htmx + Tailwind
|
||||
| Pricelists | CRUD /api/pricelists, GET /latest, POST /compare |
|
||||
| Projects | CRUD /api/projects/:uuid (Phase 3) |
|
||||
| Specs | CRUD /api/specs/:uuid, POST /upgrade, GET /diff (Phase 3) |
|
||||
| Configs | POST /:uuid/refresh-prices (обновить цены из local_components) |
|
||||
| Sync | GET /status, POST /components, /pricelists, /push, /pull, /resolve-conflict |
|
||||
| Export | GET /api/specs/:uuid/export, /api/projects/:uuid/export |
|
||||
|
||||
## Commands
|
||||
```bash
|
||||
go run ./cmd/server # Dev server
|
||||
go run ./cmd/cron -job=X # cleanup-pricelists | update-prices | update-popularity
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/quoteforge ./cmd/server
|
||||
# Development
|
||||
go run ./cmd/qfs # Dev server
|
||||
make run # Dev server (via Makefile)
|
||||
|
||||
# Production build
|
||||
make build-release # Optimized build with version (recommended)
|
||||
VERSION=$(git describe --tags --always --dirty)
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=$VERSION" -o bin/qfs ./cmd/qfs
|
||||
|
||||
# Cron jobs
|
||||
go run ./cmd/cron -job=cleanup-pricelists # Remove old unused pricelists
|
||||
go run ./cmd/cron -job=update-prices # Recalculate all prices
|
||||
go run ./cmd/cron -job=update-popularity # Update popularity scores
|
||||
|
||||
# Check version
|
||||
./bin/qfs -version
|
||||
```
|
||||
|
||||
## Code Style
|
||||
|
||||
@@ -60,7 +60,7 @@ localConfigService := services.NewLocalConfigurationService(
|
||||
### Шаг 1: Обновить main.go
|
||||
|
||||
```go
|
||||
// В cmd/server/main.go
|
||||
// В cmd/qfs/main.go
|
||||
syncService := sync.NewService(pricelistRepo, configRepo, local)
|
||||
|
||||
// Создать isOnline функцию
|
||||
@@ -165,7 +165,7 @@ type PendingChange struct {
|
||||
|
||||
```bash
|
||||
# Compile
|
||||
go build ./cmd/server
|
||||
go build ./cmd/qfs
|
||||
|
||||
# Run
|
||||
./quoteforge
|
||||
|
||||
@@ -89,7 +89,7 @@ mysql -u user -p RFQ_LOG < migrations/004_add_price_updated_at.sql
|
||||
- `internal/models/configuration.go` - добавлено поле `PriceUpdatedAt`
|
||||
- `internal/services/configuration.go` - добавлен метод `RefreshPrices()`
|
||||
- `internal/handlers/configuration.go` - добавлен обработчик `RefreshPrices()`
|
||||
- `cmd/server/main.go` - добавлен маршрут `/api/configs/:uuid/refresh-prices`
|
||||
- `cmd/qfs/main.go` - добавлен маршрут `/api/configs/:uuid/refresh-prices`
|
||||
- `web/templates/index.html` - добавлена кнопка и JavaScript функции
|
||||
- `migrations/004_add_price_updated_at.sql` - SQL миграция
|
||||
- `CLAUDE.md` - обновлена документация
|
||||
|
||||
97
Makefile
Normal file
97
Makefile
Normal file
@@ -0,0 +1,97 @@
|
||||
.PHONY: build build-release clean test run version
|
||||
|
||||
# Get version from git
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
BUILD_TIME := $(shell date -u '+%Y-%m-%d_%H:%M:%S')
|
||||
LDFLAGS := -s -w -X main.Version=$(VERSION)
|
||||
|
||||
# Binary name
|
||||
BINARY := qfs
|
||||
|
||||
# Build for development (with debug info)
|
||||
build:
|
||||
go build -o bin/$(BINARY) ./cmd/qfs
|
||||
|
||||
# Build for release (optimized, with version)
|
||||
build-release:
|
||||
@echo "Building $(BINARY) version $(VERSION)..."
|
||||
CGO_ENABLED=0 go build -ldflags="$(LDFLAGS)" -o bin/$(BINARY) ./cmd/qfs
|
||||
@echo "✓ Built: bin/$(BINARY)"
|
||||
@./bin/$(BINARY) -version
|
||||
|
||||
# Build release for Linux (cross-compile)
|
||||
build-linux:
|
||||
@echo "Building $(BINARY) for Linux..."
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o bin/$(BINARY)-linux-amd64 ./cmd/qfs
|
||||
@echo "✓ Built: bin/$(BINARY)-linux-amd64"
|
||||
|
||||
# Build release for macOS (cross-compile)
|
||||
build-macos:
|
||||
@echo "Building $(BINARY) for macOS..."
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o bin/$(BINARY)-darwin-amd64 ./cmd/qfs
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="$(LDFLAGS)" -o bin/$(BINARY)-darwin-arm64 ./cmd/qfs
|
||||
@echo "✓ Built: bin/$(BINARY)-darwin-amd64"
|
||||
@echo "✓ Built: bin/$(BINARY)-darwin-arm64"
|
||||
|
||||
# Build release for Windows (cross-compile)
|
||||
build-windows:
|
||||
@echo "Building $(BINARY) for Windows..."
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o bin/$(BINARY)-windows-amd64.exe ./cmd/qfs
|
||||
@echo "✓ Built: bin/$(BINARY)-windows-amd64.exe"
|
||||
|
||||
# Build all platforms
|
||||
build-all: build-release build-linux build-macos build-windows
|
||||
|
||||
# Create release packages for all platforms
|
||||
release:
|
||||
@./scripts/release.sh
|
||||
|
||||
# Show version
|
||||
version:
|
||||
@echo "Version: $(VERSION)"
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
rm -rf bin/
|
||||
rm -f $(BINARY)
|
||||
|
||||
# Run tests
|
||||
test:
|
||||
go test -v ./...
|
||||
|
||||
# Run development server
|
||||
run:
|
||||
go run ./cmd/qfs
|
||||
|
||||
# Run with auto-restart (requires entr: brew install entr)
|
||||
watch:
|
||||
find . -name '*.go' | entr -r go run ./cmd/qfs
|
||||
|
||||
# Install dependencies
|
||||
deps:
|
||||
go mod download
|
||||
go mod tidy
|
||||
|
||||
# Help
|
||||
help:
|
||||
@echo "QuoteForge Server (qfs) - Build Commands"
|
||||
@echo ""
|
||||
@echo "Usage: make [target]"
|
||||
@echo ""
|
||||
@echo "Targets:"
|
||||
@echo " build Build for development (with debug info)"
|
||||
@echo " build-release Build optimized release (default)"
|
||||
@echo " build-linux Cross-compile for Linux"
|
||||
@echo " build-macos Cross-compile for macOS (Intel + Apple Silicon)"
|
||||
@echo " build-windows Cross-compile for Windows"
|
||||
@echo " build-all Build for all platforms"
|
||||
@echo " release Create release packages for all platforms"
|
||||
@echo " version Show current version"
|
||||
@echo " clean Remove build artifacts"
|
||||
@echo " test Run tests"
|
||||
@echo " run Run development server"
|
||||
@echo " watch Run with auto-restart (requires entr)"
|
||||
@echo " deps Install/update dependencies"
|
||||
@echo " help Show this help"
|
||||
@echo ""
|
||||
@echo "Current version: $(VERSION)"
|
||||
49
README.md
49
README.md
@@ -82,7 +82,7 @@ auth:
|
||||
### 3. Миграции базы данных
|
||||
|
||||
```bash
|
||||
go run ./cmd/server -migrate
|
||||
go run ./cmd/qfs -migrate
|
||||
```
|
||||
|
||||
### 4. Импорт метаданных компонентов
|
||||
@@ -95,15 +95,47 @@ go run ./cmd/importer
|
||||
|
||||
```bash
|
||||
# Development
|
||||
go run ./cmd/server
|
||||
go run ./cmd/qfs
|
||||
|
||||
# Production
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/quoteforge ./cmd/server
|
||||
./bin/quoteforge
|
||||
# Production (with Makefile - recommended)
|
||||
make build-release # Builds with version info
|
||||
./bin/qfs -version # Check version
|
||||
|
||||
# Production (manual)
|
||||
VERSION=$(git describe --tags --always --dirty)
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=$VERSION" -o bin/qfs ./cmd/qfs
|
||||
./bin/qfs -version
|
||||
```
|
||||
|
||||
**Makefile команды:**
|
||||
```bash
|
||||
make build-release # Оптимизированная сборка с версией
|
||||
make build-all # Сборка для всех платформ (Linux, macOS, Windows)
|
||||
make build-windows # Только для Windows
|
||||
make run # Запуск dev сервера
|
||||
make test # Запуск тестов
|
||||
make clean # Очистка bin/
|
||||
make help # Показать все команды
|
||||
```
|
||||
|
||||
Приложение будет доступно по адресу: http://localhost:8080
|
||||
|
||||
### Локальная SQLite база (state)
|
||||
|
||||
Локальная база приложения хранится в профиле пользователя и не зависит от расположения бинарника.
|
||||
Имя файла: `qfs.db`.
|
||||
|
||||
- macOS: `~/Library/Application Support/QuoteForge/qfs.db`
|
||||
- Linux: `$XDG_STATE_HOME/quoteforge/qfs.db` (или `~/.local/state/quoteforge/qfs.db`)
|
||||
- Windows: `%LOCALAPPDATA%\\QuoteForge\\qfs.db`
|
||||
|
||||
Можно переопределить путь через `-localdb` или переменную окружения `QFS_DB_PATH`.
|
||||
|
||||
### Локальный config.yaml
|
||||
|
||||
По умолчанию `qfs` ищет `config.yaml` в той же user-state папке, где лежит `qfs.db` (а не рядом с бинарником).
|
||||
Можно переопределить путь через `-config` или `QFS_CONFIG_PATH`.
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
@@ -209,13 +241,13 @@ go run ./cmd/cron -job=update-popularity
|
||||
|
||||
```bash
|
||||
# Запуск в режиме разработки (hot reload)
|
||||
go run ./cmd/server
|
||||
go run ./cmd/qfs
|
||||
|
||||
# Запуск тестов
|
||||
go test ./...
|
||||
|
||||
# Сборка для Linux
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/quoteforge ./cmd/server
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/qfs ./cmd/qfs
|
||||
```
|
||||
|
||||
## Переменные окружения
|
||||
@@ -229,6 +261,9 @@ CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/quoteforge ./cmd/server
|
||||
| `QF_DB_PASSWORD` | Пароль БД | — |
|
||||
| `QF_JWT_SECRET` | Секрет для JWT | — |
|
||||
| `QF_SERVER_PORT` | Порт сервера | 8080 |
|
||||
| `QFS_DB_PATH` | Полный путь к локальной SQLite БД | OS-specific user state dir |
|
||||
| `QFS_STATE_DIR` | Каталог state (если `QFS_DB_PATH` не задан) | OS-specific user state dir |
|
||||
| `QFS_CONFIG_PATH` | Полный путь к `config.yaml` | OS-specific user state dir |
|
||||
|
||||
## Интеграция с существующей БД
|
||||
|
||||
|
||||
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")
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/appstate"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
@@ -16,7 +17,11 @@ import (
|
||||
|
||||
func main() {
|
||||
configPath := flag.String("config", "config.yaml", "path to config file")
|
||||
localDBPath := flag.String("localdb", "./data/settings.db", "path to local SQLite database")
|
||||
defaultLocalDBPath, err := appstate.ResolveDBPath("")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to resolve default local SQLite path: %v", err)
|
||||
}
|
||||
localDBPath := flag.String("localdb", defaultLocalDBPath, "path to local SQLite database (default: user state dir or QFS_DB_PATH)")
|
||||
dryRun := flag.Bool("dry-run", false, "show what would be migrated without actually doing it")
|
||||
flag.Parse()
|
||||
|
||||
@@ -72,10 +77,13 @@ func main() {
|
||||
if *dryRun {
|
||||
log.Println("\n[DRY RUN] Would migrate the following configurations:")
|
||||
for _, c := range configs {
|
||||
userName := "unknown"
|
||||
if c.User != nil {
|
||||
userName := c.OwnerUsername
|
||||
if userName == "" && c.User != nil {
|
||||
userName = c.User.Username
|
||||
}
|
||||
if userName == "" {
|
||||
userName = "unknown"
|
||||
}
|
||||
log.Printf(" - %s (UUID: %s, User: %s, Items: %d)", c.Name, c.UUID, userName, len(c.Items))
|
||||
}
|
||||
log.Printf("\nTotal: %d configurations", len(configs))
|
||||
@@ -110,20 +118,25 @@ func main() {
|
||||
// Create local configuration
|
||||
now := time.Now()
|
||||
localConfig := &localdb.LocalConfiguration{
|
||||
UUID: c.UUID,
|
||||
ServerID: &c.ID,
|
||||
Name: c.Name,
|
||||
Items: localItems,
|
||||
TotalPrice: c.TotalPrice,
|
||||
CustomPrice: c.CustomPrice,
|
||||
Notes: c.Notes,
|
||||
IsTemplate: c.IsTemplate,
|
||||
ServerCount: c.ServerCount,
|
||||
CreatedAt: c.CreatedAt,
|
||||
UpdatedAt: now,
|
||||
SyncedAt: &now,
|
||||
SyncStatus: "synced",
|
||||
OriginalUserID: c.UserID,
|
||||
UUID: c.UUID,
|
||||
ServerID: &c.ID,
|
||||
Name: c.Name,
|
||||
Items: localItems,
|
||||
TotalPrice: c.TotalPrice,
|
||||
CustomPrice: c.CustomPrice,
|
||||
Notes: c.Notes,
|
||||
IsTemplate: c.IsTemplate,
|
||||
ServerCount: c.ServerCount,
|
||||
CreatedAt: c.CreatedAt,
|
||||
UpdatedAt: now,
|
||||
SyncedAt: &now,
|
||||
SyncStatus: "synced",
|
||||
OriginalUserID: c.UserID,
|
||||
OriginalUsername: c.OwnerUsername,
|
||||
}
|
||||
|
||||
if localConfig.OriginalUsername == "" && c.User != nil {
|
||||
localConfig.OriginalUsername = c.User.Username
|
||||
}
|
||||
|
||||
if err := local.SaveConfiguration(localConfig); err != nil {
|
||||
|
||||
@@ -2,18 +2,25 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
qfassets "git.mchus.pro/mchus/quoteforge"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/appstate"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/db"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/handlers"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/middleware"
|
||||
@@ -24,22 +31,69 @@ 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"
|
||||
)
|
||||
|
||||
const (
|
||||
localDBPath = "./data/settings.db"
|
||||
)
|
||||
// Version is set via ldflags during build
|
||||
var Version = "dev"
|
||||
|
||||
func main() {
|
||||
configPath := flag.String("config", "config.yaml", "path to config file (optional, for server settings)")
|
||||
configPath := flag.String("config", "", "path to config file (default: user state dir or QFS_CONFIG_PATH)")
|
||||
localDBPath := flag.String("localdb", "", "path to local SQLite database (default: user state dir or QFS_DB_PATH)")
|
||||
migrate := flag.Bool("migrate", false, "run database migrations")
|
||||
version := flag.Bool("version", false, "show version information")
|
||||
flag.Parse()
|
||||
|
||||
// Show version if requested
|
||||
if *version {
|
||||
fmt.Printf("qfs version %s\n", Version)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
exePath, _ := os.Executable()
|
||||
slog.Info("starting qfs", "version", Version, "executable", exePath)
|
||||
|
||||
resolvedConfigPath, err := appstate.ResolveConfigPath(*configPath)
|
||||
if err != nil {
|
||||
slog.Error("failed to resolve config path", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
resolvedLocalDBPath, err := appstate.ResolveDBPath(*localDBPath)
|
||||
if err != nil {
|
||||
slog.Error("failed to resolve local database path", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Migrate legacy project-local config path to the user state directory when using defaults.
|
||||
if *configPath == "" && os.Getenv("QFS_CONFIG_PATH") == "" {
|
||||
migratedFrom, migrateErr := appstate.MigrateLegacyFile(resolvedConfigPath, []string{"config.yaml"})
|
||||
if migrateErr != nil {
|
||||
slog.Warn("failed to migrate legacy config file", "error", migrateErr)
|
||||
} else if migratedFrom != "" {
|
||||
slog.Info("migrated legacy config file", "from", migratedFrom, "to", resolvedConfigPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate legacy project-local DB path to the user state directory when using defaults.
|
||||
if *localDBPath == "" && os.Getenv("QFS_DB_PATH") == "" {
|
||||
legacyPaths := []string{
|
||||
filepath.Join("data", "settings.db"),
|
||||
filepath.Join("data", "qfs.db"),
|
||||
}
|
||||
migratedFrom, migrateErr := appstate.MigrateLegacyDB(resolvedLocalDBPath, legacyPaths)
|
||||
if migrateErr != nil {
|
||||
slog.Warn("failed to migrate legacy local database", "error", migrateErr)
|
||||
} else if migratedFrom != "" {
|
||||
slog.Info("migrated legacy local database", "from", migratedFrom, "to", resolvedLocalDBPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize local SQLite database (always used)
|
||||
local, err := localdb.New(localDBPath)
|
||||
local, err := localdb.New(resolvedLocalDBPath)
|
||||
if err != nil {
|
||||
slog.Error("failed to initialize local database", "error", err)
|
||||
os.Exit(1)
|
||||
@@ -53,54 +107,55 @@ func main() {
|
||||
}
|
||||
|
||||
// Load config for server settings (optional)
|
||||
cfg, err := config.Load(*configPath)
|
||||
cfg, err := config.Load(resolvedConfigPath)
|
||||
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 errors.Is(err, fs.ErrNotExist) {
|
||||
// Use defaults if config file doesn't exist
|
||||
slog.Info("config file not found, using defaults", "path", resolvedConfigPath)
|
||||
cfg = &config.Config{}
|
||||
} else {
|
||||
slog.Error("failed to load config", "path", resolvedConfigPath, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
setConfigDefaults(cfg)
|
||||
slog.Info("resolved runtime files", "config_path", resolvedConfigPath, "localdb_path", resolvedLocalDBPath)
|
||||
|
||||
setupLogger(cfg.Logging)
|
||||
|
||||
// Get DSN from local SQLite
|
||||
dsn, err := local.GetDSN()
|
||||
if err != nil {
|
||||
slog.Error("failed to get database settings", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Connect to MariaDB
|
||||
db, err := setupDatabaseFromDSN(dsn)
|
||||
if err != nil {
|
||||
slog.Error("failed to connect to database", "error", err)
|
||||
slog.Info("you may need to reconfigure connection at /setup")
|
||||
os.Exit(1)
|
||||
}
|
||||
// Create connection manager and try to connect immediately if settings exist
|
||||
connMgr := db.NewConnectionManager(local)
|
||||
|
||||
dbUser := local.GetDBUser()
|
||||
|
||||
// Ensure DB user exists in qt_users table (for foreign key constraint)
|
||||
dbUserID, err := models.EnsureDBUser(db, dbUser)
|
||||
// Try to connect to MariaDB on startup
|
||||
mariaDB, err := connMgr.GetDB()
|
||||
if err != nil {
|
||||
slog.Error("failed to ensure DB user exists", "error", err)
|
||||
os.Exit(1)
|
||||
slog.Warn("failed to connect to MariaDB on startup, starting in offline mode", "error", err)
|
||||
mariaDB = nil
|
||||
} else {
|
||||
slog.Info("successfully connected to MariaDB on startup")
|
||||
}
|
||||
|
||||
slog.Info("starting QuoteForge server",
|
||||
"version", Version,
|
||||
"host", cfg.Server.Host,
|
||||
"port", cfg.Server.Port,
|
||||
"db_user", dbUser,
|
||||
"db_user_id", dbUserID,
|
||||
"online", mariaDB != nil,
|
||||
)
|
||||
|
||||
if *migrate {
|
||||
if mariaDB == nil {
|
||||
slog.Error("cannot run migrations: database not available")
|
||||
os.Exit(1)
|
||||
}
|
||||
slog.Info("running database migrations...")
|
||||
if err := models.Migrate(db); err != nil {
|
||||
if err := models.Migrate(mariaDB); err != nil {
|
||||
slog.Error("migration failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := models.SeedCategories(db); err != nil {
|
||||
if err := models.SeedCategories(mariaDB); err != nil {
|
||||
slog.Error("seeding categories failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -108,17 +163,17 @@ func main() {
|
||||
}
|
||||
|
||||
gin.SetMode(cfg.Server.Mode)
|
||||
router, syncService, err := setupRouter(db, cfg, local, dbUserID)
|
||||
router, syncService, err := setupRouter(cfg, local, connMgr, mariaDB, dbUser)
|
||||
if err != nil {
|
||||
slog.Error("failed to setup router", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Start background sync worker
|
||||
// Start background sync worker (will auto-skip when offline)
|
||||
workerCtx, workerCancel := context.WithCancel(context.Background())
|
||||
defer workerCancel()
|
||||
|
||||
syncWorker := sync.NewWorker(syncService, db, 5*time.Minute)
|
||||
syncWorker := sync.NewWorker(syncService, connMgr, 5*time.Minute)
|
||||
go syncWorker.Start(workerCtx)
|
||||
|
||||
srv := &http.Server{
|
||||
@@ -136,6 +191,18 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
// Automatically open browser after server starts (with a small delay)
|
||||
go func() {
|
||||
time.Sleep(1 * time.Second)
|
||||
// Always use localhost for browser, even if server binds to 0.0.0.0
|
||||
browserURL := fmt.Sprintf("http://127.0.0.1:%d", cfg.Server.Port)
|
||||
slog.Info("Opening browser to", "url", browserURL)
|
||||
err := openBrowser(browserURL)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to open browser", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
@@ -159,7 +226,7 @@ func main() {
|
||||
|
||||
func setConfigDefaults(cfg *config.Config) {
|
||||
if cfg.Server.Host == "" {
|
||||
cfg.Server.Host = "0.0.0.0"
|
||||
cfg.Server.Host = "127.0.0.1"
|
||||
}
|
||||
if cfg.Server.Port == 0 {
|
||||
cfg.Server.Port = 8080
|
||||
@@ -195,7 +262,11 @@ func setConfigDefaults(cfg *config.Config) {
|
||||
|
||||
// runSetupMode starts a minimal server that only serves the setup page
|
||||
func runSetupMode(local *localdb.LocalDB) {
|
||||
setupHandler, err := handlers.NewSetupHandler(local, "web/templates")
|
||||
restartSig := make(chan struct{}, 1)
|
||||
|
||||
// In setup mode, we don't have a connection manager yet (will restart after setup)
|
||||
templatesPath := filepath.Join("web", "templates")
|
||||
setupHandler, err := handlers.NewSetupHandler(local, nil, templatesPath, restartSig)
|
||||
if err != nil {
|
||||
slog.Error("failed to create setup handler", "error", err)
|
||||
os.Exit(1)
|
||||
@@ -205,7 +276,12 @@ func runSetupMode(local *localdb.LocalDB) {
|
||||
router := gin.New()
|
||||
router.Use(gin.Recovery())
|
||||
|
||||
router.Static("/static", "web/static")
|
||||
staticPath := filepath.Join("web", "static")
|
||||
if stat, err := os.Stat(staticPath); err == nil && stat.IsDir() {
|
||||
router.Static("/static", staticPath)
|
||||
} else if staticFS, err := qfassets.StaticFS(); err == nil {
|
||||
router.StaticFS("/static", http.FS(staticFS))
|
||||
}
|
||||
|
||||
// Setup routes only
|
||||
router.GET("/", func(c *gin.Context) {
|
||||
@@ -224,9 +300,8 @@ func runSetupMode(local *localdb.LocalDB) {
|
||||
})
|
||||
})
|
||||
|
||||
addr := ":8080"
|
||||
slog.Info("starting setup mode server", "address", addr)
|
||||
slog.Info("open http://localhost:8080/setup to configure database connection")
|
||||
addr := "127.0.0.1:8080"
|
||||
slog.Info("starting setup mode server", "address", addr, "version", Version)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
@@ -240,11 +315,34 @@ func runSetupMode(local *localdb.LocalDB) {
|
||||
}
|
||||
}()
|
||||
|
||||
// Open browser to setup page
|
||||
go func() {
|
||||
time.Sleep(1 * time.Second)
|
||||
browserURL := "http://127.0.0.1:8080/setup"
|
||||
slog.Info("Opening browser to setup page", "url", browserURL)
|
||||
err := openBrowser(browserURL)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to open browser", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
slog.Info("setup mode server stopped")
|
||||
select {
|
||||
case <-quit:
|
||||
slog.Info("setup mode server stopped")
|
||||
case <-restartSig:
|
||||
slog.Info("restarting application with saved settings...")
|
||||
|
||||
// Graceful shutdown
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
srv.Shutdown(ctx)
|
||||
|
||||
// Restart process with same arguments
|
||||
restartProcess()
|
||||
}
|
||||
}
|
||||
|
||||
func setupLogger(cfg config.LoggingConfig) {
|
||||
@@ -294,56 +392,89 @@ func setupDatabaseFromDSN(dsn string) (*gorm.DB, error) {
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUserID uint) (*gin.Engine, *sync.Service, error) {
|
||||
func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.ConnectionManager, mariaDB *gorm.DB, dbUsername string) (*gin.Engine, *sync.Service, error) {
|
||||
// mariaDB may be nil if we're in offline mode
|
||||
|
||||
// Repositories
|
||||
componentRepo := repository.NewComponentRepository(db)
|
||||
categoryRepo := repository.NewCategoryRepository(db)
|
||||
priceRepo := repository.NewPriceRepository(db)
|
||||
alertRepo := repository.NewAlertRepository(db)
|
||||
statsRepo := repository.NewStatsRepository(db)
|
||||
pricelistRepo := repository.NewPricelistRepository(db)
|
||||
configRepo := repository.NewConfigurationRepository(db)
|
||||
var componentRepo *repository.ComponentRepository
|
||||
var categoryRepo *repository.CategoryRepository
|
||||
var priceRepo *repository.PriceRepository
|
||||
var alertRepo *repository.AlertRepository
|
||||
var statsRepo *repository.StatsRepository
|
||||
var pricelistRepo *repository.PricelistRepository
|
||||
|
||||
// Only initialize repositories if we have a database connection
|
||||
if mariaDB != nil {
|
||||
componentRepo = repository.NewComponentRepository(mariaDB)
|
||||
categoryRepo = repository.NewCategoryRepository(mariaDB)
|
||||
priceRepo = repository.NewPriceRepository(mariaDB)
|
||||
alertRepo = repository.NewAlertRepository(mariaDB)
|
||||
statsRepo = repository.NewStatsRepository(mariaDB)
|
||||
pricelistRepo = repository.NewPricelistRepository(mariaDB)
|
||||
} else {
|
||||
// In offline mode, we'll use nil repositories or handle them differently
|
||||
// This is handled in the sync service and other components
|
||||
}
|
||||
|
||||
// Services
|
||||
pricingService := pricing.NewService(componentRepo, priceRepo, cfg.Pricing)
|
||||
componentService := services.NewComponentService(componentRepo, categoryRepo, statsRepo)
|
||||
quoteService := services.NewQuoteService(componentRepo, statsRepo, pricingService)
|
||||
exportService := services.NewExportService(cfg.Export, categoryRepo)
|
||||
alertService := alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing)
|
||||
pricelistService := pricelist.NewService(db, pricelistRepo, componentRepo)
|
||||
syncService := sync.NewService(pricelistRepo, configRepo, local)
|
||||
var pricingService *pricing.Service
|
||||
var componentService *services.ComponentService
|
||||
var quoteService *services.QuoteService
|
||||
var exportService *services.ExportService
|
||||
var alertService *alerts.Service
|
||||
var pricelistService *pricelist.Service
|
||||
var syncService *sync.Service
|
||||
|
||||
// Sync service always uses ConnectionManager (works offline and online)
|
||||
syncService = sync.NewService(connMgr, local)
|
||||
|
||||
if mariaDB != nil {
|
||||
pricingService = pricing.NewService(componentRepo, priceRepo, cfg.Pricing)
|
||||
componentService = services.NewComponentService(componentRepo, categoryRepo, statsRepo)
|
||||
quoteService = services.NewQuoteService(componentRepo, statsRepo, pricingService)
|
||||
exportService = services.NewExportService(cfg.Export, categoryRepo)
|
||||
alertService = alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing)
|
||||
pricelistService = pricelist.NewService(mariaDB, pricelistRepo, componentRepo)
|
||||
} else {
|
||||
// In offline mode, we still need to create services that don't require DB
|
||||
pricingService = pricing.NewService(nil, nil, cfg.Pricing)
|
||||
componentService = services.NewComponentService(nil, nil, nil)
|
||||
quoteService = services.NewQuoteService(nil, nil, pricingService)
|
||||
exportService = services.NewExportService(cfg.Export, nil)
|
||||
alertService = alerts.NewService(nil, nil, nil, nil, cfg.Alerts, cfg.Pricing)
|
||||
pricelistService = pricelist.NewService(nil, nil, nil)
|
||||
}
|
||||
|
||||
// isOnline function for local-first architecture
|
||||
isOnline := func() bool {
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return sqlDB.Ping() == nil
|
||||
return connMgr.IsOnline()
|
||||
}
|
||||
|
||||
// Local-first configuration service (replaces old ConfigurationService)
|
||||
configService := services.NewLocalConfigurationService(local, syncService, quoteService, isOnline)
|
||||
|
||||
// Use filepath.Join for cross-platform path compatibility
|
||||
templatesPath := filepath.Join("web", "templates")
|
||||
|
||||
// Handlers
|
||||
componentHandler := handlers.NewComponentHandler(componentService)
|
||||
componentHandler := handlers.NewComponentHandler(componentService, local)
|
||||
quoteHandler := handlers.NewQuoteHandler(quoteService)
|
||||
exportHandler := handlers.NewExportHandler(exportService, configService, componentService)
|
||||
pricingHandler := handlers.NewPricingHandler(db, pricingService, alertService, componentRepo, priceRepo, statsRepo)
|
||||
pricingHandler := handlers.NewPricingHandler(mariaDB, pricingService, alertService, componentRepo, priceRepo, statsRepo)
|
||||
pricelistHandler := handlers.NewPricelistHandler(pricelistService, local)
|
||||
syncHandler, err := handlers.NewSyncHandler(local, syncService, db, "web/templates")
|
||||
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("creating sync handler: %w", err)
|
||||
}
|
||||
|
||||
// Setup handler (for reconfiguration)
|
||||
setupHandler, err := handlers.NewSetupHandler(local, "web/templates")
|
||||
// Setup handler (for reconfiguration) - no restart signal in normal mode
|
||||
setupHandler, err := handlers.NewSetupHandler(local, connMgr, templatesPath, nil)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("creating setup handler: %w", err)
|
||||
}
|
||||
|
||||
// Web handler (templates)
|
||||
webHandler, err := handlers.NewWebHandler("web/templates", componentService)
|
||||
webHandler, err := handlers.NewWebHandler(templatesPath, componentService)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -353,10 +484,15 @@ func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUser
|
||||
router.Use(gin.Recovery())
|
||||
router.Use(requestLogger())
|
||||
router.Use(middleware.CORS())
|
||||
router.Use(middleware.OfflineDetector(db, local))
|
||||
router.Use(middleware.OfflineDetector(connMgr, local))
|
||||
|
||||
// Static files
|
||||
router.Static("/static", "web/static")
|
||||
// Static files (use filepath.Join for Windows compatibility)
|
||||
staticPath := filepath.Join("web", "static")
|
||||
if stat, err := os.Stat(staticPath); err == nil && stat.IsDir() {
|
||||
router.Static("/static", staticPath)
|
||||
} else if staticFS, err := qfassets.StaticFS(); err == nil {
|
||||
router.StaticFS("/static", http.FS(staticFS))
|
||||
}
|
||||
|
||||
// Health check
|
||||
router.GET("/health", func(c *gin.Context) {
|
||||
@@ -366,25 +502,43 @@ func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUser
|
||||
})
|
||||
})
|
||||
|
||||
// Restart endpoint (for development purposes)
|
||||
router.POST("/api/restart", func(c *gin.Context) {
|
||||
// This will cause the server to restart by exiting
|
||||
// The restartProcess function will be called to restart the process
|
||||
slog.Info("Restart requested via API")
|
||||
go func() {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
restartProcess()
|
||||
}()
|
||||
c.JSON(http.StatusOK, gin.H{"message": "restarting..."})
|
||||
})
|
||||
|
||||
// DB status endpoint
|
||||
router.GET("/api/db-status", func(c *gin.Context) {
|
||||
var lotCount, lotLogCount, metadataCount int64
|
||||
var dbOK bool = true
|
||||
var dbOK bool = false
|
||||
var dbError string
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
dbOK = false
|
||||
dbError = err.Error()
|
||||
} else if err := sqlDB.Ping(); err != nil {
|
||||
dbOK = false
|
||||
dbError = err.Error()
|
||||
// Check if connection exists (fast check, no reconnect attempt)
|
||||
status := connMgr.GetStatus()
|
||||
if status.IsConnected {
|
||||
// Already connected, safe to use
|
||||
if db, err := connMgr.GetDB(); err == nil && db != nil {
|
||||
dbOK = true
|
||||
db.Table("lot").Count(&lotCount)
|
||||
db.Table("lot_log").Count(&lotLogCount)
|
||||
db.Table("qt_lot_metadata").Count(&metadataCount)
|
||||
}
|
||||
} else {
|
||||
// Not connected - don't try to reconnect on status check
|
||||
// This prevents 3s timeout on every request
|
||||
dbError = "Database not connected (offline mode)"
|
||||
if status.LastError != "" {
|
||||
dbError = status.LastError
|
||||
}
|
||||
}
|
||||
|
||||
db.Table("lot").Count(&lotCount)
|
||||
db.Table("lot_log").Count(&lotLogCount)
|
||||
db.Table("qt_lot_metadata").Count(&metadataCount)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"connected": dbOK,
|
||||
"error": dbError,
|
||||
@@ -413,7 +567,10 @@ func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUser
|
||||
router.GET("/", webHandler.Index)
|
||||
router.GET("/configs", webHandler.Configs)
|
||||
router.GET("/configurator", webHandler.Configurator)
|
||||
router.GET("/pricelists", webHandler.Pricelists)
|
||||
router.GET("/pricelists", func(c *gin.Context) {
|
||||
// Redirect to admin/pricing with pricelists tab
|
||||
c.Redirect(http.StatusFound, "/admin/pricing?tab=pricelists")
|
||||
})
|
||||
router.GET("/pricelists/:id", webHandler.PricelistDetail)
|
||||
router.GET("/admin/pricing", webHandler.AdminPricing)
|
||||
|
||||
@@ -487,6 +644,19 @@ func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUser
|
||||
})
|
||||
})
|
||||
|
||||
configs.POST("/import", func(c *gin.Context) {
|
||||
result, err := configService.ImportFromServer()
|
||||
if err != nil {
|
||||
if errors.Is(err, sync.ErrOffline) {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database is offline"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
})
|
||||
|
||||
configs.POST("", func(c *gin.Context) {
|
||||
var req services.CreateConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -494,7 +664,7 @@ func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUser
|
||||
return
|
||||
}
|
||||
|
||||
config, err := configService.Create(dbUserID, &req) // use DB user ID
|
||||
config, err := configService.Create(dbUsername, &req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -568,7 +738,7 @@ func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUser
|
||||
return
|
||||
}
|
||||
|
||||
config, err := configService.CloneNoAuth(uuid, req.Name, dbUserID)
|
||||
config, err := configService.CloneNoAuth(uuid, req.Name, dbUsername)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -608,6 +778,7 @@ func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUser
|
||||
syncAPI := api.Group("/sync")
|
||||
{
|
||||
syncAPI.GET("/status", syncHandler.GetStatus)
|
||||
syncAPI.GET("/info", syncHandler.GetInfo)
|
||||
syncAPI.POST("/components", syncHandler.SyncComponents)
|
||||
syncAPI.POST("/pricelists", syncHandler.SyncPricelists)
|
||||
syncAPI.POST("/all", syncHandler.SyncAll)
|
||||
@@ -620,6 +791,45 @@ func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUser
|
||||
return router, syncService, nil
|
||||
}
|
||||
|
||||
// restartProcess restarts the current process with the same arguments
|
||||
func restartProcess() {
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
slog.Error("failed to get executable path", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
args := os.Args
|
||||
env := os.Environ()
|
||||
|
||||
slog.Info("executing restart", "executable", executable, "args", args)
|
||||
|
||||
err = syscall.Exec(executable, args, env)
|
||||
if err != nil {
|
||||
slog.Error("failed to restart process", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func openBrowser(url string) error {
|
||||
var cmd string
|
||||
var args []string
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
cmd = "cmd"
|
||||
args = []string{"/c", "start", url}
|
||||
case "darwin":
|
||||
cmd = "open"
|
||||
args = []string{url}
|
||||
default: // "linux", "freebsd", "openbsd", "netbsd"
|
||||
cmd = "xdg-open"
|
||||
args = []string{url}
|
||||
}
|
||||
|
||||
return exec.Command(cmd, args...).Start()
|
||||
}
|
||||
|
||||
func requestLogger() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
@@ -2,7 +2,7 @@
|
||||
# Copy this file to config.yaml and update values
|
||||
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
host: "127.0.0.1" # Use 0.0.0.0 to listen on all interfaces
|
||||
port: 8080
|
||||
mode: "release" # debug | release
|
||||
read_timeout: "30s"
|
||||
|
||||
197
internal/appstate/path.go
Normal file
197
internal/appstate/path.go
Normal file
@@ -0,0 +1,197 @@
|
||||
package appstate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
const (
|
||||
appDirName = "QuoteForge"
|
||||
defaultDB = "qfs.db"
|
||||
defaultCfg = "config.yaml"
|
||||
envDBPath = "QFS_DB_PATH"
|
||||
envStateDir = "QFS_STATE_DIR"
|
||||
envCfgPath = "QFS_CONFIG_PATH"
|
||||
)
|
||||
|
||||
// ResolveDBPath returns the local SQLite path using priority:
|
||||
// explicit CLI path > QFS_DB_PATH > OS-specific user state directory.
|
||||
func ResolveDBPath(explicitPath string) (string, error) {
|
||||
if explicitPath != "" {
|
||||
return filepath.Clean(explicitPath), nil
|
||||
}
|
||||
|
||||
if fromEnv := os.Getenv(envDBPath); fromEnv != "" {
|
||||
return filepath.Clean(fromEnv), nil
|
||||
}
|
||||
|
||||
dir, err := defaultStateDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return filepath.Join(dir, defaultDB), nil
|
||||
}
|
||||
|
||||
// ResolveConfigPath returns the config path using priority:
|
||||
// explicit CLI path > QFS_CONFIG_PATH > OS-specific user state directory.
|
||||
func ResolveConfigPath(explicitPath string) (string, error) {
|
||||
if explicitPath != "" {
|
||||
return filepath.Clean(explicitPath), nil
|
||||
}
|
||||
|
||||
if fromEnv := os.Getenv(envCfgPath); fromEnv != "" {
|
||||
return filepath.Clean(fromEnv), nil
|
||||
}
|
||||
|
||||
dir, err := defaultStateDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return filepath.Join(dir, defaultCfg), nil
|
||||
}
|
||||
|
||||
// MigrateLegacyDB copies an existing legacy DB (and optional SQLite sidecars)
|
||||
// to targetPath if targetPath does not already exist.
|
||||
// Returns source path if migration happened.
|
||||
func MigrateLegacyDB(targetPath string, legacyPaths []string) (string, error) {
|
||||
if targetPath == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if exists(targetPath) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
|
||||
return "", fmt.Errorf("creating target db directory: %w", err)
|
||||
}
|
||||
|
||||
for _, src := range legacyPaths {
|
||||
if src == "" {
|
||||
continue
|
||||
}
|
||||
src = filepath.Clean(src)
|
||||
if src == targetPath || !exists(src) {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := copyFile(src, targetPath); err != nil {
|
||||
return "", fmt.Errorf("migrating legacy db from %s: %w", src, err)
|
||||
}
|
||||
|
||||
// Optional SQLite sidecar files.
|
||||
_ = copyIfExists(src+"-wal", targetPath+"-wal")
|
||||
_ = copyIfExists(src+"-shm", targetPath+"-shm")
|
||||
|
||||
return src, nil
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// MigrateLegacyFile copies an existing legacy file to targetPath
|
||||
// if targetPath does not already exist.
|
||||
func MigrateLegacyFile(targetPath string, legacyPaths []string) (string, error) {
|
||||
if targetPath == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if exists(targetPath) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
|
||||
return "", fmt.Errorf("creating target directory: %w", err)
|
||||
}
|
||||
|
||||
for _, src := range legacyPaths {
|
||||
if src == "" {
|
||||
continue
|
||||
}
|
||||
src = filepath.Clean(src)
|
||||
if src == targetPath || !exists(src) {
|
||||
continue
|
||||
}
|
||||
if err := copyFile(src, targetPath); err != nil {
|
||||
return "", fmt.Errorf("migrating legacy file from %s: %w", src, err)
|
||||
}
|
||||
return src, nil
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func defaultStateDir() (string, error) {
|
||||
if override := os.Getenv(envStateDir); override != "" {
|
||||
return filepath.Clean(override), nil
|
||||
}
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
base, err := os.UserConfigDir() // ~/Library/Application Support
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolving user config dir: %w", err)
|
||||
}
|
||||
return filepath.Join(base, appDirName), nil
|
||||
case "windows":
|
||||
if local := os.Getenv("LOCALAPPDATA"); local != "" {
|
||||
return filepath.Join(local, appDirName), nil
|
||||
}
|
||||
base, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolving user config dir: %w", err)
|
||||
}
|
||||
return filepath.Join(base, appDirName), nil
|
||||
default:
|
||||
if xdgState := os.Getenv("XDG_STATE_HOME"); xdgState != "" {
|
||||
return filepath.Join(xdgState, "quoteforge"), nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolving user home dir: %w", err)
|
||||
}
|
||||
return filepath.Join(home, ".local", "state", "quoteforge"), nil
|
||||
}
|
||||
}
|
||||
|
||||
func exists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func copyIfExists(src, dst string) error {
|
||||
if !exists(src) {
|
||||
return nil
|
||||
}
|
||||
return copyFile(src, dst)
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
info, err := in.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode().Perm())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return out.Sync()
|
||||
}
|
||||
@@ -106,7 +106,7 @@ func Load(path string) (*Config, error) {
|
||||
|
||||
func (c *Config) setDefaults() {
|
||||
if c.Server.Host == "" {
|
||||
c.Server.Host = "0.0.0.0"
|
||||
c.Server.Host = "127.0.0.1"
|
||||
}
|
||||
if c.Server.Port == 0 {
|
||||
c.Server.Port = 8080
|
||||
|
||||
334
internal/db/connection.go
Normal file
334
internal/db/connection.go
Normal file
@@ -0,0 +1,334 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultConnectTimeout = 5 * time.Second
|
||||
defaultPingInterval = 30 * time.Second
|
||||
defaultReconnectCooldown = 10 * time.Second
|
||||
|
||||
maxOpenConns = 10
|
||||
maxIdleConns = 2
|
||||
connMaxLifetime = 5 * time.Minute
|
||||
)
|
||||
|
||||
// ConnectionStatus represents the current status of the database connection
|
||||
type ConnectionStatus struct {
|
||||
IsConnected bool
|
||||
LastCheck time.Time
|
||||
LastError string // empty if no error
|
||||
DSNHost string // host:port for display (without password!)
|
||||
}
|
||||
|
||||
// ConnectionManager manages database connections with thread-safety and connection pooling
|
||||
type ConnectionManager struct {
|
||||
localDB *localdb.LocalDB // for getting DSN from settings
|
||||
mu sync.RWMutex // protects db and state
|
||||
db *gorm.DB // current connection (nil if not connected)
|
||||
lastError error // last connection error
|
||||
lastCheck time.Time // time of last check/attempt
|
||||
connectTimeout time.Duration // timeout for connection (default: 5s)
|
||||
pingInterval time.Duration // minimum interval between pings (default: 30s)
|
||||
reconnectCooldown time.Duration // pause after failed attempt (default: 10s)
|
||||
}
|
||||
|
||||
// NewConnectionManager creates a new ConnectionManager instance
|
||||
func NewConnectionManager(localDB *localdb.LocalDB) *ConnectionManager {
|
||||
return &ConnectionManager{
|
||||
localDB: localDB,
|
||||
connectTimeout: defaultConnectTimeout,
|
||||
pingInterval: defaultPingInterval,
|
||||
reconnectCooldown: defaultReconnectCooldown,
|
||||
db: nil,
|
||||
lastError: nil,
|
||||
lastCheck: time.Time{},
|
||||
}
|
||||
}
|
||||
|
||||
// GetDB returns the current database connection, establishing it if needed
|
||||
// Thread-safe and respects connection cooldowns
|
||||
func (cm *ConnectionManager) GetDB() (*gorm.DB, error) {
|
||||
// Handle case where localDB is nil
|
||||
if cm.localDB == nil {
|
||||
return nil, fmt.Errorf("local database not initialized")
|
||||
}
|
||||
|
||||
// First check if we already have a valid connection
|
||||
cm.mu.RLock()
|
||||
if cm.db != nil {
|
||||
// Check if connection is still valid and within ping interval
|
||||
if time.Since(cm.lastCheck) < cm.pingInterval {
|
||||
cm.mu.RUnlock()
|
||||
return cm.db, nil
|
||||
}
|
||||
}
|
||||
cm.mu.RUnlock()
|
||||
|
||||
// Upgrade to write lock
|
||||
cm.mu.Lock()
|
||||
defer cm.mu.Unlock()
|
||||
|
||||
// Double-check: someone else might have connected while we were waiting for the write lock
|
||||
if cm.db != nil {
|
||||
// Check if connection is still valid and within ping interval
|
||||
if time.Since(cm.lastCheck) < cm.pingInterval {
|
||||
return cm.db, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we're in cooldown period after a failed attempt
|
||||
if cm.lastError != nil && time.Since(cm.lastCheck) < cm.reconnectCooldown {
|
||||
return nil, cm.lastError
|
||||
}
|
||||
|
||||
// Attempt to connect
|
||||
err := cm.connect()
|
||||
if err != nil {
|
||||
// Drop stale handle so callers don't treat it as an active connection.
|
||||
cm.db = nil
|
||||
cm.lastError = err
|
||||
cm.lastCheck = time.Now()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Update last check time and return success
|
||||
cm.lastCheck = time.Now()
|
||||
cm.lastError = nil
|
||||
return cm.db, nil
|
||||
}
|
||||
|
||||
// connect establishes a new database connection
|
||||
func (cm *ConnectionManager) connect() error {
|
||||
// Get DSN from local settings
|
||||
dsn, err := cm.localDB.GetDSN()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting DSN: %w", err)
|
||||
}
|
||||
|
||||
// Create context with timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cm.connectTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Open database connection
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening database connection: %w", err)
|
||||
}
|
||||
|
||||
// Test the connection
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting sql.DB: %w", err)
|
||||
}
|
||||
|
||||
// Ping with timeout
|
||||
if err = sqlDB.PingContext(ctx); err != nil {
|
||||
return fmt.Errorf("pinging database: %w", err)
|
||||
}
|
||||
|
||||
// Set connection pool settings
|
||||
sqlDB.SetMaxOpenConns(maxOpenConns)
|
||||
sqlDB.SetMaxIdleConns(maxIdleConns)
|
||||
sqlDB.SetConnMaxLifetime(connMaxLifetime)
|
||||
|
||||
// Store the connection
|
||||
cm.db = db
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsOnline checks if the database is currently connected and responsive.
|
||||
// If disconnected, it tries to reconnect (respecting cooldowns in GetDB).
|
||||
func (cm *ConnectionManager) IsOnline() bool {
|
||||
cm.mu.RLock()
|
||||
isDisconnected := cm.db == nil
|
||||
lastErr := cm.lastError
|
||||
checkedRecently := time.Since(cm.lastCheck) < cm.pingInterval
|
||||
cm.mu.RUnlock()
|
||||
|
||||
// Try reconnect in disconnected state.
|
||||
if isDisconnected {
|
||||
_, err := cm.GetDB()
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// If we've checked recently, return cached result.
|
||||
if checkedRecently {
|
||||
return lastErr == nil
|
||||
}
|
||||
|
||||
// Need to perform actual ping.
|
||||
cm.mu.Lock()
|
||||
defer cm.mu.Unlock()
|
||||
|
||||
// Double-check after acquiring write lock
|
||||
if cm.db == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Perform ping with timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cm.connectTimeout)
|
||||
defer cancel()
|
||||
|
||||
sqlDB, err := cm.db.DB()
|
||||
if err != nil {
|
||||
cm.lastError = err
|
||||
cm.lastCheck = time.Now()
|
||||
cm.db = nil
|
||||
return false
|
||||
}
|
||||
|
||||
if err = sqlDB.PingContext(ctx); err != nil {
|
||||
cm.lastError = err
|
||||
cm.lastCheck = time.Now()
|
||||
cm.db = nil
|
||||
return false
|
||||
}
|
||||
|
||||
// Update last check time and return success
|
||||
cm.lastCheck = time.Now()
|
||||
cm.lastError = nil
|
||||
return true
|
||||
}
|
||||
|
||||
// TryConnect forces a new connection attempt (for UI "Reconnect" button)
|
||||
// Ignores cooldown period
|
||||
func (cm *ConnectionManager) TryConnect() error {
|
||||
cm.mu.Lock()
|
||||
defer cm.mu.Unlock()
|
||||
|
||||
// Attempt to connect
|
||||
err := cm.connect()
|
||||
if err != nil {
|
||||
cm.lastError = err
|
||||
cm.lastCheck = time.Now()
|
||||
return err
|
||||
}
|
||||
|
||||
// Update last check time and clear error
|
||||
cm.lastCheck = time.Now()
|
||||
cm.lastError = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// Disconnect closes the current database connection
|
||||
func (cm *ConnectionManager) Disconnect() {
|
||||
cm.mu.Lock()
|
||||
defer cm.mu.Unlock()
|
||||
|
||||
if cm.db != nil {
|
||||
sqlDB, err := cm.db.DB()
|
||||
if err == nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
}
|
||||
cm.db = nil
|
||||
cm.lastError = nil
|
||||
}
|
||||
|
||||
// GetLastError returns the last connection error (thread-safe)
|
||||
func (cm *ConnectionManager) GetLastError() error {
|
||||
cm.mu.RLock()
|
||||
defer cm.mu.RUnlock()
|
||||
return cm.lastError
|
||||
}
|
||||
|
||||
// GetStatus returns the current connection status
|
||||
func (cm *ConnectionManager) GetStatus() ConnectionStatus {
|
||||
cm.mu.RLock()
|
||||
defer cm.mu.RUnlock()
|
||||
|
||||
status := ConnectionStatus{
|
||||
IsConnected: cm.db != nil,
|
||||
LastCheck: cm.lastCheck,
|
||||
LastError: "",
|
||||
DSNHost: "",
|
||||
}
|
||||
|
||||
if cm.lastError != nil {
|
||||
status.LastError = cm.lastError.Error()
|
||||
}
|
||||
|
||||
// Extract host from DSN for display
|
||||
if cm.localDB != nil {
|
||||
if dsn, err := cm.localDB.GetDSN(); err == nil {
|
||||
// Parse DSN to extract host:port
|
||||
// Format: user:password@tcp(host:port)/database?...
|
||||
status.DSNHost = extractHostFromDSN(dsn)
|
||||
}
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
// extractHostFromDSN extracts the host:port part from a DSN string
|
||||
func extractHostFromDSN(dsn string) string {
|
||||
// Find the tcp( part
|
||||
tcpStart := 0
|
||||
if tcpStart = len("tcp("); tcpStart < len(dsn) && dsn[tcpStart] == '(' {
|
||||
// Look for the closing parenthesis
|
||||
parenEnd := -1
|
||||
for i := tcpStart + 1; i < len(dsn); i++ {
|
||||
if dsn[i] == ')' {
|
||||
parenEnd = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if parenEnd != -1 {
|
||||
// Extract host:port part between tcp( and )
|
||||
hostPort := dsn[tcpStart+1 : parenEnd]
|
||||
return hostPort
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try to find host:port by looking for @tcp( pattern
|
||||
atIndex := -1
|
||||
for i := 0; i < len(dsn)-4; i++ {
|
||||
if dsn[i:i+4] == "@tcp" {
|
||||
atIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if atIndex != -1 {
|
||||
// Look for the opening parenthesis after @tcp
|
||||
parenStart := -1
|
||||
for i := atIndex + 4; i < len(dsn); i++ {
|
||||
if dsn[i] == '(' {
|
||||
parenStart = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if parenStart != -1 {
|
||||
// Look for the closing parenthesis
|
||||
parenEnd := -1
|
||||
for i := parenStart + 1; i < len(dsn); i++ {
|
||||
if dsn[i] == ')' {
|
||||
parenEnd = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if parenEnd != -1 {
|
||||
hostPort := dsn[parenStart+1 : parenEnd]
|
||||
return hostPort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we can't parse it, return empty string
|
||||
return ""
|
||||
}
|
||||
@@ -4,17 +4,22 @@ 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 {
|
||||
componentService *services.ComponentService
|
||||
localDB *localdb.LocalDB
|
||||
}
|
||||
|
||||
func NewComponentHandler(componentService *services.ComponentService) *ComponentHandler {
|
||||
return &ComponentHandler{componentService: componentService}
|
||||
func NewComponentHandler(componentService *services.ComponentService, localDB *localdb.LocalDB) *ComponentHandler {
|
||||
return &ComponentHandler{
|
||||
componentService: componentService,
|
||||
localDB: localDB,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ComponentHandler) List(c *gin.Context) {
|
||||
@@ -34,6 +39,46 @@ func (h *ComponentHandler) List(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// If offline mode (empty result), fallback to local components
|
||||
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,
|
||||
HasPrice: filter.HasPrice,
|
||||
}
|
||||
|
||||
offset := (page - 1) * perPage
|
||||
localComps, total, err := h.localDB.ListComponents(localFilter, offset, perPage)
|
||||
if err == nil && len(localComps) > 0 {
|
||||
// Convert local components to ComponentView format
|
||||
components := make([]services.ComponentView, len(localComps))
|
||||
for i, lc := range localComps {
|
||||
components[i] = services.ComponentView{
|
||||
LotName: lc.LotName,
|
||||
Description: lc.LotDescription,
|
||||
Category: lc.Category,
|
||||
CategoryName: lc.Category, // No translation in local mode
|
||||
Model: lc.Model,
|
||||
CurrentPrice: lc.CurrentPrice,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, &services.ComponentListResult{
|
||||
Components: components,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/middleware"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ConfigurationHandler struct {
|
||||
@@ -26,11 +25,11 @@ func NewConfigurationHandler(
|
||||
}
|
||||
|
||||
func (h *ConfigurationHandler) List(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
username := middleware.GetUsername(c)
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
||||
|
||||
configs, total, err := h.configService.ListByUser(userID, page, perPage)
|
||||
configs, total, err := h.configService.ListByUser(username, page, perPage)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -45,7 +44,7 @@ func (h *ConfigurationHandler) List(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *ConfigurationHandler) Create(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
username := middleware.GetUsername(c)
|
||||
|
||||
var req services.CreateConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -53,7 +52,7 @@ func (h *ConfigurationHandler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
config, err := h.configService.Create(userID, &req)
|
||||
config, err := h.configService.Create(username, &req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -63,10 +62,10 @@ func (h *ConfigurationHandler) Create(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *ConfigurationHandler) Get(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
username := middleware.GetUsername(c)
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
config, err := h.configService.GetByUUID(uuid, userID)
|
||||
config, err := h.configService.GetByUUID(uuid, username)
|
||||
if err != nil {
|
||||
status := http.StatusNotFound
|
||||
if err == services.ErrConfigForbidden {
|
||||
@@ -80,7 +79,7 @@ func (h *ConfigurationHandler) Get(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *ConfigurationHandler) Update(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
username := middleware.GetUsername(c)
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
var req services.CreateConfigRequest
|
||||
@@ -89,7 +88,7 @@ func (h *ConfigurationHandler) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
config, err := h.configService.Update(uuid, userID, &req)
|
||||
config, err := h.configService.Update(uuid, username, &req)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if err == services.ErrConfigNotFound {
|
||||
@@ -105,10 +104,10 @@ func (h *ConfigurationHandler) Update(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *ConfigurationHandler) Delete(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
username := middleware.GetUsername(c)
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
err := h.configService.Delete(uuid, userID)
|
||||
err := h.configService.Delete(uuid, username)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if err == services.ErrConfigNotFound {
|
||||
@@ -128,7 +127,7 @@ type RenameConfigRequest struct {
|
||||
}
|
||||
|
||||
func (h *ConfigurationHandler) Rename(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
username := middleware.GetUsername(c)
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
var req RenameConfigRequest
|
||||
@@ -137,7 +136,7 @@ func (h *ConfigurationHandler) Rename(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
config, err := h.configService.Rename(uuid, userID, req.Name)
|
||||
config, err := h.configService.Rename(uuid, username, req.Name)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if err == services.ErrConfigNotFound {
|
||||
@@ -157,7 +156,7 @@ type CloneConfigRequest struct {
|
||||
}
|
||||
|
||||
func (h *ConfigurationHandler) Clone(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
username := middleware.GetUsername(c)
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
var req CloneConfigRequest
|
||||
@@ -166,7 +165,7 @@ func (h *ConfigurationHandler) Clone(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
config, err := h.configService.Clone(uuid, userID, req.Name)
|
||||
config, err := h.configService.Clone(uuid, username, req.Name)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if err == services.ErrConfigNotFound {
|
||||
@@ -182,10 +181,10 @@ func (h *ConfigurationHandler) Clone(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *ConfigurationHandler) RefreshPrices(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
username := middleware.GetUsername(c)
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
config, err := h.configService.RefreshPrices(uuid, userID)
|
||||
config, err := h.configService.RefreshPrices(uuid, username)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if err == services.ErrConfigNotFound {
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/middleware"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ExportHandler struct {
|
||||
@@ -98,10 +98,10 @@ func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ExportData
|
||||
}
|
||||
|
||||
func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
username := middleware.GetUsername(c)
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
config, err := h.configService.GetByUUID(uuid, userID)
|
||||
config, err := h.configService.GetByUUID(uuid, username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
|
||||
@@ -29,6 +29,36 @@ func (h *PricelistHandler) List(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// If offline (empty list), fallback to local pricelists
|
||||
if total == 0 && h.localDB != nil {
|
||||
localPLs, err := h.localDB.GetLocalPricelists()
|
||||
if err == nil && len(localPLs) > 0 {
|
||||
// Convert to PricelistSummary format
|
||||
summaries := make([]map[string]interface{}, len(localPLs))
|
||||
for i, lpl := range localPLs {
|
||||
summaries[i] = map[string]interface{}{
|
||||
"id": lpl.ServerID,
|
||||
"version": lpl.Version,
|
||||
"created_by": "sync",
|
||||
"item_count": 0, // Not tracked
|
||||
"usage_count": 0, // Not tracked in local
|
||||
"is_active": true,
|
||||
"created_at": lpl.CreatedAt,
|
||||
"synced_from": "local",
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"pricelists": summaries,
|
||||
"total": len(summaries),
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
"offline": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"pricelists": pricelists,
|
||||
"total": total,
|
||||
@@ -124,9 +154,33 @@ func (h *PricelistHandler) CanWrite(c *gin.Context) {
|
||||
|
||||
// GetLatest returns the most recent active pricelist
|
||||
func (h *PricelistHandler) GetLatest(c *gin.Context) {
|
||||
// Try to get from server first
|
||||
pl, err := h.service.GetLatestActive()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no active pricelists found"})
|
||||
// If offline or no server pricelists, try to get from local cache
|
||||
if h.localDB == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no database available"})
|
||||
return
|
||||
}
|
||||
localPL, localErr := h.localDB.GetLatestLocalPricelist()
|
||||
if localErr != nil {
|
||||
// No local pricelists either
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "no pricelists available",
|
||||
"local_error": localErr.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
// Return local pricelist
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": localPL.ServerID,
|
||||
"version": localPL.Version,
|
||||
"created_by": "sync",
|
||||
"item_count": 0, // Not tracked in local pricelists
|
||||
"is_active": true,
|
||||
"created_at": localPL.CreatedAt,
|
||||
"synced_from": "local",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,17 @@ func NewPricingHandler(
|
||||
}
|
||||
|
||||
func (h *PricingHandler) GetStats(c *gin.Context) {
|
||||
// Check if we're in offline mode
|
||||
if h.statsRepo == nil || h.alertService == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"new_alerts_count": 0,
|
||||
"top_components": []interface{}{},
|
||||
"trending_components": []interface{}{},
|
||||
"offline": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
newAlerts, _ := h.alertService.GetNewAlertsCount()
|
||||
topComponents, _ := h.statsRepo.GetTopComponents(10)
|
||||
trendingComponents, _ := h.statsRepo.GetTrendingComponents(10)
|
||||
@@ -86,6 +97,19 @@ type ComponentWithCount struct {
|
||||
}
|
||||
|
||||
func (h *PricingHandler) ListComponents(c *gin.Context) {
|
||||
// Check if we're in offline mode
|
||||
if h.componentRepo == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"components": []ComponentWithCount{},
|
||||
"total": 0,
|
||||
"page": 1,
|
||||
"per_page": 20,
|
||||
"offline": true,
|
||||
"message": "Управление ценами доступно только в онлайн режиме",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
||||
|
||||
@@ -213,6 +237,15 @@ func (h *PricingHandler) expandMetaPrices(metaPrices, excludeLot string) []strin
|
||||
}
|
||||
|
||||
func (h *PricingHandler) GetComponentPricing(c *gin.Context) {
|
||||
// Check if we're in offline mode
|
||||
if h.componentRepo == nil || h.pricingService == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"error": "Управление ценами доступно только в онлайн режиме",
|
||||
"offline": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
lotName := c.Param("lot_name")
|
||||
|
||||
component, err := h.componentRepo.GetByLotName(lotName)
|
||||
@@ -248,6 +281,15 @@ type UpdatePriceRequest struct {
|
||||
}
|
||||
|
||||
func (h *PricingHandler) UpdatePrice(c *gin.Context) {
|
||||
// Check if we're in offline mode
|
||||
if h.db == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"error": "Обновление цен доступно только в онлайн режиме",
|
||||
"offline": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdatePriceRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
@@ -409,6 +451,15 @@ func (h *PricingHandler) recalculateSinglePrice(lotName string) {
|
||||
}
|
||||
|
||||
func (h *PricingHandler) RecalculateAll(c *gin.Context) {
|
||||
// Check if we're in offline mode
|
||||
if h.db == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"error": "Пересчёт цен доступен только в онлайн режиме",
|
||||
"offline": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Set headers for SSE
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
@@ -588,6 +639,18 @@ func (h *PricingHandler) RecalculateAll(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *PricingHandler) ListAlerts(c *gin.Context) {
|
||||
// Check if we're in offline mode
|
||||
if h.db == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"alerts": []interface{}{},
|
||||
"total": 0,
|
||||
"page": 1,
|
||||
"per_page": 20,
|
||||
"offline": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
||||
|
||||
@@ -613,6 +676,15 @@ func (h *PricingHandler) ListAlerts(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *PricingHandler) AcknowledgeAlert(c *gin.Context) {
|
||||
// Check if we're in offline mode
|
||||
if h.db == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"error": "Управление алертами доступно только в онлайн режиме",
|
||||
"offline": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"})
|
||||
@@ -628,6 +700,15 @@ func (h *PricingHandler) AcknowledgeAlert(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *PricingHandler) ResolveAlert(c *gin.Context) {
|
||||
// Check if we're in offline mode
|
||||
if h.db == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"error": "Управление алертами доступно только в онлайн режиме",
|
||||
"offline": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"})
|
||||
@@ -643,6 +724,15 @@ func (h *PricingHandler) ResolveAlert(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *PricingHandler) IgnoreAlert(c *gin.Context) {
|
||||
// Check if we're in offline mode
|
||||
if h.db == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"error": "Управление алертами доступно только в онлайн режиме",
|
||||
"offline": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"})
|
||||
@@ -667,6 +757,15 @@ type PreviewPriceRequest struct {
|
||||
}
|
||||
|
||||
func (h *PricingHandler) PreviewPrice(c *gin.Context) {
|
||||
// Check if we're in offline mode
|
||||
if h.db == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"error": "Предпросмотр цены доступен только в онлайн режиме",
|
||||
"offline": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req PreviewPriceRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
@@ -708,8 +807,8 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) {
|
||||
medianAllTime = &median
|
||||
}
|
||||
|
||||
// Get quote count (from all relevant lots)
|
||||
var quoteCount int64
|
||||
// Get quote count (from all relevant lots) - total count
|
||||
var quoteCountTotal int64
|
||||
for _, lotName := range lotNames {
|
||||
var count int64
|
||||
if strings.HasSuffix(lotName, "*") {
|
||||
@@ -718,7 +817,25 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) {
|
||||
} else {
|
||||
h.db.Model(&models.LotLog{}).Where("lot = ?", lotName).Count(&count)
|
||||
}
|
||||
quoteCount += count
|
||||
quoteCountTotal += count
|
||||
}
|
||||
|
||||
// Get quote count for specified period (if period is > 0)
|
||||
var quoteCountPeriod int64
|
||||
if req.PeriodDays > 0 {
|
||||
for _, lotName := range lotNames {
|
||||
var count int64
|
||||
if strings.HasSuffix(lotName, "*") {
|
||||
pattern := strings.TrimSuffix(lotName, "*") + "%"
|
||||
h.db.Raw(`SELECT COUNT(*) FROM lot_log WHERE lot LIKE ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY)`, pattern, req.PeriodDays).Scan(&count)
|
||||
} else {
|
||||
h.db.Raw(`SELECT COUNT(*) FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY)`, lotName, req.PeriodDays).Scan(&count)
|
||||
}
|
||||
quoteCountPeriod += count
|
||||
}
|
||||
} else {
|
||||
// If no period specified, period count equals total count
|
||||
quoteCountPeriod = quoteCountTotal
|
||||
}
|
||||
|
||||
// Get last received price (from the main lot only)
|
||||
@@ -773,14 +890,15 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"lot_name": req.LotName,
|
||||
"current_price": comp.CurrentPrice,
|
||||
"median_all_time": medianAllTime,
|
||||
"new_price": newPrice,
|
||||
"quote_count": quoteCount,
|
||||
"manual_price": comp.ManualPrice,
|
||||
"last_price": lastPrice.Price,
|
||||
"last_price_date": lastPrice.Date,
|
||||
"lot_name": req.LotName,
|
||||
"current_price": comp.CurrentPrice,
|
||||
"median_all_time": medianAllTime,
|
||||
"new_price": newPrice,
|
||||
"quote_count_total": quoteCountTotal,
|
||||
"quote_count_period": quoteCountPeriod,
|
||||
"manual_price": comp.ManualPrice,
|
||||
"last_price": lastPrice.Price,
|
||||
"last_price_date": lastPrice.Date,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -3,24 +3,30 @@ package handlers
|
||||
import (
|
||||
"fmt"
|
||||
"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"
|
||||
)
|
||||
|
||||
type SetupHandler struct {
|
||||
localDB *localdb.LocalDB
|
||||
templates map[string]*template.Template
|
||||
localDB *localdb.LocalDB
|
||||
connMgr *db.ConnectionManager
|
||||
templates map[string]*template.Template
|
||||
restartSig chan struct{}
|
||||
}
|
||||
|
||||
func NewSetupHandler(localDB *localdb.LocalDB, templatesPath string) (*SetupHandler, error) {
|
||||
func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, templatesPath string, restartSig chan struct{}) (*SetupHandler, error) {
|
||||
funcMap := template.FuncMap{
|
||||
"sub": func(a, b int) int { return a - b },
|
||||
"add": func(a, b int) int { return a + b },
|
||||
@@ -30,15 +36,23 @@ func NewSetupHandler(localDB *localdb.LocalDB, templatesPath string) (*SetupHand
|
||||
|
||||
// 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)
|
||||
}
|
||||
templates["setup.html"] = tmpl
|
||||
|
||||
return &SetupHandler{
|
||||
localDB: localDB,
|
||||
templates: templates,
|
||||
localDB: localDB,
|
||||
connMgr: connMgr,
|
||||
templates: templates,
|
||||
restartSig: restartSig,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -72,6 +86,13 @@ func (h *SetupHandler) TestConnection(c *gin.Context) {
|
||||
port = p
|
||||
}
|
||||
|
||||
// If password is empty, try to use saved password
|
||||
if password == "" {
|
||||
if settings, err := h.localDB.GetSettings(); err == nil && settings != nil {
|
||||
password = settings.PasswordEncrypted // GetSettings returns decrypted password in this field
|
||||
}
|
||||
}
|
||||
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s",
|
||||
user, password, host, port, database)
|
||||
|
||||
@@ -138,6 +159,13 @@ func (h *SetupHandler) SaveConnection(c *gin.Context) {
|
||||
port = p
|
||||
}
|
||||
|
||||
// If password is empty, use saved password
|
||||
if password == "" {
|
||||
if settings, err := h.localDB.GetSettings(); err == nil && settings != nil {
|
||||
password = settings.PasswordEncrypted // GetSettings returns decrypted password in this field
|
||||
}
|
||||
}
|
||||
|
||||
// Test connection first
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s",
|
||||
user, password, host, port, database)
|
||||
@@ -165,10 +193,30 @@ func (h *SetupHandler) SaveConnection(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Try to connect immediately to verify settings
|
||||
if h.connMgr != nil {
|
||||
if err := h.connMgr.TryConnect(); err != nil {
|
||||
slog.Warn("failed to connect after saving settings", "error", err)
|
||||
} else {
|
||||
slog.Info("successfully connected to database after saving settings")
|
||||
}
|
||||
}
|
||||
|
||||
// Always restart to properly initialize all services with the new connection
|
||||
restartRequired := h.restartSig == nil
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Settings saved. Please restart the application.",
|
||||
"success": true,
|
||||
"message": "Settings saved.",
|
||||
"restart_required": restartRequired,
|
||||
})
|
||||
|
||||
// Signal restart after response is sent (if restart signal is configured)
|
||||
if h.restartSig != nil {
|
||||
go func() {
|
||||
time.Sleep(500 * time.Millisecond) // Give time for response to be sent
|
||||
h.restartSig <- struct{}{}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// GetStatus returns the current setup status
|
||||
|
||||
@@ -4,28 +4,36 @@ 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"
|
||||
"gorm.io/gorm"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SyncHandler handles sync API endpoints
|
||||
type SyncHandler struct {
|
||||
localDB *localdb.LocalDB
|
||||
syncService *sync.Service
|
||||
mariaDB *gorm.DB
|
||||
connMgr *db.ConnectionManager
|
||||
tmpl *template.Template
|
||||
}
|
||||
|
||||
// NewSyncHandler creates a new sync handler
|
||||
func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, mariaDB *gorm.DB, templatesPath string) (*SyncHandler, error) {
|
||||
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
|
||||
}
|
||||
@@ -33,21 +41,21 @@ func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, mariaDB
|
||||
return &SyncHandler{
|
||||
localDB: localDB,
|
||||
syncService: syncService,
|
||||
mariaDB: mariaDB,
|
||||
connMgr: connMgr,
|
||||
tmpl: tmpl,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -109,7 +117,17 @@ func (h *SyncHandler) SyncComponents(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.localDB.SyncComponents(h.mariaDB)
|
||||
// Get database connection from ConnectionManager
|
||||
mariaDB, err := h.connMgr.GetDB()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"success": false,
|
||||
"error": "Database connection failed: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.localDB.SyncComponents(mariaDB)
|
||||
if err != nil {
|
||||
slog.Error("component sync failed", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
@@ -159,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
|
||||
@@ -181,7 +199,16 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
|
||||
var componentsSynced, pricelistsSynced int
|
||||
|
||||
// Sync components
|
||||
compResult, err := h.localDB.SyncComponents(h.mariaDB)
|
||||
mariaDB, err := h.connMgr.GetDB()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"success": false,
|
||||
"error": "Database connection failed: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
compResult, err := h.localDB.SyncComponents(mariaDB)
|
||||
if err != nil {
|
||||
slog.Error("component sync failed during full sync", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
@@ -197,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
|
||||
@@ -215,16 +242,7 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
|
||||
|
||||
// checkOnline checks if MariaDB is accessible
|
||||
func (h *SyncHandler) checkOnline() bool {
|
||||
sqlDB, err := h.mariaDB.DB()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
return h.connMgr.IsOnline()
|
||||
}
|
||||
|
||||
// PushPendingChanges pushes all pending changes to the server
|
||||
@@ -282,23 +300,97 @@ 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"`
|
||||
Errors []SyncError `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
// SyncError represents a sync error
|
||||
type SyncError struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// GetInfo returns sync information for modal
|
||||
// GET /api/sync/info
|
||||
func (h *SyncHandler) GetInfo(c *gin.Context) {
|
||||
// Check online status by pinging MariaDB
|
||||
isOnline := h.checkOnline()
|
||||
|
||||
// Get sync times
|
||||
lastPricelistSync := h.localDB.GetLastSyncTime()
|
||||
|
||||
// Get error count (only changes with LastError != "")
|
||||
errorCount := int(h.localDB.CountErroredChanges())
|
||||
|
||||
// Get recent errors (last 10)
|
||||
changes, err := h.localDB.GetPendingChanges()
|
||||
if err != nil {
|
||||
slog.Error("failed to get pending changes for sync info", "error", err)
|
||||
// Even if we can't get changes, we can still return the error count
|
||||
c.JSON(http.StatusOK, SyncInfoResponse{
|
||||
LastSyncAt: lastPricelistSync,
|
||||
IsOnline: isOnline,
|
||||
ErrorCount: errorCount,
|
||||
Errors: []SyncError{}, // Return empty errors list
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var errors []SyncError
|
||||
for _, change := range changes {
|
||||
// Check if there's a last error and it's not empty
|
||||
if change.LastError != "" {
|
||||
errors = append(errors, SyncError{
|
||||
Timestamp: change.CreatedAt,
|
||||
Message: change.LastError,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Limit to last 10 errors
|
||||
if len(errors) > 10 {
|
||||
errors = errors[:10]
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SyncInfoResponse{
|
||||
LastSyncAt: lastPricelistSync,
|
||||
IsOnline: isOnline,
|
||||
ErrorCount: errorCount,
|
||||
Errors: errors,
|
||||
})
|
||||
}
|
||||
|
||||
// SyncStatusPartial renders the sync status partial for htmx
|
||||
// GET /partials/sync-status
|
||||
func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {
|
||||
// Check online status
|
||||
isOffline, _ := c.Get("is_offline")
|
||||
// Check online status from middleware
|
||||
isOfflineValue, exists := c.Get("is_offline")
|
||||
isOffline := false
|
||||
if exists {
|
||||
isOffline = isOfflineValue.(bool)
|
||||
} else {
|
||||
// Fallback: check directly if middleware didn't set it
|
||||
isOffline = !h.checkOnline()
|
||||
slog.Warn("is_offline not found in context, checking directly")
|
||||
}
|
||||
|
||||
// Get pending count
|
||||
pendingCount := h.localDB.GetPendingCount()
|
||||
|
||||
slog.Debug("rendering sync status", "is_offline", isOffline, "pending_count", pendingCount)
|
||||
|
||||
data := gin.H{
|
||||
"IsOffline": isOffline.(bool),
|
||||
"IsOffline": isOffline,
|
||||
"PendingCount": pendingCount,
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
if err := h.tmpl.ExecuteTemplate(c.Writer, "sync_status", data); err != nil {
|
||||
slog.Error("failed to render sync_status template", "error", err)
|
||||
c.String(http.StatusInternalServerError, "Template error")
|
||||
c.String(http.StatusInternalServerError, "Template error: "+err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -9,6 +9,13 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ComponentFilter for searching with filters
|
||||
type ComponentFilter struct {
|
||||
Category string
|
||||
Search string
|
||||
HasPrice bool
|
||||
}
|
||||
|
||||
// ComponentSyncResult contains statistics from component sync
|
||||
type ComponentSyncResult struct {
|
||||
TotalSynced int
|
||||
@@ -196,6 +203,44 @@ func (l *LocalDB) SearchLocalComponentsByCategory(category string, query string,
|
||||
return components, err
|
||||
}
|
||||
|
||||
// ListComponents returns components with filtering and pagination
|
||||
func (l *LocalDB) ListComponents(filter ComponentFilter, offset, limit int) ([]LocalComponent, int64, error) {
|
||||
db := l.db
|
||||
|
||||
// Apply category filter
|
||||
if filter.Category != "" {
|
||||
db = db.Where("LOWER(category) = ?", strings.ToLower(filter.Category))
|
||||
}
|
||||
|
||||
// Apply search filter
|
||||
if filter.Search != "" {
|
||||
searchPattern := "%" + strings.ToLower(filter.Search) + "%"
|
||||
db = db.Where(
|
||||
"LOWER(lot_name) LIKE ? OR LOWER(lot_description) LIKE ? OR LOWER(category) LIKE ? OR LOWER(model) LIKE ?",
|
||||
searchPattern, searchPattern, searchPattern, searchPattern,
|
||||
)
|
||||
}
|
||||
|
||||
// Apply price filter
|
||||
if filter.HasPrice {
|
||||
db = db.Where("current_price IS NOT NULL")
|
||||
}
|
||||
|
||||
// Get total count
|
||||
var total int64
|
||||
if err := db.Model(&LocalComponent{}).Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Apply pagination and get results
|
||||
var components []LocalComponent
|
||||
if err := db.Order("lot_name").Offset(offset).Limit(limit).Find(&components).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return components, total, nil
|
||||
}
|
||||
|
||||
// GetLocalComponent returns a single component by lot_name
|
||||
func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) {
|
||||
var component LocalComponent
|
||||
@@ -266,3 +311,100 @@ func (l *LocalDB) NeedComponentSync(maxAgeHours int) bool {
|
||||
}
|
||||
return time.Since(*syncTime).Hours() > float64(maxAgeHours)
|
||||
}
|
||||
|
||||
// UpdateComponentPricesFromPricelist updates current_price in local_components from pricelist items
|
||||
// This allows offline price updates using synced pricelists without MariaDB connection
|
||||
func (l *LocalDB) UpdateComponentPricesFromPricelist(pricelistID uint) (int, error) {
|
||||
// Get all items from the specified pricelist
|
||||
var items []LocalPricelistItem
|
||||
if err := l.db.Where("pricelist_id = ?", pricelistID).Find(&items).Error; err != nil {
|
||||
return 0, fmt.Errorf("fetching pricelist items: %w", err)
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
slog.Warn("no items found in pricelist", "pricelist_id", pricelistID)
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Update current_price for each component
|
||||
updated := 0
|
||||
err := l.db.Transaction(func(tx *gorm.DB) error {
|
||||
for _, item := range items {
|
||||
result := tx.Model(&LocalComponent{}).
|
||||
Where("lot_name = ?", item.LotName).
|
||||
Update("current_price", item.Price)
|
||||
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("updating price for %s: %w", item.LotName, result.Error)
|
||||
}
|
||||
|
||||
if result.RowsAffected > 0 {
|
||||
updated++
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
slog.Info("updated component prices from pricelist",
|
||||
"pricelist_id", pricelistID,
|
||||
"total_items", len(items),
|
||||
"updated_components", updated)
|
||||
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// EnsureComponentPricesFromPricelists loads prices from the latest pricelist into local_components
|
||||
// if no components exist or all current prices are NULL
|
||||
func (l *LocalDB) EnsureComponentPricesFromPricelists() error {
|
||||
// Check if we have any components with prices
|
||||
var count int64
|
||||
if err := l.db.Model(&LocalComponent{}).Where("current_price IS NOT NULL").Count(&count).Error; err != nil {
|
||||
return fmt.Errorf("checking component prices: %w", err)
|
||||
}
|
||||
|
||||
// If we have components with prices, don't load from pricelists
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if we have any components at all
|
||||
var totalComponents int64
|
||||
if err := l.db.Model(&LocalComponent{}).Count(&totalComponents).Error; err != nil {
|
||||
return fmt.Errorf("counting components: %w", err)
|
||||
}
|
||||
|
||||
// If we have no components, we need to load them from pricelists
|
||||
if totalComponents == 0 {
|
||||
slog.Info("no components found in local database, loading from latest pricelist")
|
||||
// This would typically be called from the sync service or setup process
|
||||
// For now, we'll just return nil to indicate no action needed
|
||||
return nil
|
||||
}
|
||||
|
||||
// If we have components but no prices, we should load prices from pricelists
|
||||
// Find the latest pricelist
|
||||
var latestPricelist LocalPricelist
|
||||
if err := l.db.Order("created_at DESC").First(&latestPricelist).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
slog.Warn("no pricelists found in local database")
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("finding latest pricelist: %w", err)
|
||||
}
|
||||
|
||||
// Update prices from the latest pricelist
|
||||
updated, err := l.UpdateComponentPricesFromPricelist(latestPricelist.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("updating component prices from pricelist: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("loaded component prices from latest pricelist",
|
||||
"pricelist_id", latestPricelist.ID,
|
||||
"updated_components", updated)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -18,18 +18,24 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
|
||||
}
|
||||
|
||||
local := &LocalConfiguration{
|
||||
UUID: cfg.UUID,
|
||||
Name: cfg.Name,
|
||||
Items: items,
|
||||
TotalPrice: cfg.TotalPrice,
|
||||
CustomPrice: cfg.CustomPrice,
|
||||
Notes: cfg.Notes,
|
||||
IsTemplate: cfg.IsTemplate,
|
||||
ServerCount: cfg.ServerCount,
|
||||
CreatedAt: cfg.CreatedAt,
|
||||
UpdatedAt: time.Now(),
|
||||
SyncStatus: "pending",
|
||||
OriginalUserID: cfg.UserID,
|
||||
UUID: cfg.UUID,
|
||||
Name: cfg.Name,
|
||||
Items: items,
|
||||
TotalPrice: cfg.TotalPrice,
|
||||
CustomPrice: cfg.CustomPrice,
|
||||
Notes: cfg.Notes,
|
||||
IsTemplate: cfg.IsTemplate,
|
||||
ServerCount: cfg.ServerCount,
|
||||
PriceUpdatedAt: cfg.PriceUpdatedAt,
|
||||
CreatedAt: cfg.CreatedAt,
|
||||
UpdatedAt: time.Now(),
|
||||
SyncStatus: "pending",
|
||||
OriginalUserID: cfg.UserID,
|
||||
OriginalUsername: cfg.OwnerUsername,
|
||||
}
|
||||
|
||||
if local.OriginalUsername == "" && cfg.User != nil {
|
||||
local.OriginalUsername = cfg.User.Username
|
||||
}
|
||||
|
||||
if cfg.ID > 0 {
|
||||
@@ -52,16 +58,18 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
|
||||
}
|
||||
|
||||
cfg := &models.Configuration{
|
||||
UUID: local.UUID,
|
||||
UserID: local.OriginalUserID,
|
||||
Name: local.Name,
|
||||
Items: items,
|
||||
TotalPrice: local.TotalPrice,
|
||||
CustomPrice: local.CustomPrice,
|
||||
Notes: local.Notes,
|
||||
IsTemplate: local.IsTemplate,
|
||||
ServerCount: local.ServerCount,
|
||||
CreatedAt: local.CreatedAt,
|
||||
UUID: local.UUID,
|
||||
UserID: local.OriginalUserID,
|
||||
OwnerUsername: local.OriginalUsername,
|
||||
Name: local.Name,
|
||||
Items: items,
|
||||
TotalPrice: local.TotalPrice,
|
||||
CustomPrice: local.CustomPrice,
|
||||
Notes: local.Notes,
|
||||
IsTemplate: local.IsTemplate,
|
||||
ServerCount: local.ServerCount,
|
||||
PriceUpdatedAt: local.PriceUpdatedAt,
|
||||
CreatedAt: local.CreatedAt,
|
||||
}
|
||||
|
||||
if local.ServerID != nil {
|
||||
|
||||
@@ -132,7 +132,11 @@ func (l *LocalDB) GetDSN() (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
||||
// Add aggressive timeouts for offline-first architecture
|
||||
// timeout: connection establishment timeout (3s)
|
||||
// readTimeout: I/O read timeout (3s)
|
||||
// writeTimeout: I/O write timeout (3s)
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=3s&readTimeout=3s&writeTimeout=3s",
|
||||
settings.User,
|
||||
settings.PasswordEncrypted, // Contains decrypted password after GetSettings
|
||||
settings.Host,
|
||||
@@ -396,6 +400,13 @@ func (l *LocalDB) CountPendingChangesByType(entityType string) int64 {
|
||||
return count
|
||||
}
|
||||
|
||||
// CountErroredChanges returns the number of pending changes with errors
|
||||
func (l *LocalDB) CountErroredChanges() int64 {
|
||||
var count int64
|
||||
l.db.Model(&PendingChange{}).Where("last_error != ?", "").Count(&count)
|
||||
return count
|
||||
}
|
||||
|
||||
// MarkChangesSynced marks multiple pending changes as synced by deleting them
|
||||
func (l *LocalDB) MarkChangesSynced(ids []int64) error {
|
||||
if len(ids) == 0 {
|
||||
|
||||
@@ -59,21 +59,23 @@ func (c LocalConfigItems) Total() float64 {
|
||||
|
||||
// LocalConfiguration stores configurations in local SQLite
|
||||
type LocalConfiguration struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UUID string `gorm:"uniqueIndex;not null" json:"uuid"`
|
||||
ServerID *uint `json:"server_id"` // ID on MariaDB server, NULL if local only
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Items LocalConfigItems `gorm:"type:text" json:"items"` // JSON stored as text in SQLite
|
||||
TotalPrice *float64 `json:"total_price"`
|
||||
CustomPrice *float64 `json:"custom_price"`
|
||||
Notes string `json:"notes"`
|
||||
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
||||
ServerCount int `gorm:"default:1" json:"server_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
SyncedAt *time.Time `json:"synced_at"`
|
||||
SyncStatus string `gorm:"default:'local'" json:"sync_status"` // 'local', 'synced', 'modified'
|
||||
OriginalUserID uint `json:"original_user_id"` // UserID from MariaDB for reference
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UUID string `gorm:"uniqueIndex;not null" json:"uuid"`
|
||||
ServerID *uint `json:"server_id"` // ID on MariaDB server, NULL if local only
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Items LocalConfigItems `gorm:"type:text" json:"items"` // JSON stored as text in SQLite
|
||||
TotalPrice *float64 `json:"total_price"`
|
||||
CustomPrice *float64 `json:"custom_price"`
|
||||
Notes string `json:"notes"`
|
||||
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
||||
ServerCount int `gorm:"default:1" json:"server_count"`
|
||||
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
SyncedAt *time.Time `json:"synced_at"`
|
||||
SyncStatus string `gorm:"default:'local'" json:"sync_status"` // 'local', 'synced', 'modified'
|
||||
OriginalUserID uint `json:"original_user_id"` // UserID from MariaDB for reference
|
||||
OriginalUsername string `gorm:"not null;default:'';index" json:"original_username"`
|
||||
}
|
||||
|
||||
func (LocalConfiguration) TableName() string {
|
||||
@@ -82,13 +84,13 @@ func (LocalConfiguration) TableName() string {
|
||||
|
||||
// LocalPricelist stores cached pricelists from server
|
||||
type LocalPricelist struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
ServerID uint `gorm:"not null" json:"server_id"` // ID on MariaDB server
|
||||
Version string `gorm:"uniqueIndex;not null" json:"version"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
SyncedAt time.Time `json:"synced_at"`
|
||||
IsUsed bool `gorm:"default:false" json:"is_used"` // Used by any local configuration
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
ServerID uint `gorm:"not null" json:"server_id"` // ID on MariaDB server
|
||||
Version string `gorm:"uniqueIndex;not null" json:"version"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
SyncedAt time.Time `json:"synced_at"`
|
||||
IsUsed bool `gorm:"default:false" json:"is_used"` // Used by any local configuration
|
||||
}
|
||||
|
||||
func (LocalPricelist) TableName() string {
|
||||
|
||||
@@ -4,9 +4,9 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -99,3 +99,12 @@ func GetUserID(c *gin.Context) uint {
|
||||
}
|
||||
return claims.UserID
|
||||
}
|
||||
|
||||
// GetUsername extracts username from context
|
||||
func GetUsername(c *gin.Context) string {
|
||||
claims := GetClaims(c)
|
||||
if claims == nil {
|
||||
return ""
|
||||
}
|
||||
return claims.Username
|
||||
}
|
||||
|
||||
@@ -4,17 +4,17 @@ import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/db"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// OfflineDetector creates middleware that detects offline mode
|
||||
// Sets context values:
|
||||
// - "is_offline" (bool) - true if MariaDB is unavailable
|
||||
// - "localdb" (*localdb.LocalDB) - local database instance for fallback
|
||||
func OfflineDetector(mariaDB *gorm.DB, local *localdb.LocalDB) gin.HandlerFunc {
|
||||
func OfflineDetector(connMgr *db.ConnectionManager, local *localdb.LocalDB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
isOffline := !checkMariaDBOnline(mariaDB)
|
||||
isOffline := !connMgr.IsOnline()
|
||||
|
||||
// Set context values for handlers
|
||||
c.Set("is_offline", isOffline)
|
||||
@@ -27,17 +27,3 @@ func OfflineDetector(mariaDB *gorm.DB, local *localdb.LocalDB) gin.HandlerFunc {
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// checkMariaDBOnline checks if MariaDB is accessible
|
||||
func checkMariaDBOnline(mariaDB *gorm.DB) bool {
|
||||
sqlDB, err := mariaDB.DB()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -40,18 +40,19 @@ func (c ConfigItems) Total() float64 {
|
||||
}
|
||||
|
||||
type Configuration struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
|
||||
UserID uint `gorm:"not null" json:"user_id"`
|
||||
Name string `gorm:"size:200;not null" json:"name"`
|
||||
Items ConfigItems `gorm:"type:json;not null" json:"items"`
|
||||
TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"`
|
||||
CustomPrice *float64 `gorm:"type:decimal(12,2)" json:"custom_price"`
|
||||
Notes string `gorm:"type:text" json:"notes"`
|
||||
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
||||
ServerCount int `gorm:"default:1" json:"server_count"`
|
||||
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
|
||||
UserID uint `gorm:"not null" json:"user_id"` // Legacy owner field (kept for backward compatibility)
|
||||
OwnerUsername string `gorm:"size:100;not null;default:'';index" json:"owner_username"`
|
||||
Name string `gorm:"size:200;not null" json:"name"`
|
||||
Items ConfigItems `gorm:"type:json;not null" json:"items"`
|
||||
TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"`
|
||||
CustomPrice *float64 `gorm:"type:decimal(12,2)" json:"custom_price"`
|
||||
Notes string `gorm:"type:text" json:"notes"`
|
||||
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
||||
ServerCount int `gorm:"default:1" json:"server_count"`
|
||||
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
|
||||
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||
}
|
||||
|
||||
@@ -43,13 +43,16 @@ func (r *ConfigurationRepository) Delete(id uint) error {
|
||||
return r.db.Delete(&models.Configuration{}, id).Error
|
||||
}
|
||||
|
||||
func (r *ConfigurationRepository) ListByUser(userID uint, offset, limit int) ([]models.Configuration, int64, error) {
|
||||
func (r *ConfigurationRepository) ListByUser(ownerUsername string, offset, limit int) ([]models.Configuration, int64, error) {
|
||||
var configs []models.Configuration
|
||||
var total int64
|
||||
|
||||
r.db.Model(&models.Configuration{}).Where("user_id = ?", userID).Count(&total)
|
||||
ownerScope := "owner_username = ? OR (COALESCE(owner_username, '') = '' AND user_id IN (SELECT id FROM qt_users WHERE username = ?))"
|
||||
|
||||
r.db.Model(&models.Configuration{}).Where(ownerScope, ownerUsername, ownerUsername).Count(&total)
|
||||
err := r.db.
|
||||
Where("user_id = ?", userID).
|
||||
Preload("User").
|
||||
Where(ownerScope, ownerUsername, ownerUsername).
|
||||
Order("created_at DESC").
|
||||
Offset(offset).
|
||||
Limit(limit).
|
||||
@@ -81,6 +84,7 @@ func (r *ConfigurationRepository) ListAll(offset, limit int) ([]models.Configura
|
||||
|
||||
r.db.Model(&models.Configuration{}).Count(&total)
|
||||
err := r.db.
|
||||
Preload("User").
|
||||
Order("created_at DESC").
|
||||
Offset(offset).
|
||||
Limit(limit).
|
||||
|
||||
@@ -29,6 +29,34 @@ func (r *PricelistRepository) List(offset, limit int) ([]models.PricelistSummary
|
||||
return nil, 0, fmt.Errorf("listing pricelists: %w", err)
|
||||
}
|
||||
|
||||
return r.toSummaries(pricelists), total, nil
|
||||
}
|
||||
|
||||
// ListActive returns active pricelists with pagination.
|
||||
func (r *PricelistRepository) ListActive(offset, limit int) ([]models.PricelistSummary, int64, error) {
|
||||
var total int64
|
||||
if err := r.db.Model(&models.Pricelist{}).Where("is_active = ?", true).Count(&total).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("counting active pricelists: %w", err)
|
||||
}
|
||||
|
||||
var pricelists []models.Pricelist
|
||||
if err := r.db.Where("is_active = ?", true).Order("created_at DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("listing active pricelists: %w", err)
|
||||
}
|
||||
|
||||
return r.toSummaries(pricelists), total, nil
|
||||
}
|
||||
|
||||
// CountActive returns the number of active pricelists.
|
||||
func (r *PricelistRepository) CountActive() (int64, error) {
|
||||
var total int64
|
||||
if err := r.db.Model(&models.Pricelist{}).Where("is_active = ?", true).Count(&total).Error; err != nil {
|
||||
return 0, fmt.Errorf("counting active pricelists: %w", err)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (r *PricelistRepository) toSummaries(pricelists []models.Pricelist) []models.PricelistSummary {
|
||||
// Get item counts for each pricelist
|
||||
summaries := make([]models.PricelistSummary, len(pricelists))
|
||||
for i, pl := range pricelists {
|
||||
@@ -48,7 +76,7 @@ func (r *PricelistRepository) List(offset, limit int) ([]models.PricelistSummary
|
||||
}
|
||||
}
|
||||
|
||||
return summaries, total, nil
|
||||
return summaries
|
||||
}
|
||||
|
||||
// GetByID returns a pricelist by ID
|
||||
|
||||
@@ -19,7 +19,7 @@ type DataSource interface {
|
||||
|
||||
// Configurations
|
||||
SaveConfiguration(cfg *models.Configuration) error
|
||||
GetConfigurations(userID uint) ([]models.Configuration, error)
|
||||
GetConfigurations(ownerUsername string) ([]models.Configuration, error)
|
||||
GetConfigurationByUUID(uuid string) (*models.Configuration, error)
|
||||
DeleteConfiguration(uuid string) error
|
||||
|
||||
@@ -159,16 +159,17 @@ func (r *UnifiedRepo) SaveConfiguration(cfg *models.Configuration) error {
|
||||
|
||||
// Offline: save to local SQLite and queue for sync
|
||||
localCfg := &localdb.LocalConfiguration{
|
||||
UUID: cfg.UUID,
|
||||
Name: cfg.Name,
|
||||
TotalPrice: cfg.TotalPrice,
|
||||
CustomPrice: cfg.CustomPrice,
|
||||
Notes: cfg.Notes,
|
||||
IsTemplate: cfg.IsTemplate,
|
||||
ServerCount: cfg.ServerCount,
|
||||
CreatedAt: cfg.CreatedAt,
|
||||
UpdatedAt: time.Now(),
|
||||
SyncStatus: "pending",
|
||||
UUID: cfg.UUID,
|
||||
Name: cfg.Name,
|
||||
TotalPrice: cfg.TotalPrice,
|
||||
CustomPrice: cfg.CustomPrice,
|
||||
Notes: cfg.Notes,
|
||||
IsTemplate: cfg.IsTemplate,
|
||||
ServerCount: cfg.ServerCount,
|
||||
CreatedAt: cfg.CreatedAt,
|
||||
UpdatedAt: time.Now(),
|
||||
SyncStatus: "pending",
|
||||
OriginalUsername: cfg.OwnerUsername,
|
||||
}
|
||||
|
||||
// Convert items
|
||||
@@ -196,10 +197,10 @@ func (r *UnifiedRepo) SaveConfiguration(cfg *models.Configuration) error {
|
||||
}
|
||||
|
||||
// GetConfigurations returns all configurations for a user
|
||||
func (r *UnifiedRepo) GetConfigurations(userID uint) ([]models.Configuration, error) {
|
||||
func (r *UnifiedRepo) GetConfigurations(ownerUsername string) ([]models.Configuration, error) {
|
||||
if r.isOnline {
|
||||
repo := NewConfigurationRepository(r.mariaDB)
|
||||
configs, _, err := repo.ListByUser(userID, 0, 1000)
|
||||
configs, _, err := repo.ListByUser(ownerUsername, 0, 1000)
|
||||
return configs, err
|
||||
}
|
||||
|
||||
@@ -222,15 +223,16 @@ func (r *UnifiedRepo) GetConfigurations(userID uint) ([]models.Configuration, er
|
||||
}
|
||||
|
||||
result[i] = models.Configuration{
|
||||
UUID: lc.UUID,
|
||||
Name: lc.Name,
|
||||
Items: items,
|
||||
TotalPrice: lc.TotalPrice,
|
||||
CustomPrice: lc.CustomPrice,
|
||||
Notes: lc.Notes,
|
||||
IsTemplate: lc.IsTemplate,
|
||||
ServerCount: lc.ServerCount,
|
||||
CreatedAt: lc.CreatedAt,
|
||||
UUID: lc.UUID,
|
||||
OwnerUsername: lc.OriginalUsername,
|
||||
Name: lc.Name,
|
||||
Items: items,
|
||||
TotalPrice: lc.TotalPrice,
|
||||
CustomPrice: lc.CustomPrice,
|
||||
Notes: lc.Notes,
|
||||
IsTemplate: lc.IsTemplate,
|
||||
ServerCount: lc.ServerCount,
|
||||
CreatedAt: lc.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
@@ -59,6 +60,17 @@ type ComponentView struct {
|
||||
}
|
||||
|
||||
func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage int) (*ComponentListResult, error) {
|
||||
// If no database connection (offline mode), return empty list
|
||||
// Components should be loaded via /api/sync/components first
|
||||
if s.componentRepo == nil {
|
||||
return &ComponentListResult{
|
||||
Components: []ComponentView{},
|
||||
Total: 0,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
@@ -106,6 +118,11 @@ func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage
|
||||
}
|
||||
|
||||
func (s *ComponentService) GetByLotName(lotName string) (*ComponentView, error) {
|
||||
// If no database connection (offline mode), return error
|
||||
if s.componentRepo == nil {
|
||||
return nil, fmt.Errorf("offline mode: component data not available")
|
||||
}
|
||||
|
||||
c, err := s.componentRepo.GetByLotName(lotName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -135,11 +152,20 @@ func (s *ComponentService) GetByLotName(lotName string) (*ComponentView, error)
|
||||
}
|
||||
|
||||
func (s *ComponentService) GetCategories() ([]models.Category, error) {
|
||||
// If no database connection (offline mode), return default categories
|
||||
if s.categoryRepo == nil {
|
||||
return models.DefaultCategories, nil
|
||||
}
|
||||
return s.categoryRepo.GetAll()
|
||||
}
|
||||
|
||||
// ImportFromLot creates metadata entries for lots that don't have them
|
||||
func (s *ComponentService) ImportFromLot() (int, error) {
|
||||
// If no database connection (offline mode), return error
|
||||
if s.componentRepo == nil || s.categoryRepo == nil {
|
||||
return 0, fmt.Errorf("offline mode: import not available")
|
||||
}
|
||||
|
||||
lots, err := s.componentRepo.GetLotsWithoutMetadata()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
|
||||
@@ -4,20 +4,20 @@ import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrConfigNotFound = errors.New("configuration not found")
|
||||
ErrConfigForbidden = errors.New("access to configuration forbidden")
|
||||
ErrConfigNotFound = errors.New("configuration not found")
|
||||
ErrConfigForbidden = errors.New("access to configuration forbidden")
|
||||
)
|
||||
|
||||
// ConfigurationGetter is an interface for services that can retrieve configurations
|
||||
// Used by handlers to work with both ConfigurationService and LocalConfigurationService
|
||||
type ConfigurationGetter interface {
|
||||
GetByUUID(uuid string, userID uint) (*models.Configuration, error)
|
||||
GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error)
|
||||
}
|
||||
|
||||
type ConfigurationService struct {
|
||||
@@ -39,15 +39,15 @@ func NewConfigurationService(
|
||||
}
|
||||
|
||||
type CreateConfigRequest struct {
|
||||
Name string `json:"name"`
|
||||
Items models.ConfigItems `json:"items"`
|
||||
CustomPrice *float64 `json:"custom_price"`
|
||||
Notes string `json:"notes"`
|
||||
IsTemplate bool `json:"is_template"`
|
||||
ServerCount int `json:"server_count"`
|
||||
Name string `json:"name"`
|
||||
Items models.ConfigItems `json:"items"`
|
||||
CustomPrice *float64 `json:"custom_price"`
|
||||
Notes string `json:"notes"`
|
||||
IsTemplate bool `json:"is_template"`
|
||||
ServerCount int `json:"server_count"`
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) Create(userID uint, req *CreateConfigRequest) (*models.Configuration, error) {
|
||||
func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
|
||||
total := req.Items.Total()
|
||||
|
||||
// If server count is greater than 1, multiply the total by server count
|
||||
@@ -56,15 +56,15 @@ func (s *ConfigurationService) Create(userID uint, req *CreateConfigRequest) (*m
|
||||
}
|
||||
|
||||
config := &models.Configuration{
|
||||
UUID: uuid.New().String(),
|
||||
UserID: userID,
|
||||
Name: req.Name,
|
||||
Items: req.Items,
|
||||
TotalPrice: &total,
|
||||
CustomPrice: req.CustomPrice,
|
||||
Notes: req.Notes,
|
||||
IsTemplate: req.IsTemplate,
|
||||
ServerCount: req.ServerCount,
|
||||
UUID: uuid.New().String(),
|
||||
OwnerUsername: ownerUsername,
|
||||
Name: req.Name,
|
||||
Items: req.Items,
|
||||
TotalPrice: &total,
|
||||
CustomPrice: req.CustomPrice,
|
||||
Notes: req.Notes,
|
||||
IsTemplate: req.IsTemplate,
|
||||
ServerCount: req.ServerCount,
|
||||
}
|
||||
|
||||
if err := s.configRepo.Create(config); err != nil {
|
||||
@@ -77,27 +77,27 @@ func (s *ConfigurationService) Create(userID uint, req *CreateConfigRequest) (*m
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) GetByUUID(uuid string, userID uint) (*models.Configuration, error) {
|
||||
func (s *ConfigurationService) GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error) {
|
||||
config, err := s.configRepo.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
// Allow access if user owns config or it's a template
|
||||
if config.UserID != userID && !config.IsTemplate {
|
||||
if !s.isOwner(config, ownerUsername) && !config.IsTemplate {
|
||||
return nil, ErrConfigForbidden
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) Update(uuid string, userID uint, req *CreateConfigRequest) (*models.Configuration, error) {
|
||||
func (s *ConfigurationService) Update(uuid string, ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
|
||||
config, err := s.configRepo.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
if config.UserID != userID {
|
||||
if !s.isOwner(config, ownerUsername) {
|
||||
return nil, ErrConfigForbidden
|
||||
}
|
||||
|
||||
@@ -123,26 +123,26 @@ func (s *ConfigurationService) Update(uuid string, userID uint, req *CreateConfi
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) Delete(uuid string, userID uint) error {
|
||||
func (s *ConfigurationService) Delete(uuid string, ownerUsername string) error {
|
||||
config, err := s.configRepo.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
return ErrConfigNotFound
|
||||
}
|
||||
|
||||
if config.UserID != userID {
|
||||
if !s.isOwner(config, ownerUsername) {
|
||||
return ErrConfigForbidden
|
||||
}
|
||||
|
||||
return s.configRepo.Delete(config.ID)
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) Rename(uuid string, userID uint, newName string) (*models.Configuration, error) {
|
||||
func (s *ConfigurationService) Rename(uuid string, ownerUsername string, newName string) (*models.Configuration, error) {
|
||||
config, err := s.configRepo.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
if config.UserID != userID {
|
||||
if !s.isOwner(config, ownerUsername) {
|
||||
return nil, ErrConfigForbidden
|
||||
}
|
||||
|
||||
@@ -155,8 +155,8 @@ func (s *ConfigurationService) Rename(uuid string, userID uint, newName string)
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) Clone(configUUID string, userID uint, newName string) (*models.Configuration, error) {
|
||||
original, err := s.GetByUUID(configUUID, userID)
|
||||
func (s *ConfigurationService) Clone(configUUID string, ownerUsername string, newName string) (*models.Configuration, error) {
|
||||
original, err := s.GetByUUID(configUUID, ownerUsername)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -170,15 +170,15 @@ func (s *ConfigurationService) Clone(configUUID string, userID uint, newName str
|
||||
}
|
||||
|
||||
clone := &models.Configuration{
|
||||
UUID: uuid.New().String(),
|
||||
UserID: userID,
|
||||
Name: newName,
|
||||
Items: original.Items,
|
||||
TotalPrice: &total,
|
||||
CustomPrice: original.CustomPrice,
|
||||
Notes: original.Notes,
|
||||
IsTemplate: false, // Clone is never a template
|
||||
ServerCount: original.ServerCount,
|
||||
UUID: uuid.New().String(),
|
||||
OwnerUsername: ownerUsername,
|
||||
Name: newName,
|
||||
Items: original.Items,
|
||||
TotalPrice: &total,
|
||||
CustomPrice: original.CustomPrice,
|
||||
Notes: original.Notes,
|
||||
IsTemplate: false, // Clone is never a template
|
||||
ServerCount: original.ServerCount,
|
||||
}
|
||||
|
||||
if err := s.configRepo.Create(clone); err != nil {
|
||||
@@ -188,7 +188,7 @@ func (s *ConfigurationService) Clone(configUUID string, userID uint, newName str
|
||||
return clone, nil
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) ListByUser(userID uint, page, perPage int) ([]models.Configuration, int64, error) {
|
||||
func (s *ConfigurationService) ListByUser(ownerUsername string, page, perPage int) ([]models.Configuration, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
@@ -197,7 +197,7 @@ func (s *ConfigurationService) ListByUser(userID uint, page, perPage int) ([]mod
|
||||
}
|
||||
offset := (page - 1) * perPage
|
||||
|
||||
return s.configRepo.ListByUser(userID, offset, perPage)
|
||||
return s.configRepo.ListByUser(ownerUsername, offset, perPage)
|
||||
}
|
||||
|
||||
// ListAll returns all configurations without user filter (for use when auth is disabled)
|
||||
@@ -274,7 +274,7 @@ func (s *ConfigurationService) RenameNoAuth(uuid string, newName string) (*model
|
||||
}
|
||||
|
||||
// CloneNoAuth clones configuration without ownership check
|
||||
func (s *ConfigurationService) CloneNoAuth(configUUID string, newName string, userID uint) (*models.Configuration, error) {
|
||||
func (s *ConfigurationService) CloneNoAuth(configUUID string, newName string, ownerUsername string) (*models.Configuration, error) {
|
||||
original, err := s.configRepo.GetByUUID(configUUID)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
@@ -286,15 +286,15 @@ func (s *ConfigurationService) CloneNoAuth(configUUID string, newName string, us
|
||||
}
|
||||
|
||||
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,
|
||||
UUID: uuid.New().String(),
|
||||
OwnerUsername: ownerUsername,
|
||||
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 {
|
||||
@@ -356,13 +356,13 @@ func (s *ConfigurationService) ListTemplates(page, perPage int) ([]models.Config
|
||||
}
|
||||
|
||||
// RefreshPrices updates all component prices in the configuration with current prices
|
||||
func (s *ConfigurationService) RefreshPrices(uuid string, userID uint) (*models.Configuration, error) {
|
||||
func (s *ConfigurationService) RefreshPrices(uuid string, ownerUsername string) (*models.Configuration, error) {
|
||||
config, err := s.configRepo.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
if config.UserID != userID {
|
||||
if !s.isOwner(config, ownerUsername) {
|
||||
return nil, ErrConfigForbidden
|
||||
}
|
||||
|
||||
@@ -407,6 +407,19 @@ func (s *ConfigurationService) RefreshPrices(uuid string, userID uint) (*models.
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) isOwner(config *models.Configuration, ownerUsername string) bool {
|
||||
if config == nil || ownerUsername == "" {
|
||||
return false
|
||||
}
|
||||
if config.OwnerUsername != "" {
|
||||
return config.OwnerUsername == ownerUsername
|
||||
}
|
||||
if config.User != nil {
|
||||
return config.User.Username == ownerUsername
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// // Export configuration as JSON
|
||||
// type ConfigExport struct {
|
||||
// Name string `json:"name"`
|
||||
|
||||
@@ -2,13 +2,12 @@ package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// LocalConfigurationService handles configurations in local-first mode
|
||||
@@ -36,7 +35,7 @@ func NewLocalConfigurationService(
|
||||
}
|
||||
|
||||
// Create creates a new configuration in local SQLite and queues it for sync
|
||||
func (s *LocalConfigurationService) Create(userID uint, req *CreateConfigRequest) (*models.Configuration, error) {
|
||||
func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
|
||||
// If online, check for new pricelists first
|
||||
if s.isOnline() {
|
||||
if err := s.syncService.SyncPricelistsIfNeeded(); err != nil {
|
||||
@@ -50,16 +49,16 @@ func (s *LocalConfigurationService) Create(userID uint, req *CreateConfigRequest
|
||||
}
|
||||
|
||||
cfg := &models.Configuration{
|
||||
UUID: uuid.New().String(),
|
||||
UserID: userID,
|
||||
Name: req.Name,
|
||||
Items: req.Items,
|
||||
TotalPrice: &total,
|
||||
CustomPrice: req.CustomPrice,
|
||||
Notes: req.Notes,
|
||||
IsTemplate: req.IsTemplate,
|
||||
ServerCount: req.ServerCount,
|
||||
CreatedAt: time.Now(),
|
||||
UUID: uuid.New().String(),
|
||||
OwnerUsername: ownerUsername,
|
||||
Name: req.Name,
|
||||
Items: req.Items,
|
||||
TotalPrice: &total,
|
||||
CustomPrice: req.CustomPrice,
|
||||
Notes: req.Notes,
|
||||
IsTemplate: req.IsTemplate,
|
||||
ServerCount: req.ServerCount,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Convert to local model
|
||||
@@ -86,7 +85,7 @@ func (s *LocalConfigurationService) Create(userID uint, req *CreateConfigRequest
|
||||
}
|
||||
|
||||
// GetByUUID returns a configuration from local SQLite
|
||||
func (s *LocalConfigurationService) GetByUUID(uuid string, userID uint) (*models.Configuration, error) {
|
||||
func (s *LocalConfigurationService) GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error) {
|
||||
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
@@ -96,7 +95,7 @@ func (s *LocalConfigurationService) GetByUUID(uuid string, userID uint) (*models
|
||||
cfg := localdb.LocalToConfiguration(localCfg)
|
||||
|
||||
// Allow access if user owns config or it's a template
|
||||
if cfg.UserID != userID && !cfg.IsTemplate {
|
||||
if !s.isOwner(localCfg, ownerUsername) && !cfg.IsTemplate {
|
||||
return nil, ErrConfigForbidden
|
||||
}
|
||||
|
||||
@@ -104,13 +103,13 @@ func (s *LocalConfigurationService) GetByUUID(uuid string, userID uint) (*models
|
||||
}
|
||||
|
||||
// Update updates a configuration in local SQLite and queues it for sync
|
||||
func (s *LocalConfigurationService) Update(uuid string, userID uint, req *CreateConfigRequest) (*models.Configuration, error) {
|
||||
func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
|
||||
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
if localCfg.OriginalUserID != userID {
|
||||
if !s.isOwner(localCfg, ownerUsername) {
|
||||
return nil, ErrConfigForbidden
|
||||
}
|
||||
|
||||
@@ -156,13 +155,13 @@ func (s *LocalConfigurationService) Update(uuid string, userID uint, req *Create
|
||||
}
|
||||
|
||||
// Delete deletes a configuration from local SQLite and queues it for sync
|
||||
func (s *LocalConfigurationService) Delete(uuid string, userID uint) error {
|
||||
func (s *LocalConfigurationService) Delete(uuid string, ownerUsername string) error {
|
||||
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||
if err != nil {
|
||||
return ErrConfigNotFound
|
||||
}
|
||||
|
||||
if localCfg.OriginalUserID != userID {
|
||||
if !s.isOwner(localCfg, ownerUsername) {
|
||||
return ErrConfigForbidden
|
||||
}
|
||||
|
||||
@@ -180,13 +179,13 @@ func (s *LocalConfigurationService) Delete(uuid string, userID uint) error {
|
||||
}
|
||||
|
||||
// Rename renames a configuration
|
||||
func (s *LocalConfigurationService) Rename(uuid string, userID uint, newName string) (*models.Configuration, error) {
|
||||
func (s *LocalConfigurationService) Rename(uuid string, ownerUsername string, newName string) (*models.Configuration, error) {
|
||||
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
if localCfg.OriginalUserID != userID {
|
||||
if !s.isOwner(localCfg, ownerUsername) {
|
||||
return nil, ErrConfigForbidden
|
||||
}
|
||||
|
||||
@@ -212,8 +211,8 @@ func (s *LocalConfigurationService) Rename(uuid string, userID uint, newName str
|
||||
}
|
||||
|
||||
// Clone clones a configuration
|
||||
func (s *LocalConfigurationService) Clone(configUUID string, userID uint, newName string) (*models.Configuration, error) {
|
||||
original, err := s.GetByUUID(configUUID, userID)
|
||||
func (s *LocalConfigurationService) Clone(configUUID string, ownerUsername string, newName string) (*models.Configuration, error) {
|
||||
original, err := s.GetByUUID(configUUID, ownerUsername)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -224,16 +223,16 @@ func (s *LocalConfigurationService) Clone(configUUID string, userID uint, newNam
|
||||
}
|
||||
|
||||
clone := &models.Configuration{
|
||||
UUID: uuid.New().String(),
|
||||
UserID: userID,
|
||||
Name: newName,
|
||||
Items: original.Items,
|
||||
TotalPrice: &total,
|
||||
CustomPrice: original.CustomPrice,
|
||||
Notes: original.Notes,
|
||||
IsTemplate: false,
|
||||
ServerCount: original.ServerCount,
|
||||
CreatedAt: time.Now(),
|
||||
UUID: uuid.New().String(),
|
||||
OwnerUsername: ownerUsername,
|
||||
Name: newName,
|
||||
Items: original.Items,
|
||||
TotalPrice: &total,
|
||||
CustomPrice: original.CustomPrice,
|
||||
Notes: original.Notes,
|
||||
IsTemplate: false,
|
||||
ServerCount: original.ServerCount,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
localCfg := localdb.ConfigurationToLocal(clone)
|
||||
@@ -254,7 +253,7 @@ func (s *LocalConfigurationService) Clone(configUUID string, userID uint, newNam
|
||||
}
|
||||
|
||||
// ListByUser returns all configurations for a user from local SQLite
|
||||
func (s *LocalConfigurationService) ListByUser(userID uint, page, perPage int) ([]models.Configuration, int64, error) {
|
||||
func (s *LocalConfigurationService) ListByUser(ownerUsername string, page, perPage int) ([]models.Configuration, int64, error) {
|
||||
// Get all local configurations
|
||||
localConfigs, err := s.localDB.GetConfigurations()
|
||||
if err != nil {
|
||||
@@ -264,7 +263,7 @@ func (s *LocalConfigurationService) ListByUser(userID uint, page, perPage int) (
|
||||
// Filter by user
|
||||
var userConfigs []models.Configuration
|
||||
for _, lc := range localConfigs {
|
||||
if lc.OriginalUserID == userID || lc.IsTemplate {
|
||||
if (lc.OriginalUsername == ownerUsername) || lc.IsTemplate {
|
||||
userConfigs = append(userConfigs, *localdb.LocalToConfiguration(&lc))
|
||||
}
|
||||
}
|
||||
@@ -292,11 +291,71 @@ func (s *LocalConfigurationService) ListByUser(userID uint, page, perPage int) (
|
||||
return userConfigs[start:end], total, nil
|
||||
}
|
||||
|
||||
// RefreshPrices updates all component prices in the configuration
|
||||
func (s *LocalConfigurationService) RefreshPrices(uuid string, userID uint) (*models.Configuration, error) {
|
||||
// This requires access to component prices from local cache
|
||||
// For now, return error as we need to implement component price lookup from local cache
|
||||
return nil, errors.New("refresh prices not yet implemented for local-first mode")
|
||||
// RefreshPrices updates all component prices in the configuration from local cache
|
||||
func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername string) (*models.Configuration, error) {
|
||||
// Get configuration from local SQLite
|
||||
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
// Check ownership
|
||||
if !s.isOwner(localCfg, ownerUsername) {
|
||||
return nil, ErrConfigForbidden
|
||||
}
|
||||
|
||||
// Update prices for all items
|
||||
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
||||
for i, item := range localCfg.Items {
|
||||
// Get current component price from local cache
|
||||
component, err := s.localDB.GetLocalComponent(item.LotName)
|
||||
if err != nil || component.CurrentPrice == nil {
|
||||
// Keep original item if component not found or no price available
|
||||
updatedItems[i] = item
|
||||
continue
|
||||
}
|
||||
|
||||
// Update item with current price from local cache
|
||||
updatedItems[i] = localdb.LocalConfigItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: *component.CurrentPrice,
|
||||
}
|
||||
}
|
||||
|
||||
// Update configuration
|
||||
localCfg.Items = updatedItems
|
||||
total := updatedItems.Total()
|
||||
|
||||
// If server count is greater than 1, multiply the total by server count
|
||||
if localCfg.ServerCount > 1 {
|
||||
total *= float64(localCfg.ServerCount)
|
||||
}
|
||||
|
||||
localCfg.TotalPrice = &total
|
||||
|
||||
// Set price update timestamp and mark for sync
|
||||
now := time.Now()
|
||||
localCfg.PriceUpdatedAt = &now
|
||||
localCfg.UpdatedAt = now
|
||||
localCfg.SyncStatus = "pending"
|
||||
|
||||
// Save to local SQLite
|
||||
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add to pending sync queue
|
||||
cfg := localdb.LocalToConfiguration(localCfg)
|
||||
payload, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.localDB.AddPendingChange("configuration", uuid, "update", string(payload)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// GetByUUIDNoAuth returns configuration without ownership check
|
||||
@@ -389,7 +448,7 @@ func (s *LocalConfigurationService) RenameNoAuth(uuid string, newName string) (*
|
||||
}
|
||||
|
||||
// CloneNoAuth clones configuration without ownership check
|
||||
func (s *LocalConfigurationService) CloneNoAuth(configUUID string, newName string, userID uint) (*models.Configuration, error) {
|
||||
func (s *LocalConfigurationService) CloneNoAuth(configUUID string, newName string, ownerUsername string) (*models.Configuration, error) {
|
||||
original, err := s.GetByUUIDNoAuth(configUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -401,16 +460,16 @@ func (s *LocalConfigurationService) CloneNoAuth(configUUID string, newName strin
|
||||
}
|
||||
|
||||
clone := &models.Configuration{
|
||||
UUID: uuid.New().String(),
|
||||
UserID: userID,
|
||||
Name: newName,
|
||||
Items: original.Items,
|
||||
TotalPrice: &total,
|
||||
CustomPrice: original.CustomPrice,
|
||||
Notes: original.Notes,
|
||||
IsTemplate: false,
|
||||
ServerCount: original.ServerCount,
|
||||
CreatedAt: time.Now(),
|
||||
UUID: uuid.New().String(),
|
||||
OwnerUsername: ownerUsername,
|
||||
Name: newName,
|
||||
Items: original.Items,
|
||||
TotalPrice: &total,
|
||||
CustomPrice: original.CustomPrice,
|
||||
Notes: original.Notes,
|
||||
IsTemplate: false,
|
||||
ServerCount: original.ServerCount,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
localCfg := localdb.ConfigurationToLocal(clone)
|
||||
@@ -503,7 +562,77 @@ func (s *LocalConfigurationService) ListTemplates(page, perPage int) ([]models.C
|
||||
|
||||
// RefreshPricesNoAuth updates all component prices in the configuration without ownership check
|
||||
func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configuration, error) {
|
||||
// This requires access to component prices from local cache
|
||||
// For now, return error as we need to implement component price lookup from local cache
|
||||
return nil, errors.New("refresh prices not yet implemented for local-first mode")
|
||||
// Get configuration from local SQLite
|
||||
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||
if err != nil {
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
// Update prices for all items
|
||||
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
||||
for i, item := range localCfg.Items {
|
||||
// Get current component price from local cache
|
||||
component, err := s.localDB.GetLocalComponent(item.LotName)
|
||||
if err != nil || component.CurrentPrice == nil {
|
||||
// Keep original item if component not found or no price available
|
||||
updatedItems[i] = item
|
||||
continue
|
||||
}
|
||||
|
||||
// Update item with current price from local cache
|
||||
updatedItems[i] = localdb.LocalConfigItem{
|
||||
LotName: item.LotName,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: *component.CurrentPrice,
|
||||
}
|
||||
}
|
||||
|
||||
// Update configuration
|
||||
localCfg.Items = updatedItems
|
||||
total := updatedItems.Total()
|
||||
|
||||
// If server count is greater than 1, multiply the total by server count
|
||||
if localCfg.ServerCount > 1 {
|
||||
total *= float64(localCfg.ServerCount)
|
||||
}
|
||||
|
||||
localCfg.TotalPrice = &total
|
||||
|
||||
// Set price update timestamp and mark for sync
|
||||
now := time.Now()
|
||||
localCfg.PriceUpdatedAt = &now
|
||||
localCfg.UpdatedAt = now
|
||||
localCfg.SyncStatus = "pending"
|
||||
|
||||
// Save to local SQLite
|
||||
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add to pending sync queue
|
||||
cfg := localdb.LocalToConfiguration(localCfg)
|
||||
payload, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.localDB.AddPendingChange("configuration", uuid, "update", string(payload)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// ImportFromServer imports configurations from MariaDB to local SQLite cache.
|
||||
func (s *LocalConfigurationService) ImportFromServer() (*sync.ConfigImportResult, error) {
|
||||
return s.syncService.ImportConfigurationsToLocal()
|
||||
}
|
||||
|
||||
func (s *LocalConfigurationService) isOwner(cfg *localdb.LocalConfiguration, ownerUsername string) bool {
|
||||
if cfg == nil || ownerUsername == "" {
|
||||
return false
|
||||
}
|
||||
if cfg.OriginalUsername != "" {
|
||||
return cfg.OriginalUsername == ownerUsername
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -26,6 +26,10 @@ func NewService(db *gorm.DB, repo *repository.PricelistRepository, componentRepo
|
||||
|
||||
// CreateFromCurrentPrices creates a new pricelist by taking a snapshot of current prices
|
||||
func (s *Service) CreateFromCurrentPrices(createdBy string) (*models.Pricelist, error) {
|
||||
if s.repo == nil || s.db == nil {
|
||||
return nil, fmt.Errorf("offline mode: cannot create pricelists")
|
||||
}
|
||||
|
||||
version, err := s.repo.GenerateVersion()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generating version: %w", err)
|
||||
@@ -88,6 +92,11 @@ func (s *Service) CreateFromCurrentPrices(createdBy string) (*models.Pricelist,
|
||||
|
||||
// List returns pricelists with pagination
|
||||
func (s *Service) List(page, perPage int) ([]models.PricelistSummary, int64, error) {
|
||||
// If no database connection (offline mode), return empty list
|
||||
if s.repo == nil {
|
||||
return []models.PricelistSummary{}, 0, nil
|
||||
}
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
@@ -100,11 +109,17 @@ func (s *Service) List(page, perPage int) ([]models.PricelistSummary, int64, err
|
||||
|
||||
// GetByID returns a pricelist by ID
|
||||
func (s *Service) GetByID(id uint) (*models.Pricelist, error) {
|
||||
if s.repo == nil {
|
||||
return nil, fmt.Errorf("offline mode: pricelist service not available")
|
||||
}
|
||||
return s.repo.GetByID(id)
|
||||
}
|
||||
|
||||
// GetItems returns pricelist items with pagination
|
||||
func (s *Service) GetItems(pricelistID uint, page, perPage int, search string) ([]models.PricelistItem, int64, error) {
|
||||
if s.repo == nil {
|
||||
return []models.PricelistItem{}, 0, nil
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
@@ -117,26 +132,42 @@ func (s *Service) GetItems(pricelistID uint, page, perPage int, search string) (
|
||||
|
||||
// Delete deletes a pricelist by ID
|
||||
func (s *Service) Delete(id uint) error {
|
||||
if s.repo == nil {
|
||||
return fmt.Errorf("offline mode: cannot delete pricelists")
|
||||
}
|
||||
return s.repo.Delete(id)
|
||||
}
|
||||
|
||||
// CanWrite returns true if the user can create pricelists
|
||||
func (s *Service) CanWrite() bool {
|
||||
if s.repo == nil {
|
||||
return false
|
||||
}
|
||||
return s.repo.CanWrite()
|
||||
}
|
||||
|
||||
// CanWriteDebug returns write permission status with debug info
|
||||
func (s *Service) CanWriteDebug() (bool, string) {
|
||||
if s.repo == nil {
|
||||
return false, "offline mode"
|
||||
}
|
||||
return s.repo.CanWriteDebug()
|
||||
}
|
||||
|
||||
// GetLatestActive returns the most recent active pricelist
|
||||
func (s *Service) GetLatestActive() (*models.Pricelist, error) {
|
||||
if s.repo == nil {
|
||||
return nil, fmt.Errorf("offline mode: pricelist service not available")
|
||||
}
|
||||
return s.repo.GetLatestActive()
|
||||
}
|
||||
|
||||
// CleanupExpired deletes expired and unused pricelists
|
||||
func (s *Service) CleanupExpired() (int, error) {
|
||||
if s.repo == nil {
|
||||
return 0, fmt.Errorf("offline mode: cleanup not available")
|
||||
}
|
||||
|
||||
expired, err := s.repo.GetExpiredUnused()
|
||||
if err != nil {
|
||||
return 0, 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,
|
||||
@@ -129,6 +132,11 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation
|
||||
|
||||
// RecordUsage records that components were used in a quote
|
||||
func (s *QuoteService) RecordUsage(items []models.ConfigItem) error {
|
||||
if s.statsRepo == nil {
|
||||
// Offline mode: usage stats are unavailable and should not block config saves.
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
revenue := item.UnitPrice * float64(item.Quantity)
|
||||
if err := s.statsRepo.IncrementUsage(item.LotName, item.Quantity, revenue); err != nil {
|
||||
|
||||
@@ -2,28 +2,31 @@ package sync
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/db"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var ErrOffline = errors.New("database is offline")
|
||||
|
||||
// Service handles synchronization between MariaDB and local SQLite
|
||||
type Service struct {
|
||||
pricelistRepo *repository.PricelistRepository
|
||||
configRepo *repository.ConfigurationRepository
|
||||
localDB *localdb.LocalDB
|
||||
connMgr *db.ConnectionManager
|
||||
localDB *localdb.LocalDB
|
||||
}
|
||||
|
||||
// NewService creates a new sync service
|
||||
func NewService(pricelistRepo *repository.PricelistRepository, configRepo *repository.ConfigurationRepository, localDB *localdb.LocalDB) *Service {
|
||||
func NewService(connMgr *db.ConnectionManager, localDB *localdb.LocalDB) *Service {
|
||||
return &Service{
|
||||
pricelistRepo: pricelistRepo,
|
||||
configRepo: configRepo,
|
||||
localDB: localDB,
|
||||
connMgr: connMgr,
|
||||
localDB: localDB,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,14 +38,86 @@ type SyncStatus struct {
|
||||
NeedsSync bool `json:"needs_sync"`
|
||||
}
|
||||
|
||||
// ConfigImportResult represents server->local configuration import stats.
|
||||
type ConfigImportResult struct {
|
||||
Imported int `json:"imported"`
|
||||
Updated int `json:"updated"`
|
||||
Skipped int `json:"skipped"`
|
||||
}
|
||||
|
||||
// ImportConfigurationsToLocal imports configurations from MariaDB into local SQLite.
|
||||
// Existing local configs with pending local changes are skipped to avoid data loss.
|
||||
func (s *Service) ImportConfigurationsToLocal() (*ConfigImportResult, error) {
|
||||
mariaDB, err := s.connMgr.GetDB()
|
||||
if err != nil {
|
||||
return nil, ErrOffline
|
||||
}
|
||||
|
||||
configRepo := repository.NewConfigurationRepository(mariaDB)
|
||||
result := &ConfigImportResult{}
|
||||
|
||||
offset := 0
|
||||
const limit = 200
|
||||
for {
|
||||
serverConfigs, _, err := configRepo.ListAll(offset, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing server configurations: %w", err)
|
||||
}
|
||||
if len(serverConfigs) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
for i := range serverConfigs {
|
||||
cfg := serverConfigs[i]
|
||||
existing, err := s.localDB.GetConfigurationByUUID(cfg.UUID)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, fmt.Errorf("getting local configuration %s: %w", cfg.UUID, err)
|
||||
}
|
||||
|
||||
if existing != nil && err == nil && existing.SyncStatus == "pending" {
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
localCfg := localdb.ConfigurationToLocal(&cfg)
|
||||
now := time.Now()
|
||||
localCfg.SyncedAt = &now
|
||||
localCfg.SyncStatus = "synced"
|
||||
localCfg.UpdatedAt = now
|
||||
|
||||
if existing != nil && err == nil {
|
||||
localCfg.ID = existing.ID
|
||||
result.Updated++
|
||||
} else {
|
||||
result.Imported++
|
||||
}
|
||||
|
||||
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
|
||||
return nil, fmt.Errorf("saving local configuration %s: %w", cfg.UUID, err)
|
||||
}
|
||||
}
|
||||
|
||||
offset += len(serverConfigs)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetStatus returns the current sync status
|
||||
func (s *Service) GetStatus() (*SyncStatus, error) {
|
||||
lastSync := s.localDB.GetLastSyncTime()
|
||||
|
||||
// Count server pricelists
|
||||
serverPricelists, _, err := s.pricelistRepo.List(0, 1)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("counting server pricelists: %w", err)
|
||||
// Count server pricelists (only if already connected, don't reconnect)
|
||||
serverCount := 0
|
||||
connStatus := s.connMgr.GetStatus()
|
||||
if connStatus.IsConnected {
|
||||
if mariaDB, err := s.connMgr.GetDB(); err == nil && mariaDB != nil {
|
||||
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
||||
activeCount, err := pricelistRepo.CountActive()
|
||||
if err == nil {
|
||||
serverCount = int(activeCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Count local pricelists
|
||||
@@ -52,7 +127,7 @@ func (s *Service) GetStatus() (*SyncStatus, error) {
|
||||
|
||||
return &SyncStatus{
|
||||
LastSyncAt: lastSync,
|
||||
ServerPricelists: len(serverPricelists),
|
||||
ServerPricelists: serverCount,
|
||||
LocalPricelists: int(localCount),
|
||||
NeedsSync: needsSync,
|
||||
}, nil
|
||||
@@ -73,8 +148,21 @@ func (s *Service) NeedSync() (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Check if there are new pricelists on server
|
||||
latestServer, err := s.pricelistRepo.GetLatestActive()
|
||||
// Check if there are new pricelists on server (only if already connected)
|
||||
connStatus := s.connMgr.GetStatus()
|
||||
if !connStatus.IsConnected {
|
||||
// If offline, can't check server, no need to sync
|
||||
return false, nil
|
||||
}
|
||||
|
||||
mariaDB, err := s.connMgr.GetDB()
|
||||
if err != nil {
|
||||
// If offline, can't check server, no need to sync
|
||||
return false, nil
|
||||
}
|
||||
|
||||
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
||||
latestServer, err := pricelistRepo.GetLatestActive()
|
||||
if err != nil {
|
||||
// If no pricelists on server, no need to sync
|
||||
return false, nil
|
||||
@@ -98,18 +186,33 @@ func (s *Service) NeedSync() (bool, error) {
|
||||
func (s *Service) SyncPricelists() (int, error) {
|
||||
slog.Info("starting pricelist sync")
|
||||
|
||||
// Get all active pricelists from server (up to 100)
|
||||
serverPricelists, _, err := s.pricelistRepo.List(0, 100)
|
||||
// Get database connection
|
||||
mariaDB, err := s.connMgr.GetDB()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("getting server pricelists: %w", err)
|
||||
return 0, fmt.Errorf("database not available: %w", err)
|
||||
}
|
||||
|
||||
// Create repository
|
||||
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
||||
|
||||
// Get active pricelists from server (up to 100)
|
||||
serverPricelists, _, err := pricelistRepo.ListActive(0, 100)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("getting active server pricelists: %w", err)
|
||||
}
|
||||
|
||||
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, skip
|
||||
// Already synced, track latest by server ID
|
||||
if pl.ID > latestServerID {
|
||||
latestServerID = pl.ID
|
||||
latestLocalID = existing.ID
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -128,8 +231,30 @@ func (s *Service) SyncPricelists() (int, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Sync items for the newly created pricelist
|
||||
itemCount, err := s.SyncPricelistItems(localPL.ID)
|
||||
if err != nil {
|
||||
slog.Warn("failed to sync pricelist items", "version", pl.Version, "error", err)
|
||||
// Continue even if items sync fails - we have the pricelist metadata
|
||||
} else {
|
||||
slog.Debug("synced pricelist with items", "version", pl.Version, "items", itemCount)
|
||||
}
|
||||
|
||||
if pl.ID > latestServerID {
|
||||
latestServerID = pl.ID
|
||||
latestLocalID = localPL.ID
|
||||
}
|
||||
synced++
|
||||
slog.Debug("synced pricelist", "version", pl.Version, "server_id", pl.ID)
|
||||
}
|
||||
|
||||
// Update component prices from latest pricelist
|
||||
if latestLocalID > 0 {
|
||||
updated, err := s.localDB.UpdateComponentPricesFromPricelist(latestLocalID)
|
||||
if err != nil {
|
||||
slog.Warn("failed to update component prices from pricelist", "error", err)
|
||||
} else {
|
||||
slog.Info("updated component prices from latest pricelist", "updated", updated)
|
||||
}
|
||||
}
|
||||
|
||||
// Update last sync time
|
||||
@@ -154,8 +279,17 @@ func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
|
||||
return int(existingCount), nil
|
||||
}
|
||||
|
||||
// Get database connection
|
||||
mariaDB, err := s.connMgr.GetDB()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("database not available: %w", err)
|
||||
}
|
||||
|
||||
// Create repository
|
||||
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
||||
|
||||
// Get items from server
|
||||
serverItems, _, err := s.pricelistRepo.GetItems(localPL.ServerID, 0, 10000, "")
|
||||
serverItems, _, err := pricelistRepo.GetItems(localPL.ServerID, 0, 10000, "")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("getting server pricelist items: %w", err)
|
||||
}
|
||||
@@ -312,8 +446,17 @@ func (s *Service) pushConfigurationCreate(change *localdb.PendingChange) error {
|
||||
return fmt.Errorf("unmarshaling configuration: %w", err)
|
||||
}
|
||||
|
||||
// Get database connection
|
||||
mariaDB, err := s.connMgr.GetDB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("database not available: %w", err)
|
||||
}
|
||||
|
||||
// Create repository
|
||||
configRepo := repository.NewConfigurationRepository(mariaDB)
|
||||
|
||||
// Create on server
|
||||
if err := s.configRepo.Create(&cfg); err != nil {
|
||||
if err := configRepo.Create(&cfg); err != nil {
|
||||
return fmt.Errorf("creating configuration on server: %w", err)
|
||||
}
|
||||
|
||||
@@ -337,8 +480,42 @@ func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
|
||||
return fmt.Errorf("unmarshaling configuration: %w", err)
|
||||
}
|
||||
|
||||
// Get database connection
|
||||
mariaDB, err := s.connMgr.GetDB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("database not available: %w", err)
|
||||
}
|
||||
|
||||
// Create repository
|
||||
configRepo := repository.NewConfigurationRepository(mariaDB)
|
||||
|
||||
// Ensure we have a server ID before updating
|
||||
// If the payload doesn't have ID, get it from local configuration
|
||||
if cfg.ID == 0 {
|
||||
localCfg, err := s.localDB.GetConfigurationByUUID(cfg.UUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting local configuration: %w", err)
|
||||
}
|
||||
|
||||
if localCfg.ServerID == nil {
|
||||
// Configuration hasn't been synced yet, try to find it on server by UUID
|
||||
serverCfg, err := configRepo.GetByUUID(cfg.UUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("configuration not yet synced to server: %w", err)
|
||||
}
|
||||
cfg.ID = serverCfg.ID
|
||||
|
||||
// Update local with server ID
|
||||
serverID := serverCfg.ID
|
||||
localCfg.ServerID = &serverID
|
||||
s.localDB.SaveConfiguration(localCfg)
|
||||
} else {
|
||||
cfg.ID = *localCfg.ServerID
|
||||
}
|
||||
}
|
||||
|
||||
// Update on server
|
||||
if err := s.configRepo.Update(&cfg); err != nil {
|
||||
if err := configRepo.Update(&cfg); err != nil {
|
||||
return fmt.Errorf("updating configuration on server: %w", err)
|
||||
}
|
||||
|
||||
@@ -355,8 +532,17 @@ func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
|
||||
|
||||
// pushConfigurationDelete deletes a configuration from the server
|
||||
func (s *Service) pushConfigurationDelete(change *localdb.PendingChange) error {
|
||||
// Get database connection
|
||||
mariaDB, err := s.connMgr.GetDB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("database not available: %w", err)
|
||||
}
|
||||
|
||||
// Create repository
|
||||
configRepo := repository.NewConfigurationRepository(mariaDB)
|
||||
|
||||
// Get the configuration from server by UUID to get the ID
|
||||
cfg, err := s.configRepo.GetByUUID(change.EntityUUID)
|
||||
cfg, err := configRepo.GetByUUID(change.EntityUUID)
|
||||
if err != nil {
|
||||
// Already deleted or not found, consider it successful
|
||||
slog.Warn("configuration not found on server, considering delete successful", "uuid", change.EntityUUID)
|
||||
@@ -364,7 +550,7 @@ func (s *Service) pushConfigurationDelete(change *localdb.PendingChange) error {
|
||||
}
|
||||
|
||||
// Delete from server
|
||||
if err := s.configRepo.Delete(cfg.ID); err != nil {
|
||||
if err := configRepo.Delete(cfg.ID); err != nil {
|
||||
return fmt.Errorf("deleting configuration from server: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,23 +5,23 @@ import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/db"
|
||||
)
|
||||
|
||||
// Worker performs background synchronization at regular intervals
|
||||
type Worker struct {
|
||||
service *Service
|
||||
db *gorm.DB
|
||||
connMgr *db.ConnectionManager
|
||||
interval time.Duration
|
||||
logger *slog.Logger
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
// NewWorker creates a new background sync worker
|
||||
func NewWorker(service *Service, db *gorm.DB, interval time.Duration) *Worker {
|
||||
func NewWorker(service *Service, connMgr *db.ConnectionManager, interval time.Duration) *Worker {
|
||||
return &Worker{
|
||||
service: service,
|
||||
db: db,
|
||||
connMgr: connMgr,
|
||||
interval: interval,
|
||||
logger: slog.Default(),
|
||||
stopCh: make(chan struct{}),
|
||||
@@ -30,11 +30,7 @@ func NewWorker(service *Service, db *gorm.DB, interval time.Duration) *Worker {
|
||||
|
||||
// isOnline checks if the database connection is available
|
||||
func (w *Worker) isOnline() bool {
|
||||
sqlDB, err := w.db.DB()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return sqlDB.Ping() == nil
|
||||
return w.connMgr.IsOnline()
|
||||
}
|
||||
|
||||
// Start begins the background sync loop in a goroutine
|
||||
@@ -75,21 +71,19 @@ func (w *Worker) runSync() {
|
||||
return
|
||||
}
|
||||
|
||||
w.logger.Debug("running background sync")
|
||||
|
||||
// Push pending changes first
|
||||
pushed, err := w.service.PushPendingChanges()
|
||||
if err != nil {
|
||||
w.logger.Warn("failed to push pending changes", "error", err)
|
||||
w.logger.Warn("background sync: failed to push pending changes", "error", err)
|
||||
} else if pushed > 0 {
|
||||
w.logger.Info("pushed pending changes", "count", pushed)
|
||||
w.logger.Info("background sync: pushed pending changes", "count", pushed)
|
||||
}
|
||||
|
||||
// Then check for new pricelists
|
||||
err = w.service.SyncPricelistsIfNeeded()
|
||||
if err != nil {
|
||||
w.logger.Warn("failed to sync pricelists", "error", err)
|
||||
w.logger.Warn("background sync: failed to sync pricelists", "error", err)
|
||||
}
|
||||
|
||||
w.logger.Debug("background sync completed")
|
||||
w.logger.Info("background sync cycle completed")
|
||||
}
|
||||
|
||||
10
migrations/005_add_owner_username.sql
Normal file
10
migrations/005_add_owner_username.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- Store configuration owner as username (instead of relying on numeric user_id)
|
||||
ALTER TABLE qt_configurations
|
||||
ADD COLUMN owner_username VARCHAR(100) NOT NULL DEFAULT '' AFTER user_id,
|
||||
ADD INDEX idx_qt_configurations_owner_username (owner_username);
|
||||
|
||||
-- Backfill owner_username from qt_users for existing rows
|
||||
UPDATE qt_configurations c
|
||||
LEFT JOIN qt_users u ON u.id = c.user_id
|
||||
SET c.owner_username = COALESCE(u.username, c.owner_username)
|
||||
WHERE c.owner_username = '';
|
||||
100
scripts/release.sh
Executable file
100
scripts/release.sh
Executable file
@@ -0,0 +1,100 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# QuoteForge Release Build Script
|
||||
# Creates binaries for all platforms and packages them for release
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Get version from git
|
||||
VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
if [[ $VERSION == *"dirty"* ]]; then
|
||||
echo -e "${RED}✗ Error: Working directory has uncommitted changes${NC}"
|
||||
echo " Commit your changes first"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}Building QuoteForge version: ${VERSION}${NC}"
|
||||
echo ""
|
||||
|
||||
# Create release directory
|
||||
RELEASE_DIR="releases/${VERSION}"
|
||||
mkdir -p "${RELEASE_DIR}"
|
||||
|
||||
# Build for all platforms
|
||||
echo -e "${YELLOW}→ Building binaries...${NC}"
|
||||
make build-all
|
||||
|
||||
# Package binaries with checksums
|
||||
echo ""
|
||||
echo -e "${YELLOW}→ Creating release packages...${NC}"
|
||||
|
||||
# Linux AMD64
|
||||
if [ -f "bin/qfs-linux-amd64" ]; then
|
||||
cd bin
|
||||
tar -czf "../${RELEASE_DIR}/qfs-${VERSION}-linux-amd64.tar.gz" qfs-linux-amd64
|
||||
cd ..
|
||||
echo -e "${GREEN} ✓ qfs-${VERSION}-linux-amd64.tar.gz${NC}"
|
||||
fi
|
||||
|
||||
# macOS Intel
|
||||
if [ -f "bin/qfs-darwin-amd64" ]; then
|
||||
cd bin
|
||||
tar -czf "../${RELEASE_DIR}/qfs-${VERSION}-darwin-amd64.tar.gz" qfs-darwin-amd64
|
||||
cd ..
|
||||
echo -e "${GREEN} ✓ qfs-${VERSION}-darwin-amd64.tar.gz${NC}"
|
||||
fi
|
||||
|
||||
# macOS Apple Silicon
|
||||
if [ -f "bin/qfs-darwin-arm64" ]; then
|
||||
cd bin
|
||||
tar -czf "../${RELEASE_DIR}/qfs-${VERSION}-darwin-arm64.tar.gz" qfs-darwin-arm64
|
||||
cd ..
|
||||
echo -e "${GREEN} ✓ qfs-${VERSION}-darwin-arm64.tar.gz${NC}"
|
||||
fi
|
||||
|
||||
# Windows AMD64
|
||||
if [ -f "bin/qfs-windows-amd64.exe" ]; then
|
||||
cd bin
|
||||
zip -q "../${RELEASE_DIR}/qfs-${VERSION}-windows-amd64.zip" qfs-windows-amd64.exe
|
||||
cd ..
|
||||
echo -e "${GREEN} ✓ qfs-${VERSION}-windows-amd64.zip${NC}"
|
||||
fi
|
||||
|
||||
# Generate checksums
|
||||
echo ""
|
||||
echo -e "${YELLOW}→ Generating checksums...${NC}"
|
||||
cd "${RELEASE_DIR}"
|
||||
shasum -a 256 *.tar.gz *.zip > SHA256SUMS.txt 2>/dev/null || shasum -a 256 * | grep -v SHA256SUMS > SHA256SUMS.txt
|
||||
cd ../..
|
||||
echo -e "${GREEN} ✓ SHA256SUMS.txt${NC}"
|
||||
|
||||
# List release files
|
||||
echo ""
|
||||
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "${GREEN}Release ${VERSION} built successfully!${NC}"
|
||||
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo ""
|
||||
echo "Files in ${RELEASE_DIR}:"
|
||||
ls -lh "${RELEASE_DIR}"
|
||||
echo ""
|
||||
|
||||
# Show next steps
|
||||
echo -e "${YELLOW}Next steps:${NC}"
|
||||
echo " 1. Create git tag:"
|
||||
echo " git tag -a ${VERSION} -m \"Release ${VERSION}\""
|
||||
echo ""
|
||||
echo " 2. Push tag to remote:"
|
||||
echo " git push origin ${VERSION}"
|
||||
echo ""
|
||||
echo " 3. Create release on git.mchus.pro:"
|
||||
echo " - Go to: https://git.mchus.pro/mchus/QuoteForge/releases"
|
||||
echo " - Click 'New Release'"
|
||||
echo " - Select tag: ${VERSION}"
|
||||
echo " - Upload files from: ${RELEASE_DIR}/"
|
||||
echo ""
|
||||
echo -e "${GREEN}Done!${NC}"
|
||||
@@ -9,6 +9,7 @@
|
||||
<div class="flex gap-4">
|
||||
<button onclick="loadTab('alerts')" id="btn-alerts" class="text-blue-600 font-medium">Алерты</button>
|
||||
<button onclick="loadTab('components')" id="btn-components" class="text-gray-600">Компоненты</button>
|
||||
<button onclick="loadTab('pricelists')" id="btn-pricelists" class="text-gray-600">Прайслисты</button>
|
||||
<button onclick="loadTab('all-configs')" id="btn-all-configs" class="text-gray-600 hidden">Все конфигурации</button>
|
||||
</div>
|
||||
<button onclick="recalculateAll()" id="btn-recalc" class="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700">
|
||||
@@ -53,6 +54,60 @@
|
||||
<div class="text-center py-8 text-gray-500">Загрузка...</div>
|
||||
</div>
|
||||
|
||||
<!-- Pricelists Tab Content (hidden by default) -->
|
||||
<div id="pricelists-tab-content" class="hidden">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-semibold">Прайслисты</h2>
|
||||
<div id="pricelists-create-btn-container"></div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Версия</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Дата</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Автор</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase">Позиций</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase">Исп.</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase">Статус</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pricelists-body" class="bg-white divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td colspan="7" class="px-6 py-4 text-center text-gray-500">Загрузка...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div id="pricelists-pagination" class="flex justify-center space-x-2 mt-4"></div>
|
||||
</div>
|
||||
|
||||
<!-- Create Modal -->
|
||||
<div id="pricelists-create-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
||||
<h2 class="text-xl font-bold mb-4">Создать прайслист</h2>
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
Будет создан снимок текущих цен из базы данных.<br>
|
||||
Автор прайслиста: <span id="pricelists-db-username" class="font-medium">загрузка...</span>
|
||||
</p>
|
||||
<form id="pricelists-create-form" class="space-y-4">
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" onclick="closePricelistsCreateModal()"
|
||||
class="px-4 py-2 text-gray-700 border border-gray-300 rounded-md hover:bg-gray-50">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
|
||||
Создать
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div id="pagination" class="flex justify-between items-center mt-4 pt-4 border-t hidden">
|
||||
<span id="page-info" class="text-sm text-gray-600"></span>
|
||||
@@ -157,6 +212,10 @@ let currentSearch = '';
|
||||
let componentsCache = [];
|
||||
let sortField = 'popularity_score';
|
||||
let sortDir = 'desc';
|
||||
let pricelistsPage = 1;
|
||||
let pricelistsCanWrite = false;
|
||||
let isCreatingPricelist = false;
|
||||
let cachedDbUsername = null;
|
||||
|
||||
async function loadTab(tab) {
|
||||
currentTab = tab;
|
||||
@@ -166,6 +225,7 @@ async function loadTab(tab) {
|
||||
|
||||
document.getElementById('btn-alerts').className = tab === 'alerts' ? 'text-blue-600 font-medium' : 'text-gray-600';
|
||||
document.getElementById('btn-components').className = tab === 'components' ? 'text-blue-600 font-medium' : 'text-gray-600';
|
||||
document.getElementById('btn-pricelists').className = tab === 'pricelists' ? 'text-blue-600 font-medium' : 'text-gray-600';
|
||||
document.getElementById('btn-all-configs').className = tab === 'all-configs' ? 'text-blue-600 font-medium' : 'text-gray-600 hidden';
|
||||
|
||||
// Show/hide elements based on tab
|
||||
@@ -173,17 +233,34 @@ async function loadTab(tab) {
|
||||
document.getElementById('search-bar').className = 'mb-4';
|
||||
document.getElementById('pagination').className = 'flex justify-between items-center mt-4 pt-4 border-t';
|
||||
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden'; // Hide this tab for components
|
||||
document.getElementById('pricelists-tab-content').className = 'hidden';
|
||||
document.getElementById('tab-content').className = '';
|
||||
} else if (tab === 'all-configs') {
|
||||
document.getElementById('search-bar').className = 'mb-4 hidden'; // Hide search for all configs
|
||||
document.getElementById('pagination').className = 'flex justify-between items-center mt-4 pt-4 border-t'; // Show pagination
|
||||
document.getElementById('btn-all-configs').className = 'text-blue-600 font-medium'; // Show this tab for all configs
|
||||
document.getElementById('pricelists-tab-content').className = 'hidden';
|
||||
document.getElementById('tab-content').className = '';
|
||||
} else if (tab === 'pricelists') {
|
||||
document.getElementById('search-bar').className = 'mb-4 hidden';
|
||||
document.getElementById('pagination').className = 'hidden';
|
||||
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
|
||||
document.getElementById('pricelists-tab-content').className = '';
|
||||
document.getElementById('tab-content').className = 'hidden';
|
||||
// Load pricelists when pricelists tab is selected
|
||||
checkPricelistWritePermission();
|
||||
loadPricelists(1);
|
||||
} else {
|
||||
document.getElementById('search-bar').className = 'mb-4 hidden';
|
||||
document.getElementById('pagination').className = 'hidden';
|
||||
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
|
||||
document.getElementById('pricelists-tab-content').className = 'hidden';
|
||||
document.getElementById('tab-content').className = '';
|
||||
}
|
||||
|
||||
await loadData();
|
||||
if (tab !== 'pricelists') {
|
||||
await loadData();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
@@ -514,8 +591,21 @@ async function fetchPreview() {
|
||||
document.getElementById('modal-new-price').textContent =
|
||||
data.new_price ? '$' + parseFloat(data.new_price).toFixed(2) : '—';
|
||||
|
||||
// Update quote count
|
||||
document.getElementById('modal-quote-count').textContent = data.quote_count || 0;
|
||||
// Update quote count with new format "N (всего: M)"
|
||||
let quoteCountText = '';
|
||||
if (data.quote_count_period !== undefined && data.quote_count_total !== undefined) {
|
||||
if (data.quote_count_period === data.quote_count_total) {
|
||||
// If period count equals total count, just show the total
|
||||
quoteCountText = data.quote_count_total;
|
||||
} else {
|
||||
// Show both counts in format "N (всего: M)"
|
||||
quoteCountText = data.quote_count_period + ' (всего: ' + data.quote_count_total + ')';
|
||||
}
|
||||
} else {
|
||||
// Fallback for older API responses
|
||||
quoteCountText = data.quote_count || 0;
|
||||
}
|
||||
document.getElementById('modal-quote-count').textContent = quoteCountText;
|
||||
}
|
||||
} catch(e) {
|
||||
console.error('Preview fetch error:', e);
|
||||
@@ -771,7 +861,7 @@ function renderAllConfigs(configs) {
|
||||
const date = new Date(c.created_at).toLocaleDateString('ru-RU');
|
||||
const total = c.total_price ? '$' + c.total_price.toLocaleString('en-US', {minimumFractionDigits: 2}) : '—';
|
||||
const serverCount = c.server_count ? c.server_count : 1;
|
||||
const username = c.user ? c.user.username : '—';
|
||||
const username = c.owner_username || (c.user ? c.user.username : '—');
|
||||
|
||||
html += '<tr class="hover:bg-gray-50">';
|
||||
html += '<td class="px-3 py-2 text-sm text-gray-500">' + date + '</td>';
|
||||
@@ -803,7 +893,10 @@ function renderAllConfigs(configs) {
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadTab('alerts');
|
||||
// Check URL params for initial tab
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const initialTab = urlParams.get('tab') || 'alerts';
|
||||
loadTab(initialTab);
|
||||
|
||||
// Add event listeners for preview updates
|
||||
document.getElementById('modal-period').addEventListener('change', fetchPreview);
|
||||
@@ -811,6 +904,217 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
document.getElementById('modal-manual-price').addEventListener('input', debounceFetchPreview);
|
||||
document.getElementById('modal-meta-prices').addEventListener('input', debounceFetchPreview);
|
||||
});
|
||||
|
||||
// Pricelists functions
|
||||
let canWrite = false;
|
||||
|
||||
async function checkPricelistWritePermission() {
|
||||
try {
|
||||
const resp = await fetch('/api/pricelists/can-write');
|
||||
const data = await resp.json();
|
||||
pricelistsCanWrite = data.can_write;
|
||||
|
||||
if (pricelistsCanWrite) {
|
||||
document.getElementById('pricelists-create-btn-container').innerHTML = `
|
||||
<button onclick="openPricelistsCreateModal()" class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
|
||||
Создать прайслист
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to check pricelist write permission:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPricelists(page = 1) {
|
||||
pricelistsPage = page;
|
||||
try {
|
||||
const resp = await fetch(`/api/pricelists?page=${page}&per_page=20`);
|
||||
const data = await resp.json();
|
||||
|
||||
renderPricelists(data.pricelists || []);
|
||||
renderPricelistsPagination(data.total, data.page, data.per_page);
|
||||
} catch (e) {
|
||||
document.getElementById('pricelists-body').innerHTML = `
|
||||
<tr>
|
||||
<td colspan="7" class="px-6 py-4 text-center text-red-500">
|
||||
Ошибка загрузки: ${e.message}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
// Hide pagination when there's an error
|
||||
document.getElementById('pricelists-pagination').innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
function renderPricelists(pricelists) {
|
||||
if (pricelists.length === 0) {
|
||||
document.getElementById('pricelists-body').innerHTML = `
|
||||
<tr>
|
||||
<td colspan="7" class="px-6 py-4 text-center text-gray-500">
|
||||
Прайслисты не найдены. ${pricelistsCanWrite ? 'Создайте первый прайслист.' : ''}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const html = pricelists.map(pl => {
|
||||
const date = new Date(pl.created_at).toLocaleDateString('ru-RU');
|
||||
const statusClass = pl.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800';
|
||||
const statusText = pl.is_active ? 'Активен' : 'Неактивен';
|
||||
|
||||
let actions = `<a href="/pricelists/${pl.id}" class="text-blue-600 hover:text-blue-800 text-sm">Просмотр</a>`;
|
||||
if (pricelistsCanWrite && pl.usage_count === 0) {
|
||||
actions += ` <button onclick="deletePricelist(${pl.id})" class="text-red-600 hover:text-red-800 text-sm ml-2">Удалить</button>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="font-mono text-sm">${pl.version}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${date}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${pl.created_by || '-'}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center text-sm">${pl.item_count}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center text-sm">${pl.usage_count}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
<span class="px-2 py-1 text-xs rounded-full ${statusClass}">${statusText}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right">${actions}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
document.getElementById('pricelists-body').innerHTML = html;
|
||||
}
|
||||
|
||||
function renderPricelistsPagination(total, page, perPage) {
|
||||
const totalPages = Math.ceil(total / perPage);
|
||||
if (totalPages <= 1) {
|
||||
document.getElementById('pricelists-pagination').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
const activeClass = i === page ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50';
|
||||
html += `<button onclick="loadPricelists(${i})" class="px-3 py-1 rounded border ${activeClass}">${i}</button>`;
|
||||
}
|
||||
|
||||
document.getElementById('pricelists-pagination').innerHTML = html;
|
||||
}
|
||||
|
||||
async function loadPricelistsDbUsername() {
|
||||
if (cachedDbUsername) {
|
||||
document.getElementById('pricelists-db-username').textContent = cachedDbUsername;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/current-user');
|
||||
const data = await resp.json();
|
||||
cachedDbUsername = data.username || 'неизвестно';
|
||||
document.getElementById('pricelists-db-username').textContent = cachedDbUsername;
|
||||
} catch (e) {
|
||||
document.getElementById('pricelists-db-username').textContent = 'неизвестно';
|
||||
}
|
||||
}
|
||||
|
||||
function openPricelistsCreateModal() {
|
||||
document.getElementById('pricelists-create-modal').classList.remove('hidden');
|
||||
document.getElementById('pricelists-create-modal').classList.add('flex');
|
||||
loadPricelistsDbUsername();
|
||||
}
|
||||
|
||||
function closePricelistsCreateModal() {
|
||||
document.getElementById('pricelists-create-modal').classList.add('hidden');
|
||||
document.getElementById('pricelists-create-modal').classList.remove('flex');
|
||||
}
|
||||
|
||||
async function checkOnlineStatus() {
|
||||
try {
|
||||
const resp = await fetch('/api/db-status');
|
||||
const data = await resp.json();
|
||||
return data.connected === true;
|
||||
} catch(e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createPricelist() {
|
||||
// Check if online before creating
|
||||
const isOnline = await checkOnlineStatus();
|
||||
if (!isOnline) {
|
||||
throw new Error('Создание прайслистов доступно только в онлайн режиме');
|
||||
}
|
||||
|
||||
const resp = await fetch('/api/pricelists', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json();
|
||||
throw new Error(data.error || 'Failed to create pricelist');
|
||||
}
|
||||
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
async function deletePricelist(id) {
|
||||
// Check if online before deleting
|
||||
const isOnline = await checkOnlineStatus();
|
||||
if (!isOnline) {
|
||||
showToast('Удаление прайслистов доступно только в онлайн режиме', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm('Удалить этот прайслист?')) return;
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/pricelists/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json();
|
||||
throw new Error(data.error || 'Failed to delete');
|
||||
}
|
||||
|
||||
showToast('Прайслист удален', 'success');
|
||||
loadPricelists(pricelistsPage);
|
||||
} catch (e) {
|
||||
showToast('Ошибка: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('pricelists-create-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (isCreatingPricelist) return; // protection from double-submit
|
||||
isCreatingPricelist = true;
|
||||
|
||||
const submitBtn = this.querySelector('button[type="submit"]');
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Создание...';
|
||||
|
||||
try {
|
||||
const pl = await createPricelist();
|
||||
closePricelistsCreateModal();
|
||||
showToast(`Прайслист ${pl.version} создан (${pl.item_count} позиций)`, 'success');
|
||||
loadPricelists(1);
|
||||
} catch (e) {
|
||||
showToast('Ошибка: ' + e.message, 'error');
|
||||
} finally {
|
||||
isCreatingPricelist = false;
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Создать';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
|
||||
@@ -19,19 +19,18 @@
|
||||
<div class="flex items-center space-x-8">
|
||||
<a href="/" class="text-xl font-bold text-blue-600">QuoteForge</a>
|
||||
<div class="hidden md:flex space-x-4">
|
||||
<a href="/pricelists" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Прайслисты</a>
|
||||
<a href="/configurator" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Конфигуратор</a>
|
||||
<a id="admin-pricing-link" href="/admin/pricing" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm hidden">Администратор цен</a>
|
||||
<a id="admin-pricing-link" href="/admin/pricing" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Администратор цен</a>
|
||||
<a href="/setup" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Настройки</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- Sync Status Indicator (htmx-powered) -->
|
||||
<div id="sync-status"
|
||||
class="flex items-center gap-3 text-sm"
|
||||
hx-get="/partials/sync-status"
|
||||
hx-trigger="load, refresh from:body, every 30s"
|
||||
hx-swap="innerHTML">
|
||||
<span class="animate-pulse text-gray-400 text-xs">Загрузка...</span>
|
||||
</div>
|
||||
<span id="db-user" class="text-sm text-gray-600"></span>
|
||||
</div>
|
||||
@@ -45,6 +44,52 @@
|
||||
|
||||
<div id="toast" class="fixed bottom-4 right-4 z-50"></div>
|
||||
|
||||
<!-- Sync Info Modal -->
|
||||
<div id="sync-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
|
||||
<div class="bg-white rounded-lg shadow-xl max-w-md w-full mx-4">
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-medium text-gray-900">Информация о синхронизации</h3>
|
||||
<button onclick="closeSyncModal()" class="text-gray-400 hover:text-gray-500">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-900">Статус БД</h4>
|
||||
<p id="modal-db-status" class="text-sm text-gray-600">Проверка...</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-900">Количество ошибок</h4>
|
||||
<p id="modal-error-count" class="text-sm text-gray-600">0</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-900">Последняя синхронизация</h4>
|
||||
<p id="modal-last-sync" class="text-sm text-gray-600">-</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-900">Список ошибок</h4>
|
||||
<div id="modal-errors-list" class="mt-2 text-sm text-gray-600 max-h-40 overflow-y-auto">
|
||||
<p>Нет ошибок</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button onclick="closeSyncModal()" class="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700">
|
||||
Закрыть
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="fixed bottom-0 left-0 right-0 bg-gray-800 text-gray-300 text-xs py-1 px-4">
|
||||
<div class="max-w-7xl mx-auto flex justify-between">
|
||||
<span id="db-status">БД: проверка...</span>
|
||||
@@ -60,6 +105,115 @@
|
||||
setTimeout(() => el.innerHTML = '', 3000);
|
||||
}
|
||||
|
||||
// Open sync modal
|
||||
function openSyncModal() {
|
||||
const modal = document.getElementById('sync-modal');
|
||||
if (modal) {
|
||||
modal.classList.remove('hidden');
|
||||
// Load sync info when modal opens
|
||||
loadSyncInfo();
|
||||
}
|
||||
}
|
||||
|
||||
// Close sync modal
|
||||
function closeSyncModal() {
|
||||
const modal = document.getElementById('sync-modal');
|
||||
if (modal) {
|
||||
modal.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Load sync info for modal
|
||||
async function loadSyncInfo() {
|
||||
try {
|
||||
const resp = await fetch('/api/sync/info');
|
||||
const data = await resp.json();
|
||||
|
||||
document.getElementById('modal-db-status').textContent = data.is_online ? 'Подключено' : 'Отключено';
|
||||
document.getElementById('modal-error-count').textContent = data.error_count;
|
||||
|
||||
if (data.last_sync_at) {
|
||||
const date = new Date(data.last_sync_at);
|
||||
document.getElementById('modal-last-sync').textContent = date.toLocaleString('ru-RU');
|
||||
} else {
|
||||
document.getElementById('modal-last-sync').textContent = 'Нет данных';
|
||||
}
|
||||
|
||||
// Load error list
|
||||
const errorsList = document.getElementById('modal-errors-list');
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
errorsList.innerHTML = data.errors.map(error =>
|
||||
`<div class="mb-1"><span class="font-medium">${error.timestamp}</span>: ${error.message}</div>`
|
||||
).join('');
|
||||
} else {
|
||||
errorsList.innerHTML = '<p>Нет ошибок</p>';
|
||||
}
|
||||
} catch(e) {
|
||||
console.error('Failed to load sync info:', e);
|
||||
document.getElementById('modal-db-status').textContent = 'Ошибка загрузки';
|
||||
document.getElementById('modal-error-count').textContent = '0';
|
||||
document.getElementById('modal-last-sync').textContent = '-';
|
||||
document.getElementById('modal-errors-list').innerHTML = '<p>Ошибка загрузки данных</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// Event delegation for sync dropdown and actions
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
checkDbStatus();
|
||||
checkWritePermission();
|
||||
});
|
||||
|
||||
// Event delegation for sync actions
|
||||
document.body.addEventListener('click', function(e) {
|
||||
// Handle sync button click (full sync only)
|
||||
const syncButton = e.target.closest('#sync-button');
|
||||
if (syncButton) {
|
||||
e.stopPropagation();
|
||||
const button = syncButton;
|
||||
|
||||
// Add loading state
|
||||
const originalHTML = button.innerHTML;
|
||||
button.disabled = true;
|
||||
button.innerHTML = '<svg class="animate-spin w-4 h-4 inline mr-2" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>';
|
||||
|
||||
fullSync(button, originalHTML);
|
||||
}
|
||||
});
|
||||
|
||||
// Refactored sync action function to reduce duplication
|
||||
async function syncAction(endpoint, successMessage, button, originalHTML) {
|
||||
try {
|
||||
const resp = await fetch(endpoint, { method: 'POST' });
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.success) {
|
||||
showToast(successMessage, 'success');
|
||||
// Update last sync time - removed since dropdown is gone
|
||||
// loadLastSyncTime();
|
||||
} else {
|
||||
showToast('Ошибка: ' + (data.error || 'неизвестная ошибка'), 'error');
|
||||
}
|
||||
|
||||
htmx.trigger('#sync-status', 'refresh');
|
||||
} catch (error) {
|
||||
showToast('Ошибка: ' + error.message, 'error');
|
||||
} finally {
|
||||
// Reset button state
|
||||
if (button) {
|
||||
button.disabled = false;
|
||||
button.innerHTML = originalHTML;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pushPendingChanges(button, originalHTML) {
|
||||
syncAction('/api/sync/push', 'Изменения синхронизированы', button, originalHTML);
|
||||
}
|
||||
|
||||
function fullSync(button, originalHTML) {
|
||||
syncAction('/api/sync/all', 'Полная синхронизация завершена', button, originalHTML);
|
||||
}
|
||||
|
||||
async function checkDbStatus() {
|
||||
try {
|
||||
const resp = await fetch('/api/db-status');
|
||||
@@ -83,23 +237,36 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Admin pricing link is now always visible
|
||||
// Write permission is checked at operation time (create/delete)
|
||||
async function checkWritePermission() {
|
||||
try {
|
||||
const resp = await fetch('/api/pricelists/can-write');
|
||||
const data = await resp.json();
|
||||
if (data.can_write) {
|
||||
const link = document.getElementById('admin-pricing-link');
|
||||
if (link) link.classList.remove('hidden');
|
||||
}
|
||||
} catch(e) {
|
||||
console.error('Failed to check write permission:', e);
|
||||
}
|
||||
// No longer needed - link always visible in offline-first mode
|
||||
// Operations will check online status when executed
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
checkDbStatus();
|
||||
checkWritePermission();
|
||||
});
|
||||
// Load last sync time for dropdown (removed since dropdown is gone)
|
||||
// async function loadLastSyncTime() {
|
||||
// try {
|
||||
// const resp = await fetch('/api/sync/status');
|
||||
// const data = await resp.json();
|
||||
// if (data.last_pricelist_sync) {
|
||||
// const date = new Date(data.last_pricelist_sync);
|
||||
// document.getElementById('last-sync-time').textContent = date.toLocaleString('ru-RU');
|
||||
// } else {
|
||||
// document.getElementById('last-sync-time').textContent = 'Нет данных';
|
||||
// }
|
||||
// } catch(e) {
|
||||
// console.error('Failed to load last sync time:', e);
|
||||
// }
|
||||
// }
|
||||
|
||||
// Call functions immediately to ensure they run even before DOMContentLoaded
|
||||
// This ensures username and admin link are visible ASAP
|
||||
checkDbStatus();
|
||||
checkWritePermission();
|
||||
|
||||
// Load last sync time - removed since dropdown is gone
|
||||
// document.addEventListener('DOMContentLoaded', loadLastSyncTime);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,10 +4,22 @@
|
||||
<div class="space-y-4">
|
||||
<h1 class="text-2xl font-bold">Мои конфигурации</h1>
|
||||
|
||||
<div class="mt-4">
|
||||
<button onclick="openCreateModal()" class="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
|
||||
<div class="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<button onclick="openCreateModal()" class="py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
|
||||
+ Создать новую конфигурацию
|
||||
</button>
|
||||
<button id="import-configs-btn" onclick="importConfigsFromServer()" class="py-3 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 font-medium">
|
||||
Импорт с сервера
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="pricelist-badge" class="mt-4 text-sm text-gray-600 hidden">
|
||||
<span class="bg-blue-100 text-blue-800 px-2 py-1 rounded-full">
|
||||
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
Активный прайслист: <span id="pricelist-version">-</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div id="configs-list">
|
||||
@@ -115,6 +127,7 @@ function renderConfigs(configs) {
|
||||
html += '<thead class="bg-gray-50"><tr>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Дата</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Название</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Автор</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Цена (за 1 шт)</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Кол-во</th>';
|
||||
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Сумма</th>';
|
||||
@@ -125,6 +138,7 @@ function renderConfigs(configs) {
|
||||
const date = new Date(c.created_at).toLocaleDateString('ru-RU');
|
||||
const total = c.total_price ? '$' + c.total_price.toLocaleString('en-US', {minimumFractionDigits: 2}) : '—';
|
||||
const serverCount = c.server_count ? c.server_count : 1;
|
||||
const author = c.owner_username || (c.user && c.user.username) || '—';
|
||||
|
||||
// Calculate price per unit (total / server count)
|
||||
let pricePerUnit = '—';
|
||||
@@ -136,6 +150,7 @@ function renderConfigs(configs) {
|
||||
html += '<tr class="hover:bg-gray-50">';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + date + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm font-medium"><a href="/configurator?uuid=' + c.uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(c.name) + '</a></td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(author) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + pricePerUnit + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + serverCount + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-right">' + total + '</td>';
|
||||
@@ -398,7 +413,71 @@ async function loadConfigs() {
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', loadConfigs);
|
||||
async function importConfigsFromServer() {
|
||||
const button = document.getElementById('import-configs-btn');
|
||||
const originalText = button.textContent;
|
||||
button.disabled = true;
|
||||
button.textContent = 'Импорт...';
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/configs/import', { method: 'POST' });
|
||||
const data = await resp.json();
|
||||
|
||||
if (!resp.ok) {
|
||||
alert('Ошибка импорта: ' + (data.error || 'неизвестная ошибка'));
|
||||
return;
|
||||
}
|
||||
|
||||
alert(
|
||||
'Импорт завершен:\n' +
|
||||
'- Новых: ' + (data.imported || 0) + '\n' +
|
||||
'- Обновлено: ' + (data.updated || 0) + '\n' +
|
||||
'- Пропущено (локальные изменения): ' + (data.skipped || 0)
|
||||
);
|
||||
|
||||
currentPage = 1;
|
||||
await loadConfigs();
|
||||
} catch (e) {
|
||||
alert('Ошибка импорта с сервера');
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadConfigs();
|
||||
|
||||
// Load latest pricelist version for badge
|
||||
loadLatestPricelistVersion();
|
||||
});
|
||||
|
||||
async function loadLatestPricelistVersion() {
|
||||
try {
|
||||
const resp = await fetch('/api/pricelists/latest');
|
||||
if (resp.ok) {
|
||||
const pricelist = await resp.json();
|
||||
document.getElementById('pricelist-version').textContent = pricelist.version;
|
||||
document.getElementById('pricelist-badge').classList.remove('hidden');
|
||||
} else if (resp.status === 404) {
|
||||
// No active pricelist (normal in offline mode or when not synced)
|
||||
document.getElementById('pricelist-version').textContent = 'Не загружен';
|
||||
document.getElementById('pricelist-badge').classList.remove('hidden');
|
||||
document.getElementById('pricelist-badge').classList.add('bg-gray-100', 'text-gray-600');
|
||||
} else {
|
||||
// Real error
|
||||
document.getElementById('pricelist-version').textContent = 'Ошибка загрузки';
|
||||
document.getElementById('pricelist-badge').classList.remove('hidden');
|
||||
document.getElementById('pricelist-badge').classList.add('bg-red-100', 'text-red-800');
|
||||
}
|
||||
} catch(e) {
|
||||
// Network error or other exception
|
||||
console.error('Failed to load pricelist version:', e);
|
||||
document.getElementById('pricelist-version').textContent = 'Не доступен';
|
||||
document.getElementById('pricelist-badge').classList.remove('hidden');
|
||||
document.getElementById('pricelist-badge').classList.add('bg-gray-100', 'text-gray-600');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
{{define "sync_status"}}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-2 relative">
|
||||
{{if .IsOffline}}
|
||||
<span class="flex items-center gap-1 text-red-600" title="Offline">
|
||||
<span class="w-2 h-2 bg-red-500 rounded-full"></span>
|
||||
<span class="text-xs">Offline</span>
|
||||
<span class="flex items-center gap-1 text-red-600 cursor-pointer" title="Offline" onclick="openSyncModal()">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||
</svg>
|
||||
</span>
|
||||
{{else}}
|
||||
<span class="flex items-center gap-1 text-green-600" title="Online">
|
||||
<span class="w-2 h-2 bg-green-500 rounded-full"></span>
|
||||
<span class="text-xs">Online</span>
|
||||
<span class="flex items-center gap-1 text-green-600 cursor-pointer" title="Online" onclick="openSyncModal()">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
</span>
|
||||
{{end}}
|
||||
|
||||
{{if gt .PendingCount 0}}
|
||||
<span class="bg-yellow-100 text-yellow-800 px-2 py-0.5 rounded-full text-xs font-medium">
|
||||
{{.PendingCount}} pending
|
||||
<span class="bg-yellow-100 text-yellow-800 px-2 py-0.5 rounded-full text-xs font-medium flex items-center gap-1 cursor-pointer" onclick="openSyncModal()">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||
</svg>
|
||||
{{.PendingCount}}
|
||||
</span>
|
||||
<button hx-post="/api/sync/push"
|
||||
hx-swap="none"
|
||||
hx-on::after-request="
|
||||
if(event.detail.successful) {
|
||||
const resp = JSON.parse(event.detail.xhr.response);
|
||||
if(resp.success) {
|
||||
showToast('Синхронизировано: ' + resp.synced + ' изменений', 'success');
|
||||
} else {
|
||||
showToast('Ошибка: ' + (resp.error || 'неизвестная ошибка'), 'error');
|
||||
}
|
||||
htmx.trigger('#sync-status', 'refresh');
|
||||
}
|
||||
"
|
||||
class="text-blue-600 hover:text-blue-800 text-xs underline cursor-pointer">
|
||||
Sync
|
||||
</button>
|
||||
{{end}}
|
||||
|
||||
<!-- Sync button (full sync only) -->
|
||||
<div class="relative">
|
||||
<button id="sync-button"
|
||||
aria-label="Синхронизация"
|
||||
class="text-gray-600 hover:text-gray-800 text-xs flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -61,6 +61,12 @@
|
||||
<div id="status" class="hidden p-3 rounded-md text-sm"></div>
|
||||
|
||||
<div class="flex space-x-3 pt-4">
|
||||
{{if .Settings}}
|
||||
<a href="/"
|
||||
class="flex-1 px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition text-center">
|
||||
Назад
|
||||
</a>
|
||||
{{end}}
|
||||
<button type="button" onclick="testConnection()"
|
||||
class="flex-1 px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition">
|
||||
Проверить
|
||||
@@ -81,12 +87,14 @@
|
||||
<script>
|
||||
function showStatus(message, type) {
|
||||
const status = document.getElementById('status');
|
||||
status.classList.remove('hidden', 'bg-green-100', 'text-green-800', 'bg-red-100', 'text-red-800', 'bg-blue-100', 'text-blue-800');
|
||||
status.classList.remove('hidden', 'bg-green-100', 'text-green-800', 'bg-red-100', 'text-red-800', 'bg-blue-100', 'text-blue-800', 'bg-yellow-100', 'text-yellow-800');
|
||||
|
||||
if (type === 'success') {
|
||||
status.classList.add('bg-green-100', 'text-green-800');
|
||||
} else if (type === 'error') {
|
||||
status.classList.add('bg-red-100', 'text-red-800');
|
||||
} else if (type === 'warning') {
|
||||
status.classList.add('bg-yellow-100', 'text-yellow-800');
|
||||
} else {
|
||||
status.classList.add('bg-blue-100', 'text-blue-800');
|
||||
}
|
||||
@@ -122,6 +130,44 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function checkServerReady() {
|
||||
let attempts = 0;
|
||||
const maxAttempts = 30; // 30 seconds max
|
||||
|
||||
const checkInterval = setInterval(async () => {
|
||||
attempts++;
|
||||
try {
|
||||
const resp = await fetch('/health', { method: 'GET' });
|
||||
const data = await resp.json();
|
||||
|
||||
// Check if we're out of setup mode
|
||||
if (data.status === 'ok') {
|
||||
clearInterval(checkInterval);
|
||||
showStatus('✓ Приложение запущено! Перенаправление...', 'success');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 1000);
|
||||
}
|
||||
} catch (e) {
|
||||
// Server still restarting, continue polling
|
||||
if (attempts >= maxAttempts) {
|
||||
clearInterval(checkInterval);
|
||||
showStatus('Сервер не отвечает. Обновите страницу вручную.', 'error');
|
||||
}
|
||||
}
|
||||
}, 1000); // Check every second
|
||||
}
|
||||
|
||||
async function requestRestartAndWait() {
|
||||
showStatus('Перезапуск приложения...', 'info');
|
||||
try {
|
||||
await fetch('/api/restart', { method: 'POST' });
|
||||
} catch (e) {
|
||||
// Ignore network errors here: restart may break connection immediately.
|
||||
}
|
||||
checkServerReady();
|
||||
}
|
||||
|
||||
document.getElementById('setup-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
showStatus('Сохранение настроек...', 'info');
|
||||
@@ -136,10 +182,21 @@
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.success) {
|
||||
showStatus(data.message + ' Перенаправление...', 'success');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 2000);
|
||||
showStatus('✓ ' + data.message, 'success');
|
||||
|
||||
// Check if restart is required
|
||||
if (data.restart_required) {
|
||||
setTimeout(() => {
|
||||
requestRestartAndWait();
|
||||
}, 2000);
|
||||
} else {
|
||||
// In setup mode, auto-restart is happening
|
||||
setTimeout(() => {
|
||||
showStatus('✓ Настройки сохранены. Проверка подключения...', 'success');
|
||||
// Poll until server is back
|
||||
checkServerReady();
|
||||
}, 2000);
|
||||
}
|
||||
} else {
|
||||
showStatus(data.error, 'error');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user