29 Commits

Author SHA1 Message Date
Mikhail Chusavitin
f4f92dea66 Store configuration owner by MariaDB username 2026-02-04 12:20:41 +03:00
Mikhail Chusavitin
f42b850734 Recover DB connection automatically after network returns 2026-02-04 11:43:31 +03:00
Mikhail Chusavitin
d094d39427 Add server-to-local configuration import in web UI 2026-02-04 11:31:23 +03:00
Mikhail Chusavitin
4509e93864 Store config in user state and clean old release notes 2026-02-04 11:21:48 +03:00
Mikhail Chusavitin
e2800b06f9 Log binary version and executable path on startup 2026-02-04 10:21:18 +03:00
Mikhail Chusavitin
7c606af2bb Fix missing config handling and auto-restart after setup 2026-02-04 10:19:35 +03:00
Mikhail Chusavitin
fabd30650d Store local DB in user state dir as qfs.db 2026-02-04 10:03:17 +03:00
Mikhail Chusavitin
40ade651b0 Ignore local Go cache directory 2026-02-04 09:55:36 +03:00
Mikhail Chusavitin
1b87c53609 Fix offline usage tracking and active pricelist sync 2026-02-04 09:54:13 +03:00
a3dc264efd Merge feature/phase2-sqlite-sync into main 2026-02-03 22:04:17 +03:00
20056f3593 Embed assets and fix offline/sync/pricing issues 2026-02-03 21:58:02 +03:00
Mikhail Chusavitin
8a37542929 docs: add release notes for v0.2.7 2026-02-03 11:39:23 +03:00
Mikhail Chusavitin
0eb6730a55 fix: Windows compatibility and localhost binding
**Windows compatibility:**
- Added filepath.Join for all template and static paths
- Fixes "path not found" errors on Windows

**Localhost binding:**
- Changed default host from 0.0.0.0 to 127.0.0.1
- Browser always opens on 127.0.0.1 (localhost)
- Setup mode now listens on 127.0.0.1:8080
- Updated config.example.yaml with comment about 0.0.0.0

This ensures the app works correctly on Windows and opens
browser on the correct localhost address.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 11:38:28 +03:00
Mikhail Chusavitin
e2d056e7cb feat: add Windows support to build system
- Add make build-windows for Windows AMD64
- Update make build-all to include Windows
- Update release script to package Windows binary as .zip
- Add Windows installation instructions to docs
- Windows binary: qfs-windows-amd64.exe (~17MB)

All platforms now supported:
- Linux AMD64 (.tar.gz)
- macOS Intel/ARM (.tar.gz)
- Windows AMD64 (.zip)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 11:04:04 +03:00
Mikhail Chusavitin
1bce8086d6 feat: add release build script for multi-platform binaries
- Add scripts/release.sh for automated release builds
- Creates tar.gz packages for Linux and macOS
- Generates SHA256 checksums
- Add 'make release' target
- Add releases/ to .gitignore

Usage:
  make release  # Build and package for all platforms

Output: releases/v0.2.5/*.tar.gz + SHA256SUMS.txt

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 10:58:41 +03:00
Mikhail Chusavitin
0bdd163728 feat: add version flag and Makefile for release builds
- Add -version flag to show build version
- Add Makefile with build targets:
  - make build-release: optimized build with version
  - make build-all: cross-compile for Linux/macOS
  - make run/test/clean: dev commands
- Update documentation with build commands
- Version is embedded via ldflags during build

Usage:
  make build-release  # Build with version
  ./bin/qfs -version  # Show version

Version format: v0.2.5-1-gfa0f5e3 (tag-commits-hash)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 10:57:22 +03:00
Mikhail Chusavitin
fa0f5e321d refactor: rename binary from quoteforge to qfs
- Rename cmd/server to cmd/qfs for shorter binary name
- Update all documentation references (README, CLAUDE.md, etc.)
- Update build commands to output bin/qfs
- Binary name now matches directory name

Usage:
  go run ./cmd/qfs              # Development
  go build -o bin/qfs ./cmd/qfs # Production

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 10:55:14 +03:00
Mikhail Chusavitin
502832ac9a Merge feature/phase2-sqlite-sync into main
This merge brings Phase 2.5 (Full Offline Mode) with the following improvements:

- Local-first architecture: all operations work through SQLite
- Background sync worker for automatic synchronization
- Sync queue (pending_changes table) for reliable data push
- LocalConfigurationService for offline-capable CRUD operations
- Pre-create pricelist check before configuration creation
- RefreshPrices works in offline mode using local_components
- UI improvements: sync status indicator, pricelist badge, unified admin tabs
- Fixed online mode: automatic MariaDB connection on startup
- Fixed nil pointer dereference in PricingHandler alert methods
- Improved setup flow with restart requirement notification

Phase 2.5 is now complete. Ready for production.
2026-02-03 10:51:48 +03:00
Mikhail Chusavitin
8d84484412 fix: fix online mode after offline-first architecture changes
- Fix nil pointer dereference in PricingHandler alert methods
- Add automatic MariaDB connection on startup if settings exist
- Update setupRouter to accept mariaDB as parameter
- Fix offline mode checks: use h.db instead of h.alertService
- Update setup handler to show restart required message
- Add warning status support in setup.html UI

This ensures that after saving connection settings, the application
works correctly in online mode after restart. All repositories are
properly initialized with MariaDB connection on startup.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 10:50:07 +03:00
2510d9e36e feat: show local pricelists in offline mode
**Problem:**
Pricelist page showed empty list in offline mode even though
local pricelists existed in SQLite cache.

**Solution:**
Modified PricelistHandler.List() to fallback to local pricelists:

1. Check if server list is empty (offline)
2. Load from localDB.GetLocalPricelists()
3. Convert LocalPricelist to summary format
4. Add "synced_from": "local" field
5. Add "offline": true flag

**Response format:**
```json
{
  "offline": true,
  "total": 4,
  "pricelists": [
    {
      "version": "2026-02-02-002",
      "created_by": "sync",
      "synced_from": "local",
      "is_active": true
    }
  ]
}
```

**Impact:**
-  Local pricelists visible in offline mode
-  UI can show cached pricelist versions
-  Users can browse pricelists without connection
-  Clear indication of local/remote source

Part of Phase 2.5: Full Offline Mode

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 07:19:43 +03:00
d7285fc730 fix: prevent PricingHandler panics in offline mode
**Problem:**
Opening /admin/pricing page caused nil pointer panic when offline
because PricingHandler methods accessed nil repositories.

**Solution:**
Added offline checks to all PricingHandler public methods:

1. **GetStats** - returns empty stats with offline flag
2. **ListComponents** - returns empty list with message
3. **GetComponentPricing** - returns 503 with offline error
4. **UpdatePrice** - blocks mutations with offline error
5. **RecalculateAll** - blocks recalculation with offline error
6. **PreviewPrice** - blocks preview with offline error

**Response format:**
```json
{
  "offline": true,
  "message": "Управление ценами доступно только в онлайн режиме",
  "components": [],
  "total": 0
}
```

**Impact:**
-  No panics when viewing admin pricing offline
-  Clear offline status indication
-  Graceful degradation for all operations
-  UI can detect offline and show appropriate message

Fixes Phase 2.5 admin panel offline issue.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 07:17:58 +03:00
e33a3f2c88 fix: enable component search and pricing in offline mode
**Problem:**
Configurator was broken in offline mode - no component search
and no price calculation because /api/components returned empty list.

**Solution:**
Added local component fallback to ComponentHandler:

1. **ComponentHandler with localDB** (component.go)
   - Added localDB parameter to NewComponentHandler
   - List() now fallbacks to local_components when offline
   - Converts LocalComponent to ComponentView format
   - Preserves prices from local cache

2. **Updated initialization** (main.go)
   - Pass localDB to NewComponentHandler

**Impact:**
-  Component search works offline
-  Prices load from local_components table
-  Configuration creation fully functional offline
-  Price calculation works with cached prices

**Testing:**
- Verified /api/components returns local components
- Verified current_price field populated from cache
- Search, filtering, and pagination work correctly

Fixes critical Phase 2.5 offline mode issue.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 07:15:03 +03:00
4735e2b9bb feat: always show admin menu with online checks for operations
**Changes:**

1. **Admin menu always visible** (base.html)
   - Removed 'hidden' class from "Администратор цен" link
   - Menu no longer depends on write permission check
   - Users can access pricing/pricelists pages in offline mode

2. **Online status checks for mutations** (admin_pricing.html)
   - Added checkOnlineStatus() helper function
   - createPricelist() checks online before creating
   - deletePricelist() checks online before deleting
   - Clear user feedback when operations blocked offline

**User Impact:**
- Admin menu accessible in both online and offline modes
- View-only access to pricelists when offline
- Clear error messages when attempting mutations offline
- Better offline-first UX

Part of Phase 2.5: Full Offline Mode

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 07:12:18 +03:00
cdf5cef2cf perf: eliminate connection timeouts in offline mode
Fixed application freezing in offline mode by preventing unnecessary
reconnection attempts:

**Changes:**

1. **DSN timeouts** (localdb.go)
   - Added timeout=3s, readTimeout=3s, writeTimeout=3s to MySQL DSN
   - Reduces connection timeout from 75s to 3s when MariaDB unreachable

2. **Fast /api/db-status** (main.go)
   - Check connection status before attempting GetDB()
   - Avoid reconnection attempts on every status request
   - Returns cached offline status instantly

3. **Optimized sync service** (sync/service.go)
   - GetStatus() checks connection status before GetDB()
   - NeedSync() skips server check if already offline
   - Prevents repeated 3s timeouts on every sync info request

4. **Local pricelist fallback** (pricelist.go)
   - GetLatest() returns local pricelists when server offline
   - UI can now display pricelist version in offline mode

5. **Better UI error messages** (configs.html)
   - 404 shows "Не загружен" instead of "Ошибка загрузки"
   - Network errors show "Не доступен" in gray
   - Distinguishes between missing data and real errors

**Performance:**
- Before: 75s timeout on every offline request
- After: <5ms response time in offline mode
- Cached error state prevents repeated connection attempts

**User Impact:**
- UI no longer freezes when loading pages offline
- Instant page loads and API responses
- Pricelist version displays correctly in offline mode
- Clear visual feedback for offline state

Fixes Phase 2.5 offline mode performance issues.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 07:10:53 +03:00
7f030e7db7 refactor: migrate sync service and handlers to use ConnectionManager
Updated sync-related code to use ConnectionManager instead of direct
database references:

- SyncService now creates repositories on-demand when connection available
- SyncHandler uses ConnectionManager for lazy DB access
- Added ComponentFilter and ListComponents to localdb for offline queries
- All sync operations check connection status before attempting MariaDB access

This completes the transition to offline-first architecture where all
database access goes through ConnectionManager.

Part of Phase 2.5: Full Offline Mode

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 23:29:36 +03:00
3d222b7f14 feat: add ConnectionManager for lazy database connections
Introduced ConnectionManager to support offline-first architecture:

- New internal/db/connection.go with thread-safe connection management
- Lazy connection establishment (5s timeout, 10s cooldown)
- Automatic ping caching (30s interval) to avoid excessive checks
- Updated middleware/offline.go to use ConnectionManager.IsOnline()
- Updated sync/worker.go to use ConnectionManager instead of direct DB

This enables the application to start without MariaDB and gracefully
handle offline/online transitions.

Part of Phase 2.5: Full Offline Mode

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 23:29:04 +03:00
c024b96de7 fix: enable instant startup and offline mode for server
Fixed two critical issues preventing offline-first operation:

1. **Instant startup** - Removed blocking GetDB() call during server
   initialization. Server now starts in <10ms instead of 1+ minute.
   - Changed setupRouter() to use lazy DB connection via ConnectionManager
   - mariaDB connection is now nil on startup, established only when needed
   - Fixes timeout issues when MariaDB is unreachable

2. **Offline mode nil pointer panics** - Added graceful degradation
   when database is offline:
   - ComponentService.GetCategories() returns DefaultCategories if repo is nil
   - ComponentService.List/GetByLotName checks for nil repo
   - PricelistService methods return empty/error responses in offline mode
   - All methods properly handle nil repositories

**Before**: Server startup took 1min+ and crashed with nil pointer panic
when trying to load /configurator page offline.

**After**: Server starts instantly and serves pages in offline mode using
DefaultCategories and SQLite data.

Related to Phase 2.5: Full Offline Mode (local-first architecture)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 23:28:14 +03:00
2c75a7ccb8 feat: improve admin pricing modal quote count display to show period and total counts 2026-02-02 21:34:51 +03:00
Mikhail Chusavitin
f25477a25e add todo 2026-02-02 19:44:45 +03:00
45 changed files with 2316 additions and 499 deletions

4
.gitignore vendored
View File

@@ -13,6 +13,9 @@ config.yaml
/cron /cron
/bin/ /bin/
# Local Go build cache used in sandboxed runs
.gocache/
# ---> macOS # ---> macOS
# General # General
.DS_Store .DS_Store
@@ -41,3 +44,4 @@ Network Trash Folder
Temporary Items Temporary Items
.apdisk .apdisk
releases/

View File

@@ -46,30 +46,31 @@
**TODO:** **TODO:**
- ❌ Conflict resolution (Phase 4, last-write-wins default) - ❌ Conflict resolution (Phase 4, last-write-wins default)
### UI Improvements 🔶 IN PROGRESS ### UI Improvements ✅ MOSTLY DONE
**1. Sync icon + pricelist badge в header (tasks 4+2):** **1. Sync UI + pricelist badge: ✅ DONE**
- `sync_status.html`: заменить текст Online/Offline на SVG иконку - `sync_status.html`: SVG иконки Online/Offline (кликабельные → открывают модал)
- Кнопка sync → иконка (circular arrows) вместо текста - Кнопка sync → иконка circular arrows (только full sync)
- ❌ Dropdown при клике: Push changes, Full sync, статус последней синхронизации - ✅ Модальное окно "Статус системы" в `base.html` (info о БД, ошибки синхронизации)
- `configs.html`: рядом с кнопкой "Создать" показать badge с версией активного прайслиста - `configs.html`: badge с версией активного прайслиста
- Загружать через `/api/pricelists/latest` при DOMContentLoaded - Загрузка через `/api/pricelists/latest` при DOMContentLoaded
- ✅ Удалён dropdown с Push changes (упрощение UI)
**2. Прайслисты → вкладка в "Администратор цен" (task 1):** **2. Прайслисты → вкладка в "Администратор цен": ✅ DONE**
- `base.html`: убрать отдельную ссылку "Прайслисты" из навигации - `base.html`: убрана ссылка "Прайслисты" из навигации
- `admin_pricing.html`: добавить 4-ю вкладку "Прайслисты" - `admin_pricing.html`: добавлена вкладка "Прайслисты"
- ❌ Перенести логику из `pricelists.html` (table, create modal, CRUD) в эту вкладку - ✅ Логика перенесена из `pricelists.html` (table, create modal, CRUD)
- Route `/pricelists` → редирект на `/admin/pricing?tab=pricelists` или удалить - Route `/pricelists` → редирект на `/admin/pricing?tab=pricelists`
- ✅ Поддержка URL param `?tab=pricelists`
**3. Страница настроек: расширить + синхронизация (task 3):** **3. Модал "Настройка цены" - кол-во котировок с учётом периода: ❌ TODO**
- `setup.html`: переделать на `{{template "base" .}}` структуру - Текущее: показывает только общее кол-во котировок
- ❌ Увеличить до `max-w-4xl`, разделить на 2 секции - Новое: показывать `N (всего: M)` где N - за выбранный период, M - всего
-Секция A: Подключение к БД (текущая форма) -`admin_pricing.html`: обновить `#modal-quote-count`
-Секция B: Синхронизация данных: -`admin_pricing_handler.go`: в `/api/admin/pricing/preview` возвращать `quote_count_period` + `quote_count_total`
- Статус Online/Offline
- Кнопки: "Синхронизировать всё", "Обновить компоненты", "Обновить прайслисты" **4. Страница настроек: ❌ ОТЛОЖЕНО**
- Журнал синхронизации (последние N операций) - Перенесено в Phase 3 (после основных UI улучшений)
- ❌ Возможно: новый API endpoint для sync log
### Phase 3: Projects and Specifications ### Phase 3: Projects and Specifications
- qt_projects, qt_specifications tables (MariaDB) - qt_projects, qt_specifications tables (MariaDB)
@@ -133,9 +134,22 @@ Go 1.22+ | Gin | GORM | MariaDB 11 | SQLite (glebarez/sqlite) | htmx + Tailwind
## Commands ## Commands
```bash ```bash
go run ./cmd/server # Dev server # Development
go run ./cmd/cron -job=X # cleanup-pricelists | update-prices | update-popularity go run ./cmd/qfs # Dev server
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/quoteforge ./cmd/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 ## Code Style

View File

@@ -60,7 +60,7 @@ localConfigService := services.NewLocalConfigurationService(
### Шаг 1: Обновить main.go ### Шаг 1: Обновить main.go
```go ```go
// В cmd/server/main.go // В cmd/qfs/main.go
syncService := sync.NewService(pricelistRepo, configRepo, local) syncService := sync.NewService(pricelistRepo, configRepo, local)
// Создать isOnline функцию // Создать isOnline функцию
@@ -165,7 +165,7 @@ type PendingChange struct {
```bash ```bash
# Compile # Compile
go build ./cmd/server go build ./cmd/qfs
# Run # Run
./quoteforge ./quoteforge

View File

@@ -89,7 +89,7 @@ mysql -u user -p RFQ_LOG < migrations/004_add_price_updated_at.sql
- `internal/models/configuration.go` - добавлено поле `PriceUpdatedAt` - `internal/models/configuration.go` - добавлено поле `PriceUpdatedAt`
- `internal/services/configuration.go` - добавлен метод `RefreshPrices()` - `internal/services/configuration.go` - добавлен метод `RefreshPrices()`
- `internal/handlers/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 функции - `web/templates/index.html` - добавлена кнопка и JavaScript функции
- `migrations/004_add_price_updated_at.sql` - SQL миграция - `migrations/004_add_price_updated_at.sql` - SQL миграция
- `CLAUDE.md` - обновлена документация - `CLAUDE.md` - обновлена документация

97
Makefile Normal file
View 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)"

View File

@@ -82,7 +82,7 @@ auth:
### 3. Миграции базы данных ### 3. Миграции базы данных
```bash ```bash
go run ./cmd/server -migrate go run ./cmd/qfs -migrate
``` ```
### 4. Импорт метаданных компонентов ### 4. Импорт метаданных компонентов
@@ -95,15 +95,47 @@ go run ./cmd/importer
```bash ```bash
# Development # Development
go run ./cmd/server go run ./cmd/qfs
# Production # Production (with Makefile - recommended)
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/quoteforge ./cmd/server make build-release # Builds with version info
./bin/quoteforge ./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 Приложение будет доступно по адресу: 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 ## Docker
```bash ```bash
@@ -209,13 +241,13 @@ go run ./cmd/cron -job=update-popularity
```bash ```bash
# Запуск в режиме разработки (hot reload) # Запуск в режиме разработки (hot reload)
go run ./cmd/server go run ./cmd/qfs
# Запуск тестов # Запуск тестов
go test ./... go test ./...
# Сборка для Linux # Сборка для 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_DB_PASSWORD` | Пароль БД | — |
| `QF_JWT_SECRET` | Секрет для JWT | — | | `QF_JWT_SECRET` | Секрет для JWT | — |
| `QF_SERVER_PORT` | Порт сервера | 8080 | | `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
View 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")
}

View File

@@ -6,6 +6,7 @@ import (
"log" "log"
"time" "time"
"git.mchus.pro/mchus/quoteforge/internal/appstate"
"git.mchus.pro/mchus/quoteforge/internal/config" "git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/models"
@@ -16,7 +17,11 @@ import (
func main() { func main() {
configPath := flag.String("config", "config.yaml", "path to config file") 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") dryRun := flag.Bool("dry-run", false, "show what would be migrated without actually doing it")
flag.Parse() flag.Parse()
@@ -72,10 +77,13 @@ func main() {
if *dryRun { if *dryRun {
log.Println("\n[DRY RUN] Would migrate the following configurations:") log.Println("\n[DRY RUN] Would migrate the following configurations:")
for _, c := range configs { for _, c := range configs {
userName := "unknown" userName := c.OwnerUsername
if c.User != nil { if userName == "" && c.User != nil {
userName = c.User.Username 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(" - %s (UUID: %s, User: %s, Items: %d)", c.Name, c.UUID, userName, len(c.Items))
} }
log.Printf("\nTotal: %d configurations", len(configs)) log.Printf("\nTotal: %d configurations", len(configs))
@@ -124,6 +132,11 @@ func main() {
SyncedAt: &now, SyncedAt: &now,
SyncStatus: "synced", SyncStatus: "synced",
OriginalUserID: c.UserID, OriginalUserID: c.UserID,
OriginalUsername: c.OwnerUsername,
}
if localConfig.OriginalUsername == "" && c.User != nil {
localConfig.OriginalUsername = c.User.Username
} }
if err := local.SaveConfiguration(localConfig); err != nil { if err := local.SaveConfiguration(localConfig); err != nil {

View File

@@ -2,18 +2,25 @@ package main
import ( import (
"context" "context"
"errors"
"flag" "flag"
"fmt" "fmt"
"io/fs"
"log/slog" "log/slog"
"net/http" "net/http"
"os" "os"
"os/exec"
"os/signal" "os/signal"
"path/filepath"
"runtime"
"strconv" "strconv"
"syscall" "syscall"
"time" "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/config"
"git.mchus.pro/mchus/quoteforge/internal/db"
"git.mchus.pro/mchus/quoteforge/internal/handlers" "git.mchus.pro/mchus/quoteforge/internal/handlers"
"git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/middleware" "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/pricelist"
"git.mchus.pro/mchus/quoteforge/internal/services/pricing" "git.mchus.pro/mchus/quoteforge/internal/services/pricing"
"git.mchus.pro/mchus/quoteforge/internal/services/sync" "git.mchus.pro/mchus/quoteforge/internal/services/sync"
"github.com/gin-gonic/gin"
"gorm.io/driver/mysql" "gorm.io/driver/mysql"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
) )
const ( // Version is set via ldflags during build
localDBPath = "./data/settings.db" var Version = "dev"
)
func main() { 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") migrate := flag.Bool("migrate", false, "run database migrations")
version := flag.Bool("version", false, "show version information")
flag.Parse() 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) // Initialize local SQLite database (always used)
local, err := localdb.New(localDBPath) local, err := localdb.New(resolvedLocalDBPath)
if err != nil { if err != nil {
slog.Error("failed to initialize local database", "error", err) slog.Error("failed to initialize local database", "error", err)
os.Exit(1) os.Exit(1)
@@ -53,54 +107,55 @@ func main() {
} }
// Load config for server settings (optional) // Load config for server settings (optional)
cfg, err := config.Load(*configPath) cfg, err := config.Load(resolvedConfigPath)
if err != nil { if err != nil {
if errors.Is(err, fs.ErrNotExist) {
// Use defaults if config file doesn't exist // Use defaults if config file doesn't exist
slog.Info("config file not found, using defaults", "path", *configPath) slog.Info("config file not found, using defaults", "path", resolvedConfigPath)
cfg = &config.Config{} cfg = &config.Config{}
} else {
slog.Error("failed to load config", "path", resolvedConfigPath, "error", err)
os.Exit(1)
}
} }
setConfigDefaults(cfg) setConfigDefaults(cfg)
slog.Info("resolved runtime files", "config_path", resolvedConfigPath, "localdb_path", resolvedLocalDBPath)
setupLogger(cfg.Logging) setupLogger(cfg.Logging)
// Get DSN from local SQLite // Create connection manager and try to connect immediately if settings exist
dsn, err := local.GetDSN() connMgr := db.NewConnectionManager(local)
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)
}
dbUser := local.GetDBUser() dbUser := local.GetDBUser()
// Ensure DB user exists in qt_users table (for foreign key constraint) // Try to connect to MariaDB on startup
dbUserID, err := models.EnsureDBUser(db, dbUser) mariaDB, err := connMgr.GetDB()
if err != nil { if err != nil {
slog.Error("failed to ensure DB user exists", "error", err) slog.Warn("failed to connect to MariaDB on startup, starting in offline mode", "error", err)
os.Exit(1) mariaDB = nil
} else {
slog.Info("successfully connected to MariaDB on startup")
} }
slog.Info("starting QuoteForge server", slog.Info("starting QuoteForge server",
"version", Version,
"host", cfg.Server.Host, "host", cfg.Server.Host,
"port", cfg.Server.Port, "port", cfg.Server.Port,
"db_user", dbUser, "db_user", dbUser,
"db_user_id", dbUserID, "online", mariaDB != nil,
) )
if *migrate { if *migrate {
if mariaDB == nil {
slog.Error("cannot run migrations: database not available")
os.Exit(1)
}
slog.Info("running database migrations...") 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) slog.Error("migration failed", "error", err)
os.Exit(1) os.Exit(1)
} }
if err := models.SeedCategories(db); err != nil { if err := models.SeedCategories(mariaDB); err != nil {
slog.Error("seeding categories failed", "error", err) slog.Error("seeding categories failed", "error", err)
os.Exit(1) os.Exit(1)
} }
@@ -108,17 +163,17 @@ func main() {
} }
gin.SetMode(cfg.Server.Mode) 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 { if err != nil {
slog.Error("failed to setup router", "error", err) slog.Error("failed to setup router", "error", err)
os.Exit(1) os.Exit(1)
} }
// Start background sync worker // Start background sync worker (will auto-skip when offline)
workerCtx, workerCancel := context.WithCancel(context.Background()) workerCtx, workerCancel := context.WithCancel(context.Background())
defer workerCancel() defer workerCancel()
syncWorker := sync.NewWorker(syncService, db, 5*time.Minute) syncWorker := sync.NewWorker(syncService, connMgr, 5*time.Minute)
go syncWorker.Start(workerCtx) go syncWorker.Start(workerCtx)
srv := &http.Server{ 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) quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit <-quit
@@ -159,7 +226,7 @@ func main() {
func setConfigDefaults(cfg *config.Config) { func setConfigDefaults(cfg *config.Config) {
if cfg.Server.Host == "" { if cfg.Server.Host == "" {
cfg.Server.Host = "0.0.0.0" cfg.Server.Host = "127.0.0.1"
} }
if cfg.Server.Port == 0 { if cfg.Server.Port == 0 {
cfg.Server.Port = 8080 cfg.Server.Port = 8080
@@ -197,7 +264,9 @@ func setConfigDefaults(cfg *config.Config) {
func runSetupMode(local *localdb.LocalDB) { func runSetupMode(local *localdb.LocalDB) {
restartSig := make(chan struct{}, 1) restartSig := make(chan struct{}, 1)
setupHandler, err := handlers.NewSetupHandler(local, "web/templates", restartSig) // 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 { if err != nil {
slog.Error("failed to create setup handler", "error", err) slog.Error("failed to create setup handler", "error", err)
os.Exit(1) os.Exit(1)
@@ -207,7 +276,12 @@ func runSetupMode(local *localdb.LocalDB) {
router := gin.New() router := gin.New()
router.Use(gin.Recovery()) 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 // Setup routes only
router.GET("/", func(c *gin.Context) { router.GET("/", func(c *gin.Context) {
@@ -226,9 +300,8 @@ func runSetupMode(local *localdb.LocalDB) {
}) })
}) })
addr := ":8080" addr := "127.0.0.1:8080"
slog.Info("starting setup mode server", "address", addr) slog.Info("starting setup mode server", "address", addr, "version", Version)
slog.Info("open http://localhost:8080/setup to configure database connection")
srv := &http.Server{ srv := &http.Server{
Addr: addr, Addr: addr,
@@ -242,6 +315,17 @@ 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) quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
@@ -308,56 +392,89 @@ func setupDatabaseFromDSN(dsn string) (*gorm.DB, error) {
return db, nil 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 // Repositories
componentRepo := repository.NewComponentRepository(db) var componentRepo *repository.ComponentRepository
categoryRepo := repository.NewCategoryRepository(db) var categoryRepo *repository.CategoryRepository
priceRepo := repository.NewPriceRepository(db) var priceRepo *repository.PriceRepository
alertRepo := repository.NewAlertRepository(db) var alertRepo *repository.AlertRepository
statsRepo := repository.NewStatsRepository(db) var statsRepo *repository.StatsRepository
pricelistRepo := repository.NewPricelistRepository(db) var pricelistRepo *repository.PricelistRepository
configRepo := repository.NewConfigurationRepository(db)
// 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 // Services
pricingService := pricing.NewService(componentRepo, priceRepo, cfg.Pricing) var pricingService *pricing.Service
componentService := services.NewComponentService(componentRepo, categoryRepo, statsRepo) var componentService *services.ComponentService
quoteService := services.NewQuoteService(componentRepo, statsRepo, pricingService) var quoteService *services.QuoteService
exportService := services.NewExportService(cfg.Export, categoryRepo) var exportService *services.ExportService
alertService := alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing) var alertService *alerts.Service
pricelistService := pricelist.NewService(db, pricelistRepo, componentRepo) var pricelistService *pricelist.Service
syncService := sync.NewService(pricelistRepo, configRepo, local) 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 function for local-first architecture
isOnline := func() bool { isOnline := func() bool {
sqlDB, err := db.DB() return connMgr.IsOnline()
if err != nil {
return false
}
return sqlDB.Ping() == nil
} }
// Local-first configuration service (replaces old ConfigurationService) // Local-first configuration service (replaces old ConfigurationService)
configService := services.NewLocalConfigurationService(local, syncService, quoteService, isOnline) configService := services.NewLocalConfigurationService(local, syncService, quoteService, isOnline)
// Use filepath.Join for cross-platform path compatibility
templatesPath := filepath.Join("web", "templates")
// Handlers // Handlers
componentHandler := handlers.NewComponentHandler(componentService) componentHandler := handlers.NewComponentHandler(componentService, local)
quoteHandler := handlers.NewQuoteHandler(quoteService) quoteHandler := handlers.NewQuoteHandler(quoteService)
exportHandler := handlers.NewExportHandler(exportService, configService, componentService) 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) 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 { if err != nil {
return nil, nil, fmt.Errorf("creating sync handler: %w", err) return nil, nil, fmt.Errorf("creating sync handler: %w", err)
} }
// Setup handler (for reconfiguration) - no restart signal in normal mode // Setup handler (for reconfiguration) - no restart signal in normal mode
setupHandler, err := handlers.NewSetupHandler(local, "web/templates", nil) setupHandler, err := handlers.NewSetupHandler(local, connMgr, templatesPath, nil)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("creating setup handler: %w", err) return nil, nil, fmt.Errorf("creating setup handler: %w", err)
} }
// Web handler (templates) // Web handler (templates)
webHandler, err := handlers.NewWebHandler("web/templates", componentService) webHandler, err := handlers.NewWebHandler(templatesPath, componentService)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@@ -367,10 +484,15 @@ func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUser
router.Use(gin.Recovery()) router.Use(gin.Recovery())
router.Use(requestLogger()) router.Use(requestLogger())
router.Use(middleware.CORS()) router.Use(middleware.CORS())
router.Use(middleware.OfflineDetector(db, local)) router.Use(middleware.OfflineDetector(connMgr, local))
// Static files // Static files (use filepath.Join for Windows compatibility)
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))
}
// Health check // Health check
router.GET("/health", func(c *gin.Context) { router.GET("/health", func(c *gin.Context) {
@@ -380,24 +502,42 @@ 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 // DB status endpoint
router.GET("/api/db-status", func(c *gin.Context) { router.GET("/api/db-status", func(c *gin.Context) {
var lotCount, lotLogCount, metadataCount int64 var lotCount, lotLogCount, metadataCount int64
var dbOK bool = true var dbOK bool = false
var dbError string var dbError string
sqlDB, err := db.DB() // Check if connection exists (fast check, no reconnect attempt)
if err != nil { status := connMgr.GetStatus()
dbOK = false if status.IsConnected {
dbError = err.Error() // Already connected, safe to use
} else if err := sqlDB.Ping(); err != nil { if db, err := connMgr.GetDB(); err == nil && db != nil {
dbOK = false dbOK = true
dbError = err.Error()
}
db.Table("lot").Count(&lotCount) db.Table("lot").Count(&lotCount)
db.Table("lot_log").Count(&lotLogCount) db.Table("lot_log").Count(&lotLogCount)
db.Table("qt_lot_metadata").Count(&metadataCount) 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
}
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"connected": dbOK, "connected": dbOK,
@@ -504,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) { configs.POST("", func(c *gin.Context) {
var req services.CreateConfigRequest var req services.CreateConfigRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@@ -511,7 +664,7 @@ func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUser
return return
} }
config, err := configService.Create(dbUserID, &req) // use DB user ID config, err := configService.Create(dbUsername, &req)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -585,7 +738,7 @@ func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUser
return return
} }
config, err := configService.CloneNoAuth(uuid, req.Name, dbUserID) config, err := configService.CloneNoAuth(uuid, req.Name, dbUsername)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -658,6 +811,25 @@ func restartProcess() {
} }
} }
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 { func requestLogger() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
start := time.Now() start := time.Now()

View File

@@ -2,7 +2,7 @@
# Copy this file to config.yaml and update values # Copy this file to config.yaml and update values
server: server:
host: "0.0.0.0" host: "127.0.0.1" # Use 0.0.0.0 to listen on all interfaces
port: 8080 port: 8080
mode: "release" # debug | release mode: "release" # debug | release
read_timeout: "30s" read_timeout: "30s"

197
internal/appstate/path.go Normal file
View 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()
}

View File

@@ -106,7 +106,7 @@ func Load(path string) (*Config, error) {
func (c *Config) setDefaults() { func (c *Config) setDefaults() {
if c.Server.Host == "" { if c.Server.Host == "" {
c.Server.Host = "0.0.0.0" c.Server.Host = "127.0.0.1"
} }
if c.Server.Port == 0 { if c.Server.Port == 0 {
c.Server.Port = 8080 c.Server.Port = 8080

334
internal/db/connection.go Normal file
View 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 ""
}

View File

@@ -4,17 +4,22 @@ import (
"net/http" "net/http"
"strconv" "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/repository"
"git.mchus.pro/mchus/quoteforge/internal/services" "git.mchus.pro/mchus/quoteforge/internal/services"
"github.com/gin-gonic/gin"
) )
type ComponentHandler struct { type ComponentHandler struct {
componentService *services.ComponentService componentService *services.ComponentService
localDB *localdb.LocalDB
} }
func NewComponentHandler(componentService *services.ComponentService) *ComponentHandler { func NewComponentHandler(componentService *services.ComponentService, localDB *localdb.LocalDB) *ComponentHandler {
return &ComponentHandler{componentService: componentService} return &ComponentHandler{
componentService: componentService,
localDB: localDB,
}
} }
func (h *ComponentHandler) List(c *gin.Context) { func (h *ComponentHandler) List(c *gin.Context) {
@@ -34,6 +39,46 @@ func (h *ComponentHandler) List(c *gin.Context) {
return 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) c.JSON(http.StatusOK, result)
} }

View File

@@ -1,13 +1,12 @@
package handlers package handlers
import ( import (
"net/http" "net/http"
"strconv" "strconv"
"github.com/gin-gonic/gin"
"git.mchus.pro/mchus/quoteforge/internal/middleware" "git.mchus.pro/mchus/quoteforge/internal/middleware"
"git.mchus.pro/mchus/quoteforge/internal/services" "git.mchus.pro/mchus/quoteforge/internal/services"
"github.com/gin-gonic/gin"
) )
type ConfigurationHandler struct { type ConfigurationHandler struct {
@@ -26,11 +25,11 @@ func NewConfigurationHandler(
} }
func (h *ConfigurationHandler) List(c *gin.Context) { func (h *ConfigurationHandler) List(c *gin.Context) {
userID := middleware.GetUserID(c) username := middleware.GetUsername(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20")) 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 { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -45,7 +44,7 @@ func (h *ConfigurationHandler) List(c *gin.Context) {
} }
func (h *ConfigurationHandler) Create(c *gin.Context) { func (h *ConfigurationHandler) Create(c *gin.Context) {
userID := middleware.GetUserID(c) username := middleware.GetUsername(c)
var req services.CreateConfigRequest var req services.CreateConfigRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@@ -53,7 +52,7 @@ func (h *ConfigurationHandler) Create(c *gin.Context) {
return return
} }
config, err := h.configService.Create(userID, &req) config, err := h.configService.Create(username, &req)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -63,10 +62,10 @@ func (h *ConfigurationHandler) Create(c *gin.Context) {
} }
func (h *ConfigurationHandler) Get(c *gin.Context) { func (h *ConfigurationHandler) Get(c *gin.Context) {
userID := middleware.GetUserID(c) username := middleware.GetUsername(c)
uuid := c.Param("uuid") uuid := c.Param("uuid")
config, err := h.configService.GetByUUID(uuid, userID) config, err := h.configService.GetByUUID(uuid, username)
if err != nil { if err != nil {
status := http.StatusNotFound status := http.StatusNotFound
if err == services.ErrConfigForbidden { if err == services.ErrConfigForbidden {
@@ -80,7 +79,7 @@ func (h *ConfigurationHandler) Get(c *gin.Context) {
} }
func (h *ConfigurationHandler) Update(c *gin.Context) { func (h *ConfigurationHandler) Update(c *gin.Context) {
userID := middleware.GetUserID(c) username := middleware.GetUsername(c)
uuid := c.Param("uuid") uuid := c.Param("uuid")
var req services.CreateConfigRequest var req services.CreateConfigRequest
@@ -89,7 +88,7 @@ func (h *ConfigurationHandler) Update(c *gin.Context) {
return return
} }
config, err := h.configService.Update(uuid, userID, &req) config, err := h.configService.Update(uuid, username, &req)
if err != nil { if err != nil {
status := http.StatusInternalServerError status := http.StatusInternalServerError
if err == services.ErrConfigNotFound { if err == services.ErrConfigNotFound {
@@ -105,10 +104,10 @@ func (h *ConfigurationHandler) Update(c *gin.Context) {
} }
func (h *ConfigurationHandler) Delete(c *gin.Context) { func (h *ConfigurationHandler) Delete(c *gin.Context) {
userID := middleware.GetUserID(c) username := middleware.GetUsername(c)
uuid := c.Param("uuid") uuid := c.Param("uuid")
err := h.configService.Delete(uuid, userID) err := h.configService.Delete(uuid, username)
if err != nil { if err != nil {
status := http.StatusInternalServerError status := http.StatusInternalServerError
if err == services.ErrConfigNotFound { if err == services.ErrConfigNotFound {
@@ -128,7 +127,7 @@ type RenameConfigRequest struct {
} }
func (h *ConfigurationHandler) Rename(c *gin.Context) { func (h *ConfigurationHandler) Rename(c *gin.Context) {
userID := middleware.GetUserID(c) username := middleware.GetUsername(c)
uuid := c.Param("uuid") uuid := c.Param("uuid")
var req RenameConfigRequest var req RenameConfigRequest
@@ -137,7 +136,7 @@ func (h *ConfigurationHandler) Rename(c *gin.Context) {
return return
} }
config, err := h.configService.Rename(uuid, userID, req.Name) config, err := h.configService.Rename(uuid, username, req.Name)
if err != nil { if err != nil {
status := http.StatusInternalServerError status := http.StatusInternalServerError
if err == services.ErrConfigNotFound { if err == services.ErrConfigNotFound {
@@ -157,7 +156,7 @@ type CloneConfigRequest struct {
} }
func (h *ConfigurationHandler) Clone(c *gin.Context) { func (h *ConfigurationHandler) Clone(c *gin.Context) {
userID := middleware.GetUserID(c) username := middleware.GetUsername(c)
uuid := c.Param("uuid") uuid := c.Param("uuid")
var req CloneConfigRequest var req CloneConfigRequest
@@ -166,7 +165,7 @@ func (h *ConfigurationHandler) Clone(c *gin.Context) {
return return
} }
config, err := h.configService.Clone(uuid, userID, req.Name) config, err := h.configService.Clone(uuid, username, req.Name)
if err != nil { if err != nil {
status := http.StatusInternalServerError status := http.StatusInternalServerError
if err == services.ErrConfigNotFound { if err == services.ErrConfigNotFound {
@@ -182,10 +181,10 @@ func (h *ConfigurationHandler) Clone(c *gin.Context) {
} }
func (h *ConfigurationHandler) RefreshPrices(c *gin.Context) { func (h *ConfigurationHandler) RefreshPrices(c *gin.Context) {
userID := middleware.GetUserID(c) username := middleware.GetUsername(c)
uuid := c.Param("uuid") uuid := c.Param("uuid")
config, err := h.configService.RefreshPrices(uuid, userID) config, err := h.configService.RefreshPrices(uuid, username)
if err != nil { if err != nil {
status := http.StatusInternalServerError status := http.StatusInternalServerError
if err == services.ErrConfigNotFound { if err == services.ErrConfigNotFound {

View File

@@ -5,9 +5,9 @@ import (
"net/http" "net/http"
"time" "time"
"github.com/gin-gonic/gin"
"git.mchus.pro/mchus/quoteforge/internal/middleware" "git.mchus.pro/mchus/quoteforge/internal/middleware"
"git.mchus.pro/mchus/quoteforge/internal/services" "git.mchus.pro/mchus/quoteforge/internal/services"
"github.com/gin-gonic/gin"
) )
type ExportHandler struct { type ExportHandler struct {
@@ -98,10 +98,10 @@ func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ExportData
} }
func (h *ExportHandler) ExportConfigCSV(c *gin.Context) { func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
userID := middleware.GetUserID(c) username := middleware.GetUsername(c)
uuid := c.Param("uuid") uuid := c.Param("uuid")
config, err := h.configService.GetByUUID(uuid, userID) config, err := h.configService.GetByUUID(uuid, username)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return return

View File

@@ -29,6 +29,36 @@ func (h *PricelistHandler) List(c *gin.Context) {
return 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{ c.JSON(http.StatusOK, gin.H{
"pricelists": pricelists, "pricelists": pricelists,
"total": total, "total": total,
@@ -124,9 +154,33 @@ func (h *PricelistHandler) CanWrite(c *gin.Context) {
// GetLatest returns the most recent active pricelist // GetLatest returns the most recent active pricelist
func (h *PricelistHandler) GetLatest(c *gin.Context) { func (h *PricelistHandler) GetLatest(c *gin.Context) {
// Try to get from server first
pl, err := h.service.GetLatestActive() pl, err := h.service.GetLatestActive()
if err != nil { 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 return
} }

View File

@@ -68,6 +68,17 @@ func NewPricingHandler(
} }
func (h *PricingHandler) GetStats(c *gin.Context) { 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() newAlerts, _ := h.alertService.GetNewAlertsCount()
topComponents, _ := h.statsRepo.GetTopComponents(10) topComponents, _ := h.statsRepo.GetTopComponents(10)
trendingComponents, _ := h.statsRepo.GetTrendingComponents(10) trendingComponents, _ := h.statsRepo.GetTrendingComponents(10)
@@ -86,6 +97,19 @@ type ComponentWithCount struct {
} }
func (h *PricingHandler) ListComponents(c *gin.Context) { 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")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20")) 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) { 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") lotName := c.Param("lot_name")
component, err := h.componentRepo.GetByLotName(lotName) component, err := h.componentRepo.GetByLotName(lotName)
@@ -248,6 +281,15 @@ type UpdatePriceRequest struct {
} }
func (h *PricingHandler) UpdatePrice(c *gin.Context) { 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 var req UpdatePriceRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) 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) { 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 // Set headers for SSE
c.Header("Content-Type", "text/event-stream") c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache") c.Header("Cache-Control", "no-cache")
@@ -588,6 +639,18 @@ func (h *PricingHandler) RecalculateAll(c *gin.Context) {
} }
func (h *PricingHandler) ListAlerts(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")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20")) 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) { 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) id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"}) 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) { 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) id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"}) 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) { 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) id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"}) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"})
@@ -667,6 +757,15 @@ type PreviewPriceRequest struct {
} }
func (h *PricingHandler) PreviewPrice(c *gin.Context) { 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 var req PreviewPriceRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -708,8 +807,8 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) {
medianAllTime = &median medianAllTime = &median
} }
// Get quote count (from all relevant lots) // Get quote count (from all relevant lots) - total count
var quoteCount int64 var quoteCountTotal int64
for _, lotName := range lotNames { for _, lotName := range lotNames {
var count int64 var count int64
if strings.HasSuffix(lotName, "*") { if strings.HasSuffix(lotName, "*") {
@@ -718,7 +817,25 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) {
} else { } else {
h.db.Model(&models.LotLog{}).Where("lot = ?", lotName).Count(&count) 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) // Get last received price (from the main lot only)
@@ -777,7 +894,8 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) {
"current_price": comp.CurrentPrice, "current_price": comp.CurrentPrice,
"median_all_time": medianAllTime, "median_all_time": medianAllTime,
"new_price": newPrice, "new_price": newPrice,
"quote_count": quoteCount, "quote_count_total": quoteCountTotal,
"quote_count_period": quoteCountPeriod,
"manual_price": comp.ManualPrice, "manual_price": comp.ManualPrice,
"last_price": lastPrice.Price, "last_price": lastPrice.Price,
"last_price_date": lastPrice.Date, "last_price_date": lastPrice.Date,

View File

@@ -3,13 +3,17 @@ package handlers
import ( import (
"fmt" "fmt"
"html/template" "html/template"
"log/slog"
"net/http" "net/http"
"os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"time" "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/localdb"
"github.com/gin-gonic/gin"
"gorm.io/driver/mysql" "gorm.io/driver/mysql"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
@@ -17,11 +21,12 @@ import (
type SetupHandler struct { type SetupHandler struct {
localDB *localdb.LocalDB localDB *localdb.LocalDB
connMgr *db.ConnectionManager
templates map[string]*template.Template templates map[string]*template.Template
restartSig chan struct{} restartSig chan struct{}
} }
func NewSetupHandler(localDB *localdb.LocalDB, templatesPath string, restartSig chan struct{}) (*SetupHandler, error) { func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, templatesPath string, restartSig chan struct{}) (*SetupHandler, error) {
funcMap := template.FuncMap{ funcMap := template.FuncMap{
"sub": func(a, b int) int { return a - b }, "sub": func(a, b int) int { return a - b },
"add": func(a, b int) int { return a + b }, "add": func(a, b int) int { return a + b },
@@ -31,7 +36,13 @@ func NewSetupHandler(localDB *localdb.LocalDB, templatesPath string, restartSig
// Load setup template (standalone, no base needed) // Load setup template (standalone, no base needed)
setupPath := filepath.Join(templatesPath, "setup.html") 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 { if err != nil {
return nil, fmt.Errorf("parsing setup template: %w", err) return nil, fmt.Errorf("parsing setup template: %w", err)
} }
@@ -39,6 +50,7 @@ func NewSetupHandler(localDB *localdb.LocalDB, templatesPath string, restartSig
return &SetupHandler{ return &SetupHandler{
localDB: localDB, localDB: localDB,
connMgr: connMgr,
templates: templates, templates: templates,
restartSig: restartSig, restartSig: restartSig,
}, nil }, nil
@@ -181,12 +193,24 @@ func (h *SetupHandler) SaveConnection(c *gin.Context) {
return 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{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "Settings saved. Restarting application...", "message": "Settings saved.",
"restart_required": restartRequired,
}) })
// Signal restart after response is sent // Signal restart after response is sent (if restart signal is configured)
if h.restartSig != nil { if h.restartSig != nil {
go func() { go func() {
time.Sleep(500 * time.Millisecond) // Give time for response to be sent time.Sleep(500 * time.Millisecond) // Give time for response to be sent

View File

@@ -4,28 +4,36 @@ import (
"html/template" "html/template"
"log/slog" "log/slog"
"net/http" "net/http"
"os"
"path/filepath" "path/filepath"
"time" "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/localdb"
"git.mchus.pro/mchus/quoteforge/internal/services/sync" "git.mchus.pro/mchus/quoteforge/internal/services/sync"
"gorm.io/gorm" "github.com/gin-gonic/gin"
) )
// SyncHandler handles sync API endpoints // SyncHandler handles sync API endpoints
type SyncHandler struct { type SyncHandler struct {
localDB *localdb.LocalDB localDB *localdb.LocalDB
syncService *sync.Service syncService *sync.Service
mariaDB *gorm.DB connMgr *db.ConnectionManager
tmpl *template.Template tmpl *template.Template
} }
// NewSyncHandler creates a new sync handler // 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 // Load sync_status partial template
partialPath := filepath.Join(templatesPath, "partials", "sync_status.html") 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 { if err != nil {
return nil, err return nil, err
} }
@@ -33,7 +41,7 @@ func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, mariaDB
return &SyncHandler{ return &SyncHandler{
localDB: localDB, localDB: localDB,
syncService: syncService, syncService: syncService,
mariaDB: mariaDB, connMgr: connMgr,
tmpl: tmpl, tmpl: tmpl,
}, nil }, nil
} }
@@ -109,7 +117,17 @@ func (h *SyncHandler) SyncComponents(c *gin.Context) {
return 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 { if err != nil {
slog.Error("component sync failed", "error", err) slog.Error("component sync failed", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
@@ -181,7 +199,16 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
var componentsSynced, pricelistsSynced int var componentsSynced, pricelistsSynced int
// Sync components // 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 { if err != nil {
slog.Error("component sync failed during full sync", "error", err) slog.Error("component sync failed during full sync", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
@@ -215,16 +242,7 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
// checkOnline checks if MariaDB is accessible // checkOnline checks if MariaDB is accessible
func (h *SyncHandler) checkOnline() bool { func (h *SyncHandler) checkOnline() bool {
sqlDB, err := h.mariaDB.DB() return h.connMgr.IsOnline()
if err != nil {
return false
}
if err := sqlDB.Ping(); err != nil {
return false
}
return true
} }
// PushPendingChanges pushes all pending changes to the server // PushPendingChanges pushes all pending changes to the server

View File

@@ -2,12 +2,14 @@ package handlers
import ( import (
"html/template" "html/template"
"os"
"path/filepath" "path/filepath"
"strconv" "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/repository"
"git.mchus.pro/mchus/quoteforge/internal/services" "git.mchus.pro/mchus/quoteforge/internal/services"
"github.com/gin-gonic/gin"
) )
type WebHandler struct { type WebHandler struct {
@@ -59,12 +61,26 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
templates := make(map[string]*template.Template) templates := make(map[string]*template.Template)
basePath := filepath.Join(templatesPath, "base.html") 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 // Load each page template with base
simplePages := []string{"login.html", "configs.html", "admin_pricing.html", "pricelists.html", "pricelist_detail.html"} simplePages := []string{"login.html", "configs.html", "admin_pricing.html", "pricelists.html", "pricelist_detail.html"}
for _, page := range simplePages { for _, page := range simplePages {
pagePath := filepath.Join(templatesPath, page) 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 { if err != nil {
return nil, err return nil, err
} }
@@ -74,7 +90,18 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
// Index page needs components_list.html as well // Index page needs components_list.html as well
indexPath := filepath.Join(templatesPath, "index.html") indexPath := filepath.Join(templatesPath, "index.html")
componentsListPath := filepath.Join(templatesPath, "components_list.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 { if err != nil {
return nil, err return nil, err
} }
@@ -84,7 +111,16 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
partials := []string{"components_list.html"} partials := []string{"components_list.html"}
for _, partial := range partials { for _, partial := range partials {
partialPath := filepath.Join(templatesPath, partial) 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 { if err != nil {
return nil, err return nil, err
} }

View File

@@ -9,6 +9,13 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// ComponentFilter for searching with filters
type ComponentFilter struct {
Category string
Search string
HasPrice bool
}
// ComponentSyncResult contains statistics from component sync // ComponentSyncResult contains statistics from component sync
type ComponentSyncResult struct { type ComponentSyncResult struct {
TotalSynced int TotalSynced int
@@ -196,6 +203,44 @@ func (l *LocalDB) SearchLocalComponentsByCategory(category string, query string,
return components, err 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 // GetLocalComponent returns a single component by lot_name
func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) { func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) {
var component LocalComponent var component LocalComponent
@@ -266,3 +311,100 @@ func (l *LocalDB) NeedComponentSync(maxAgeHours int) bool {
} }
return time.Since(*syncTime).Hours() > float64(maxAgeHours) 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
}

View File

@@ -31,6 +31,11 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
SyncStatus: "pending", SyncStatus: "pending",
OriginalUserID: cfg.UserID, OriginalUserID: cfg.UserID,
OriginalUsername: cfg.OwnerUsername,
}
if local.OriginalUsername == "" && cfg.User != nil {
local.OriginalUsername = cfg.User.Username
} }
if cfg.ID > 0 { if cfg.ID > 0 {
@@ -55,6 +60,7 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
cfg := &models.Configuration{ cfg := &models.Configuration{
UUID: local.UUID, UUID: local.UUID,
UserID: local.OriginalUserID, UserID: local.OriginalUserID,
OwnerUsername: local.OriginalUsername,
Name: local.Name, Name: local.Name,
Items: items, Items: items,
TotalPrice: local.TotalPrice, TotalPrice: local.TotalPrice,

View File

@@ -132,7 +132,11 @@ func (l *LocalDB) GetDSN() (string, error) {
return "", err 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.User,
settings.PasswordEncrypted, // Contains decrypted password after GetSettings settings.PasswordEncrypted, // Contains decrypted password after GetSettings
settings.Host, settings.Host,

View File

@@ -75,6 +75,7 @@ type LocalConfiguration struct {
SyncedAt *time.Time `json:"synced_at"` SyncedAt *time.Time `json:"synced_at"`
SyncStatus string `gorm:"default:'local'" json:"sync_status"` // 'local', 'synced', 'modified' SyncStatus string `gorm:"default:'local'" json:"sync_status"` // 'local', 'synced', 'modified'
OriginalUserID uint `json:"original_user_id"` // UserID from MariaDB for reference 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 { func (LocalConfiguration) TableName() string {

View File

@@ -4,9 +4,9 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/gin-gonic/gin"
"git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/services" "git.mchus.pro/mchus/quoteforge/internal/services"
"github.com/gin-gonic/gin"
) )
const ( const (
@@ -99,3 +99,12 @@ func GetUserID(c *gin.Context) uint {
} }
return claims.UserID return claims.UserID
} }
// GetUsername extracts username from context
func GetUsername(c *gin.Context) string {
claims := GetClaims(c)
if claims == nil {
return ""
}
return claims.Username
}

View File

@@ -4,17 +4,17 @@ import (
"log/slog" "log/slog"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.mchus.pro/mchus/quoteforge/internal/db"
"git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/localdb"
"gorm.io/gorm"
) )
// OfflineDetector creates middleware that detects offline mode // OfflineDetector creates middleware that detects offline mode
// Sets context values: // Sets context values:
// - "is_offline" (bool) - true if MariaDB is unavailable // - "is_offline" (bool) - true if MariaDB is unavailable
// - "localdb" (*localdb.LocalDB) - local database instance for fallback // - "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) { return func(c *gin.Context) {
isOffline := !checkMariaDBOnline(mariaDB) isOffline := !connMgr.IsOnline()
// Set context values for handlers // Set context values for handlers
c.Set("is_offline", isOffline) c.Set("is_offline", isOffline)
@@ -27,17 +27,3 @@ func OfflineDetector(mariaDB *gorm.DB, local *localdb.LocalDB) gin.HandlerFunc {
c.Next() 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
}

View File

@@ -42,7 +42,8 @@ func (c ConfigItems) Total() float64 {
type Configuration struct { type Configuration struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"` ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"` UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
UserID uint `gorm:"not null" json:"user_id"` 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"` Name string `gorm:"size:200;not null" json:"name"`
Items ConfigItems `gorm:"type:json;not null" json:"items"` Items ConfigItems `gorm:"type:json;not null" json:"items"`
TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"` TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"`

View File

@@ -43,13 +43,16 @@ func (r *ConfigurationRepository) Delete(id uint) error {
return r.db.Delete(&models.Configuration{}, id).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 configs []models.Configuration
var total int64 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. err := r.db.
Where("user_id = ?", userID). Preload("User").
Where(ownerScope, ownerUsername, ownerUsername).
Order("created_at DESC"). Order("created_at DESC").
Offset(offset). Offset(offset).
Limit(limit). Limit(limit).
@@ -81,6 +84,7 @@ func (r *ConfigurationRepository) ListAll(offset, limit int) ([]models.Configura
r.db.Model(&models.Configuration{}).Count(&total) r.db.Model(&models.Configuration{}).Count(&total)
err := r.db. err := r.db.
Preload("User").
Order("created_at DESC"). Order("created_at DESC").
Offset(offset). Offset(offset).
Limit(limit). Limit(limit).

View File

@@ -29,6 +29,34 @@ func (r *PricelistRepository) List(offset, limit int) ([]models.PricelistSummary
return nil, 0, fmt.Errorf("listing pricelists: %w", err) 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 // Get item counts for each pricelist
summaries := make([]models.PricelistSummary, len(pricelists)) summaries := make([]models.PricelistSummary, len(pricelists))
for i, pl := range 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 // GetByID returns a pricelist by ID

View File

@@ -19,7 +19,7 @@ type DataSource interface {
// Configurations // Configurations
SaveConfiguration(cfg *models.Configuration) error SaveConfiguration(cfg *models.Configuration) error
GetConfigurations(userID uint) ([]models.Configuration, error) GetConfigurations(ownerUsername string) ([]models.Configuration, error)
GetConfigurationByUUID(uuid string) (*models.Configuration, error) GetConfigurationByUUID(uuid string) (*models.Configuration, error)
DeleteConfiguration(uuid string) error DeleteConfiguration(uuid string) error
@@ -169,6 +169,7 @@ func (r *UnifiedRepo) SaveConfiguration(cfg *models.Configuration) error {
CreatedAt: cfg.CreatedAt, CreatedAt: cfg.CreatedAt,
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
SyncStatus: "pending", SyncStatus: "pending",
OriginalUsername: cfg.OwnerUsername,
} }
// Convert items // Convert items
@@ -196,10 +197,10 @@ func (r *UnifiedRepo) SaveConfiguration(cfg *models.Configuration) error {
} }
// GetConfigurations returns all configurations for a user // 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 { if r.isOnline {
repo := NewConfigurationRepository(r.mariaDB) repo := NewConfigurationRepository(r.mariaDB)
configs, _, err := repo.ListByUser(userID, 0, 1000) configs, _, err := repo.ListByUser(ownerUsername, 0, 1000)
return configs, err return configs, err
} }
@@ -223,6 +224,7 @@ func (r *UnifiedRepo) GetConfigurations(userID uint) ([]models.Configuration, er
result[i] = models.Configuration{ result[i] = models.Configuration{
UUID: lc.UUID, UUID: lc.UUID,
OwnerUsername: lc.OriginalUsername,
Name: lc.Name, Name: lc.Name,
Items: items, Items: items,
TotalPrice: lc.TotalPrice, TotalPrice: lc.TotalPrice,

View File

@@ -1,6 +1,7 @@
package services package services
import ( import (
"fmt"
"strings" "strings"
"git.mchus.pro/mchus/quoteforge/internal/models" "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) { 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 { if page < 1 {
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) { 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) c, err := s.componentRepo.GetByLotName(lotName)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -135,11 +152,20 @@ func (s *ComponentService) GetByLotName(lotName string) (*ComponentView, error)
} }
func (s *ComponentService) GetCategories() ([]models.Category, 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() return s.categoryRepo.GetAll()
} }
// ImportFromLot creates metadata entries for lots that don't have them // ImportFromLot creates metadata entries for lots that don't have them
func (s *ComponentService) ImportFromLot() (int, error) { 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() lots, err := s.componentRepo.GetLotsWithoutMetadata()
if err != nil { if err != nil {
return 0, err return 0, err

View File

@@ -4,9 +4,9 @@ import (
"errors" "errors"
"time" "time"
"github.com/google/uuid"
"git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository" "git.mchus.pro/mchus/quoteforge/internal/repository"
"github.com/google/uuid"
) )
var ( var (
@@ -17,7 +17,7 @@ var (
// ConfigurationGetter is an interface for services that can retrieve configurations // ConfigurationGetter is an interface for services that can retrieve configurations
// Used by handlers to work with both ConfigurationService and LocalConfigurationService // Used by handlers to work with both ConfigurationService and LocalConfigurationService
type ConfigurationGetter interface { type ConfigurationGetter interface {
GetByUUID(uuid string, userID uint) (*models.Configuration, error) GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error)
} }
type ConfigurationService struct { type ConfigurationService struct {
@@ -47,7 +47,7 @@ type CreateConfigRequest struct {
ServerCount int `json:"server_count"` 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() total := req.Items.Total()
// If server count is greater than 1, multiply the total by server count // If server count is greater than 1, multiply the total by server count
@@ -57,7 +57,7 @@ func (s *ConfigurationService) Create(userID uint, req *CreateConfigRequest) (*m
config := &models.Configuration{ config := &models.Configuration{
UUID: uuid.New().String(), UUID: uuid.New().String(),
UserID: userID, OwnerUsername: ownerUsername,
Name: req.Name, Name: req.Name,
Items: req.Items, Items: req.Items,
TotalPrice: &total, TotalPrice: &total,
@@ -77,27 +77,27 @@ func (s *ConfigurationService) Create(userID uint, req *CreateConfigRequest) (*m
return config, nil 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) config, err := s.configRepo.GetByUUID(uuid)
if err != nil { if err != nil {
return nil, ErrConfigNotFound return nil, ErrConfigNotFound
} }
// Allow access if user owns config or it's a template // 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 nil, ErrConfigForbidden
} }
return config, nil 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) config, err := s.configRepo.GetByUUID(uuid)
if err != nil { if err != nil {
return nil, ErrConfigNotFound return nil, ErrConfigNotFound
} }
if config.UserID != userID { if !s.isOwner(config, ownerUsername) {
return nil, ErrConfigForbidden return nil, ErrConfigForbidden
} }
@@ -123,26 +123,26 @@ func (s *ConfigurationService) Update(uuid string, userID uint, req *CreateConfi
return config, nil 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) config, err := s.configRepo.GetByUUID(uuid)
if err != nil { if err != nil {
return ErrConfigNotFound return ErrConfigNotFound
} }
if config.UserID != userID { if !s.isOwner(config, ownerUsername) {
return ErrConfigForbidden return ErrConfigForbidden
} }
return s.configRepo.Delete(config.ID) 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) config, err := s.configRepo.GetByUUID(uuid)
if err != nil { if err != nil {
return nil, ErrConfigNotFound return nil, ErrConfigNotFound
} }
if config.UserID != userID { if !s.isOwner(config, ownerUsername) {
return nil, ErrConfigForbidden return nil, ErrConfigForbidden
} }
@@ -155,8 +155,8 @@ func (s *ConfigurationService) Rename(uuid string, userID uint, newName string)
return config, nil return config, nil
} }
func (s *ConfigurationService) Clone(configUUID string, userID uint, newName string) (*models.Configuration, error) { func (s *ConfigurationService) Clone(configUUID string, ownerUsername string, newName string) (*models.Configuration, error) {
original, err := s.GetByUUID(configUUID, userID) original, err := s.GetByUUID(configUUID, ownerUsername)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -171,7 +171,7 @@ func (s *ConfigurationService) Clone(configUUID string, userID uint, newName str
clone := &models.Configuration{ clone := &models.Configuration{
UUID: uuid.New().String(), UUID: uuid.New().String(),
UserID: userID, OwnerUsername: ownerUsername,
Name: newName, Name: newName,
Items: original.Items, Items: original.Items,
TotalPrice: &total, TotalPrice: &total,
@@ -188,7 +188,7 @@ func (s *ConfigurationService) Clone(configUUID string, userID uint, newName str
return clone, nil 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 { if page < 1 {
page = 1 page = 1
} }
@@ -197,7 +197,7 @@ func (s *ConfigurationService) ListByUser(userID uint, page, perPage int) ([]mod
} }
offset := (page - 1) * perPage 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) // 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 // 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) original, err := s.configRepo.GetByUUID(configUUID)
if err != nil { if err != nil {
return nil, ErrConfigNotFound return nil, ErrConfigNotFound
@@ -287,7 +287,7 @@ func (s *ConfigurationService) CloneNoAuth(configUUID string, newName string, us
clone := &models.Configuration{ clone := &models.Configuration{
UUID: uuid.New().String(), UUID: uuid.New().String(),
UserID: userID, // Use provided user ID OwnerUsername: ownerUsername,
Name: newName, Name: newName,
Items: original.Items, Items: original.Items,
TotalPrice: &total, TotalPrice: &total,
@@ -356,13 +356,13 @@ func (s *ConfigurationService) ListTemplates(page, perPage int) ([]models.Config
} }
// RefreshPrices updates all component prices in the configuration with current prices // 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) config, err := s.configRepo.GetByUUID(uuid)
if err != nil { if err != nil {
return nil, ErrConfigNotFound return nil, ErrConfigNotFound
} }
if config.UserID != userID { if !s.isOwner(config, ownerUsername) {
return nil, ErrConfigForbidden return nil, ErrConfigForbidden
} }
@@ -407,6 +407,19 @@ func (s *ConfigurationService) RefreshPrices(uuid string, userID uint) (*models.
return config, nil 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 // // Export configuration as JSON
// type ConfigExport struct { // type ConfigExport struct {
// Name string `json:"name"` // Name string `json:"name"`

View File

@@ -4,10 +4,10 @@ import (
"encoding/json" "encoding/json"
"time" "time"
"github.com/google/uuid"
"git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/services/sync" "git.mchus.pro/mchus/quoteforge/internal/services/sync"
"github.com/google/uuid"
) )
// LocalConfigurationService handles configurations in local-first mode // LocalConfigurationService handles configurations in local-first mode
@@ -35,7 +35,7 @@ func NewLocalConfigurationService(
} }
// Create creates a new configuration in local SQLite and queues it for sync // 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 online, check for new pricelists first
if s.isOnline() { if s.isOnline() {
if err := s.syncService.SyncPricelistsIfNeeded(); err != nil { if err := s.syncService.SyncPricelistsIfNeeded(); err != nil {
@@ -50,7 +50,7 @@ func (s *LocalConfigurationService) Create(userID uint, req *CreateConfigRequest
cfg := &models.Configuration{ cfg := &models.Configuration{
UUID: uuid.New().String(), UUID: uuid.New().String(),
UserID: userID, OwnerUsername: ownerUsername,
Name: req.Name, Name: req.Name,
Items: req.Items, Items: req.Items,
TotalPrice: &total, TotalPrice: &total,
@@ -85,7 +85,7 @@ func (s *LocalConfigurationService) Create(userID uint, req *CreateConfigRequest
} }
// GetByUUID returns a configuration from local SQLite // 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) localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil { if err != nil {
return nil, ErrConfigNotFound return nil, ErrConfigNotFound
@@ -95,7 +95,7 @@ func (s *LocalConfigurationService) GetByUUID(uuid string, userID uint) (*models
cfg := localdb.LocalToConfiguration(localCfg) cfg := localdb.LocalToConfiguration(localCfg)
// Allow access if user owns config or it's a template // 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 return nil, ErrConfigForbidden
} }
@@ -103,13 +103,13 @@ func (s *LocalConfigurationService) GetByUUID(uuid string, userID uint) (*models
} }
// Update updates a configuration in local SQLite and queues it for sync // 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) localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil { if err != nil {
return nil, ErrConfigNotFound return nil, ErrConfigNotFound
} }
if localCfg.OriginalUserID != userID { if !s.isOwner(localCfg, ownerUsername) {
return nil, ErrConfigForbidden return nil, ErrConfigForbidden
} }
@@ -155,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 // 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) localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil { if err != nil {
return ErrConfigNotFound return ErrConfigNotFound
} }
if localCfg.OriginalUserID != userID { if !s.isOwner(localCfg, ownerUsername) {
return ErrConfigForbidden return ErrConfigForbidden
} }
@@ -179,13 +179,13 @@ func (s *LocalConfigurationService) Delete(uuid string, userID uint) error {
} }
// Rename renames a configuration // 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) localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil { if err != nil {
return nil, ErrConfigNotFound return nil, ErrConfigNotFound
} }
if localCfg.OriginalUserID != userID { if !s.isOwner(localCfg, ownerUsername) {
return nil, ErrConfigForbidden return nil, ErrConfigForbidden
} }
@@ -211,8 +211,8 @@ func (s *LocalConfigurationService) Rename(uuid string, userID uint, newName str
} }
// Clone clones a configuration // Clone clones a configuration
func (s *LocalConfigurationService) Clone(configUUID string, userID uint, newName string) (*models.Configuration, error) { func (s *LocalConfigurationService) Clone(configUUID string, ownerUsername string, newName string) (*models.Configuration, error) {
original, err := s.GetByUUID(configUUID, userID) original, err := s.GetByUUID(configUUID, ownerUsername)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -224,7 +224,7 @@ func (s *LocalConfigurationService) Clone(configUUID string, userID uint, newNam
clone := &models.Configuration{ clone := &models.Configuration{
UUID: uuid.New().String(), UUID: uuid.New().String(),
UserID: userID, OwnerUsername: ownerUsername,
Name: newName, Name: newName,
Items: original.Items, Items: original.Items,
TotalPrice: &total, TotalPrice: &total,
@@ -253,7 +253,7 @@ func (s *LocalConfigurationService) Clone(configUUID string, userID uint, newNam
} }
// ListByUser returns all configurations for a user from local SQLite // 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 // Get all local configurations
localConfigs, err := s.localDB.GetConfigurations() localConfigs, err := s.localDB.GetConfigurations()
if err != nil { if err != nil {
@@ -263,7 +263,7 @@ func (s *LocalConfigurationService) ListByUser(userID uint, page, perPage int) (
// Filter by user // Filter by user
var userConfigs []models.Configuration var userConfigs []models.Configuration
for _, lc := range localConfigs { for _, lc := range localConfigs {
if lc.OriginalUserID == userID || lc.IsTemplate { if (lc.OriginalUsername == ownerUsername) || lc.IsTemplate {
userConfigs = append(userConfigs, *localdb.LocalToConfiguration(&lc)) userConfigs = append(userConfigs, *localdb.LocalToConfiguration(&lc))
} }
} }
@@ -292,7 +292,7 @@ func (s *LocalConfigurationService) ListByUser(userID uint, page, perPage int) (
} }
// RefreshPrices updates all component prices in the configuration from local cache // RefreshPrices updates all component prices in the configuration from local cache
func (s *LocalConfigurationService) RefreshPrices(uuid string, userID uint) (*models.Configuration, error) { func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername string) (*models.Configuration, error) {
// Get configuration from local SQLite // Get configuration from local SQLite
localCfg, err := s.localDB.GetConfigurationByUUID(uuid) localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
if err != nil { if err != nil {
@@ -300,7 +300,7 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, userID uint) (*mo
} }
// Check ownership // Check ownership
if localCfg.OriginalUserID != userID { if !s.isOwner(localCfg, ownerUsername) {
return nil, ErrConfigForbidden return nil, ErrConfigForbidden
} }
@@ -448,7 +448,7 @@ func (s *LocalConfigurationService) RenameNoAuth(uuid string, newName string) (*
} }
// CloneNoAuth clones configuration without ownership check // 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) original, err := s.GetByUUIDNoAuth(configUUID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -461,7 +461,7 @@ func (s *LocalConfigurationService) CloneNoAuth(configUUID string, newName strin
clone := &models.Configuration{ clone := &models.Configuration{
UUID: uuid.New().String(), UUID: uuid.New().String(),
UserID: userID, OwnerUsername: ownerUsername,
Name: newName, Name: newName,
Items: original.Items, Items: original.Items,
TotalPrice: &total, TotalPrice: &total,
@@ -621,3 +621,18 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
return cfg, nil 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
}

View File

@@ -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 // CreateFromCurrentPrices creates a new pricelist by taking a snapshot of current prices
func (s *Service) CreateFromCurrentPrices(createdBy string) (*models.Pricelist, error) { 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() version, err := s.repo.GenerateVersion()
if err != nil { if err != nil {
return nil, fmt.Errorf("generating version: %w", err) 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 // List returns pricelists with pagination
func (s *Service) List(page, perPage int) ([]models.PricelistSummary, int64, error) { 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 { if page < 1 {
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 // GetByID returns a pricelist by ID
func (s *Service) GetByID(id uint) (*models.Pricelist, error) { 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) return s.repo.GetByID(id)
} }
// GetItems returns pricelist items with pagination // GetItems returns pricelist items with pagination
func (s *Service) GetItems(pricelistID uint, page, perPage int, search string) ([]models.PricelistItem, int64, error) { 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 { if page < 1 {
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 // Delete deletes a pricelist by ID
func (s *Service) Delete(id uint) error { func (s *Service) Delete(id uint) error {
if s.repo == nil {
return fmt.Errorf("offline mode: cannot delete pricelists")
}
return s.repo.Delete(id) return s.repo.Delete(id)
} }
// CanWrite returns true if the user can create pricelists // CanWrite returns true if the user can create pricelists
func (s *Service) CanWrite() bool { func (s *Service) CanWrite() bool {
if s.repo == nil {
return false
}
return s.repo.CanWrite() return s.repo.CanWrite()
} }
// CanWriteDebug returns write permission status with debug info // CanWriteDebug returns write permission status with debug info
func (s *Service) CanWriteDebug() (bool, string) { func (s *Service) CanWriteDebug() (bool, string) {
if s.repo == nil {
return false, "offline mode"
}
return s.repo.CanWriteDebug() return s.repo.CanWriteDebug()
} }
// GetLatestActive returns the most recent active pricelist // GetLatestActive returns the most recent active pricelist
func (s *Service) GetLatestActive() (*models.Pricelist, error) { 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() return s.repo.GetLatestActive()
} }
// CleanupExpired deletes expired and unused pricelists // CleanupExpired deletes expired and unused pricelists
func (s *Service) CleanupExpired() (int, error) { func (s *Service) CleanupExpired() (int, error) {
if s.repo == nil {
return 0, fmt.Errorf("offline mode: cleanup not available")
}
expired, err := s.repo.GetExpiredUnused() expired, err := s.repo.GetExpiredUnused()
if err != nil { if err != nil {
return 0, err return 0, err

View File

@@ -68,7 +68,7 @@ func (s *Service) CalculatePrice(lotName string, method models.PriceMethod, peri
case models.PriceMethodAverage: case models.PriceMethodAverage:
return CalculateAverage(prices), nil return CalculateAverage(prices), nil
case models.PriceMethodWeightedMedian: case models.PriceMethodWeightedMedian:
return CalculateWeightedMedian(points, s.config.DefaultPeriodDays), nil return CalculateWeightedMedian(points, periodDays), nil
case models.PriceMethodMedian: case models.PriceMethodMedian:
fallthrough fallthrough
default: default:

View File

@@ -61,6 +61,9 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation
if len(req.Items) == 0 { if len(req.Items) == 0 {
return nil, ErrEmptyQuote return nil, ErrEmptyQuote
} }
if s.componentRepo == nil || s.pricingService == nil {
return nil, errors.New("offline mode: quote calculation not available")
}
result := &QuoteValidationResult{ result := &QuoteValidationResult{
Valid: true, Valid: true,
@@ -129,6 +132,11 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation
// RecordUsage records that components were used in a quote // RecordUsage records that components were used in a quote
func (s *QuoteService) RecordUsage(items []models.ConfigItem) error { 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 { for _, item := range items {
revenue := item.UnitPrice * float64(item.Quantity) revenue := item.UnitPrice * float64(item.Quantity)
if err := s.statsRepo.IncrementUsage(item.LotName, item.Quantity, revenue); err != nil { if err := s.statsRepo.IncrementUsage(item.LotName, item.Quantity, revenue); err != nil {

View File

@@ -2,27 +2,30 @@ package sync
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"time" "time"
"git.mchus.pro/mchus/quoteforge/internal/db"
"git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository" "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 // Service handles synchronization between MariaDB and local SQLite
type Service struct { type Service struct {
pricelistRepo *repository.PricelistRepository connMgr *db.ConnectionManager
configRepo *repository.ConfigurationRepository
localDB *localdb.LocalDB localDB *localdb.LocalDB
} }
// NewService creates a new sync service // 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{ return &Service{
pricelistRepo: pricelistRepo, connMgr: connMgr,
configRepo: configRepo,
localDB: localDB, localDB: localDB,
} }
} }
@@ -35,14 +38,86 @@ type SyncStatus struct {
NeedsSync bool `json:"needs_sync"` 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 // GetStatus returns the current sync status
func (s *Service) GetStatus() (*SyncStatus, error) { func (s *Service) GetStatus() (*SyncStatus, error) {
lastSync := s.localDB.GetLastSyncTime() lastSync := s.localDB.GetLastSyncTime()
// Count server pricelists // Count server pricelists (only if already connected, don't reconnect)
serverPricelists, _, err := s.pricelistRepo.List(0, 1) serverCount := 0
if err != nil { connStatus := s.connMgr.GetStatus()
return nil, fmt.Errorf("counting server pricelists: %w", err) 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 // Count local pricelists
@@ -52,7 +127,7 @@ func (s *Service) GetStatus() (*SyncStatus, error) {
return &SyncStatus{ return &SyncStatus{
LastSyncAt: lastSync, LastSyncAt: lastSync,
ServerPricelists: len(serverPricelists), ServerPricelists: serverCount,
LocalPricelists: int(localCount), LocalPricelists: int(localCount),
NeedsSync: needsSync, NeedsSync: needsSync,
}, nil }, nil
@@ -73,8 +148,21 @@ func (s *Service) NeedSync() (bool, error) {
return true, nil return true, nil
} }
// Check if there are new pricelists on server // Check if there are new pricelists on server (only if already connected)
latestServer, err := s.pricelistRepo.GetLatestActive() 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 err != nil {
// If no pricelists on server, no need to sync // If no pricelists on server, no need to sync
return false, nil return false, nil
@@ -98,18 +186,33 @@ func (s *Service) NeedSync() (bool, error) {
func (s *Service) SyncPricelists() (int, error) { func (s *Service) SyncPricelists() (int, error) {
slog.Info("starting pricelist sync") slog.Info("starting pricelist sync")
// Get all active pricelists from server (up to 100) // Get database connection
serverPricelists, _, err := s.pricelistRepo.List(0, 100) mariaDB, err := s.connMgr.GetDB()
if err != nil { 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 synced := 0
var latestLocalID uint
var latestServerID uint
for _, pl := range serverPricelists { for _, pl := range serverPricelists {
// Check if pricelist already exists locally // Check if pricelist already exists locally
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID) existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
if existing != nil { if existing != nil {
// Already synced, skip // Already synced, track latest by server ID
if pl.ID > latestServerID {
latestServerID = pl.ID
latestLocalID = existing.ID
}
continue continue
} }
@@ -128,8 +231,30 @@ func (s *Service) SyncPricelists() (int, error) {
continue 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++ 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 // Update last sync time
@@ -154,8 +279,17 @@ func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
return int(existingCount), nil 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 // Get items from server
serverItems, _, err := s.pricelistRepo.GetItems(localPL.ServerID, 0, 10000, "") serverItems, _, err := pricelistRepo.GetItems(localPL.ServerID, 0, 10000, "")
if err != nil { if err != nil {
return 0, fmt.Errorf("getting server pricelist items: %w", err) 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) 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 // 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) return fmt.Errorf("creating configuration on server: %w", err)
} }
@@ -337,6 +480,15 @@ func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
return fmt.Errorf("unmarshaling configuration: %w", err) 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 // Ensure we have a server ID before updating
// If the payload doesn't have ID, get it from local configuration // If the payload doesn't have ID, get it from local configuration
if cfg.ID == 0 { if cfg.ID == 0 {
@@ -347,7 +499,7 @@ func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
if localCfg.ServerID == nil { if localCfg.ServerID == nil {
// Configuration hasn't been synced yet, try to find it on server by UUID // Configuration hasn't been synced yet, try to find it on server by UUID
serverCfg, err := s.configRepo.GetByUUID(cfg.UUID) serverCfg, err := configRepo.GetByUUID(cfg.UUID)
if err != nil { if err != nil {
return fmt.Errorf("configuration not yet synced to server: %w", err) return fmt.Errorf("configuration not yet synced to server: %w", err)
} }
@@ -363,7 +515,7 @@ func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
} }
// Update on server // 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) return fmt.Errorf("updating configuration on server: %w", err)
} }
@@ -380,8 +532,17 @@ func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
// pushConfigurationDelete deletes a configuration from the server // pushConfigurationDelete deletes a configuration from the server
func (s *Service) pushConfigurationDelete(change *localdb.PendingChange) error { 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 // 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 { if err != nil {
// Already deleted or not found, consider it successful // Already deleted or not found, consider it successful
slog.Warn("configuration not found on server, considering delete successful", "uuid", change.EntityUUID) slog.Warn("configuration not found on server, considering delete successful", "uuid", change.EntityUUID)
@@ -389,7 +550,7 @@ func (s *Service) pushConfigurationDelete(change *localdb.PendingChange) error {
} }
// Delete from server // 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) return fmt.Errorf("deleting configuration from server: %w", err)
} }

View File

@@ -5,23 +5,23 @@ import (
"log/slog" "log/slog"
"time" "time"
"gorm.io/gorm" "git.mchus.pro/mchus/quoteforge/internal/db"
) )
// Worker performs background synchronization at regular intervals // Worker performs background synchronization at regular intervals
type Worker struct { type Worker struct {
service *Service service *Service
db *gorm.DB connMgr *db.ConnectionManager
interval time.Duration interval time.Duration
logger *slog.Logger logger *slog.Logger
stopCh chan struct{} stopCh chan struct{}
} }
// NewWorker creates a new background sync worker // 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{ return &Worker{
service: service, service: service,
db: db, connMgr: connMgr,
interval: interval, interval: interval,
logger: slog.Default(), logger: slog.Default(),
stopCh: make(chan struct{}), 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 // isOnline checks if the database connection is available
func (w *Worker) isOnline() bool { func (w *Worker) isOnline() bool {
sqlDB, err := w.db.DB() return w.connMgr.IsOnline()
if err != nil {
return false
}
return sqlDB.Ping() == nil
} }
// Start begins the background sync loop in a goroutine // Start begins the background sync loop in a goroutine

View 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
View 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}"

View File

@@ -591,8 +591,21 @@ async function fetchPreview() {
document.getElementById('modal-new-price').textContent = document.getElementById('modal-new-price').textContent =
data.new_price ? '$' + parseFloat(data.new_price).toFixed(2) : '—'; data.new_price ? '$' + parseFloat(data.new_price).toFixed(2) : '—';
// Update quote count // Update quote count with new format "N (всего: M)"
document.getElementById('modal-quote-count').textContent = data.quote_count || 0; 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) { } catch(e) {
console.error('Preview fetch error:', e); console.error('Preview fetch error:', e);
@@ -848,7 +861,7 @@ function renderAllConfigs(configs) {
const date = new Date(c.created_at).toLocaleDateString('ru-RU'); const date = new Date(c.created_at).toLocaleDateString('ru-RU');
const total = c.total_price ? '$' + c.total_price.toLocaleString('en-US', {minimumFractionDigits: 2}) : '—'; const total = c.total_price ? '$' + c.total_price.toLocaleString('en-US', {minimumFractionDigits: 2}) : '—';
const serverCount = c.server_count ? c.server_count : 1; 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 += '<tr class="hover:bg-gray-50">';
html += '<td class="px-3 py-2 text-sm text-gray-500">' + date + '</td>'; html += '<td class="px-3 py-2 text-sm text-gray-500">' + date + '</td>';
@@ -1019,7 +1032,23 @@ function closePricelistsCreateModal() {
document.getElementById('pricelists-create-modal').classList.remove('flex'); 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() { async function createPricelist() {
// Check if online before creating
const isOnline = await checkOnlineStatus();
if (!isOnline) {
throw new Error('Создание прайслистов доступно только в онлайн режиме');
}
const resp = await fetch('/api/pricelists', { const resp = await fetch('/api/pricelists', {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -1037,6 +1066,13 @@ async function createPricelist() {
} }
async function deletePricelist(id) { async function deletePricelist(id) {
// Check if online before deleting
const isOnline = await checkOnlineStatus();
if (!isOnline) {
showToast('Удаление прайслистов доступно только в онлайн режиме', 'error');
return;
}
if (!confirm('Удалить этот прайслист?')) return; if (!confirm('Удалить этот прайслист?')) return;
try { try {

View File

@@ -20,7 +20,7 @@
<a href="/" class="text-xl font-bold text-blue-600">QuoteForge</a> <a href="/" class="text-xl font-bold text-blue-600">QuoteForge</a>
<div class="hidden md:flex space-x-4"> <div class="hidden md:flex space-x-4">
<a href="/configurator" 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> <a href="/setup" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Настройки</a>
</div> </div>
</div> </div>
@@ -237,17 +237,11 @@
} }
} }
// Admin pricing link is now always visible
// Write permission is checked at operation time (create/delete)
async function checkWritePermission() { async function checkWritePermission() {
try { // No longer needed - link always visible in offline-first mode
const resp = await fetch('/api/pricelists/can-write'); // Operations will check online status when executed
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);
}
} }
// Load last sync time for dropdown (removed since dropdown is gone) // Load last sync time for dropdown (removed since dropdown is gone)

View File

@@ -4,10 +4,13 @@
<div class="space-y-4"> <div class="space-y-4">
<h1 class="text-2xl font-bold">Мои конфигурации</h1> <h1 class="text-2xl font-bold">Мои конфигурации</h1>
<div class="mt-4"> <div class="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3">
<button onclick="openCreateModal()" class="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"> <button onclick="openCreateModal()" class="py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
+ Создать новую конфигурацию + Создать новую конфигурацию
</button> </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>
<div id="pricelist-badge" class="mt-4 text-sm text-gray-600 hidden"> <div id="pricelist-badge" class="mt-4 text-sm text-gray-600 hidden">
@@ -124,6 +127,7 @@ function renderConfigs(configs) {
html += '<thead class="bg-gray-50"><tr>'; 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">Название</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">Цена (за 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-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>'; html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Сумма</th>';
@@ -134,6 +138,7 @@ function renderConfigs(configs) {
const date = new Date(c.created_at).toLocaleDateString('ru-RU'); const date = new Date(c.created_at).toLocaleDateString('ru-RU');
const total = c.total_price ? '$' + c.total_price.toLocaleString('en-US', {minimumFractionDigits: 2}) : '—'; const total = c.total_price ? '$' + c.total_price.toLocaleString('en-US', {minimumFractionDigits: 2}) : '—';
const serverCount = c.server_count ? c.server_count : 1; 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) // Calculate price per unit (total / server count)
let pricePerUnit = '—'; let pricePerUnit = '—';
@@ -145,6 +150,7 @@ function renderConfigs(configs) {
html += '<tr class="hover:bg-gray-50">'; 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 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 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">' + 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-gray-500">' + serverCount + '</td>';
html += '<td class="px-4 py-3 text-sm text-right">' + total + '</td>'; html += '<td class="px-4 py-3 text-sm text-right">' + total + '</td>';
@@ -407,6 +413,38 @@ async function 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() { document.addEventListener('DOMContentLoaded', function() {
loadConfigs(); loadConfigs();
@@ -421,18 +459,23 @@ async function loadLatestPricelistVersion() {
const pricelist = await resp.json(); const pricelist = await resp.json();
document.getElementById('pricelist-version').textContent = pricelist.version; document.getElementById('pricelist-version').textContent = pricelist.version;
document.getElementById('pricelist-badge').classList.remove('hidden'); 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 { } else {
// Show error in badge // Real error
document.getElementById('pricelist-version').textContent = 'Ошибка загрузки'; document.getElementById('pricelist-version').textContent = 'Ошибка загрузки';
document.getElementById('pricelist-badge').classList.remove('hidden'); document.getElementById('pricelist-badge').classList.remove('hidden');
document.getElementById('pricelist-badge').classList.add('bg-red-100', 'text-red-800'); document.getElementById('pricelist-badge').classList.add('bg-red-100', 'text-red-800');
} }
} catch(e) { } catch(e) {
// Show error in badge // Network error or other exception
console.error('Failed to load pricelist version:', e); console.error('Failed to load pricelist version:', e);
document.getElementById('pricelist-version').textContent = 'Ошибка загрузки'; document.getElementById('pricelist-version').textContent = 'Не доступен';
document.getElementById('pricelist-badge').classList.remove('hidden'); document.getElementById('pricelist-badge').classList.remove('hidden');
document.getElementById('pricelist-badge').classList.add('bg-red-100', 'text-red-800'); document.getElementById('pricelist-badge').classList.add('bg-gray-100', 'text-gray-600');
} }
} }
</script> </script>

View File

@@ -87,12 +87,14 @@
<script> <script>
function showStatus(message, type) { function showStatus(message, type) {
const status = document.getElementById('status'); 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') { if (type === 'success') {
status.classList.add('bg-green-100', 'text-green-800'); status.classList.add('bg-green-100', 'text-green-800');
} else if (type === 'error') { } else if (type === 'error') {
status.classList.add('bg-red-100', 'text-red-800'); status.classList.add('bg-red-100', 'text-red-800');
} else if (type === 'warning') {
status.classList.add('bg-yellow-100', 'text-yellow-800');
} else { } else {
status.classList.add('bg-blue-100', 'text-blue-800'); status.classList.add('bg-blue-100', 'text-blue-800');
} }
@@ -156,6 +158,16 @@
}, 1000); // Check every second }, 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) { document.getElementById('setup-form').addEventListener('submit', async function(e) {
e.preventDefault(); e.preventDefault();
showStatus('Сохранение настроек...', 'info'); showStatus('Сохранение настроек...', 'info');
@@ -171,12 +183,20 @@
if (data.success) { if (data.success) {
showStatus('✓ ' + data.message, 'success'); showStatus('✓ ' + data.message, 'success');
// Wait for restart and redirect to home
// Check if restart is required
if (data.restart_required) {
setTimeout(() => {
requestRestartAndWait();
}, 2000);
} else {
// In setup mode, auto-restart is happening
setTimeout(() => { setTimeout(() => {
showStatus('✓ Настройки сохранены. Проверка подключения...', 'success'); showStatus('✓ Настройки сохранены. Проверка подключения...', 'success');
// Poll until server is back // Poll until server is back
checkServerReady(); checkServerReady();
}, 2000); }, 2000);
}
} else { } else {
showStatus(data.error, 'error'); showStatus(data.error, 'error');
} }