29 Commits

Author SHA1 Message Date
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
Mikhail Chusavitin
0bde12a39d fix: display only real sync errors in error count and list
- Added CountErroredChanges() method to count only pending changes with LastError
- Previously, error count included all pending changes, not just failed ones
- Added /api/sync/info endpoint with proper error count and error list
- Added sync info modal to display sync status, error count, and error details
- Made sync status indicators clickable to open the modal
- Fixed disconnect between "Error count: 4" and "No errors" in the list

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 17:19:52 +03:00
Mikhail Chusavitin
e0404186ad fix: remove duplicate showToast declaration causing JavaScript error
Root cause: admin_pricing.html declared 'const showToast' while base.html
already defined 'function showToast', causing SyntaxError that prevented
all JavaScript from executing on the admin pricing page.

Changes:
- Removed duplicate showToast declaration from admin_pricing.html (lines 206-210)
- Removed debug logging added in previous commit
- Kept immediate function calls in base.html to ensure early initialization

This fixes the issue where username and "Администратор цен" link
disappeared when navigating to /admin/pricing.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 14:54:13 +03:00
Mikhail Chusavitin
eda0e7cb47 debug: add logging to diagnose admin pricing page issue
- Added immediate calls to checkDbStatus() and checkWritePermission() in base.html
- Calls happen right after function definitions, before DOMContentLoaded
- Added console.log statements to track function execution and API responses
- Removed duplicate calls from admin_pricing.html to avoid conflicts
- This will help diagnose why username and admin link disappear on admin pricing page

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 14:51:38 +03:00
35 changed files with 2117 additions and 387 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()

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,63 @@ 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 {
// Use defaults if config file doesn't exist if errors.Is(err, fs.ErrNotExist) {
slog.Info("config file not found, using defaults", "path", *configPath) // Use defaults if config file doesn't exist
cfg = &config.Config{} slog.Info("config file not found, using defaults", "path", resolvedConfigPath)
cfg = &config.Config{}
} else {
slog.Error("failed to load config", "path", resolvedConfigPath, "error", err)
os.Exit(1)
}
} }
setConfigDefaults(cfg) 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()
dbUserID := uint(1)
// 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")
// Ensure DB user exists and get their ID
if dbUserID, err = models.EnsureDBUser(mariaDB, dbUser); err != nil {
slog.Error("failed to ensure DB user", "error", err)
// Continue with default ID
dbUserID = uint(1)
}
} }
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, "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 +171,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, dbUserID)
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 +199,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 +234,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 +272,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 +284,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 +308,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 +323,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 +400,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, dbUserID uint) (*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 +492,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,25 +510,43 @@ func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUser
}) })
}) })
// Restart endpoint (for development purposes)
router.POST("/api/restart", func(c *gin.Context) {
// This will cause the server to restart by exiting
// The restartProcess function will be called to restart the process
slog.Info("Restart requested via API")
go func() {
time.Sleep(100 * time.Millisecond)
restartProcess()
}()
c.JSON(http.StatusOK, gin.H{"message": "restarting..."})
})
// DB status endpoint // 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_log").Count(&lotLogCount)
db.Table("qt_lot_metadata").Count(&metadataCount)
}
} else {
// Not connected - don't try to reconnect on status check
// This prevents 3s timeout on every request
dbError = "Database not connected (offline mode)"
if status.LastError != "" {
dbError = status.LastError
}
} }
db.Table("lot").Count(&lotCount)
db.Table("lot_log").Count(&lotLogCount)
db.Table("qt_lot_metadata").Count(&metadataCount)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"connected": dbOK, "connected": dbOK,
"error": dbError, "error": dbError,
@@ -625,6 +773,7 @@ func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUser
syncAPI := api.Group("/sync") syncAPI := api.Group("/sync")
{ {
syncAPI.GET("/status", syncHandler.GetStatus) syncAPI.GET("/status", syncHandler.GetStatus)
syncAPI.GET("/info", syncHandler.GetInfo)
syncAPI.POST("/components", syncHandler.SyncComponents) syncAPI.POST("/components", syncHandler.SyncComponents)
syncAPI.POST("/pricelists", syncHandler.SyncPricelists) syncAPI.POST("/pricelists", syncHandler.SyncPricelists)
syncAPI.POST("/all", syncHandler.SyncAll) syncAPI.POST("/all", syncHandler.SyncAll)
@@ -657,6 +806,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

328
internal/db/connection.go Normal file
View File

@@ -0,0 +1,328 @@
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 {
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
// Does not attempt to reconnect, only checks current state with caching
func (cm *ConnectionManager) IsOnline() bool {
cm.mu.RLock()
if cm.db == nil {
cm.mu.RUnlock()
return false
}
// If we've checked recently, return cached result
if time.Since(cm.lastCheck) < cm.pingInterval {
cm.mu.RUnlock()
return true
}
cm.mu.RUnlock()
// 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

@@ -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)
@@ -773,14 +890,15 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) {
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"lot_name": req.LotName, "lot_name": req.LotName,
"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,
"manual_price": comp.ManualPrice, "quote_count_period": quoteCountPeriod,
"last_price": lastPrice.Price, "manual_price": comp.ManualPrice,
"last_price_date": lastPrice.Date, "last_price": lastPrice.Price,
"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,21 +41,21 @@ 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
} }
// SyncStatusResponse represents the sync status // SyncStatusResponse represents the sync status
type SyncStatusResponse struct { type SyncStatusResponse struct {
LastComponentSync *time.Time `json:"last_component_sync"` LastComponentSync *time.Time `json:"last_component_sync"`
LastPricelistSync *time.Time `json:"last_pricelist_sync"` LastPricelistSync *time.Time `json:"last_pricelist_sync"`
IsOnline bool `json:"is_online"` IsOnline bool `json:"is_online"`
ComponentsCount int64 `json:"components_count"` ComponentsCount int64 `json:"components_count"`
PricelistsCount int64 `json:"pricelists_count"` PricelistsCount int64 `json:"pricelists_count"`
ServerPricelists int `json:"server_pricelists"` ServerPricelists int `json:"server_pricelists"`
NeedComponentSync bool `json:"need_component_sync"` NeedComponentSync bool `json:"need_component_sync"`
NeedPricelistSync bool `json:"need_pricelist_sync"` NeedPricelistSync bool `json:"need_pricelist_sync"`
} }
// GetStatus returns current sync status // GetStatus returns current sync status
@@ -79,14 +87,14 @@ func (h *SyncHandler) GetStatus(c *gin.Context) {
needComponentSync := h.localDB.NeedComponentSync(24) needComponentSync := h.localDB.NeedComponentSync(24)
c.JSON(http.StatusOK, SyncStatusResponse{ c.JSON(http.StatusOK, SyncStatusResponse{
LastComponentSync: lastComponentSync, LastComponentSync: lastComponentSync,
LastPricelistSync: lastPricelistSync, LastPricelistSync: lastPricelistSync,
IsOnline: isOnline, IsOnline: isOnline,
ComponentsCount: componentsCount, ComponentsCount: componentsCount,
PricelistsCount: pricelistsCount, PricelistsCount: pricelistsCount,
ServerPricelists: serverPricelists, ServerPricelists: serverPricelists,
NeedComponentSync: needComponentSync, NeedComponentSync: needComponentSync,
NeedPricelistSync: needPricelistSync, NeedPricelistSync: needPricelistSync,
}) })
} }
@@ -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{
@@ -159,11 +177,11 @@ func (h *SyncHandler) SyncPricelists(c *gin.Context) {
// SyncAllResponse represents result of full sync // SyncAllResponse represents result of full sync
type SyncAllResponse struct { type SyncAllResponse struct {
Success bool `json:"success"` Success bool `json:"success"`
Message string `json:"message"` Message string `json:"message"`
ComponentsSynced int `json:"components_synced"` ComponentsSynced int `json:"components_synced"`
PricelistsSynced int `json:"pricelists_synced"` PricelistsSynced int `json:"pricelists_synced"`
Duration string `json:"duration"` Duration string `json:"duration"`
} }
// SyncAll syncs both components and pricelists // SyncAll syncs both components and pricelists
@@ -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{
@@ -197,8 +224,8 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
if err != nil { if err != nil {
slog.Error("pricelist sync failed during full sync", "error", err) slog.Error("pricelist sync failed during full sync", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"success": false, "success": false,
"error": "Pricelist sync failed: " + err.Error(), "error": "Pricelist sync failed: " + err.Error(),
"components_synced": componentsSynced, "components_synced": componentsSynced,
}) })
return return
@@ -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
@@ -282,6 +300,70 @@ func (h *SyncHandler) GetPendingChanges(c *gin.Context) {
}) })
} }
// SyncInfoResponse represents sync information
type SyncInfoResponse struct {
LastSyncAt *time.Time `json:"last_sync_at"`
IsOnline bool `json:"is_online"`
ErrorCount int `json:"error_count"`
Errors []SyncError `json:"errors,omitempty"`
}
// SyncError represents a sync error
type SyncError struct {
Timestamp time.Time `json:"timestamp"`
Message string `json:"message"`
}
// GetInfo returns sync information for modal
// GET /api/sync/info
func (h *SyncHandler) GetInfo(c *gin.Context) {
// Check online status by pinging MariaDB
isOnline := h.checkOnline()
// Get sync times
lastPricelistSync := h.localDB.GetLastSyncTime()
// Get error count (only changes with LastError != "")
errorCount := int(h.localDB.CountErroredChanges())
// Get recent errors (last 10)
changes, err := h.localDB.GetPendingChanges()
if err != nil {
slog.Error("failed to get pending changes for sync info", "error", err)
// Even if we can't get changes, we can still return the error count
c.JSON(http.StatusOK, SyncInfoResponse{
LastSyncAt: lastPricelistSync,
IsOnline: isOnline,
ErrorCount: errorCount,
Errors: []SyncError{}, // Return empty errors list
})
return
}
var errors []SyncError
for _, change := range changes {
// Check if there's a last error and it's not empty
if change.LastError != "" {
errors = append(errors, SyncError{
Timestamp: change.CreatedAt,
Message: change.LastError,
})
}
}
// Limit to last 10 errors
if len(errors) > 10 {
errors = errors[:10]
}
c.JSON(http.StatusOK, SyncInfoResponse{
LastSyncAt: lastPricelistSync,
IsOnline: isOnline,
ErrorCount: errorCount,
Errors: errors,
})
}
// SyncStatusPartial renders the sync status partial for htmx // SyncStatusPartial renders the sync status partial for htmx
// GET /partials/sync-status // GET /partials/sync-status
func (h *SyncHandler) SyncStatusPartial(c *gin.Context) { func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {

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

@@ -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,
@@ -396,6 +400,13 @@ func (l *LocalDB) CountPendingChangesByType(entityType string) int64 {
return count return count
} }
// CountErroredChanges returns the number of pending changes with errors
func (l *LocalDB) CountErroredChanges() int64 {
var count int64
l.db.Model(&PendingChange{}).Where("last_error != ?", "").Count(&count)
return count
}
// MarkChangesSynced marks multiple pending changes as synced by deleting them // MarkChangesSynced marks multiple pending changes as synced by deleting them
func (l *LocalDB) MarkChangesSynced(ids []int64) error { func (l *LocalDB) MarkChangesSynced(ids []int64) error {
if len(ids) == 0 { if len(ids) == 0 {

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

@@ -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

@@ -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

@@ -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:
@@ -149,17 +149,17 @@ func (s *Service) GetPriceStats(lotName string, periodDays int) (*PriceStats, er
} }
return &PriceStats{ return &PriceStats{
QuoteCount: len(points), QuoteCount: len(points),
MinPrice: CalculatePercentile(prices, 0), MinPrice: CalculatePercentile(prices, 0),
MaxPrice: CalculatePercentile(prices, 100), MaxPrice: CalculatePercentile(prices, 100),
MedianPrice: CalculateMedian(prices), MedianPrice: CalculateMedian(prices),
AveragePrice: CalculateAverage(prices), AveragePrice: CalculateAverage(prices),
StdDeviation: CalculateStdDev(prices), StdDeviation: CalculateStdDev(prices),
LatestPrice: points[0].Price, LatestPrice: points[0].Price,
LatestDate: points[0].Date, LatestDate: points[0].Date,
OldestDate: points[len(points)-1].Date, OldestDate: points[len(points)-1].Date,
Percentile25: CalculatePercentile(prices, 25), Percentile25: CalculatePercentile(prices, 25),
Percentile75: CalculatePercentile(prices, 75), Percentile75: CalculatePercentile(prices, 75),
}, nil }, nil
} }

View File

@@ -9,14 +9,14 @@ import (
) )
var ( var (
ErrEmptyQuote = errors.New("quote cannot be empty") ErrEmptyQuote = errors.New("quote cannot be empty")
ErrComponentNotFound = errors.New("component not found") ErrComponentNotFound = errors.New("component not found")
ErrNoPriceAvailable = errors.New("no price available for component") ErrNoPriceAvailable = errors.New("no price available for component")
) )
type QuoteService struct { type QuoteService struct {
componentRepo *repository.ComponentRepository componentRepo *repository.ComponentRepository
statsRepo *repository.StatsRepository statsRepo *repository.StatsRepository
pricingService *pricing.Service pricingService *pricing.Service
} }
@@ -43,11 +43,11 @@ type QuoteItem struct {
} }
type QuoteValidationResult struct { type QuoteValidationResult struct {
Valid bool `json:"valid"` Valid bool `json:"valid"`
Items []QuoteItem `json:"items"` Items []QuoteItem `json:"items"`
Errors []string `json:"errors"` Errors []string `json:"errors"`
Warnings []string `json:"warnings"` Warnings []string `json:"warnings"`
Total float64 `json:"total"` Total float64 `json:"total"`
} }
type QuoteRequest struct { type QuoteRequest struct {
@@ -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

@@ -6,6 +6,7 @@ import (
"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"
@@ -13,17 +14,15 @@ import (
// 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,
} }
} }
@@ -39,10 +38,17 @@ type SyncStatus struct {
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 +58,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 +79,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 +117,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 +162,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 +210,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 +377,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 +411,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 +430,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 +446,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 +463,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 +481,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

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

@@ -203,12 +203,6 @@
</div> </div>
<script> <script>
// Fallback showToast function for cases where base.html isn't loaded properly
const showToast = window.showToast || function(msg, type) {
// Simple fallback: just alert the message
alert(`${type ? type + ': ' : ''}${msg}`);
};
let currentTab = 'alerts'; let currentTab = 'alerts';
let currentPage = 1; let currentPage = 1;
let totalPages = 1; let totalPages = 1;
@@ -597,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);
@@ -891,9 +898,6 @@ document.addEventListener('DOMContentLoaded', () => {
const initialTab = urlParams.get('tab') || 'alerts'; const initialTab = urlParams.get('tab') || 'alerts';
loadTab(initialTab); loadTab(initialTab);
// Check write permission for admin pricing link
checkWritePermission();
// Add event listeners for preview updates // Add event listeners for preview updates
document.getElementById('modal-period').addEventListener('change', fetchPreview); document.getElementById('modal-period').addEventListener('change', fetchPreview);
document.getElementById('modal-coefficient').addEventListener('input', debounceFetchPreview); document.getElementById('modal-coefficient').addEventListener('input', debounceFetchPreview);
@@ -1028,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: {
@@ -1046,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>
@@ -44,6 +44,52 @@
<div id="toast" class="fixed bottom-4 right-4 z-50"></div> <div id="toast" class="fixed bottom-4 right-4 z-50"></div>
<!-- Sync Info Modal -->
<div id="sync-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
<div class="bg-white rounded-lg shadow-xl max-w-md w-full mx-4">
<div class="p-6">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-medium text-gray-900">Информация о синхронизации</h3>
<button onclick="closeSyncModal()" class="text-gray-400 hover:text-gray-500">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="space-y-4">
<div>
<h4 class="font-medium text-gray-900">Статус БД</h4>
<p id="modal-db-status" class="text-sm text-gray-600">Проверка...</p>
</div>
<div>
<h4 class="font-medium text-gray-900">Количество ошибок</h4>
<p id="modal-error-count" class="text-sm text-gray-600">0</p>
</div>
<div>
<h4 class="font-medium text-gray-900">Последняя синхронизация</h4>
<p id="modal-last-sync" class="text-sm text-gray-600">-</p>
</div>
<div>
<h4 class="font-medium text-gray-900">Список ошибок</h4>
<div id="modal-errors-list" class="mt-2 text-sm text-gray-600 max-h-40 overflow-y-auto">
<p>Нет ошибок</p>
</div>
</div>
</div>
<div class="mt-6 flex justify-end">
<button onclick="closeSyncModal()" class="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700">
Закрыть
</button>
</div>
</div>
</div>
</div>
<footer class="fixed bottom-0 left-0 right-0 bg-gray-800 text-gray-300 text-xs py-1 px-4"> <footer class="fixed bottom-0 left-0 right-0 bg-gray-800 text-gray-300 text-xs py-1 px-4">
<div class="max-w-7xl mx-auto flex justify-between"> <div class="max-w-7xl mx-auto flex justify-between">
<span id="db-status">БД: проверка...</span> <span id="db-status">БД: проверка...</span>
@@ -59,68 +105,78 @@
setTimeout(() => el.innerHTML = '', 3000); setTimeout(() => el.innerHTML = '', 3000);
} }
// Open sync modal
function openSyncModal() {
const modal = document.getElementById('sync-modal');
if (modal) {
modal.classList.remove('hidden');
// Load sync info when modal opens
loadSyncInfo();
}
}
// Close sync modal
function closeSyncModal() {
const modal = document.getElementById('sync-modal');
if (modal) {
modal.classList.add('hidden');
}
}
// Load sync info for modal
async function loadSyncInfo() {
try {
const resp = await fetch('/api/sync/info');
const data = await resp.json();
document.getElementById('modal-db-status').textContent = data.is_online ? 'Подключено' : 'Отключено';
document.getElementById('modal-error-count').textContent = data.error_count;
if (data.last_sync_at) {
const date = new Date(data.last_sync_at);
document.getElementById('modal-last-sync').textContent = date.toLocaleString('ru-RU');
} else {
document.getElementById('modal-last-sync').textContent = 'Нет данных';
}
// Load error list
const errorsList = document.getElementById('modal-errors-list');
if (data.errors && data.errors.length > 0) {
errorsList.innerHTML = data.errors.map(error =>
`<div class="mb-1"><span class="font-medium">${error.timestamp}</span>: ${error.message}</div>`
).join('');
} else {
errorsList.innerHTML = '<p>Нет ошибок</p>';
}
} catch(e) {
console.error('Failed to load sync info:', e);
document.getElementById('modal-db-status').textContent = 'Ошибка загрузки';
document.getElementById('modal-error-count').textContent = '0';
document.getElementById('modal-last-sync').textContent = '-';
document.getElementById('modal-errors-list').innerHTML = '<p>Ошибка загрузки данных</p>';
}
}
// Event delegation for sync dropdown and actions // Event delegation for sync dropdown and actions
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
checkDbStatus(); checkDbStatus();
checkWritePermission(); checkWritePermission();
}); });
// Handle keyboard navigation for dropdown // Event delegation for sync actions
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
const dropdownMenu = document.getElementById('sync-dropdown-menu');
if (dropdownMenu) {
dropdownMenu.classList.add('hidden');
}
}
});
// Event delegation for all sync actions
document.body.addEventListener('click', function(e) { document.body.addEventListener('click', function(e) {
// Handle dropdown toggle // Handle sync button click (full sync only)
const dropdownButton = e.target.closest('#sync-dropdown-button'); const syncButton = e.target.closest('#sync-button');
if (dropdownButton) { if (syncButton) {
e.stopPropagation(); e.stopPropagation();
const dropdownMenu = document.getElementById('sync-dropdown-menu'); const button = syncButton;
if (dropdownMenu) {
dropdownMenu.classList.toggle('hidden');
// Update aria-expanded
const isExpanded = dropdownMenu.classList.contains('hidden');
dropdownButton.setAttribute('aria-expanded', !isExpanded);
}
}
// Handle sync actions
const actionButton = e.target.closest('[data-action]');
if (actionButton) {
const action = actionButton.dataset.action;
const button = actionButton; // Keep reference to original button
// Add loading state // Add loading state
const originalHTML = button.innerHTML; const originalHTML = button.innerHTML;
button.disabled = true; button.disabled = true;
button.innerHTML = '<svg class="animate-spin w-4 h-4 inline mr-2" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg> Синхронизация...'; button.innerHTML = '<svg class="animate-spin w-4 h-4 inline mr-2" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>';
if (action === 'push-changes') { fullSync(button, originalHTML);
pushPendingChanges(button, originalHTML);
} else if (action === 'full-sync') {
fullSync(button, originalHTML);
}
}
});
// Close dropdown when clicking outside
document.body.addEventListener('click', function(e) {
const dropdownButton = document.getElementById('sync-dropdown-button');
const dropdownMenu = document.getElementById('sync-dropdown-menu');
if (dropdownButton && dropdownMenu &&
!dropdownButton.contains(e.target) &&
!dropdownMenu.contains(e.target)) {
dropdownMenu.classList.add('hidden');
if (dropdownButton) {
dropdownButton.setAttribute('aria-expanded', 'false');
}
} }
}); });
@@ -132,8 +188,8 @@
if (data.success) { if (data.success) {
showToast(successMessage, 'success'); showToast(successMessage, 'success');
// Update last sync time // Update last sync time - removed since dropdown is gone
loadLastSyncTime(); // loadLastSyncTime();
} else { } else {
showToast('Ошибка: ' + (data.error || 'неизвестная ошибка'), 'error'); showToast('Ошибка: ' + (data.error || 'неизвестная ошибка'), 'error');
} }
@@ -181,37 +237,36 @@
} }
} }
// 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 // Load last sync time for dropdown (removed since dropdown is gone)
async function loadLastSyncTime() { // async function loadLastSyncTime() {
try { // try {
const resp = await fetch('/api/sync/status'); // const resp = await fetch('/api/sync/status');
const data = await resp.json(); // const data = await resp.json();
if (data.last_pricelist_sync) { // if (data.last_pricelist_sync) {
const date = new Date(data.last_pricelist_sync); // const date = new Date(data.last_pricelist_sync);
document.getElementById('last-sync-time').textContent = date.toLocaleString('ru-RU'); // document.getElementById('last-sync-time').textContent = date.toLocaleString('ru-RU');
} else { // } else {
document.getElementById('last-sync-time').textContent = 'Нет данных'; // document.getElementById('last-sync-time').textContent = 'Нет данных';
} // }
} catch(e) { // } catch(e) {
console.error('Failed to load last sync time:', e); // console.error('Failed to load last sync time:', e);
} // }
} // }
// Load last sync time when page loads // Call functions immediately to ensure they run even before DOMContentLoaded
document.addEventListener('DOMContentLoaded', loadLastSyncTime); // This ensures username and admin link are visible ASAP
checkDbStatus();
checkWritePermission();
// Load last sync time - removed since dropdown is gone
// document.addEventListener('DOMContentLoaded', loadLastSyncTime);
</script> </script>
</body> </body>
</html> </html>

View File

@@ -421,18 +421,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

@@ -1,13 +1,13 @@
{{define "sync_status"}} {{define "sync_status"}}
<div class="flex items-center gap-2 relative"> <div class="flex items-center gap-2 relative">
{{if .IsOffline}} {{if .IsOffline}}
<span class="flex items-center gap-1 text-red-600" title="Offline"> <span class="flex items-center gap-1 text-red-600 cursor-pointer" title="Offline" onclick="openSyncModal()">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg> </svg>
</span> </span>
{{else}} {{else}}
<span class="flex items-center gap-1 text-green-600" title="Online"> <span class="flex items-center gap-1 text-green-600 cursor-pointer" title="Online" onclick="openSyncModal()">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg> </svg>
@@ -15,7 +15,7 @@
{{end}} {{end}}
{{if gt .PendingCount 0}} {{if gt .PendingCount 0}}
<span class="bg-yellow-100 text-yellow-800 px-2 py-0.5 rounded-full text-xs font-medium flex items-center gap-1"> <span class="bg-yellow-100 text-yellow-800 px-2 py-0.5 rounded-full text-xs font-medium flex items-center gap-1 cursor-pointer" onclick="openSyncModal()">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg> </svg>
@@ -23,40 +23,15 @@
</span> </span>
{{end}} {{end}}
<!-- Dropdown button for sync actions --> <!-- Sync button (full sync only) -->
<div class="relative"> <div class="relative">
<button id="sync-dropdown-button" <button id="sync-button"
aria-label="Меню синхронизации" aria-label="Синхронизация"
aria-haspopup="true"
aria-expanded="false"
class="text-gray-600 hover:text-gray-800 text-xs flex items-center gap-1"> class="text-gray-600 hover:text-gray-800 text-xs flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg> </svg>
</button> </button>
<!-- Dropdown menu -->
<div id="sync-dropdown-menu" class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 hidden z-50">
<button data-action="push-changes" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 w-full text-left">
<svg class="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
Push changes
</button>
<button data-action="full-sync" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 w-full text-left">
<svg class="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
Full sync
</button>
<div class="border-t border-gray-100 my-1"></div>
<div class="px-4 py-2 text-xs text-gray-500">
<svg class="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
Последняя синхронизация: <span id="last-sync-time">-</span>
</div>
</div>
</div> </div>
</div> </div>
{{end}} {{end}}

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
setTimeout(() => { // Check if restart is required
showStatus('✓ Настройки сохранены. Проверка подключения...', 'success'); if (data.restart_required) {
// Poll until server is back setTimeout(() => {
checkServerReady(); requestRestartAndWait();
}, 2000); }, 2000);
} else {
// In setup mode, auto-restart is happening
setTimeout(() => {
showStatus('✓ Настройки сохранены. Проверка подключения...', 'success');
// Poll until server is back
checkServerReady();
}, 2000);
}
} else { } else {
showStatus(data.error, 'error'); showStatus(data.error, 'error');
} }