Add Phase 2: Local SQLite database with sync functionality

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

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

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

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

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

6
.gitignore vendored
View File

@@ -1,6 +1,12 @@
# QuoteForge
config.yaml
# Local SQLite database (contains encrypted credentials)
/data/*.db
/data/*.db-journal
/data/*.db-shm
/data/*.db-wal
# Binaries
/server
/importer

683
CLAUDE.md
View File

@@ -1,638 +1,107 @@
# QuoteForge - Claude Code Instructions
## Project Overview
QuoteForge — корпоративный инструмент для конфигурирования серверов и формирования коммерческих предложений (КП). Приложение работает с серверной базой данных MariaDB (RFQ_LOG) и локальной SQLite для оффлайн-работы.
## Overview
Корпоративный конфигуратор серверов и формирование КП. MariaDB (RFQ_LOG) + SQLite для оффлайн.
## Development Phases
### Phase 1: Pricelists in MariaDB
- Настройка подключения к БД при первом запуске
- Таблицы qt_pricelists и qt_pricelist_items
- CRUD операции для прайслистов (при наличии прав записи)
### Phase 1: Pricelists in MariaDB ✅ DONE
### Phase 2: Local SQLite Database ✅ DONE
### Phase 2: Projects and Specifications
- Таблицы qt_projects и qt_specifications
- Замена qt_configurations на новую структуру
- Поля: opty, customer_requirement, variant, qty, rev
### Phase 2.5: Full Offline Mode 🔶 IN PROGRESS
Приложение должно полностью работать без MariaDB, синхронизация при восстановлении связи.
### Phase 3: Local SQLite Database
- Локальное хранение настроек подключения
- Кэширование прайслистов
- Локальные проекты и спецификации
- Синхронизация с сервером
**Architecture:**
- Dual-source pattern: все операции идут через unified service layer
- Online: read/write MariaDB, async cache to SQLite
- Offline: read/write SQLite, queue changes for sync
**TODO:**
- ❌ Unified repository interface (online/offline transparent switching)
- ❌ Sync queue table (pending_changes: entity_type, entity_uuid, operation, payload, created_at)
- ❌ Background sync worker (push local changes when online)
- ❌ Conflict resolution (last-write-wins by updated_at, or manual)
- ❌ Initial data bootstrap (first sync downloads all needed data)
- ❌ Handlers use context.IsOffline to choose data source
- ❌ UI: pending changes counter, manual sync button, conflict alerts
**Sync flow:**
1. Online → Offline: continue work, changes saved locally with sync_status='pending'
2. Offline → Online: background worker pushes pending_changes, pulls updates
3. Conflict: if server version newer, mark as 'conflict' for manual resolution
### Phase 3: Projects and Specifications
- qt_projects, qt_specifications tables (MariaDB)
- Replace qt_configurations → Project/Specification hierarchy
- Fields: opty, customer_requirement, variant, qty, rev
- Local projects/specs with server sync
### Phase 4: Price Versioning
- Привязка спецификаций к версиям прайслистов
- Актуализация прайслистов с показом разницы цен
- Автоочистка старых прайслистов (>1 года, usage_count=0)
- Bind specifications to pricelist versions
- Price diff comparison
- Auto-cleanup expired pricelists (>1 year, usage_count=0)
## Tech Stack
Go 1.22+ | Gin | GORM | MariaDB 11 | SQLite (glebarez/sqlite) | htmx + Tailwind CDN | excelize
- **Language:** Go 1.22+
- **Web Framework:** Gin (github.com/gin-gonic/gin)
- **ORM:** GORM (gorm.io/gorm)
- **Server Database:** MariaDB 11 (existing database RFQ_LOG)
- **Local Database:** SQLite (github.com/glebarez/sqlite for pure Go)
- **Frontend:** HTML templates + htmx + Tailwind CSS (CDN)
- **Excel Export:** excelize (github.com/xuri/excelize/v2)
## Key Tables
## Project Structure
### READ-ONLY (external systems)
- `lot` (lot_name PK, lot_description)
- `lot_log` (lot, supplier, date, price, quality, comments)
- `supplier` (supplier_name PK)
```
quoteforge/
├── cmd/
│ ├── server/main.go # Main HTTP server
│ ├── importer/main.go # Import metadata from lot table
│ └── cron/main.go # Cron jobs
├── internal/
│ ├── config/
│ │ └── config.go # Load settings from SQLite
│ ├── db/
│ │ ├── mariadb.go # Server DB connection
│ │ └── sqlite.go # Local DB connection
│ ├── models/
│ │ ├── lot.go # Existing lot tables
│ │ ├── pricelist.go # Pricelists
│ │ ├── project.go # Projects
│ │ ├── specification.go # Specifications
│ │ └── local_models.go # SQLite models
│ ├── handlers/
│ │ ├── setup_handler.go # Initial DB setup
│ │ ├── pricelist_handler.go # Pricelist CRUD
│ │ ├── project_handler.go # Project CRUD
│ │ ├── spec_handler.go # Specification CRUD
│ │ └── sync_handler.go # Sync operations
│ ├── services/
│ │ ├── pricelist_service.go # Pricelist business logic
│ │ ├── project_service.go # Project business logic
│ │ ├── sync_service.go # Sync with server
│ │ └── price_service.go # Price calculations
│ ├── middleware/
│ │ └── db_check.go # Check DB connection
│ └── repository/
│ ├── mariadb_repo.go # Server DB queries
│ └── sqlite_repo.go # Local DB queries
├── web/
│ ├── templates/
│ │ ├── setup.html # DB connection setup
│ │ ├── projects.html # Project list
│ │ ├── project_detail.html # Project with specs
│ │ ├── spec_editor.html # Specification editor
│ │ └── pricelists.html # Pricelist management
│ └── static/
├── data/ # SQLite database location
│ └── quoteforge.db
├── migrations/
└── go.mod
```
### MariaDB (qt_* prefix)
- `qt_lot_metadata` - component prices, methods, popularity
- `qt_categories` - category codes and names
- `qt_pricelists` - version snapshots (YYYY-MM-DD-NNN format)
- `qt_pricelist_items` - prices per pricelist
- `qt_projects` - uuid, opty, customer_requirement, name (Phase 3)
- `qt_specifications` - project_id, pricelist_id, variant, rev, qty, items JSON (Phase 3)
## Existing Database Tables (READ-ONLY - DO NOT MODIFY)
### SQLite (data/quoteforge.db)
- `connection_settings` - encrypted DB credentials
- `local_pricelists/items` - cached from server
- `local_components` - lot cache for offline search
- `local_configurations` - with sync_status (pending/synced/conflict)
- `local_projects/specifications` - Phase 3
- `pending_changes` - sync queue (entity_type, uuid, op, payload, created_at)
These tables are used by other systems. Our app only reads from them:
## Business Logic
```sql
-- Component catalog
CREATE TABLE lot (
lot_name CHAR(255) PRIMARY KEY,
lot_description VARCHAR(10000)
);
**Part number parsing:** `CPU_AMD_9654` → category=`CPU`, model=`AMD_9654`
-- Price history from suppliers
CREATE TABLE lot_log (
lot_log_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
lot CHAR(255) NOT NULL,
supplier CHAR(255) NOT NULL,
date DATE NOT NULL,
price DOUBLE NOT NULL,
quality CHAR(255),
comments VARCHAR(15000),
FOREIGN KEY (lot) REFERENCES lot(lot_name),
FOREIGN KEY (supplier) REFERENCES supplier(supplier_name)
);
**Price methods:** manual | median | average | weighted_median
-- Supplier catalog
CREATE TABLE supplier (
supplier_name CHAR(255) PRIMARY KEY,
supplier_comment VARCHAR(10000)
);
```
**Price freshness:** fresh (<30d, ≥3 quotes) | normal (<60d) | stale (<90d) | critical
## New MariaDB Tables (prefix qt_)
### Core Tables
```sql
-- Component metadata (extends lot table)
CREATE TABLE qt_lot_metadata (
lot_name CHAR(255) PRIMARY KEY,
category_id INT,
model VARCHAR(100),
specs JSON,
current_price DECIMAL(12,2),
price_method ENUM('manual', 'median', 'average', 'weighted_median') DEFAULT 'median',
price_period_days INT DEFAULT 90,
price_updated_at TIMESTAMP,
request_count INT DEFAULT 0,
last_request_date DATE,
popularity_score DECIMAL(10,4),
FOREIGN KEY (lot_name) REFERENCES lot(lot_name)
);
-- Categories
CREATE TABLE qt_categories (
id INT AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(20) UNIQUE NOT NULL,
name VARCHAR(100) NOT NULL,
name_ru VARCHAR(100),
display_order INT DEFAULT 0,
is_required BOOLEAN DEFAULT FALSE
);
-- Usage statistics
CREATE TABLE qt_component_usage_stats (
lot_name CHAR(255) PRIMARY KEY,
quotes_total INT DEFAULT 0,
quotes_last_30d INT DEFAULT 0,
quotes_last_7d INT DEFAULT 0,
total_quantity INT DEFAULT 0,
total_revenue DECIMAL(14,2) DEFAULT 0,
trend_direction ENUM('up', 'stable', 'down') DEFAULT 'stable',
trend_percent DECIMAL(5,2) DEFAULT 0,
last_used_at TIMESTAMP,
config_count INT DEFAULT 0 -- Number of configurations using this component
);
```
### Pricelist Tables
```sql
-- Pricelists (versioned price snapshots)
CREATE TABLE qt_pricelists (
id INT AUTO_INCREMENT PRIMARY KEY,
version VARCHAR(20) NOT NULL, -- Format: "YYYY-MM-DD-NNN" (e.g., "2024-01-31-001")
name VARCHAR(200), -- Optional display name
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(100), -- Username of creator
is_active BOOLEAN DEFAULT TRUE,
usage_count INT DEFAULT 0, -- How many specifications use this pricelist
expires_at DATE, -- Auto-calculated: created_at + 1 year
UNIQUE KEY (version)
);
-- Pricelist items
CREATE TABLE qt_pricelist_items (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
pricelist_id INT NOT NULL,
lot_name CHAR(255) NOT NULL,
price DECIMAL(12,2) NOT NULL,
price_method ENUM('manual', 'median', 'average', 'weighted_median'),
FOREIGN KEY (pricelist_id) REFERENCES qt_pricelists(id) ON DELETE CASCADE,
FOREIGN KEY (lot_name) REFERENCES lot(lot_name),
INDEX idx_pricelist_lot (pricelist_id, lot_name)
);
```
### Project Tables
```sql
-- Projects (group of specifications)
CREATE TABLE qt_projects (
id INT AUTO_INCREMENT PRIMARY KEY,
uuid VARCHAR(36) UNIQUE NOT NULL,
opty VARCHAR(50), -- Opportunity/project number
customer_requirement TEXT, -- Link to customer requirements/TZ
name VARCHAR(200) NOT NULL,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- Specifications (replaces qt_configurations)
CREATE TABLE qt_specifications (
id INT AUTO_INCREMENT PRIMARY KEY,
uuid VARCHAR(36) UNIQUE NOT NULL,
project_id INT NOT NULL,
pricelist_id INT NOT NULL, -- Bound to specific pricelist version
variant VARCHAR(50) NOT NULL, -- Calculation variant (A, B, C, Base, Extended...)
rev INT DEFAULT 1, -- Revision number
qty INT DEFAULT 1, -- Number of servers
items JSON NOT NULL, -- [{"lot_name": "CPU_AMD_9654", "quantity": 2, "unit_price": 11500}]
total_price DECIMAL(12,2),
custom_price DECIMAL(12,2), -- User-defined target price
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES qt_projects(id) ON DELETE CASCADE,
FOREIGN KEY (pricelist_id) REFERENCES qt_pricelists(id),
UNIQUE KEY (project_id, variant, rev)
);
```
### Legacy Tables (will be deprecated)
```sql
-- Users (RBAC disabled in Phase 1-3)
CREATE TABLE qt_users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(100) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role ENUM('viewer', 'editor', 'pricing_admin', 'admin') DEFAULT 'viewer',
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- Price overrides (for future use)
CREATE TABLE qt_price_overrides (
id INT AUTO_INCREMENT PRIMARY KEY,
lot_name CHAR(255) NOT NULL,
price DECIMAL(12,2) NOT NULL,
valid_from DATE NOT NULL,
valid_until DATE,
reason TEXT,
created_by INT NOT NULL,
FOREIGN KEY (lot_name) REFERENCES lot(lot_name)
);
-- Alerts (for future use)
CREATE TABLE qt_pricing_alerts (
id INT AUTO_INCREMENT PRIMARY KEY,
lot_name CHAR(255) NOT NULL,
alert_type ENUM('high_demand_stale_price', 'price_spike', 'price_drop', 'no_recent_quotes', 'trending_no_price') NOT NULL,
severity ENUM('low', 'medium', 'high', 'critical') DEFAULT 'medium',
message TEXT NOT NULL,
details JSON,
status ENUM('new', 'acknowledged', 'resolved', 'ignored') DEFAULT 'new',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
## Local SQLite Database
Located at `data/quoteforge.db`:
```sql
-- Application settings (connection credentials stored encrypted)
CREATE TABLE app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
-- Keys: db_host, db_port, db_name, db_user, db_password (encrypted), last_sync
-- Cached pricelists from server
CREATE TABLE local_pricelists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INT NOT NULL, -- ID on MariaDB server
version TEXT NOT NULL UNIQUE,
name TEXT,
created_at TEXT,
synced_at TEXT DEFAULT CURRENT_TIMESTAMP,
is_used INTEGER DEFAULT 0 -- 1 if used by any specification
);
CREATE TABLE local_pricelist_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pricelist_id INTEGER NOT NULL,
lot_name TEXT NOT NULL,
price REAL NOT NULL,
FOREIGN KEY (pricelist_id) REFERENCES local_pricelists(id) ON DELETE CASCADE
);
-- Local projects (can be synced to server)
CREATE TABLE local_projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT UNIQUE NOT NULL,
server_id INTEGER, -- NULL if not synced yet
opty TEXT,
customer_requirement TEXT,
name TEXT NOT NULL,
notes TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
synced_at TEXT, -- NULL if has local changes
sync_status TEXT DEFAULT 'local' -- 'local', 'synced', 'modified'
);
-- Local specifications
CREATE TABLE local_specifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT UNIQUE NOT NULL,
server_id INTEGER,
project_id INTEGER NOT NULL,
pricelist_id INTEGER NOT NULL,
variant TEXT NOT NULL,
rev INTEGER DEFAULT 1,
qty INTEGER DEFAULT 1,
items TEXT NOT NULL, -- JSON string
total_price REAL,
custom_price REAL,
notes TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
synced_at TEXT,
sync_status TEXT DEFAULT 'local',
FOREIGN KEY (project_id) REFERENCES local_projects(id) ON DELETE CASCADE,
FOREIGN KEY (pricelist_id) REFERENCES local_pricelists(id)
);
-- Component cache (for offline search)
CREATE TABLE local_components (
lot_name TEXT PRIMARY KEY,
lot_description TEXT,
category TEXT,
model TEXT,
synced_at TEXT DEFAULT CURRENT_TIMESTAMP
);
```
## Key Business Logic
### 1. Database Connection Setup
```go
// First launch: show setup form
// User provides: host, port, database, username, password
// Credentials are encrypted and stored in SQLite
// Connection is tested before saving
func SetupConnection(host, port, dbName, user, password string) error {
// Test connection to MariaDB
// If successful, encrypt and save to SQLite
// Create/migrate qt_* tables if user has permissions
}
func CheckWritePermission(db *gorm.DB, tableName string) bool {
// Check if current user can INSERT into table
// Used to enable/disable pricelist creation UI
}
```
### 2. Pricelist Creation
```go
// Create snapshot of current prices from qt_lot_metadata
// Version format: YYYY-MM-DD-NNN (NNN = sequential number for the day)
func CreatePricelist(name string, createdBy string) (*Pricelist, error) {
version := generateVersion() // e.g., "2024-01-31-001"
expiresAt := time.Now().AddDate(1, 0, 0) // +1 year
// Copy all prices from qt_lot_metadata
// Insert into qt_pricelists and qt_pricelist_items
}
func generateVersion() string {
today := time.Now().Format("2006-01-02")
// Count existing pricelists for today
// Return "YYYY-MM-DD-NNN"
}
```
### 3. Pricelist Comparison
```go
type PriceDiff struct {
LotName string
OldPrice float64
NewPrice float64
Difference float64
PercentDiff float64
}
// Compare two pricelists and return differences
func ComparePricelists(oldID, newID int) ([]PriceDiff, error)
// Compare specification's pricelist with latest available
func GetSpecificationPriceDiff(specUUID string) ([]PriceDiff, float64, error) {
// Returns item diffs and total price difference
}
```
### 4. Specification Upgrade
```go
// Upgrade specification to use newer pricelist
func UpgradeSpecificationPricelist(specUUID string, newPricelistID int) error {
// Update pricelist_id
// Recalculate prices from new pricelist
// Increment revision number
// Update old pricelist usage_count--
// Update new pricelist usage_count++
}
```
### 5. Pricelist Cleanup
```go
// Cron job: cleanup old unused pricelists
// Run weekly: 0 4 * * 0
func CleanupOldPricelists() error {
// Delete pricelists where:
// - expires_at < NOW()
// - usage_count = 0
// - is_active = false OR created_at < NOW() - 1 year
}
```
### 6. Sync Service
```go
// Sync pricelists from server to local SQLite
func SyncPricelists() error {
// Fetch all active pricelists from MariaDB
// Update local_pricelists table
// For pricelists used by local specs, also sync items
}
// Check if sync is needed
func NeedSync() bool {
// Compare last_sync timestamp with server
// Return true if new pricelists available
}
```
### 7. Part Number Parsing
```go
// "CPU_AMD_9654" → category="CPU", model="AMD_9654"
// "MB_INTEL_4.Sapphire_2S" → category="MB", model="INTEL_4.Sapphire_2S"
func ParsePartNumber(lotName string) (category, model string) {
parts := strings.SplitN(lotName, "_", 2)
if len(parts) >= 1 {
category = parts[0]
}
if len(parts) >= 2 {
model = parts[1]
}
return
}
```
### 8. Price Calculation Methods
```go
func CalculateMedian(prices []float64) float64
func CalculateAverage(prices []float64) float64
func CalculateWeightedMedian(prices []PricePoint, decayDays int) float64
```
### 9. Price Freshness
```go
func GetPriceFreshness(daysSinceUpdate int, quoteCount int) string {
if daysSinceUpdate < 30 && quoteCount >= 3 {
return "fresh" // green
} else if daysSinceUpdate < 60 {
return "normal" // yellow
} else if daysSinceUpdate < 90 {
return "stale" // orange
}
return "critical" // red
}
```
**Pricelist version:** `YYYY-MM-DD-NNN` (e.g., `2024-01-31-001`)
## API Endpoints
### Setup (no auth required)
```
GET /setup → DB connection form (if not configured)
POST /setup → Save connection settings
POST /setup/test → Test connection without saving
GET /setup/status → Check if configured and connected
```
### Components
```
GET /api/components → List with pagination
GET /api/components?category=CPU&search=AMD → Filtered list
GET /api/components/:lot_name → Single component details
GET /api/categories → Category list
```
### Pricelists
```
GET /api/pricelists → List all pricelists
POST /api/pricelists → Create new pricelist (requires write permission)
GET /api/pricelists/:id → Pricelist details
GET /api/pricelists/:id/items → Pricelist items with pagination
DELETE /api/pricelists/:id → Delete pricelist (if usage_count=0)
GET /api/pricelists/latest → Get latest active pricelist
POST /api/pricelists/compare → Compare two pricelists
```
### Projects
```
GET /api/projects → List all projects
POST /api/projects → Create project
GET /api/projects/:uuid → Project with specifications
PUT /api/projects/:uuid → Update project
DELETE /api/projects/:uuid → Delete project and specs
```
### Specifications
```
GET /api/projects/:uuid/specs → List specifications
POST /api/projects/:uuid/specs → Create specification
GET /api/specs/:spec_uuid → Specification details
PUT /api/specs/:spec_uuid → Update specification
DELETE /api/specs/:spec_uuid → Delete specification
POST /api/specs/:spec_uuid/upgrade → Upgrade to new pricelist
GET /api/specs/:spec_uuid/diff → Show price diff with latest pricelist
POST /api/specs/:spec_uuid/new-revision → Create new revision
```
### Sync
```
GET /api/sync/status → Sync status (last sync, pending changes)
POST /api/sync/pricelists → Sync pricelists from server
POST /api/sync/push → Push local changes to server
POST /api/sync/pull → Pull all data from server
```
### Export
```
POST /api/export/xlsx → Export specification as XLSX
POST /api/export/pdf → Export specification as PDF (future)
GET /api/specs/:uuid/export → Export single spec
GET /api/projects/:uuid/export → Export all project specs
```
### htmx Partials
```
GET /partials/components?category=CPU → Component list HTML
GET /partials/spec-items/:spec_uuid → Specification items HTML
GET /partials/price-diff/:spec_uuid → Price diff table HTML
GET /partials/project-specs/:project_uuid → Project specifications list
```
## Frontend Guidelines
- **Mobile-first** design
- Use **htmx** for interactivity (hx-get, hx-post, hx-target, hx-swap)
- Use **Tailwind CSS** via CDN
- Minimal custom JavaScript
- Color scheme for price freshness:
- `text-green-600 bg-green-50` - fresh
- `text-yellow-600 bg-yellow-50` - normal
- `text-orange-600 bg-orange-50` - stale
- `text-red-600 bg-red-50` - critical
- Sync status indicator in header
- Offline mode indicator when server unavailable
| Group | Endpoints |
|-------|-----------|
| Setup | GET/POST /setup, POST /setup/test |
| Components | GET /api/components, /api/categories |
| Pricelists | CRUD /api/pricelists, GET /latest, POST /compare |
| Projects | CRUD /api/projects/:uuid (Phase 3) |
| Specs | CRUD /api/specs/:uuid, POST /upgrade, GET /diff (Phase 3) |
| Sync | GET /status, POST /components, /pricelists, /push, /pull, /resolve-conflict |
| Export | GET /api/specs/:uuid/export, /api/projects/:uuid/export |
## Commands
```bash
# Run development server
go run ./cmd/server
# Run importer (one-time setup)
go run ./cmd/importer
# Run cron jobs manually
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
# Build for production
go run ./cmd/server # Dev server
go run ./cmd/cron -job=X # cleanup-pricelists | update-prices | update-popularity
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/quoteforge ./cmd/server
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/quoteforge-cron ./cmd/cron
# Run tests
go test ./...
```
## Cron Jobs
- **Pricelist cleanup**: Weekly on Sunday at 4 AM (0 4 * * 0)
- **Price updates**: Daily at 2 AM (0 2 * * *)
- **Popularity score updates**: Daily at 3 AM (0 3 * * *)
## Code Style
- gofmt, structured logging (slog), wrap errors with context
- snake_case files, PascalCase types
- RBAC disabled: DB username = user_id via `models.EnsureDBUser()`
- Use standard Go formatting (gofmt)
- Error handling: always check errors, wrap with context
- Logging: use structured logging (slog)
- Comments: in Russian or English, be consistent
- File naming: snake_case for files, PascalCase for types
## Migration Notes
### From qt_configurations to qt_specifications
When migrating existing data:
1. Create a default project for orphan configurations
2. Create initial pricelist from current qt_lot_metadata prices
3. Convert qt_configurations to qt_specifications with default variant="Base", rev=1
4. Link all specs to initial pricelist
### RBAC Disabled
During Phase 1-3, RBAC is disabled:
- No login required
- All users have full access
- Write permissions determined by MariaDB user privileges
- qt_users table exists but not used
## UI Guidelines
- htmx (hx-get/post/target/swap), Tailwind CDN
- Freshness colors: green (fresh) → yellow → orange → red (critical)
- Sync status + offline indicator in header

View File

@@ -3,7 +3,6 @@ package main
import (
"flag"
"log"
"time"
"git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/models"

162
cmd/migrate/main.go Normal file
View File

@@ -0,0 +1,162 @@
package main
import (
"flag"
"fmt"
"log"
"time"
"git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func main() {
configPath := flag.String("config", "config.yaml", "path to config file")
localDBPath := flag.String("localdb", "./data/settings.db", "path to local SQLite database")
dryRun := flag.Bool("dry-run", false, "show what would be migrated without actually doing it")
flag.Parse()
log.Println("QuoteForge Configuration Migration Tool")
log.Println("========================================")
// Load config for MariaDB connection
cfg, err := config.Load(*configPath)
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
// Connect to MariaDB
log.Printf("Connecting to MariaDB at %s:%d...", cfg.Database.Host, cfg.Database.Port)
mariaDB, err := gorm.Open(mysql.Open(cfg.Database.DSN()), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
log.Fatalf("Failed to connect to MariaDB: %v", err)
}
log.Println("Connected to MariaDB")
// Initialize local SQLite
log.Printf("Opening local SQLite at %s...", *localDBPath)
local, err := localdb.New(*localDBPath)
if err != nil {
log.Fatalf("Failed to initialize local database: %v", err)
}
log.Println("Local SQLite initialized")
// Count configurations in MariaDB
var serverCount int64
if err := mariaDB.Model(&models.Configuration{}).Count(&serverCount).Error; err != nil {
log.Fatalf("Failed to count configurations: %v", err)
}
log.Printf("Found %d configurations in MariaDB", serverCount)
if serverCount == 0 {
log.Println("No configurations to migrate")
return
}
// Get all configurations from MariaDB
var configs []models.Configuration
if err := mariaDB.Preload("User").Find(&configs).Error; err != nil {
log.Fatalf("Failed to fetch configurations: %v", err)
}
// Check existing local configurations
localCount := local.CountConfigurations()
log.Printf("Found %d configurations in local SQLite", localCount)
if *dryRun {
log.Println("\n[DRY RUN] Would migrate the following configurations:")
for _, c := range configs {
userName := "unknown"
if c.User != nil {
userName = c.User.Username
}
log.Printf(" - %s (UUID: %s, User: %s, Items: %d)", c.Name, c.UUID, userName, len(c.Items))
}
log.Printf("\nTotal: %d configurations", len(configs))
return
}
// Migrate configurations
log.Println("\nMigrating configurations...")
migrated := 0
skipped := 0
errors := 0
for _, c := range configs {
// Check if already exists
existing, err := local.GetConfigurationByUUID(c.UUID)
if err == nil && existing.ID > 0 {
log.Printf(" SKIP: %s (already exists)", c.Name)
skipped++
continue
}
// Convert items
localItems := make(localdb.LocalConfigItems, len(c.Items))
for i, item := range c.Items {
localItems[i] = localdb.LocalConfigItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: item.UnitPrice,
}
}
// Create local configuration
now := time.Now()
localConfig := &localdb.LocalConfiguration{
UUID: c.UUID,
ServerID: &c.ID,
Name: c.Name,
Items: localItems,
TotalPrice: c.TotalPrice,
CustomPrice: c.CustomPrice,
Notes: c.Notes,
IsTemplate: c.IsTemplate,
ServerCount: c.ServerCount,
CreatedAt: c.CreatedAt,
UpdatedAt: now,
SyncedAt: &now,
SyncStatus: "synced",
OriginalUserID: c.UserID,
}
if err := local.SaveConfiguration(localConfig); err != nil {
log.Printf(" ERROR: %s - %v", c.Name, err)
errors++
continue
}
log.Printf(" OK: %s (%d items)", c.Name, len(c.Items))
migrated++
}
log.Println("\n========================================")
log.Printf("Migration complete!")
log.Printf(" Migrated: %d", migrated)
log.Printf(" Skipped: %d", skipped)
log.Printf(" Errors: %d", errors)
// Save connection settings to local SQLite if not exists
if !local.HasSettings() {
log.Println("\nSaving connection settings to local SQLite...")
if err := local.SaveSettings(
cfg.Database.Host,
cfg.Database.Port,
cfg.Database.Name,
cfg.Database.User,
cfg.Database.Password,
); err != nil {
log.Printf("Warning: Failed to save settings: %v", err)
} else {
log.Println("Connection settings saved")
}
}
fmt.Println("\nDone! You can now run the server with: go run ./cmd/server")
}

View File

@@ -3,53 +3,97 @@ package main
import (
"context"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"strconv"
"syscall"
"time"
"github.com/gin-gonic/gin"
"git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/handlers"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/middleware"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"git.mchus.pro/mchus/quoteforge/internal/services"
"git.mchus.pro/mchus/quoteforge/internal/services/alerts"
"git.mchus.pro/mchus/quoteforge/internal/services/pricelist"
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
"golang.org/x/crypto/bcrypt"
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
const (
localDBPath = "./data/settings.db"
)
func main() {
configPath := flag.String("config", "config.yaml", "path to config file")
configPath := flag.String("config", "config.yaml", "path to config file (optional, for server settings)")
migrate := flag.Bool("migrate", false, "run database migrations")
flag.Parse()
cfg, err := config.Load(*configPath)
// Initialize local SQLite database (always used)
local, err := localdb.New(localDBPath)
if err != nil {
slog.Error("failed to load config", "error", err)
slog.Error("failed to initialize local database", "error", err)
os.Exit(1)
}
// Check if running in setup mode (no connection settings)
if !local.HasSettings() {
slog.Info("no database settings found, starting setup mode")
runSetupMode(local)
return
}
// Load config for server settings (optional)
cfg, err := config.Load(*configPath)
if err != nil {
// Use defaults if config file doesn't exist
slog.Info("config file not found, using defaults", "path", *configPath)
cfg = &config.Config{}
}
setConfigDefaults(cfg)
setupLogger(cfg.Logging)
// Get DSN from local SQLite
dsn, err := local.GetDSN()
if err != nil {
slog.Error("failed to get database settings", "error", err)
os.Exit(1)
}
// Connect to MariaDB
db, err := setupDatabaseFromDSN(dsn)
if err != nil {
slog.Error("failed to connect to database", "error", err)
slog.Info("you may need to reconfigure connection at /setup")
os.Exit(1)
}
dbUser := local.GetDBUser()
// Ensure DB user exists in qt_users table (for foreign key constraint)
dbUserID, err := models.EnsureDBUser(db, dbUser)
if err != nil {
slog.Error("failed to ensure DB user exists", "error", err)
os.Exit(1)
}
slog.Info("starting QuoteForge server",
"host", cfg.Server.Host,
"port", cfg.Server.Port,
"mode", cfg.Server.Mode,
"db_user", dbUser,
"db_user_id", dbUserID,
)
db, err := setupDatabase(cfg.Database)
if err != nil {
slog.Error("failed to connect to database", "error", err)
os.Exit(1)
}
if *migrate {
slog.Info("running database migrations...")
if err := models.Migrate(db); err != nil {
@@ -60,17 +104,11 @@ func main() {
slog.Error("seeding categories failed", "error", err)
os.Exit(1)
}
// Create default admin user (admin / admin123)
adminHash, _ := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost)
if err := models.SeedAdminUser(db, string(adminHash)); err != nil {
slog.Error("seeding admin user failed", "error", err)
os.Exit(1)
}
slog.Info("migrations completed")
}
gin.SetMode(cfg.Server.Mode)
router, err := setupRouter(db, cfg)
router, err := setupRouter(db, cfg, local, dbUserID)
if err != nil {
slog.Error("failed to setup router", "error", err)
os.Exit(1)
@@ -107,6 +145,96 @@ func main() {
slog.Info("server stopped")
}
func setConfigDefaults(cfg *config.Config) {
if cfg.Server.Host == "" {
cfg.Server.Host = "0.0.0.0"
}
if cfg.Server.Port == 0 {
cfg.Server.Port = 8080
}
if cfg.Server.Mode == "" {
cfg.Server.Mode = "release"
}
if cfg.Server.ReadTimeout == 0 {
cfg.Server.ReadTimeout = 30 * time.Second
}
if cfg.Server.WriteTimeout == 0 {
cfg.Server.WriteTimeout = 30 * time.Second
}
if cfg.Pricing.DefaultMethod == "" {
cfg.Pricing.DefaultMethod = "weighted_median"
}
if cfg.Pricing.DefaultPeriodDays == 0 {
cfg.Pricing.DefaultPeriodDays = 90
}
if cfg.Pricing.FreshnessGreenDays == 0 {
cfg.Pricing.FreshnessGreenDays = 30
}
if cfg.Pricing.FreshnessYellowDays == 0 {
cfg.Pricing.FreshnessYellowDays = 60
}
if cfg.Pricing.FreshnessRedDays == 0 {
cfg.Pricing.FreshnessRedDays = 90
}
if cfg.Pricing.MinQuotesForMedian == 0 {
cfg.Pricing.MinQuotesForMedian = 3
}
}
// runSetupMode starts a minimal server that only serves the setup page
func runSetupMode(local *localdb.LocalDB) {
setupHandler, err := handlers.NewSetupHandler(local, "web/templates")
if err != nil {
slog.Error("failed to create setup handler", "error", err)
os.Exit(1)
}
gin.SetMode(gin.ReleaseMode)
router := gin.New()
router.Use(gin.Recovery())
router.Static("/static", "web/static")
// Setup routes only
router.GET("/", func(c *gin.Context) {
c.Redirect(http.StatusFound, "/setup")
})
router.GET("/setup", setupHandler.ShowSetup)
router.POST("/setup", setupHandler.SaveConnection)
router.POST("/setup/test", setupHandler.TestConnection)
router.GET("/setup/status", setupHandler.GetStatus)
// Health check
router.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "setup_required",
"time": time.Now().UTC().Format(time.RFC3339),
})
})
addr := ":8080"
slog.Info("starting setup mode server", "address", addr)
slog.Info("open http://localhost:8080/setup to configure database connection")
srv := &http.Server{
Addr: addr,
Handler: router,
}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("server error", "error", err)
os.Exit(1)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
slog.Info("setup mode server stopped")
}
func setupLogger(cfg config.LoggingConfig) {
var level slog.Level
switch cfg.Level {
@@ -132,10 +260,10 @@ func setupLogger(cfg config.LoggingConfig) {
slog.SetDefault(slog.New(handler))
}
func setupDatabase(cfg config.DatabaseConfig) (*gorm.DB, error) {
func setupDatabaseFromDSN(dsn string) (*gorm.DB, error) {
gormLogger := logger.Default.LogMode(logger.Silent)
db, err := gorm.Open(mysql.Open(cfg.DSN()), &gorm.Config{
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: gormLogger,
})
if err != nil {
@@ -147,39 +275,46 @@ func setupDatabase(cfg config.DatabaseConfig) (*gorm.DB, error) {
return nil, err
}
sqlDB.SetMaxOpenConns(cfg.MaxOpenConns)
sqlDB.SetMaxIdleConns(cfg.MaxIdleConns)
sqlDB.SetConnMaxLifetime(cfg.ConnMaxLifetime)
sqlDB.SetMaxOpenConns(25)
sqlDB.SetMaxIdleConns(5)
sqlDB.SetConnMaxLifetime(5 * time.Minute)
return db, nil
}
func setupRouter(db *gorm.DB, cfg *config.Config) (*gin.Engine, error) {
func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUserID uint) (*gin.Engine, error) {
// Repositories
userRepo := repository.NewUserRepository(db)
componentRepo := repository.NewComponentRepository(db)
categoryRepo := repository.NewCategoryRepository(db)
priceRepo := repository.NewPriceRepository(db)
configRepo := repository.NewConfigurationRepository(db)
alertRepo := repository.NewAlertRepository(db)
statsRepo := repository.NewStatsRepository(db)
pricelistRepo := repository.NewPricelistRepository(db)
configRepo := repository.NewConfigurationRepository(db)
// Services
authService := services.NewAuthService(userRepo, cfg.Auth)
pricingService := pricing.NewService(componentRepo, priceRepo, cfg.Pricing)
componentService := services.NewComponentService(componentRepo, categoryRepo, statsRepo)
quoteService := services.NewQuoteService(componentRepo, statsRepo, pricingService)
configService := services.NewConfigurationService(configRepo, componentRepo, quoteService)
exportService := services.NewExportService(cfg.Export, categoryRepo)
alertService := alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing)
pricelistService := pricelist.NewService(db, pricelistRepo, componentRepo)
configService := services.NewConfigurationService(configRepo, componentRepo, quoteService)
syncService := sync.NewService(pricelistRepo, local)
// Handlers
authHandler := handlers.NewAuthHandler(authService, userRepo)
componentHandler := handlers.NewComponentHandler(componentService)
quoteHandler := handlers.NewQuoteHandler(quoteService)
configHandler := handlers.NewConfigurationHandler(configService, exportService)
exportHandler := handlers.NewExportHandler(exportService, configService, componentService)
pricingHandler := handlers.NewPricingHandler(db, pricingService, alertService, componentRepo, priceRepo, statsRepo)
pricelistHandler := handlers.NewPricelistHandler(pricelistService, local)
syncHandler := handlers.NewSyncHandler(local, syncService, db)
// Setup handler (for reconfiguration)
setupHandler, err := handlers.NewSetupHandler(local, "web/templates")
if err != nil {
return nil, fmt.Errorf("creating setup handler: %w", err)
}
// Web handler (templates)
webHandler, err := handlers.NewWebHandler("web/templates", componentService)
@@ -192,6 +327,7 @@ func setupRouter(db *gorm.DB, cfg *config.Config) (*gin.Engine, error) {
router.Use(gin.Recovery())
router.Use(requestLogger())
router.Use(middleware.CORS())
router.Use(middleware.OfflineDetector(db, local))
// Static files
router.Static("/static", "web/static")
@@ -229,14 +365,30 @@ func setupRouter(db *gorm.DB, cfg *config.Config) (*gin.Engine, error) {
"lot_count": lotCount,
"lot_log_count": lotLogCount,
"metadata_count": metadataCount,
"db_user": local.GetDBUser(),
})
})
// Current user info (DB user, not app user)
router.GET("/api/current-user", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"username": local.GetDBUser(),
"role": "db_user",
})
})
// Setup routes (for reconfiguration)
router.GET("/setup", setupHandler.ShowSetup)
router.POST("/setup", setupHandler.SaveConnection)
router.POST("/setup/test", setupHandler.TestConnection)
router.GET("/setup/status", setupHandler.GetStatus)
// Web pages
router.GET("/", webHandler.Index)
router.GET("/login", webHandler.Login)
router.GET("/configs", webHandler.Configs)
router.GET("/configurator", webHandler.Configurator)
router.GET("/pricelists", webHandler.Pricelists)
router.GET("/pricelists/:id", webHandler.PricelistDetail)
router.GET("/admin/pricing", webHandler.AdminPricing)
// htmx partials
@@ -252,16 +404,7 @@ func setupRouter(db *gorm.DB, cfg *config.Config) (*gin.Engine, error) {
c.JSON(http.StatusOK, gin.H{"message": "pong"})
})
// Auth (public)
auth := api.Group("/auth")
{
auth.POST("/login", authHandler.Login)
auth.POST("/refresh", authHandler.Refresh)
auth.POST("/logout", authHandler.Logout)
auth.GET("/me", middleware.Auth(authService), authHandler.Me)
}
// Components (public read, for quote builder)
// Components (public read)
components := api.Group("/components")
{
components.GET("", componentHandler.List)
@@ -271,45 +414,155 @@ func setupRouter(db *gorm.DB, cfg *config.Config) (*gin.Engine, error) {
// Categories (public)
api.GET("/categories", componentHandler.GetCategories)
// Quote (public, for anonymous quote building)
// Quote (public)
quote := api.Group("/quote")
{
quote.POST("/validate", quoteHandler.Validate)
quote.POST("/calculate", quoteHandler.Calculate)
}
// Export (public, for anonymous exports)
// Export (public)
export := api.Group("/export")
{
export.POST("/csv", exportHandler.ExportCSV)
}
// Configurations (requires auth)
configs := api.Group("/configs")
configs.Use(middleware.Auth(authService))
configs.Use(middleware.RequireEditor())
// Pricelists (public - RBAC disabled in Phase 1-3)
pricelists := api.Group("/pricelists")
{
configs.GET("", configHandler.List)
configs.POST("", configHandler.Create)
configs.GET("/:uuid", configHandler.Get)
configs.PUT("/:uuid", configHandler.Update)
configs.PATCH("/:uuid/rename", configHandler.Rename)
configs.POST("/:uuid/clone", configHandler.Clone)
configs.POST("/:uuid/refresh-prices", configHandler.RefreshPrices)
configs.DELETE("/:uuid", configHandler.Delete)
// configs.GET("/:uuid/export", configHandler.ExportJSON)
configs.GET("/:uuid/csv", exportHandler.ExportConfigCSV)
// configs.POST("/import", configHandler.ImportJSON)
pricelists.GET("", pricelistHandler.List)
pricelists.GET("/can-write", pricelistHandler.CanWrite)
pricelists.GET("/latest", pricelistHandler.GetLatest)
pricelists.GET("/:id", pricelistHandler.Get)
pricelists.GET("/:id/items", pricelistHandler.GetItems)
pricelists.POST("", pricelistHandler.Create)
pricelists.DELETE("/:id", pricelistHandler.Delete)
}
}
// Admin routes
admin := router.Group("/admin")
admin.Use(middleware.Auth(authService))
{
// Pricing admin
pricingAdmin := admin.Group("/pricing")
pricingAdmin.Use(middleware.RequirePricingAdmin())
// Configurations (public - RBAC disabled)
configs := api.Group("/configs")
{
configs.GET("", func(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
cfgs, total, err := configService.ListAll(page, perPage)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"configurations": cfgs,
"total": total,
"page": page,
"per_page": perPage,
})
})
configs.POST("", func(c *gin.Context) {
var req services.CreateConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
config, err := configService.Create(dbUserID, &req) // use DB user ID
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, config)
})
configs.GET("/:uuid", func(c *gin.Context) {
uuid := c.Param("uuid")
config, err := configService.GetByUUIDNoAuth(uuid)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"})
return
}
c.JSON(http.StatusOK, config)
})
configs.PUT("/:uuid", func(c *gin.Context) {
uuid := c.Param("uuid")
var req services.CreateConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
config, err := configService.UpdateNoAuth(uuid, &req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, config)
})
configs.DELETE("/:uuid", func(c *gin.Context) {
uuid := c.Param("uuid")
if err := configService.DeleteNoAuth(uuid); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
})
configs.PATCH("/:uuid/rename", func(c *gin.Context) {
uuid := c.Param("uuid")
var req struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
config, err := configService.RenameNoAuth(uuid, req.Name)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, config)
})
configs.POST("/:uuid/clone", func(c *gin.Context) {
uuid := c.Param("uuid")
var req struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
config, err := configService.CloneNoAuth(uuid, req.Name, dbUserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, config)
})
configs.POST("/:uuid/refresh-prices", func(c *gin.Context) {
uuid := c.Param("uuid")
config, err := configService.RefreshPricesNoAuth(uuid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, config)
})
}
// Pricing admin (public - RBAC disabled)
pricingAdmin := api.Group("/admin/pricing")
{
pricingAdmin.GET("/stats", pricingHandler.GetStats)
pricingAdmin.GET("/components", pricingHandler.ListComponents)
@@ -323,6 +576,15 @@ func setupRouter(db *gorm.DB, cfg *config.Config) (*gin.Engine, error) {
pricingAdmin.POST("/alerts/:id/resolve", pricingHandler.ResolveAlert)
pricingAdmin.POST("/alerts/:id/ignore", pricingHandler.IgnoreAlert)
}
// Sync API (for offline mode)
syncAPI := api.Group("/sync")
{
syncAPI.GET("/status", syncHandler.GetStatus)
syncAPI.POST("/components", syncHandler.SyncComponents)
syncAPI.POST("/pricelists", syncHandler.SyncPricelists)
syncAPI.POST("/all", syncHandler.SyncAll)
}
}
return router, nil

10
go.mod
View File

@@ -4,19 +4,22 @@ go 1.24.0
require (
github.com/gin-gonic/gin v1.9.1
github.com/glebarez/sqlite v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0
golang.org/x/crypto v0.43.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.5.2
gorm.io/gorm v1.25.5
gorm.io/gorm v1.25.7
)
require (
github.com/bytedance/sonic v1.9.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
@@ -31,6 +34,7 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
@@ -39,4 +43,8 @@ require (
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
)

26
go.sum
View File

@@ -7,12 +7,18 @@ github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583j
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -32,6 +38,8 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@@ -56,6 +64,9 @@ github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZ
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -85,8 +96,9 @@ golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
@@ -98,6 +110,14 @@ gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.5.2 h1:QC2HRskSE75wBuOxe0+iCkyJZ+RqpudsQtqkp+IMuXs=
gorm.io/driver/mysql v1.5.2/go.mod h1:pQLhh1Ut/WUAySdTHwBpBv6+JKcj+ua4ZFx1QQTBzb8=
gorm.io/gorm v1.25.2-0.20230530020048-26663ab9bf55/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.7 h1:VsD6acwRjz2zFxGO50gPO6AkNs7KKnvfzUjHQhZDz/A=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@@ -0,0 +1,134 @@
package handlers
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/services/pricelist"
)
type PricelistHandler struct {
service *pricelist.Service
localDB *localdb.LocalDB
}
func NewPricelistHandler(service *pricelist.Service, localDB *localdb.LocalDB) *PricelistHandler {
return &PricelistHandler{service: service, localDB: localDB}
}
// List returns all pricelists with pagination
func (h *PricelistHandler) List(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
pricelists, total, err := h.service.List(page, perPage)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"pricelists": pricelists,
"total": total,
"page": page,
"per_page": perPage,
})
}
// Get returns a single pricelist by ID
func (h *PricelistHandler) Get(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
return
}
pl, err := h.service.GetByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "pricelist not found"})
return
}
c.JSON(http.StatusOK, pl)
}
// Create creates a new pricelist from current prices
func (h *PricelistHandler) Create(c *gin.Context) {
// Get the database username as the creator
createdBy := h.localDB.GetDBUser()
if createdBy == "" {
createdBy = "unknown"
}
pl, err := h.service.CreateFromCurrentPrices(createdBy)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, pl)
}
// Delete deletes a pricelist by ID
func (h *PricelistHandler) Delete(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
return
}
if err := h.service.Delete(uint(id)); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "pricelist deleted"})
}
// GetItems returns items for a pricelist with pagination
func (h *PricelistHandler) GetItems(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "50"))
search := c.Query("search")
items, total, err := h.service.GetItems(uint(id), page, perPage, search)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"items": items,
"total": total,
"page": page,
"per_page": perPage,
})
}
// CanWrite returns whether the current user can create pricelists
func (h *PricelistHandler) CanWrite(c *gin.Context) {
canWrite, debugInfo := h.service.CanWriteDebug()
c.JSON(http.StatusOK, gin.H{"can_write": canWrite, "debug": debugInfo})
}
// GetLatest returns the most recent active pricelist
func (h *PricelistHandler) GetLatest(c *gin.Context) {
pl, err := h.service.GetLatestActive()
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no active pricelists found"})
return
}
c.JSON(http.StatusOK, pl)
}

196
internal/handlers/setup.go Normal file
View File

@@ -0,0 +1,196 @@
package handlers
import (
"fmt"
"html/template"
"net/http"
"path/filepath"
"strconv"
"time"
"github.com/gin-gonic/gin"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type SetupHandler struct {
localDB *localdb.LocalDB
templates map[string]*template.Template
}
func NewSetupHandler(localDB *localdb.LocalDB, templatesPath string) (*SetupHandler, error) {
funcMap := template.FuncMap{
"sub": func(a, b int) int { return a - b },
"add": func(a, b int) int { return a + b },
}
templates := make(map[string]*template.Template)
// Load setup template (standalone, no base needed)
setupPath := filepath.Join(templatesPath, "setup.html")
tmpl, err := template.New("").Funcs(funcMap).ParseFiles(setupPath)
if err != nil {
return nil, fmt.Errorf("parsing setup template: %w", err)
}
templates["setup.html"] = tmpl
return &SetupHandler{
localDB: localDB,
templates: templates,
}, nil
}
// ShowSetup renders the database setup form
func (h *SetupHandler) ShowSetup(c *gin.Context) {
c.Header("Content-Type", "text/html; charset=utf-8")
// Get existing settings if any
settings, _ := h.localDB.GetSettings()
data := gin.H{
"Settings": settings,
}
tmpl := h.templates["setup.html"]
if err := tmpl.ExecuteTemplate(c.Writer, "setup.html", data); err != nil {
c.String(http.StatusInternalServerError, "Template error: %v", err)
}
}
// TestConnection tests the database connection without saving
func (h *SetupHandler) TestConnection(c *gin.Context) {
host := c.PostForm("host")
portStr := c.PostForm("port")
database := c.PostForm("database")
user := c.PostForm("user")
password := c.PostForm("password")
port := 3306
if p, err := strconv.Atoi(portStr); err == nil {
port = p
}
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s",
user, password, host, port, database)
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"error": fmt.Sprintf("Connection failed: %v", err),
})
return
}
sqlDB, err := db.DB()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"error": fmt.Sprintf("Failed to get database handle: %v", err),
})
return
}
defer sqlDB.Close()
if err := sqlDB.Ping(); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"error": fmt.Sprintf("Ping failed: %v", err),
})
return
}
// Check for required tables
var lotCount int64
if err := db.Table("lot").Count(&lotCount).Error; err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"error": fmt.Sprintf("Table 'lot' not found or inaccessible: %v", err),
})
return
}
// Check write permission
canWrite := testWritePermission(db)
c.JSON(http.StatusOK, gin.H{
"success": true,
"lot_count": lotCount,
"can_write": canWrite,
"message": fmt.Sprintf("Connected successfully! Found %d components.", lotCount),
})
}
// SaveConnection saves the connection settings and signals restart
func (h *SetupHandler) SaveConnection(c *gin.Context) {
host := c.PostForm("host")
portStr := c.PostForm("port")
database := c.PostForm("database")
user := c.PostForm("user")
password := c.PostForm("password")
port := 3306
if p, err := strconv.Atoi(portStr); err == nil {
port = p
}
// Test connection first
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s",
user, password, host, port, database)
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": fmt.Sprintf("Connection failed: %v", err),
})
return
}
sqlDB, _ := db.DB()
sqlDB.Close()
// Save settings
if err := h.localDB.SaveSettings(host, port, database, user, password); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": fmt.Sprintf("Failed to save settings: %v", err),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Settings saved. Please restart the application.",
})
}
// GetStatus returns the current setup status
func (h *SetupHandler) GetStatus(c *gin.Context) {
hasSettings := h.localDB.HasSettings()
c.JSON(http.StatusOK, gin.H{
"configured": hasSettings,
})
}
func testWritePermission(db *gorm.DB) bool {
// Simple check: try to create a temporary table and drop it
testTable := fmt.Sprintf("qt_write_test_%d", time.Now().UnixNano())
// Try to create a test table
err := db.Exec(fmt.Sprintf("CREATE TABLE %s (id INT)", testTable)).Error
if err != nil {
return false
}
// Drop it immediately
db.Exec(fmt.Sprintf("DROP TABLE %s", testTable))
return true
}

217
internal/handlers/sync.go Normal file
View File

@@ -0,0 +1,217 @@
package handlers
import (
"log/slog"
"net/http"
"time"
"github.com/gin-gonic/gin"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
"gorm.io/gorm"
)
// SyncHandler handles sync API endpoints
type SyncHandler struct {
localDB *localdb.LocalDB
syncService *sync.Service
mariaDB *gorm.DB
}
// NewSyncHandler creates a new sync handler
func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, mariaDB *gorm.DB) *SyncHandler {
return &SyncHandler{
localDB: localDB,
syncService: syncService,
mariaDB: mariaDB,
}
}
// SyncStatusResponse represents the sync status
type SyncStatusResponse struct {
LastComponentSync *time.Time `json:"last_component_sync"`
LastPricelistSync *time.Time `json:"last_pricelist_sync"`
IsOnline bool `json:"is_online"`
ComponentsCount int64 `json:"components_count"`
PricelistsCount int64 `json:"pricelists_count"`
ServerPricelists int `json:"server_pricelists"`
NeedComponentSync bool `json:"need_component_sync"`
NeedPricelistSync bool `json:"need_pricelist_sync"`
}
// GetStatus returns current sync status
// GET /api/sync/status
func (h *SyncHandler) GetStatus(c *gin.Context) {
// Check online status by pinging MariaDB
isOnline := h.checkOnline()
// Get sync times
lastComponentSync := h.localDB.GetComponentSyncTime()
lastPricelistSync := h.localDB.GetLastSyncTime()
// Get counts
componentsCount := h.localDB.CountLocalComponents()
pricelistsCount := h.localDB.CountLocalPricelists()
// Get server pricelist count if online
serverPricelists := 0
needPricelistSync := false
if isOnline {
status, err := h.syncService.GetStatus()
if err == nil {
serverPricelists = status.ServerPricelists
needPricelistSync = status.NeedsSync
}
}
// Check if component sync is needed (older than 24 hours)
needComponentSync := h.localDB.NeedComponentSync(24)
c.JSON(http.StatusOK, SyncStatusResponse{
LastComponentSync: lastComponentSync,
LastPricelistSync: lastPricelistSync,
IsOnline: isOnline,
ComponentsCount: componentsCount,
PricelistsCount: pricelistsCount,
ServerPricelists: serverPricelists,
NeedComponentSync: needComponentSync,
NeedPricelistSync: needPricelistSync,
})
}
// SyncResultResponse represents sync operation result
type SyncResultResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Synced int `json:"synced"`
Duration string `json:"duration"`
}
// SyncComponents syncs components from MariaDB to local SQLite
// POST /api/sync/components
func (h *SyncHandler) SyncComponents(c *gin.Context) {
if !h.checkOnline() {
c.JSON(http.StatusServiceUnavailable, gin.H{
"success": false,
"error": "Database is offline",
})
return
}
result, err := h.localDB.SyncComponents(h.mariaDB)
if err != nil {
slog.Error("component sync failed", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": err.Error(),
})
return
}
c.JSON(http.StatusOK, SyncResultResponse{
Success: true,
Message: "Components synced successfully",
Synced: result.TotalSynced,
Duration: result.Duration.String(),
})
}
// SyncPricelists syncs pricelists from MariaDB to local SQLite
// POST /api/sync/pricelists
func (h *SyncHandler) SyncPricelists(c *gin.Context) {
if !h.checkOnline() {
c.JSON(http.StatusServiceUnavailable, gin.H{
"success": false,
"error": "Database is offline",
})
return
}
startTime := time.Now()
synced, err := h.syncService.SyncPricelists()
if err != nil {
slog.Error("pricelist sync failed", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": err.Error(),
})
return
}
c.JSON(http.StatusOK, SyncResultResponse{
Success: true,
Message: "Pricelists synced successfully",
Synced: synced,
Duration: time.Since(startTime).String(),
})
}
// SyncAllResponse represents result of full sync
type SyncAllResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
ComponentsSynced int `json:"components_synced"`
PricelistsSynced int `json:"pricelists_synced"`
Duration string `json:"duration"`
}
// SyncAll syncs both components and pricelists
// POST /api/sync/all
func (h *SyncHandler) SyncAll(c *gin.Context) {
if !h.checkOnline() {
c.JSON(http.StatusServiceUnavailable, gin.H{
"success": false,
"error": "Database is offline",
})
return
}
startTime := time.Now()
var componentsSynced, pricelistsSynced int
// Sync components
compResult, err := h.localDB.SyncComponents(h.mariaDB)
if err != nil {
slog.Error("component sync failed during full sync", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "Component sync failed: " + err.Error(),
})
return
}
componentsSynced = compResult.TotalSynced
// Sync pricelists
pricelistsSynced, err = h.syncService.SyncPricelists()
if err != nil {
slog.Error("pricelist sync failed during full sync", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "Pricelist sync failed: " + err.Error(),
"components_synced": componentsSynced,
})
return
}
c.JSON(http.StatusOK, SyncAllResponse{
Success: true,
Message: "Full sync completed successfully",
ComponentsSynced: componentsSynced,
PricelistsSynced: pricelistsSynced,
Duration: time.Since(startTime).String(),
})
}
// checkOnline checks if MariaDB is accessible
func (h *SyncHandler) checkOnline() bool {
sqlDB, err := h.mariaDB.DB()
if err != nil {
return false
}
if err := sqlDB.Ping(); err != nil {
return false
}
return true
}

View File

@@ -61,7 +61,7 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
basePath := filepath.Join(templatesPath, "base.html")
// Load each page template with base
simplePages := []string{"login.html", "configs.html", "admin_pricing.html"}
simplePages := []string{"login.html", "configs.html", "admin_pricing.html", "pricelists.html", "pricelist_detail.html"}
for _, page := range simplePages {
pagePath := filepath.Join(templatesPath, page)
tmpl, err := template.New("").Funcs(funcMap).ParseFiles(basePath, pagePath)
@@ -154,6 +154,14 @@ func (h *WebHandler) AdminPricing(c *gin.Context) {
h.render(c, "admin_pricing.html", gin.H{"ActivePage": "admin"})
}
func (h *WebHandler) Pricelists(c *gin.Context) {
h.render(c, "pricelists.html", gin.H{"ActivePage": "pricelists"})
}
func (h *WebHandler) PricelistDetail(c *gin.Context) {
h.render(c, "pricelist_detail.html", gin.H{"ActivePage": "pricelists"})
}
// Partials for htmx
func (h *WebHandler) ComponentsPartial(c *gin.Context) {

View File

@@ -0,0 +1,268 @@
package localdb
import (
"fmt"
"log/slog"
"strings"
"time"
"gorm.io/gorm"
)
// ComponentSyncResult contains statistics from component sync
type ComponentSyncResult struct {
TotalSynced int
NewCount int
UpdateCount int
Duration time.Duration
}
// SyncComponents loads components from MariaDB (lot + qt_lot_metadata) into local_components
func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error) {
startTime := time.Now()
// Query to join lot with qt_lot_metadata
// Use LEFT JOIN to include lots without metadata
type componentRow struct {
LotName string
LotDescription string
Category *string
Model *string
CurrentPrice *float64
}
var rows []componentRow
err := mariaDB.Raw(`
SELECT
l.lot_name,
l.lot_description,
COALESCE(c.code, SUBSTRING_INDEX(l.lot_name, '_', 1)) as category,
m.model,
m.current_price
FROM lot l
LEFT JOIN qt_lot_metadata m ON l.lot_name = m.lot_name
LEFT JOIN qt_categories c ON m.category_id = c.id
WHERE m.is_hidden = FALSE OR m.is_hidden IS NULL
ORDER BY l.lot_name
`).Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("querying components from MariaDB: %w", err)
}
if len(rows) == 0 {
slog.Warn("no components found in MariaDB")
return &ComponentSyncResult{
Duration: time.Since(startTime),
}, nil
}
// Get existing local components for comparison
existingMap := make(map[string]bool)
var existing []LocalComponent
if err := l.db.Find(&existing).Error; err != nil {
return nil, fmt.Errorf("reading existing local components: %w", err)
}
for _, c := range existing {
existingMap[c.LotName] = true
}
// Prepare components for batch insert/update
syncTime := time.Now()
components := make([]LocalComponent, 0, len(rows))
newCount := 0
for _, row := range rows {
category := ""
if row.Category != nil {
category = *row.Category
} else {
// Parse category from lot_name (e.g., "CPU_AMD_9654" -> "CPU")
parts := strings.SplitN(row.LotName, "_", 2)
if len(parts) >= 1 {
category = parts[0]
}
}
model := ""
if row.Model != nil {
model = *row.Model
}
comp := LocalComponent{
LotName: row.LotName,
LotDescription: row.LotDescription,
Category: category,
Model: model,
CurrentPrice: row.CurrentPrice,
SyncedAt: syncTime,
}
components = append(components, comp)
if !existingMap[row.LotName] {
newCount++
}
}
// Use transaction for bulk upsert
err = l.db.Transaction(func(tx *gorm.DB) error {
// Delete all existing and insert new (simpler than upsert for SQLite)
if err := tx.Where("1=1").Delete(&LocalComponent{}).Error; err != nil {
return fmt.Errorf("clearing local components: %w", err)
}
// Batch insert
batchSize := 500
for i := 0; i < len(components); i += batchSize {
end := i + batchSize
if end > len(components) {
end = len(components)
}
if err := tx.CreateInBatches(components[i:end], batchSize).Error; err != nil {
return fmt.Errorf("inserting components batch: %w", err)
}
}
return nil
})
if err != nil {
return nil, err
}
// Update last sync time
if err := l.SetComponentSyncTime(syncTime); err != nil {
slog.Warn("failed to update component sync time", "error", err)
}
result := &ComponentSyncResult{
TotalSynced: len(components),
NewCount: newCount,
UpdateCount: len(components) - newCount,
Duration: time.Since(startTime),
}
slog.Info("components synced",
"total", result.TotalSynced,
"new", result.NewCount,
"updated", result.UpdateCount,
"duration", result.Duration)
return result, nil
}
// SearchLocalComponents searches components in local cache by query string
// Searches in lot_name, lot_description, category, and model fields
func (l *LocalDB) SearchLocalComponents(query string, limit int) ([]LocalComponent, error) {
if limit <= 0 {
limit = 50
}
var components []LocalComponent
if query == "" {
// Return all components with limit
err := l.db.Order("lot_name").Limit(limit).Find(&components).Error
return components, err
}
// Search with LIKE on multiple fields
searchPattern := "%" + strings.ToLower(query) + "%"
err := l.db.Where(
"LOWER(lot_name) LIKE ? OR LOWER(lot_description) LIKE ? OR LOWER(category) LIKE ? OR LOWER(model) LIKE ?",
searchPattern, searchPattern, searchPattern, searchPattern,
).Order("lot_name").Limit(limit).Find(&components).Error
return components, err
}
// SearchLocalComponentsByCategory searches components by category and optional query
func (l *LocalDB) SearchLocalComponentsByCategory(category string, query string, limit int) ([]LocalComponent, error) {
if limit <= 0 {
limit = 50
}
var components []LocalComponent
db := l.db.Where("LOWER(category) = ?", strings.ToLower(category))
if query != "" {
searchPattern := "%" + strings.ToLower(query) + "%"
db = db.Where(
"LOWER(lot_name) LIKE ? OR LOWER(lot_description) LIKE ? OR LOWER(model) LIKE ?",
searchPattern, searchPattern, searchPattern,
)
}
err := db.Order("lot_name").Limit(limit).Find(&components).Error
return components, err
}
// GetLocalComponent returns a single component by lot_name
func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) {
var component LocalComponent
err := l.db.Where("lot_name = ?", lotName).First(&component).Error
if err != nil {
return nil, err
}
return &component, nil
}
// GetLocalComponentCategories returns distinct categories from local components
func (l *LocalDB) GetLocalComponentCategories() ([]string, error) {
var categories []string
err := l.db.Model(&LocalComponent{}).
Distinct("category").
Where("category != ''").
Order("category").
Pluck("category", &categories).Error
return categories, err
}
// CountLocalComponents returns the total number of local components
func (l *LocalDB) CountLocalComponents() int64 {
var count int64
l.db.Model(&LocalComponent{}).Count(&count)
return count
}
// CountLocalComponentsByCategory returns component count by category
func (l *LocalDB) CountLocalComponentsByCategory(category string) int64 {
var count int64
l.db.Model(&LocalComponent{}).Where("LOWER(category) = ?", strings.ToLower(category)).Count(&count)
return count
}
// GetComponentSyncTime returns the last component sync timestamp
func (l *LocalDB) GetComponentSyncTime() *time.Time {
var setting struct {
Value string
}
if err := l.db.Table("app_settings").
Where("key = ?", "last_component_sync").
First(&setting).Error; err != nil {
return nil
}
t, err := time.Parse(time.RFC3339, setting.Value)
if err != nil {
return nil
}
return &t
}
// SetComponentSyncTime sets the last component sync timestamp
func (l *LocalDB) SetComponentSyncTime(t time.Time) error {
return l.db.Exec(`
INSERT INTO app_settings (key, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
`, "last_component_sync", t.Format(time.RFC3339), time.Now().Format(time.RFC3339)).Error
}
// NeedComponentSync checks if component sync is needed (older than specified hours)
func (l *LocalDB) NeedComponentSync(maxAgeHours int) bool {
syncTime := l.GetComponentSyncTime()
if syncTime == nil {
return true
}
return time.Since(*syncTime).Hours() > float64(maxAgeHours)
}

View File

@@ -0,0 +1,87 @@
package localdb
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"io"
"os"
)
// getEncryptionKey derives a 32-byte key from environment variable or machine ID
func getEncryptionKey() []byte {
key := os.Getenv("QUOTEFORGE_ENCRYPTION_KEY")
if key == "" {
// Fallback to a machine-based key (hostname + fixed salt)
hostname, _ := os.Hostname()
key = hostname + "quoteforge-salt-2024"
}
// Hash to get exactly 32 bytes for AES-256
hash := sha256.Sum256([]byte(key))
return hash[:]
}
// Encrypt encrypts plaintext using AES-256-GCM
func Encrypt(plaintext string) (string, error) {
if plaintext == "" {
return "", nil
}
key := getEncryptionKey()
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
// Decrypt decrypts ciphertext that was encrypted with Encrypt
func Decrypt(ciphertext string) (string, error) {
if ciphertext == "" {
return "", nil
}
key := getEncryptionKey()
data, err := base64.StdEncoding.DecodeString(ciphertext)
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonceSize := gcm.NonceSize()
if len(data) < nonceSize {
return "", errors.New("ciphertext too short")
}
nonce, ciphertextBytes := data[:nonceSize], data[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertextBytes, nil)
if err != nil {
return "", err
}
return string(plaintext), nil
}

339
internal/localdb/localdb.go Normal file
View File

@@ -0,0 +1,339 @@
package localdb
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// ConnectionSettings stores MariaDB connection credentials
type ConnectionSettings struct {
ID uint `gorm:"primaryKey"`
Host string `gorm:"not null"`
Port int `gorm:"not null;default:3306"`
Database string `gorm:"not null"`
User string `gorm:"not null"`
PasswordEncrypted string `gorm:"not null"` // AES encrypted
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
func (ConnectionSettings) TableName() string {
return "connection_settings"
}
// LocalDB manages the local SQLite database for settings
type LocalDB struct {
db *gorm.DB
path string
}
// New creates a new LocalDB instance
func New(dbPath string) (*LocalDB, error) {
// Ensure directory exists
dir := filepath.Dir(dbPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("creating data directory: %w", err)
}
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
return nil, fmt.Errorf("opening sqlite database: %w", err)
}
// Auto-migrate all local tables
if err := db.AutoMigrate(
&ConnectionSettings{},
&LocalConfiguration{},
&LocalPricelist{},
&LocalPricelistItem{},
&LocalComponent{},
&AppSetting{},
); err != nil {
return nil, fmt.Errorf("migrating sqlite database: %w", err)
}
slog.Info("local SQLite database initialized", "path", dbPath)
return &LocalDB{
db: db,
path: dbPath,
}, nil
}
// HasSettings returns true if connection settings exist
func (l *LocalDB) HasSettings() bool {
var count int64
l.db.Model(&ConnectionSettings{}).Count(&count)
return count > 0
}
// GetSettings retrieves the connection settings with decrypted password
func (l *LocalDB) GetSettings() (*ConnectionSettings, error) {
var settings ConnectionSettings
if err := l.db.First(&settings).Error; err != nil {
return nil, fmt.Errorf("getting settings: %w", err)
}
// Decrypt password
password, err := Decrypt(settings.PasswordEncrypted)
if err != nil {
return nil, fmt.Errorf("decrypting password: %w", err)
}
settings.PasswordEncrypted = password // Return decrypted password in this field
return &settings, nil
}
// SaveSettings saves connection settings with encrypted password
func (l *LocalDB) SaveSettings(host string, port int, database, user, password string) error {
// Encrypt password
encrypted, err := Encrypt(password)
if err != nil {
return fmt.Errorf("encrypting password: %w", err)
}
settings := ConnectionSettings{
ID: 1, // Always use ID=1 for single settings row
Host: host,
Port: port,
Database: database,
User: user,
PasswordEncrypted: encrypted,
}
// Upsert: create or update
result := l.db.Save(&settings)
if result.Error != nil {
return fmt.Errorf("saving settings: %w", result.Error)
}
slog.Info("connection settings saved", "host", host, "port", port, "database", database, "user", user)
return nil
}
// DeleteSettings removes all connection settings
func (l *LocalDB) DeleteSettings() error {
return l.db.Where("1=1").Delete(&ConnectionSettings{}).Error
}
// GetDSN returns the MariaDB DSN string
func (l *LocalDB) GetDSN() (string, error) {
settings, err := l.GetSettings()
if err != nil {
return "", err
}
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
settings.User,
settings.PasswordEncrypted, // Contains decrypted password after GetSettings
settings.Host,
settings.Port,
settings.Database,
)
return dsn, nil
}
// DB returns the underlying gorm.DB for advanced operations
func (l *LocalDB) DB() *gorm.DB {
return l.db
}
// Close closes the database connection
func (l *LocalDB) Close() error {
sqlDB, err := l.db.DB()
if err != nil {
return err
}
return sqlDB.Close()
}
// GetDBUser returns the database username from settings
func (l *LocalDB) GetDBUser() string {
settings, err := l.GetSettings()
if err != nil {
return ""
}
return settings.User
}
// Configuration methods
// SaveConfiguration saves a configuration to local SQLite
func (l *LocalDB) SaveConfiguration(config *LocalConfiguration) error {
return l.db.Save(config).Error
}
// GetConfigurations returns all local configurations
func (l *LocalDB) GetConfigurations() ([]LocalConfiguration, error) {
var configs []LocalConfiguration
err := l.db.Order("created_at DESC").Find(&configs).Error
return configs, err
}
// GetConfigurationByUUID returns a configuration by UUID
func (l *LocalDB) GetConfigurationByUUID(uuid string) (*LocalConfiguration, error) {
var config LocalConfiguration
err := l.db.Where("uuid = ?", uuid).First(&config).Error
return &config, err
}
// DeleteConfiguration deletes a configuration by UUID
func (l *LocalDB) DeleteConfiguration(uuid string) error {
return l.db.Where("uuid = ?", uuid).Delete(&LocalConfiguration{}).Error
}
// CountConfigurations returns the number of local configurations
func (l *LocalDB) CountConfigurations() int64 {
var count int64
l.db.Model(&LocalConfiguration{}).Count(&count)
return count
}
// Pricelist methods
// GetLastSyncTime returns the last sync timestamp
func (l *LocalDB) GetLastSyncTime() *time.Time {
var setting struct {
Value string
}
if err := l.db.Table("app_settings").
Where("key = ?", "last_pricelist_sync").
First(&setting).Error; err != nil {
return nil
}
t, err := time.Parse(time.RFC3339, setting.Value)
if err != nil {
return nil
}
return &t
}
// SetLastSyncTime sets the last sync timestamp
func (l *LocalDB) SetLastSyncTime(t time.Time) error {
// Using raw SQL for upsert since SQLite doesn't have native UPSERT in all versions
return l.db.Exec(`
INSERT INTO app_settings (key, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
`, "last_pricelist_sync", t.Format(time.RFC3339), time.Now().Format(time.RFC3339)).Error
}
// CountLocalPricelists returns the number of local pricelists
func (l *LocalDB) CountLocalPricelists() int64 {
var count int64
l.db.Model(&LocalPricelist{}).Count(&count)
return count
}
// GetLatestLocalPricelist returns the most recently synced pricelist
func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
var pricelist LocalPricelist
if err := l.db.Order("created_at DESC").First(&pricelist).Error; err != nil {
return nil, err
}
return &pricelist, nil
}
// GetLocalPricelistByServerID returns a local pricelist by its server ID
func (l *LocalDB) GetLocalPricelistByServerID(serverID uint) (*LocalPricelist, error) {
var pricelist LocalPricelist
if err := l.db.Where("server_id = ?", serverID).First(&pricelist).Error; err != nil {
return nil, err
}
return &pricelist, nil
}
// GetLocalPricelistByID returns a local pricelist by its local ID
func (l *LocalDB) GetLocalPricelistByID(id uint) (*LocalPricelist, error) {
var pricelist LocalPricelist
if err := l.db.First(&pricelist, id).Error; err != nil {
return nil, err
}
return &pricelist, nil
}
// SaveLocalPricelist saves a pricelist to local SQLite
func (l *LocalDB) SaveLocalPricelist(pricelist *LocalPricelist) error {
return l.db.Save(pricelist).Error
}
// GetLocalPricelists returns all local pricelists
func (l *LocalDB) GetLocalPricelists() ([]LocalPricelist, error) {
var pricelists []LocalPricelist
if err := l.db.Order("created_at DESC").Find(&pricelists).Error; err != nil {
return nil, err
}
return pricelists, nil
}
// CountLocalPricelistItems returns the number of items for a pricelist
func (l *LocalDB) CountLocalPricelistItems(pricelistID uint) int64 {
var count int64
l.db.Model(&LocalPricelistItem{}).Where("pricelist_id = ?", pricelistID).Count(&count)
return count
}
// SaveLocalPricelistItems saves pricelist items to local SQLite
func (l *LocalDB) SaveLocalPricelistItems(items []LocalPricelistItem) error {
if len(items) == 0 {
return nil
}
// Batch insert
batchSize := 500
for i := 0; i < len(items); i += batchSize {
end := i + batchSize
if end > len(items) {
end = len(items)
}
if err := l.db.CreateInBatches(items[i:end], batchSize).Error; err != nil {
return err
}
}
return nil
}
// GetLocalPricelistItems returns items for a local pricelist
func (l *LocalDB) GetLocalPricelistItems(pricelistID uint) ([]LocalPricelistItem, error) {
var items []LocalPricelistItem
if err := l.db.Where("pricelist_id = ?", pricelistID).Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
// GetLocalPriceForLot returns the price for a lot from a local pricelist
func (l *LocalDB) GetLocalPriceForLot(pricelistID uint, lotName string) (float64, error) {
var item LocalPricelistItem
if err := l.db.Where("pricelist_id = ? AND lot_name = ?", pricelistID, lotName).
First(&item).Error; err != nil {
return 0, err
}
return item.Price, nil
}
// MarkPricelistAsUsed marks a pricelist as used by a configuration
func (l *LocalDB) MarkPricelistAsUsed(pricelistID uint, isUsed bool) error {
return l.db.Model(&LocalPricelist{}).Where("id = ?", pricelistID).
Update("is_used", isUsed).Error
}
// DeleteLocalPricelist deletes a pricelist and its items
func (l *LocalDB) DeleteLocalPricelist(id uint) error {
// Delete items first
if err := l.db.Where("pricelist_id = ?", id).Delete(&LocalPricelistItem{}).Error; err != nil {
return err
}
// Delete pricelist
return l.db.Delete(&LocalPricelist{}, id).Error
}

122
internal/localdb/models.go Normal file
View File

@@ -0,0 +1,122 @@
package localdb
import (
"database/sql/driver"
"encoding/json"
"errors"
"time"
)
// AppSetting stores application settings in local SQLite
type AppSetting struct {
Key string `gorm:"primaryKey" json:"key"`
Value string `gorm:"not null" json:"value"`
UpdatedAt time.Time `json:"updated_at"`
}
func (AppSetting) TableName() string {
return "app_settings"
}
// LocalConfigItem represents an item in a configuration
type LocalConfigItem struct {
LotName string `json:"lot_name"`
Quantity int `json:"quantity"`
UnitPrice float64 `json:"unit_price"`
}
// LocalConfigItems is a slice of LocalConfigItem that can be stored as JSON
type LocalConfigItems []LocalConfigItem
func (c LocalConfigItems) Value() (driver.Value, error) {
return json.Marshal(c)
}
func (c *LocalConfigItems) Scan(value interface{}) error {
if value == nil {
*c = make(LocalConfigItems, 0)
return nil
}
var bytes []byte
switch v := value.(type) {
case []byte:
bytes = v
case string:
bytes = []byte(v)
default:
return errors.New("type assertion failed for LocalConfigItems")
}
return json.Unmarshal(bytes, c)
}
func (c LocalConfigItems) Total() float64 {
var total float64
for _, item := range c {
total += item.UnitPrice * float64(item.Quantity)
}
return total
}
// LocalConfiguration stores configurations in local SQLite
type LocalConfiguration struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UUID string `gorm:"uniqueIndex;not null" json:"uuid"`
ServerID *uint `json:"server_id"` // ID on MariaDB server, NULL if local only
Name string `gorm:"not null" json:"name"`
Items LocalConfigItems `gorm:"type:text" json:"items"` // JSON stored as text in SQLite
TotalPrice *float64 `json:"total_price"`
CustomPrice *float64 `json:"custom_price"`
Notes string `json:"notes"`
IsTemplate bool `gorm:"default:false" json:"is_template"`
ServerCount int `gorm:"default:1" json:"server_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
SyncedAt *time.Time `json:"synced_at"`
SyncStatus string `gorm:"default:'local'" json:"sync_status"` // 'local', 'synced', 'modified'
OriginalUserID uint `json:"original_user_id"` // UserID from MariaDB for reference
}
func (LocalConfiguration) TableName() string {
return "local_configurations"
}
// LocalPricelist stores cached pricelists from server
type LocalPricelist struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
ServerID uint `gorm:"not null" json:"server_id"` // ID on MariaDB server
Version string `gorm:"uniqueIndex;not null" json:"version"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
SyncedAt time.Time `json:"synced_at"`
IsUsed bool `gorm:"default:false" json:"is_used"` // Used by any local configuration
}
func (LocalPricelist) TableName() string {
return "local_pricelists"
}
// LocalPricelistItem stores pricelist items
type LocalPricelistItem struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
PricelistID uint `gorm:"not null;index" json:"pricelist_id"`
LotName string `gorm:"not null" json:"lot_name"`
Price float64 `gorm:"not null" json:"price"`
}
func (LocalPricelistItem) TableName() string {
return "local_pricelist_items"
}
// LocalComponent stores cached components for offline search
type LocalComponent struct {
LotName string `gorm:"primaryKey" json:"lot_name"`
LotDescription string `json:"lot_description"`
Category string `json:"category"`
Model string `json:"model"`
CurrentPrice *float64 `json:"current_price"`
SyncedAt time.Time `json:"synced_at"`
}
func (LocalComponent) TableName() string {
return "local_components"
}

View File

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

@@ -1,6 +1,11 @@
package models
import "gorm.io/gorm"
import (
"log/slog"
"strings"
"gorm.io/gorm"
)
// AllModels returns all models for auto-migration
func AllModels() []interface{} {
@@ -12,12 +17,28 @@ func AllModels() []interface{} {
&PriceOverride{},
&PricingAlert{},
&ComponentUsageStats{},
&Pricelist{},
&PricelistItem{},
}
}
// Migrate runs auto-migration for all QuoteForge tables
// Handles MySQL constraint errors gracefully for existing tables
func Migrate(db *gorm.DB) error {
return db.AutoMigrate(AllModels()...)
for _, model := range AllModels() {
if err := db.AutoMigrate(model); err != nil {
// Skip known MySQL constraint errors for existing tables
errStr := err.Error()
if strings.Contains(errStr, "Can't DROP") ||
strings.Contains(errStr, "Duplicate key name") ||
strings.Contains(errStr, "check that it exists") {
slog.Warn("migration warning (skipped)", "model", model, "error", errStr)
continue
}
return err
}
}
return nil
}
// SeedCategories inserts default categories if not exist
@@ -49,3 +70,35 @@ func SeedAdminUser(db *gorm.DB, passwordHash string) error {
}
return db.Create(admin).Error
}
// EnsureDBUser creates or returns the user corresponding to the database connection username.
// This is used when RBAC is disabled - configurations are owned by the DB user.
// Returns the user ID that should be used for all operations.
func EnsureDBUser(db *gorm.DB, dbUsername string) (uint, error) {
if dbUsername == "" {
return 0, nil
}
var user User
err := db.Where("username = ?", dbUsername).First(&user).Error
if err == nil {
return user.ID, nil
}
// User doesn't exist, create it
user = User{
Username: dbUsername,
Email: dbUsername + "@db.local",
PasswordHash: "-", // No password - this is a DB user, not an app user
Role: RoleAdmin,
IsActive: true,
}
if err := db.Create(&user).Error; err != nil {
slog.Error("failed to create DB user", "username", dbUsername, "error", err)
return 0, err
}
slog.Info("created DB user for configurations", "username", dbUsername, "user_id", user.ID)
return user.ID, nil
}

View File

@@ -0,0 +1,58 @@
package models
import (
"time"
)
// Pricelist represents a versioned snapshot of prices
type Pricelist struct {
ID uint `gorm:"primaryKey" json:"id"`
Version string `gorm:"size:20;uniqueIndex;not null" json:"version"` // Format: YYYY-MM-DD-NNN
Notification string `gorm:"size:500" json:"notification"` // Notification shown in configurator
CreatedAt time.Time `json:"created_at"`
CreatedBy string `gorm:"size:100" json:"created_by"`
IsActive bool `gorm:"default:true" json:"is_active"`
UsageCount int `gorm:"default:0" json:"usage_count"`
ExpiresAt *time.Time `json:"expires_at"`
ItemCount int `gorm:"-" json:"item_count,omitempty"` // Virtual field for display
}
func (Pricelist) TableName() string {
return "qt_pricelists"
}
// PricelistItem represents a single item in a pricelist
type PricelistItem struct {
ID uint `gorm:"primaryKey" json:"id"`
PricelistID uint `gorm:"not null;index:idx_pricelist_lot" json:"pricelist_id"`
LotName string `gorm:"size:255;not null;index:idx_pricelist_lot" json:"lot_name"`
Price float64 `gorm:"type:decimal(12,2);not null" json:"price"`
PriceMethod string `gorm:"size:20" json:"price_method"`
// Price calculation settings (snapshot from qt_lot_metadata)
PricePeriodDays int `gorm:"default:90" json:"price_period_days"`
PriceCoefficient float64 `gorm:"type:decimal(5,2);default:0" json:"price_coefficient"`
ManualPrice *float64 `gorm:"type:decimal(12,2)" json:"manual_price,omitempty"`
MetaPrices string `gorm:"size:1000" json:"meta_prices,omitempty"`
// Virtual fields for display
LotDescription string `gorm:"-" json:"lot_description,omitempty"`
Category string `gorm:"-" json:"category,omitempty"`
}
func (PricelistItem) TableName() string {
return "qt_pricelist_items"
}
// PricelistSummary is used for list views
type PricelistSummary struct {
ID uint `json:"id"`
Version string `json:"version"`
Notification string `json:"notification"`
CreatedAt time.Time `json:"created_at"`
CreatedBy string `json:"created_by"`
IsActive bool `json:"is_active"`
UsageCount int `json:"usage_count"`
ExpiresAt *time.Time `json:"expires_at"`
ItemCount int64 `json:"item_count"`
}

View File

@@ -73,3 +73,18 @@ func (r *ConfigurationRepository) ListTemplates(offset, limit int) ([]models.Con
return configs, total, err
}
// ListAll returns all configurations without user filter
func (r *ConfigurationRepository) ListAll(offset, limit int) ([]models.Configuration, int64, error) {
var configs []models.Configuration
var total int64
r.db.Model(&models.Configuration{}).Count(&total)
err := r.db.
Order("created_at DESC").
Offset(offset).
Limit(limit).
Find(&configs).Error
return configs, total, err
}

View File

@@ -0,0 +1,259 @@
package repository
import (
"fmt"
"strings"
"time"
"git.mchus.pro/mchus/quoteforge/internal/models"
"gorm.io/gorm"
)
type PricelistRepository struct {
db *gorm.DB
}
func NewPricelistRepository(db *gorm.DB) *PricelistRepository {
return &PricelistRepository{db: db}
}
// List returns pricelists with pagination
func (r *PricelistRepository) List(offset, limit int) ([]models.PricelistSummary, int64, error) {
var total int64
if err := r.db.Model(&models.Pricelist{}).Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("counting pricelists: %w", err)
}
var pricelists []models.Pricelist
if err := r.db.Order("created_at DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
return nil, 0, fmt.Errorf("listing pricelists: %w", err)
}
// Get item counts for each pricelist
summaries := make([]models.PricelistSummary, len(pricelists))
for i, pl := range pricelists {
var itemCount int64
r.db.Model(&models.PricelistItem{}).Where("pricelist_id = ?", pl.ID).Count(&itemCount)
summaries[i] = models.PricelistSummary{
ID: pl.ID,
Version: pl.Version,
Notification: pl.Notification,
CreatedAt: pl.CreatedAt,
CreatedBy: pl.CreatedBy,
IsActive: pl.IsActive,
UsageCount: pl.UsageCount,
ExpiresAt: pl.ExpiresAt,
ItemCount: itemCount,
}
}
return summaries, total, nil
}
// GetByID returns a pricelist by ID
func (r *PricelistRepository) GetByID(id uint) (*models.Pricelist, error) {
var pricelist models.Pricelist
if err := r.db.First(&pricelist, id).Error; err != nil {
return nil, fmt.Errorf("getting pricelist: %w", err)
}
// Get item count
var itemCount int64
r.db.Model(&models.PricelistItem{}).Where("pricelist_id = ?", id).Count(&itemCount)
pricelist.ItemCount = int(itemCount)
return &pricelist, nil
}
// GetByVersion returns a pricelist by version string
func (r *PricelistRepository) GetByVersion(version string) (*models.Pricelist, error) {
var pricelist models.Pricelist
if err := r.db.Where("version = ?", version).First(&pricelist).Error; err != nil {
return nil, fmt.Errorf("getting pricelist by version: %w", err)
}
return &pricelist, nil
}
// GetLatestActive returns the most recent active pricelist
func (r *PricelistRepository) GetLatestActive() (*models.Pricelist, error) {
var pricelist models.Pricelist
if err := r.db.Where("is_active = ?", true).Order("created_at DESC").First(&pricelist).Error; err != nil {
return nil, fmt.Errorf("getting latest pricelist: %w", err)
}
return &pricelist, nil
}
// Create creates a new pricelist
func (r *PricelistRepository) Create(pricelist *models.Pricelist) error {
if err := r.db.Create(pricelist).Error; err != nil {
return fmt.Errorf("creating pricelist: %w", err)
}
return nil
}
// Update updates a pricelist
func (r *PricelistRepository) Update(pricelist *models.Pricelist) error {
if err := r.db.Save(pricelist).Error; err != nil {
return fmt.Errorf("updating pricelist: %w", err)
}
return nil
}
// Delete deletes a pricelist if usage_count is 0
func (r *PricelistRepository) Delete(id uint) error {
pricelist, err := r.GetByID(id)
if err != nil {
return err
}
if pricelist.UsageCount > 0 {
return fmt.Errorf("cannot delete pricelist with usage_count > 0 (current: %d)", pricelist.UsageCount)
}
// Delete items first
if err := r.db.Where("pricelist_id = ?", id).Delete(&models.PricelistItem{}).Error; err != nil {
return fmt.Errorf("deleting pricelist items: %w", err)
}
// Delete pricelist
if err := r.db.Delete(&models.Pricelist{}, id).Error; err != nil {
return fmt.Errorf("deleting pricelist: %w", err)
}
return nil
}
// CreateItems batch inserts pricelist items
func (r *PricelistRepository) CreateItems(items []models.PricelistItem) error {
if len(items) == 0 {
return nil
}
// Use batch insert for better performance
batchSize := 500
for i := 0; i < len(items); i += batchSize {
end := i + batchSize
if end > len(items) {
end = len(items)
}
if err := r.db.CreateInBatches(items[i:end], batchSize).Error; err != nil {
return fmt.Errorf("batch inserting pricelist items: %w", err)
}
}
return nil
}
// GetItems returns pricelist items with pagination
func (r *PricelistRepository) GetItems(pricelistID uint, offset, limit int, search string) ([]models.PricelistItem, int64, error) {
var total int64
query := r.db.Model(&models.PricelistItem{}).Where("pricelist_id = ?", pricelistID)
if search != "" {
query = query.Where("lot_name LIKE ?", "%"+search+"%")
}
if err := query.Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("counting pricelist items: %w", err)
}
var items []models.PricelistItem
if err := query.Order("lot_name").Offset(offset).Limit(limit).Find(&items).Error; err != nil {
return nil, 0, fmt.Errorf("listing pricelist items: %w", err)
}
// Enrich with lot descriptions
for i := range items {
var lot models.Lot
if err := r.db.Where("lot_name = ?", items[i].LotName).First(&lot).Error; err == nil {
items[i].LotDescription = lot.LotDescription
}
// Parse category from lot_name (e.g., "CPU_AMD_9654" -> "CPU")
parts := strings.SplitN(items[i].LotName, "_", 2)
if len(parts) >= 1 {
items[i].Category = parts[0]
}
}
return items, total, nil
}
// GenerateVersion generates a new version string in format YYYY-MM-DD-NNN
func (r *PricelistRepository) GenerateVersion() (string, error) {
today := time.Now().Format("2006-01-02")
var count int64
if err := r.db.Model(&models.Pricelist{}).
Where("version LIKE ?", today+"%").
Count(&count).Error; err != nil {
return "", fmt.Errorf("counting today's pricelists: %w", err)
}
return fmt.Sprintf("%s-%03d", today, count+1), nil
}
// CanWrite checks if the current database user has INSERT permission on qt_pricelists
func (r *PricelistRepository) CanWrite() bool {
canWrite, _ := r.CanWriteDebug()
return canWrite
}
// CanWriteDebug checks write permission and returns debug info
// Uses raw SQL with explicit columns to avoid schema mismatch issues
func (r *PricelistRepository) CanWriteDebug() (bool, string) {
// Check if table exists first
var count int64
if err := r.db.Table("qt_pricelists").Count(&count).Error; err != nil {
return false, fmt.Sprintf("table check failed: %v", err)
}
// Use raw SQL with only essential columns that always exist
// This avoids GORM model validation and schema mismatch issues
tx := r.db.Begin()
if tx.Error != nil {
return false, fmt.Sprintf("begin tx failed: %v", tx.Error)
}
defer tx.Rollback() // Always rollback - this is just a permission test
testVersion := fmt.Sprintf("test-%06d", time.Now().Unix()%1000000)
// Raw SQL insert with only core columns
err := tx.Exec(`
INSERT INTO qt_pricelists (version, created_by, is_active)
VALUES (?, 'system', 1)
`, testVersion).Error
if err != nil {
// Check if it's a permission error vs other errors
errStr := err.Error()
if strings.Contains(errStr, "INSERT command denied") ||
strings.Contains(errStr, "Access denied") {
return false, "no write permission"
}
return false, fmt.Sprintf("insert failed: %v", err)
}
return true, "ok"
}
// IncrementUsageCount increments the usage count for a pricelist
func (r *PricelistRepository) IncrementUsageCount(id uint) error {
return r.db.Model(&models.Pricelist{}).Where("id = ?", id).
UpdateColumn("usage_count", gorm.Expr("usage_count + 1")).Error
}
// DecrementUsageCount decrements the usage count for a pricelist
func (r *PricelistRepository) DecrementUsageCount(id uint) error {
return r.db.Model(&models.Pricelist{}).Where("id = ?", id).
UpdateColumn("usage_count", gorm.Expr("GREATEST(usage_count - 1, 0)")).Error
}
// GetExpiredUnused returns pricelists that are expired and unused
func (r *PricelistRepository) GetExpiredUnused() ([]models.Pricelist, error) {
var pricelists []models.Pricelist
if err := r.db.Where("expires_at < ? AND usage_count = 0", time.Now()).
Find(&pricelists).Error; err != nil {
return nil, fmt.Errorf("getting expired pricelists: %w", err)
}
return pricelists, nil
}

View File

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

View File

@@ -0,0 +1,156 @@
package pricelist
import (
"fmt"
"log/slog"
"time"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"gorm.io/gorm"
)
type Service struct {
repo *repository.PricelistRepository
componentRepo *repository.ComponentRepository
db *gorm.DB
}
func NewService(db *gorm.DB, repo *repository.PricelistRepository, componentRepo *repository.ComponentRepository) *Service {
return &Service{
repo: repo,
componentRepo: componentRepo,
db: db,
}
}
// CreateFromCurrentPrices creates a new pricelist by taking a snapshot of current prices
func (s *Service) CreateFromCurrentPrices(createdBy string) (*models.Pricelist, error) {
version, err := s.repo.GenerateVersion()
if err != nil {
return nil, fmt.Errorf("generating version: %w", err)
}
expiresAt := time.Now().AddDate(1, 0, 0) // +1 year
pricelist := &models.Pricelist{
Version: version,
CreatedBy: createdBy,
IsActive: true,
ExpiresAt: &expiresAt,
}
if err := s.repo.Create(pricelist); err != nil {
return nil, fmt.Errorf("creating pricelist: %w", err)
}
// Get all components with prices from qt_lot_metadata
var metadata []models.LotMetadata
if err := s.db.Where("current_price IS NOT NULL AND current_price > 0").Find(&metadata).Error; err != nil {
return nil, fmt.Errorf("getting lot metadata: %w", err)
}
// Create pricelist items with all price settings
items := make([]models.PricelistItem, 0, len(metadata))
for _, m := range metadata {
if m.CurrentPrice == nil || *m.CurrentPrice <= 0 {
continue
}
items = append(items, models.PricelistItem{
PricelistID: pricelist.ID,
LotName: m.LotName,
Price: *m.CurrentPrice,
PriceMethod: string(m.PriceMethod),
PricePeriodDays: m.PricePeriodDays,
PriceCoefficient: m.PriceCoefficient,
ManualPrice: m.ManualPrice,
MetaPrices: m.MetaPrices,
})
}
if err := s.repo.CreateItems(items); err != nil {
// Clean up the pricelist if items creation fails
s.repo.Delete(pricelist.ID)
return nil, fmt.Errorf("creating pricelist items: %w", err)
}
pricelist.ItemCount = len(items)
slog.Info("pricelist created",
"id", pricelist.ID,
"version", pricelist.Version,
"items", len(items),
"created_by", createdBy,
)
return pricelist, nil
}
// List returns pricelists with pagination
func (s *Service) List(page, perPage int) ([]models.PricelistSummary, int64, error) {
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 20
}
offset := (page - 1) * perPage
return s.repo.List(offset, perPage)
}
// GetByID returns a pricelist by ID
func (s *Service) GetByID(id uint) (*models.Pricelist, error) {
return s.repo.GetByID(id)
}
// GetItems returns pricelist items with pagination
func (s *Service) GetItems(pricelistID uint, page, perPage int, search string) ([]models.PricelistItem, int64, error) {
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 50
}
offset := (page - 1) * perPage
return s.repo.GetItems(pricelistID, offset, perPage, search)
}
// Delete deletes a pricelist by ID
func (s *Service) Delete(id uint) error {
return s.repo.Delete(id)
}
// CanWrite returns true if the user can create pricelists
func (s *Service) CanWrite() bool {
return s.repo.CanWrite()
}
// CanWriteDebug returns write permission status with debug info
func (s *Service) CanWriteDebug() (bool, string) {
return s.repo.CanWriteDebug()
}
// GetLatestActive returns the most recent active pricelist
func (s *Service) GetLatestActive() (*models.Pricelist, error) {
return s.repo.GetLatestActive()
}
// CleanupExpired deletes expired and unused pricelists
func (s *Service) CleanupExpired() (int, error) {
expired, err := s.repo.GetExpiredUnused()
if err != nil {
return 0, err
}
deleted := 0
for _, pl := range expired {
if err := s.repo.Delete(pl.ID); err != nil {
slog.Warn("failed to delete expired pricelist", "id", pl.ID, "error", err)
continue
}
deleted++
}
slog.Info("cleaned up expired pricelists", "deleted", deleted)
return deleted, nil
}

View File

@@ -0,0 +1,215 @@
package sync
import (
"fmt"
"log/slog"
"time"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/repository"
)
// Service handles synchronization between MariaDB and local SQLite
type Service struct {
pricelistRepo *repository.PricelistRepository
localDB *localdb.LocalDB
}
// NewService creates a new sync service
func NewService(pricelistRepo *repository.PricelistRepository, localDB *localdb.LocalDB) *Service {
return &Service{
pricelistRepo: pricelistRepo,
localDB: localDB,
}
}
// SyncStatus represents the current sync status
type SyncStatus struct {
LastSyncAt *time.Time `json:"last_sync_at"`
ServerPricelists int `json:"server_pricelists"`
LocalPricelists int `json:"local_pricelists"`
NeedsSync bool `json:"needs_sync"`
}
// GetStatus returns the current sync status
func (s *Service) GetStatus() (*SyncStatus, error) {
lastSync := s.localDB.GetLastSyncTime()
// Count server pricelists
serverPricelists, _, err := s.pricelistRepo.List(0, 1)
if err != nil {
return nil, fmt.Errorf("counting server pricelists: %w", err)
}
// Count local pricelists
localCount := s.localDB.CountLocalPricelists()
needsSync, _ := s.NeedSync()
return &SyncStatus{
LastSyncAt: lastSync,
ServerPricelists: len(serverPricelists),
LocalPricelists: int(localCount),
NeedsSync: needsSync,
}, nil
}
// NeedSync checks if synchronization is needed
// Returns true if there are new pricelists on server or last sync was >1 hour ago
func (s *Service) NeedSync() (bool, error) {
lastSync := s.localDB.GetLastSyncTime()
// If never synced, need sync
if lastSync == nil {
return true, nil
}
// If last sync was more than 1 hour ago, suggest sync
if time.Since(*lastSync) > time.Hour {
return true, nil
}
// Check if there are new pricelists on server
latestServer, err := s.pricelistRepo.GetLatestActive()
if err != nil {
// If no pricelists on server, no need to sync
return false, nil
}
latestLocal, err := s.localDB.GetLatestLocalPricelist()
if err != nil {
// No local pricelists, need to sync
return true, nil
}
// If server has newer pricelist, need sync
if latestServer.ID != latestLocal.ServerID {
return true, nil
}
return false, nil
}
// SyncPricelists synchronizes all active pricelists from server to local SQLite
func (s *Service) SyncPricelists() (int, error) {
slog.Info("starting pricelist sync")
// Get all active pricelists from server (up to 100)
serverPricelists, _, err := s.pricelistRepo.List(0, 100)
if err != nil {
return 0, fmt.Errorf("getting server pricelists: %w", err)
}
synced := 0
for _, pl := range serverPricelists {
// Check if pricelist already exists locally
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
if existing != nil {
// Already synced, skip
continue
}
// Create local pricelist
localPL := &localdb.LocalPricelist{
ServerID: pl.ID,
Version: pl.Version,
Name: pl.Notification, // Using notification as name
CreatedAt: pl.CreatedAt,
SyncedAt: time.Now(),
IsUsed: false,
}
if err := s.localDB.SaveLocalPricelist(localPL); err != nil {
slog.Warn("failed to save local pricelist", "version", pl.Version, "error", err)
continue
}
synced++
slog.Debug("synced pricelist", "version", pl.Version, "server_id", pl.ID)
}
// Update last sync time
s.localDB.SetLastSyncTime(time.Now())
slog.Info("pricelist sync completed", "synced", synced, "total", len(serverPricelists))
return synced, nil
}
// SyncPricelistItems synchronizes items for a specific pricelist
func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
// Get local pricelist
localPL, err := s.localDB.GetLocalPricelistByID(localPricelistID)
if err != nil {
return 0, fmt.Errorf("getting local pricelist: %w", err)
}
// Check if items already exist
existingCount := s.localDB.CountLocalPricelistItems(localPricelistID)
if existingCount > 0 {
slog.Debug("pricelist items already synced", "pricelist_id", localPricelistID, "count", existingCount)
return int(existingCount), nil
}
// Get items from server
serverItems, _, err := s.pricelistRepo.GetItems(localPL.ServerID, 0, 10000, "")
if err != nil {
return 0, fmt.Errorf("getting server pricelist items: %w", err)
}
// Convert and save locally
localItems := make([]localdb.LocalPricelistItem, len(serverItems))
for i, item := range serverItems {
localItems[i] = localdb.LocalPricelistItem{
PricelistID: localPricelistID,
LotName: item.LotName,
Price: item.Price,
}
}
if err := s.localDB.SaveLocalPricelistItems(localItems); err != nil {
return 0, fmt.Errorf("saving local pricelist items: %w", err)
}
slog.Info("synced pricelist items", "pricelist_id", localPricelistID, "items", len(localItems))
return len(localItems), nil
}
// SyncPricelistItemsByServerID syncs items for a pricelist by its server ID
func (s *Service) SyncPricelistItemsByServerID(serverPricelistID uint) (int, error) {
localPL, err := s.localDB.GetLocalPricelistByServerID(serverPricelistID)
if err != nil {
return 0, fmt.Errorf("local pricelist not found for server ID %d", serverPricelistID)
}
return s.SyncPricelistItems(localPL.ID)
}
// GetLocalPriceForLot returns the price for a lot from a local pricelist
func (s *Service) GetLocalPriceForLot(localPricelistID uint, lotName string) (float64, error) {
return s.localDB.GetLocalPriceForLot(localPricelistID, lotName)
}
// GetPricelistForOffline returns a pricelist suitable for offline use
// If items are not synced, it will sync them first
func (s *Service) GetPricelistForOffline(serverPricelistID uint) (*localdb.LocalPricelist, error) {
// Ensure pricelist is synced
localPL, err := s.localDB.GetLocalPricelistByServerID(serverPricelistID)
if err != nil {
// Try to sync pricelists first
if _, err := s.SyncPricelists(); err != nil {
return nil, fmt.Errorf("syncing pricelists: %w", err)
}
// Try again
localPL, err = s.localDB.GetLocalPricelistByServerID(serverPricelistID)
if err != nil {
return nil, fmt.Errorf("pricelist not found on server: %w", err)
}
}
// Ensure items are synced
if _, err := s.SyncPricelistItems(localPL.ID); err != nil {
return nil, fmt.Errorf("syncing pricelist items: %w", err)
}
return localPL, nil
}

View File

@@ -187,21 +187,11 @@ async function loadTab(tab) {
}
async function loadData() {
const token = localStorage.getItem('token');
if (!token) {
window.location.href = '/login';
return;
}
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-gray-500">Загрузка...</div>';
try {
if (currentTab === 'alerts') {
const resp = await fetch('/admin/pricing/alerts?per_page=100', {
headers: {'Authorization': 'Bearer ' + token}
});
if (resp.status === 401) { logout(); return; }
if (resp.status === 403) { window.location.href = '/'; return; }
const resp = await fetch('/api/admin/pricing/alerts?per_page=100');
const data = await resp.json();
renderAlerts(data.alerts || []);
} else if (currentTab === 'all-configs') {
@@ -210,17 +200,13 @@ async function loadData() {
if (currentSearch) {
url += '&search=' + encodeURIComponent(currentSearch);
}
const resp = await fetch(url, {
headers: {'Authorization': 'Bearer ' + token}
});
if (resp.status === 401) { logout(); return; }
if (resp.status === 403) { window.location.href = '/'; return; }
const resp = await fetch(url);
const data = await resp.json();
totalPages = Math.ceil(data.total / perPage);
renderAllConfigs(data.configurations || []);
updatePagination(data.total);
} else {
let url = '/admin/pricing/components?page=' + currentPage + '&per_page=' + perPage;
let url = '/api/admin/pricing/components?page=' + currentPage + '&per_page=' + perPage;
if (currentSearch) {
url += '&search=' + encodeURIComponent(currentSearch);
}
@@ -230,10 +216,7 @@ async function loadData() {
if (sortDir) {
url += '&dir=' + encodeURIComponent(sortDir);
}
const resp = await fetch(url, {
headers: {'Authorization': 'Bearer ' + token}
});
if (resp.status === 401) { logout(); return; }
const resp = await fetch(url);
const data = await resp.json();
totalPages = Math.ceil(data.total / perPage);
componentsCache = data.components || [];
@@ -471,9 +454,6 @@ function onMethodChange() {
}
async function fetchPreview() {
const token = localStorage.getItem('token');
if (!token) return;
const lotName = document.getElementById('modal-lot-name').value;
const method = document.getElementById('modal-method').value;
const periodDays = parseInt(document.getElementById('modal-period').value) || 0;
@@ -490,10 +470,9 @@ async function fetchPreview() {
}
try {
const resp = await fetch('/admin/pricing/preview', {
const resp = await fetch('/api/admin/pricing/preview', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({
@@ -508,8 +487,6 @@ async function fetchPreview() {
})
});
if (resp.status === 401) { logout(); return; }
if (resp.ok) {
const data = await resp.json();
@@ -584,12 +561,6 @@ function debounceFetchPreview() {
}
async function savePrice() {
const token = localStorage.getItem('token');
if (!token) {
window.location.href = '/login';
return;
}
const lotName = document.getElementById('modal-lot-name').value;
const method = document.getElementById('modal-method').value;
const periodDaysStr = document.getElementById('modal-period').value;
@@ -630,17 +601,14 @@ async function savePrice() {
}
try {
const resp = await fetch('/admin/pricing/update', {
const resp = await fetch('/api/admin/pricing/update', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
if (resp.status === 401) { logout(); return; }
if (resp.ok) {
closeModal();
loadData();
@@ -683,12 +651,6 @@ function processMetaPrices(metaPrices, originalLotName) {
}
function recalculateAll() {
const token = localStorage.getItem('token');
if (!token) {
window.location.href = '/login';
return;
}
const btn = document.getElementById('btn-recalc');
const progressContainer = document.getElementById('progress-container');
const progressBar = document.getElementById('progress-bar');
@@ -707,9 +669,8 @@ function recalculateAll() {
progressStats.textContent = 'Подготовка...';
// Use fetch with streaming for SSE
fetch('/admin/pricing/recalculate-all', {
method: 'POST',
headers: {'Authorization': 'Bearer ' + token}
fetch('/api/admin/pricing/recalculate-all', {
method: 'POST'
}).then(response => {
const reader = response.body.getReader();
const decoder = new TextDecoder();

View File

@@ -19,18 +19,18 @@
<div class="flex items-center space-x-8">
<a href="/" class="text-xl font-bold text-blue-600">QuoteForge</a>
<div class="hidden md:flex space-x-4">
<a href="/configs" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm" id="nav-configs" style="display:none;">Мои конфигурации</a>
<a href="/admin/pricing" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm" id="nav-admin" style="display:none;">Цены</a>
<a href="/pricelists" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Прайслисты</a>
<a href="/configurator" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Конфигуратор</a>
<a id="admin-pricing-link" href="/admin/pricing" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm hidden">Администратор цен</a>
<a href="/setup" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Настройки</a>
</div>
</div>
<div class="flex items-center">
<div id="user-logged-out">
<a href="/login" class="text-blue-600 hover:text-blue-800 text-sm">Войти</a>
</div>
<div id="user-logged-in" class="hidden">
<span id="user-name" class="text-sm text-gray-700 mr-3"></span>
<button onclick="logout()" class="text-red-600 hover:text-red-800 text-sm">Выйти</button>
<div class="flex items-center space-x-4">
<!-- Sync Status Indicator -->
<div id="sync-indicator" class="flex items-center space-x-2">
<span class="animate-pulse text-gray-400 text-xs">Загрузка...</span>
</div>
<span id="db-user" class="text-sm text-gray-600"></span>
</div>
</div>
</div>
@@ -50,33 +50,6 @@
</footer>
<script>
function initAuth() {
const token = localStorage.getItem('token');
const user = localStorage.getItem('user');
if (token && user) {
try {
const userData = JSON.parse(user);
document.getElementById('user-logged-out').classList.add('hidden');
document.getElementById('user-logged-in').classList.remove('hidden');
document.getElementById('user-name').textContent = userData.username;
document.getElementById('nav-configs').style.display = 'block';
if (userData.role === 'admin' || userData.role === 'pricing_admin') {
document.getElementById('nav-admin').style.display = 'block';
}
} catch(e) {
logout();
}
}
}
function logout() {
localStorage.removeItem('token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user');
window.location.href = '/';
}
function showToast(msg, type) {
const colors = { success: 'bg-green-500', error: 'bg-red-500', info: 'bg-blue-500' };
const el = document.getElementById('toast');
@@ -84,15 +57,85 @@
setTimeout(() => el.innerHTML = '', 3000);
}
async function checkSyncStatus() {
try {
const resp = await fetch('/api/sync/status');
const data = await resp.json();
updateSyncIndicator(data);
} catch(e) {
console.error('Failed to check sync status:', e);
const indicator = document.getElementById('sync-indicator');
if (indicator) {
indicator.innerHTML = '<span class="w-2 h-2 rounded-full bg-red-500"></span><span class="text-xs text-red-600">Offline</span>';
}
}
}
function updateSyncIndicator(data) {
const indicator = document.getElementById('sync-indicator');
if (!indicator) return;
const statusColor = data.is_online ? 'bg-green-500' : 'bg-red-500';
const statusText = data.is_online ? 'Online' : 'Offline';
const textColor = data.is_online ? 'text-green-700' : 'text-red-700';
const needSync = data.need_component_sync || data.need_pricelist_sync;
const syncWarning = needSync ? '<span class="text-yellow-600 ml-1" title="Требуется синхронизация">⚠</span>' : '';
let html = `
<div class="flex items-center space-x-2">
<span class="w-2 h-2 rounded-full ${statusColor}" title="${statusText}"></span>
<span class="text-xs ${textColor}">${statusText}</span>
${syncWarning}
${data.is_online ? `
<button onclick="syncAll()"
class="text-xs px-2 py-1 bg-blue-500 text-white rounded hover:bg-blue-600 transition"
title="Синхронизировать все">
Sync
</button>
` : ''}
</div>
`;
indicator.innerHTML = html;
}
async function syncAll() {
const btn = event.target;
btn.disabled = true;
btn.textContent = '...';
try {
const resp = await fetch('/api/sync/all', { method: 'POST' });
const data = await resp.json();
if (data.success) {
showToast(`Синхронизация завершена: компоненты ${data.components_synced}, прайслисты ${data.pricelists_synced}`, 'success');
checkSyncStatus();
} else {
showToast('Ошибка синхронизации: ' + (data.error || 'неизвестная ошибка'), 'error');
}
} catch(e) {
showToast('Ошибка синхронизации: ' + e.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = 'Sync';
}
}
async function checkDbStatus() {
try {
const resp = await fetch('/api/db-status');
const data = await resp.json();
const statusEl = document.getElementById('db-status');
const countsEl = document.getElementById('db-counts');
const userEl = document.getElementById('db-user');
if (data.connected) {
statusEl.innerHTML = '<span class="text-green-400">БД: подключено</span>';
if (data.db_user) {
userEl.innerHTML = '<span class="text-gray-500">@</span>' + data.db_user;
}
} else {
statusEl.innerHTML = '<span class="text-red-400">БД: ошибка - ' + data.error + '</span>';
}
@@ -103,9 +146,25 @@
}
}
async function checkWritePermission() {
try {
const resp = await fetch('/api/pricelists/can-write');
const data = await resp.json();
if (data.can_write) {
const link = document.getElementById('admin-pricing-link');
if (link) link.classList.remove('hidden');
}
} catch(e) {
console.error('Failed to check write permission:', e);
}
}
document.addEventListener('DOMContentLoaded', function() {
initAuth();
checkDbStatus();
checkWritePermission();
checkSyncStatus();
// Auto-refresh sync status every 30 seconds
setInterval(checkSyncStatus, 30000);
});
</script>
</body>

View File

@@ -99,38 +99,10 @@
</div>
<script>
async function loadConfigs() {
const token = localStorage.getItem('token');
if (!token) {
document.getElementById('configs-list').innerHTML =
'<div class="bg-white rounded-lg shadow p-8 text-center"><a href="/login" class="text-blue-600">Войдите для просмотра</a></div>';
return;
}
try {
const resp = await fetch('/api/configs', {
headers: {'Authorization': 'Bearer ' + token}
});
if (resp.status === 401) {
logout();
return;
}
if (resp.status === 403) {
document.getElementById('configs-list').innerHTML =
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Нет доступа</div>';
return;
}
const data = await resp.json();
renderConfigs(data.configurations || []);
} catch(e) {
document.getElementById('configs-list').innerHTML =
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Ошибка загрузки</div>';
}
}
// Pagination state
let currentPage = 1;
let totalPages = 1;
let perPage = 20;
function renderConfigs(configs) {
if (configs.length === 0) {
@@ -198,20 +170,13 @@ function escapeHtml(text) {
async function deleteConfig(uuid) {
if (!confirm('Удалить?')) return;
const token = localStorage.getItem('token');
await fetch('/api/configs/' + uuid, {
method: 'DELETE',
headers: {'Authorization': 'Bearer ' + token}
method: 'DELETE'
});
loadConfigs();
}
function openRenameModal(uuid, currentName) {
const token = localStorage.getItem('token');
if (!token) {
window.location.href = '/login';
return;
}
document.getElementById('rename-uuid').value = uuid;
document.getElementById('rename-input').value = currentName;
document.getElementById('rename-modal').classList.remove('hidden');
@@ -226,7 +191,6 @@ function closeRenameModal() {
}
async function renameConfig() {
const token = localStorage.getItem('token');
const uuid = document.getElementById('rename-uuid').value;
const name = document.getElementById('rename-input').value.trim();
@@ -239,17 +203,11 @@ async function renameConfig() {
const resp = await fetch('/api/configs/' + uuid + '/rename', {
method: 'PATCH',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ name: name })
});
if (resp.status === 401) {
logout();
return;
}
if (!resp.ok) {
const err = await resp.json();
alert('Ошибка: ' + (err.error || 'Не удалось переименовать'));
@@ -264,11 +222,6 @@ async function renameConfig() {
}
function openCloneModal(uuid, currentName) {
const token = localStorage.getItem('token');
if (!token) {
window.location.href = '/login';
return;
}
document.getElementById('clone-uuid').value = uuid;
document.getElementById('clone-input').value = currentName + ' (копия)';
document.getElementById('clone-modal').classList.remove('hidden');
@@ -283,7 +236,6 @@ function closeCloneModal() {
}
async function cloneConfig() {
const token = localStorage.getItem('token');
const uuid = document.getElementById('clone-uuid').value;
const name = document.getElementById('clone-input').value.trim();
@@ -296,17 +248,11 @@ async function cloneConfig() {
const resp = await fetch('/api/configs/' + uuid + '/clone', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ name: name })
});
if (resp.status === 401) {
logout();
return;
}
if (!resp.ok) {
const err = await resp.json();
alert('Ошибка: ' + (err.error || 'Не удалось скопировать'));
@@ -321,11 +267,6 @@ async function cloneConfig() {
}
function openCreateModal() {
const token = localStorage.getItem('token');
if (!token) {
window.location.href = '/login';
return;
}
document.getElementById('opportunity-number').value = '';
document.getElementById('create-modal').classList.remove('hidden');
document.getElementById('create-modal').classList.add('flex');
@@ -338,7 +279,6 @@ function closeCreateModal() {
}
async function createConfig() {
const token = localStorage.getItem('token');
const name = document.getElementById('opportunity-number').value.trim();
if (!name) {
@@ -350,7 +290,6 @@ async function createConfig() {
const resp = await fetch('/api/configs', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({
@@ -361,11 +300,6 @@ async function createConfig() {
})
});
if (resp.status === 401) {
logout();
return;
}
if (!resp.ok) {
const err = await resp.json();
alert('Ошибка: ' + (err.error || 'Не удалось создать'));
@@ -421,11 +355,6 @@ document.getElementById('clone-input').addEventListener('keydown', function(e) {
}
});
// Pagination functions
let currentPage = 1;
let totalPages = 1;
let perPage = 20;
function prevPage() {
if (currentPage > 1) {
currentPage--;
@@ -451,27 +380,12 @@ function updatePagination(total) {
// Load configs with pagination
async function loadConfigs() {
const token = localStorage.getItem('token');
if (!token) {
document.getElementById('configs-list').innerHTML =
'<div class="bg-white rounded-lg shadow p-8 text-center"><a href="/login" class="text-blue-600">Войдите для просмотра</a></div>';
return;
}
try {
const resp = await fetch('/api/configs?page=' + currentPage + '&per_page=' + perPage, {
headers: {'Authorization': 'Bearer ' + token}
});
const resp = await fetch('/api/configs?page=' + currentPage + '&per_page=' + perPage);
if (resp.status === 401) {
logout();
return;
}
if (resp.status === 403) {
if (!resp.ok) {
document.getElementById('configs-list').innerHTML =
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Нет доступа</div>';
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Ошибка загрузки</div>';
return;
}

View File

@@ -269,9 +269,8 @@ async function loadCategoriesFromAPI() {
// Initialize
document.addEventListener('DOMContentLoaded', async function() {
const token = localStorage.getItem('token');
if (!token || !configUUID) {
// RBAC disabled - no token check required
if (!configUUID) {
window.location.href = '/configs';
return;
}
@@ -280,16 +279,9 @@ document.addEventListener('DOMContentLoaded', async function() {
await loadCategoriesFromAPI();
try {
const resp = await fetch('/api/configs/' + configUUID, {
headers: {'Authorization': 'Bearer ' + token}
});
const resp = await fetch('/api/configs/' + configUUID);
if (resp.status === 401) {
window.location.href = '/login';
return;
}
if (resp.status === 403 || resp.status === 404) {
if (resp.status === 404) {
showToast('Конфигурация не найдена', 'error');
window.location.href = '/configs';
return;
@@ -1119,8 +1111,8 @@ function triggerAutoSave() {
}
async function saveConfig(showNotification = true) {
const token = localStorage.getItem('token');
if (!token || !configUUID) return;
// RBAC disabled - no token check required
if (!configUUID) return;
// Get custom price if set
const customPriceInput = document.getElementById('custom-price-input');
@@ -1134,7 +1126,6 @@ async function saveConfig(showNotification = true) {
const resp = await fetch('/api/configs/' + configUUID, {
method: 'PUT',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({
@@ -1146,11 +1137,6 @@ async function saveConfig(showNotification = true) {
})
});
if (resp.status === 401) {
window.location.href = '/login';
return;
}
if (!resp.ok) {
if (showNotification) {
showToast('Ошибка сохранения', 'error');
@@ -1308,23 +1294,17 @@ async function exportCSVWithCustomPrice() {
}
async function refreshPrices() {
const token = localStorage.getItem('token');
if (!token || !configUUID) return;
// RBAC disabled - no token check required
if (!configUUID) return;
try {
const resp = await fetch('/api/configs/' + configUUID + '/refresh-prices', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
}
});
if (resp.status === 401) {
window.location.href = '/login';
return;
}
if (!resp.ok) {
showToast('Ошибка обновления цен', 'error');
return;

View File

@@ -0,0 +1,270 @@
{{define "title"}}Прайслист - QuoteForge{{end}}
{{define "content"}}
<div class="space-y-6">
<div class="flex items-center space-x-4">
<a href="/pricelists" class="text-gray-500 hover:text-gray-700">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
</a>
<h1 id="page-title" class="text-2xl font-bold text-gray-900">Загрузка...</h1>
</div>
<div id="pricelist-info" class="bg-white rounded-lg shadow p-6">
<div id="pl-notification" class="hidden mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg text-yellow-800"></div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p class="text-sm text-gray-500">Версия</p>
<p id="pl-version" class="font-mono">-</p>
</div>
<div>
<p class="text-sm text-gray-500">Дата создания</p>
<p id="pl-date">-</p>
</div>
<div>
<p class="text-sm text-gray-500">Автор</p>
<p id="pl-author">-</p>
</div>
<div>
<p class="text-sm text-gray-500">Позиций</p>
<p id="pl-items">-</p>
</div>
<div>
<p class="text-sm text-gray-500">Использований</p>
<p id="pl-usage">-</p>
</div>
<div>
<p class="text-sm text-gray-500">Статус</p>
<p id="pl-status">-</p>
</div>
<div>
<p class="text-sm text-gray-500">Истекает</p>
<p id="pl-expires">-</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow overflow-hidden">
<div class="p-4 border-b">
<input type="text" id="search-input" placeholder="Поиск по артикулу..."
class="w-full md:w-64 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Артикул</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Категория</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Цена, $</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Настройки</th>
</tr>
</thead>
<tbody id="items-body" class="bg-white divide-y divide-gray-200">
<tr>
<td colspan="5" class="px-6 py-4 text-center text-gray-500">Загрузка...</td>
</tr>
</tbody>
</table>
<div id="items-pagination" class="p-4 border-t flex justify-between items-center">
<span id="items-info" class="text-sm text-gray-500"></span>
<div id="items-pages" class="space-x-2"></div>
</div>
</div>
</div>
<script>
const pricelistId = window.location.pathname.split('/').pop();
let currentPage = 1;
let searchQuery = '';
let searchTimeout = null;
async function loadPricelistInfo() {
try {
const resp = await fetch(`/api/pricelists/${pricelistId}`);
if (!resp.ok) throw new Error('Pricelist not found');
const pl = await resp.json();
document.getElementById('page-title').textContent = `Прайслист ${pl.version}`;
document.getElementById('pl-version').textContent = pl.version;
document.getElementById('pl-date').textContent = new Date(pl.created_at).toLocaleDateString('ru-RU');
document.getElementById('pl-author').textContent = pl.created_by || '-';
document.getElementById('pl-items').textContent = pl.item_count;
document.getElementById('pl-usage').textContent = pl.usage_count;
// Show notification if present and pricelist is active
const notificationEl = document.getElementById('pl-notification');
if (pl.notification && pl.is_active) {
notificationEl.textContent = pl.notification;
notificationEl.classList.remove('hidden');
} else {
notificationEl.classList.add('hidden');
}
const statusClass = pl.is_active ? 'text-green-600' : 'text-gray-600';
document.getElementById('pl-status').innerHTML = `<span class="${statusClass}">${pl.is_active ? 'Активен' : 'Неактивен'}</span>`;
if (pl.expires_at) {
document.getElementById('pl-expires').textContent = new Date(pl.expires_at).toLocaleDateString('ru-RU');
} else {
document.getElementById('pl-expires').textContent = '-';
}
} catch (e) {
document.getElementById('page-title').textContent = 'Ошибка';
showToast('Прайслист не найден', 'error');
}
}
async function loadItems(page = 1) {
currentPage = page;
try {
let url = `/api/pricelists/${pricelistId}/items?page=${page}&per_page=50`;
if (searchQuery) {
url += `&search=${encodeURIComponent(searchQuery)}`;
}
const resp = await fetch(url);
const data = await resp.json();
renderItems(data.items || []);
renderItemsPagination(data.total, data.page, data.per_page);
} catch (e) {
document.getElementById('items-body').innerHTML = `
<tr>
<td colspan="5" class="px-6 py-4 text-center text-red-500">
Ошибка загрузки: ${e.message}
</td>
</tr>
`;
}
}
function formatPriceSettings(item) {
// Format price settings to match admin pricing interface style
let settings = [];
const hasManualPrice = item.manual_price && item.manual_price > 0;
const hasMeta = item.meta_prices && item.meta_prices.trim() !== '';
// Method indicator
if (hasManualPrice) {
settings.push('<span class="text-orange-600 font-medium">РУЧН</span>');
} else if (item.price_method === 'average') {
settings.push('Сред');
} else {
settings.push('Мед');
}
// Period (only if not manual price)
if (!hasManualPrice) {
const period = item.price_period_days !== undefined && item.price_period_days !== null ? item.price_period_days : 90;
if (period === 7) settings.push('1н');
else if (period === 30) settings.push('1м');
else if (period === 90) settings.push('3м');
else if (period === 365) settings.push('1г');
else if (period === 0) settings.push('все');
else settings.push(period + 'д');
}
// Coefficient
if (item.price_coefficient && item.price_coefficient !== 0) {
settings.push((item.price_coefficient > 0 ? '+' : '') + item.price_coefficient + '%');
}
// Meta article indicator
if (hasMeta) {
settings.push('<span class="text-purple-600 font-medium">МЕТА</span>');
}
return settings.join(' | ') || '-';
}
function renderItems(items) {
if (items.length === 0) {
document.getElementById('items-body').innerHTML = `
<tr>
<td colspan="5" class="px-6 py-4 text-center text-gray-500">
${searchQuery ? 'Ничего не найдено' : 'Позиции не найдены'}
</td>
</tr>
`;
return;
}
const html = items.map(item => {
const price = item.price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const description = item.lot_description || '-';
const truncatedDesc = description.length > 60 ? description.substring(0, 60) + '...' : description;
return `
<tr class="hover:bg-gray-50">
<td class="px-6 py-3 whitespace-nowrap">
<span class="font-mono text-sm">${item.lot_name}</span>
</td>
<td class="px-6 py-3 whitespace-nowrap">
<span class="px-2 py-1 text-xs bg-gray-100 rounded">${item.category || '-'}</span>
</td>
<td class="px-6 py-3 text-sm text-gray-500" title="${description}">${truncatedDesc}</td>
<td class="px-6 py-3 whitespace-nowrap text-right font-mono">${price}</td>
<td class="px-6 py-3 whitespace-nowrap text-sm"><span class="text-xs bg-gray-100 px-2 py-1 rounded">${formatPriceSettings(item)}</span></td>
</tr>
`;
}).join('');
document.getElementById('items-body').innerHTML = html;
}
function renderItemsPagination(total, page, perPage) {
const totalPages = Math.ceil(total / perPage);
const start = (page - 1) * perPage + 1;
const end = Math.min(page * perPage, total);
document.getElementById('items-info').textContent = `${start}-${end} из ${total}`;
if (totalPages <= 1) {
document.getElementById('items-pages').innerHTML = '';
return;
}
let html = '';
// Previous button
if (page > 1) {
html += `<button onclick="loadItems(${page - 1})" class="px-2 py-1 text-sm text-gray-600 hover:text-gray-900">&larr;</button>`;
}
// Page numbers (show max 5 pages)
const startPage = Math.max(1, page - 2);
const endPage = Math.min(totalPages, startPage + 4);
for (let i = startPage; i <= endPage; i++) {
const activeClass = i === page ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50';
html += `<button onclick="loadItems(${i})" class="px-3 py-1 text-sm rounded border ${activeClass}">${i}</button>`;
}
// Next button
if (page < totalPages) {
html += `<button onclick="loadItems(${page + 1})" class="px-2 py-1 text-sm text-gray-600 hover:text-gray-900">&rarr;</button>`;
}
document.getElementById('items-pages').innerHTML = html;
}
document.getElementById('search-input').addEventListener('input', function(e) {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
searchQuery = e.target.value.trim();
loadItems(1);
}, 300);
});
document.addEventListener('DOMContentLoaded', function() {
loadPricelistInfo();
loadItems(1);
});
</script>
{{end}}
{{template "base" .}}

View File

@@ -0,0 +1,234 @@
{{define "title"}}Прайслисты - QuoteForge{{end}}
{{define "content"}}
<div class="space-y-6">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold text-gray-900">Прайслисты</h1>
<div id="create-btn-container"></div>
</div>
<div class="bg-white rounded-lg shadow overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Версия</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Дата</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Автор</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase">Позиций</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase">Исп.</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase">Статус</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>
</tr>
</thead>
<tbody id="pricelists-body" class="bg-white divide-y divide-gray-200">
<tr>
<td colspan="7" class="px-6 py-4 text-center text-gray-500">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
<div id="pagination" class="flex justify-center space-x-2"></div>
</div>
<!-- Create Modal -->
<div id="create-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<h2 class="text-xl font-bold mb-4">Создать прайслист</h2>
<p class="text-sm text-gray-600 mb-4">
Будет создан снимок текущих цен из базы данных.<br>
Автор прайслиста: <span id="db-username" class="font-medium">загрузка...</span>
</p>
<form id="create-form" class="space-y-4">
<div class="flex justify-end space-x-3">
<button type="button" onclick="closeCreateModal()"
class="px-4 py-2 text-gray-700 border border-gray-300 rounded-md hover:bg-gray-50">
Отмена
</button>
<button type="submit"
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
Создать
</button>
</div>
</form>
</div>
</div>
<script>
let canWrite = false;
let currentPage = 1;
async function checkPricelistWritePermission() {
try {
const resp = await fetch('/api/pricelists/can-write');
const data = await resp.json();
canWrite = data.can_write;
if (canWrite) {
document.getElementById('create-btn-container').innerHTML = `
<button onclick="openCreateModal()" class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
Создать прайслист
</button>
`;
}
} catch (e) {
console.error('Failed to check pricelist write permission:', e);
}
}
async function loadPricelists(page = 1) {
currentPage = page;
try {
const resp = await fetch(`/api/pricelists?page=${page}&per_page=20`);
const data = await resp.json();
renderPricelists(data.pricelists || []);
renderPagination(data.total, data.page, data.per_page);
} catch (e) {
document.getElementById('pricelists-body').innerHTML = `
<tr>
<td colspan="7" class="px-6 py-4 text-center text-red-500">
Ошибка загрузки: ${e.message}
</td>
</tr>
`;
}
}
function renderPricelists(pricelists) {
if (pricelists.length === 0) {
document.getElementById('pricelists-body').innerHTML = `
<tr>
<td colspan="7" class="px-6 py-4 text-center text-gray-500">
Прайслисты не найдены. ${canWrite ? 'Создайте первый прайслист.' : ''}
</td>
</tr>
`;
return;
}
const html = pricelists.map(pl => {
const date = new Date(pl.created_at).toLocaleDateString('ru-RU');
const statusClass = pl.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800';
const statusText = pl.is_active ? 'Активен' : 'Неактивен';
let actions = `<a href="/pricelists/${pl.id}" class="text-blue-600 hover:text-blue-800 text-sm">Просмотр</a>`;
if (canWrite && pl.usage_count === 0) {
actions += ` <button onclick="deletePricelist(${pl.id})" class="text-red-600 hover:text-red-800 text-sm ml-2">Удалить</button>`;
}
return `
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap">
<span class="font-mono text-sm">${pl.version}</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${date}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${pl.created_by || '-'}</td>
<td class="px-6 py-4 whitespace-nowrap text-center text-sm">${pl.item_count}</td>
<td class="px-6 py-4 whitespace-nowrap text-center text-sm">${pl.usage_count}</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<span class="px-2 py-1 text-xs rounded-full ${statusClass}">${statusText}</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">${actions}</td>
</tr>
`;
}).join('');
document.getElementById('pricelists-body').innerHTML = html;
}
function renderPagination(total, page, perPage) {
const totalPages = Math.ceil(total / perPage);
if (totalPages <= 1) {
document.getElementById('pagination').innerHTML = '';
return;
}
let html = '';
for (let i = 1; i <= totalPages; i++) {
const activeClass = i === page ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50';
html += `<button onclick="loadPricelists(${i})" class="px-3 py-1 rounded border ${activeClass}">${i}</button>`;
}
document.getElementById('pagination').innerHTML = html;
}
async function loadDbUsername() {
try {
const resp = await fetch('/api/current-user');
const data = await resp.json();
document.getElementById('db-username').textContent = data.username || 'неизвестно';
} catch (e) {
document.getElementById('db-username').textContent = 'неизвестно';
}
}
function openCreateModal() {
document.getElementById('create-modal').classList.remove('hidden');
document.getElementById('create-modal').classList.add('flex');
loadDbUsername();
}
function closeCreateModal() {
document.getElementById('create-modal').classList.add('hidden');
document.getElementById('create-modal').classList.remove('flex');
}
async function createPricelist() {
const resp = await fetch('/api/pricelists', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({})
});
if (!resp.ok) {
const data = await resp.json();
throw new Error(data.error || 'Failed to create pricelist');
}
return await resp.json();
}
async function deletePricelist(id) {
if (!confirm('Удалить этот прайслист?')) return;
try {
const resp = await fetch(`/api/pricelists/${id}`, {
method: 'DELETE'
});
if (!resp.ok) {
const data = await resp.json();
throw new Error(data.error || 'Failed to delete');
}
showToast('Прайслист удален', 'success');
loadPricelists(currentPage);
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
}
document.getElementById('create-form').addEventListener('submit', async function(e) {
e.preventDefault();
try {
const pl = await createPricelist();
closeCreateModal();
showToast(`Прайслист ${pl.version} создан (${pl.item_count} позиций)`, 'success');
loadPricelists(1);
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
});
document.addEventListener('DOMContentLoaded', function() {
checkPricelistWritePermission();
loadPricelists(1);
});
</script>
{{end}}
{{template "base" .}}

153
web/templates/setup.html Normal file
View File

@@ -0,0 +1,153 @@
{{define "setup.html"}}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>QuoteForge - Настройка подключения</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-100 min-h-screen flex items-center justify-center">
<div class="max-w-md w-full mx-4">
<div class="bg-white rounded-lg shadow-lg p-8">
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-blue-600">QuoteForge</h1>
<p class="text-gray-600 mt-2">Настройка подключения к базе данных</p>
</div>
<form id="setup-form" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Хост сервера</label>
<input type="text" name="host" id="host"
value="{{if .Settings}}{{.Settings.Host}}{{else}}localhost{{end}}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="localhost или IP-адрес">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Порт</label>
<input type="number" name="port" id="port"
value="{{if .Settings}}{{.Settings.Port}}{{else}}3306{{end}}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="3306">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">База данных</label>
<input type="text" name="database" id="database"
value="{{if .Settings}}{{.Settings.Database}}{{else}}RFQ_LOG{{end}}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="RFQ_LOG">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Пользователь</label>
<input type="text" name="user" id="user"
value="{{if .Settings}}{{.Settings.User}}{{end}}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="username">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Пароль</label>
<input type="password" name="password" id="password"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="{{if .Settings}}********{{else}}password{{end}}">
{{if .Settings}}
<p class="text-xs text-gray-500 mt-1">Оставьте пустым, чтобы сохранить текущий пароль</p>
{{end}}
</div>
<div id="status" class="hidden p-3 rounded-md text-sm"></div>
<div class="flex space-x-3 pt-4">
<button type="button" onclick="testConnection()"
class="flex-1 px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition">
Проверить
</button>
<button type="submit"
class="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition">
Сохранить
</button>
</div>
</form>
</div>
<p class="text-center text-gray-500 text-sm mt-4">
QuoteForge v1.0 - Конфигуратор серверов
</p>
</div>
<script>
function showStatus(message, type) {
const status = document.getElementById('status');
status.classList.remove('hidden', 'bg-green-100', 'text-green-800', 'bg-red-100', 'text-red-800', 'bg-blue-100', 'text-blue-800');
if (type === 'success') {
status.classList.add('bg-green-100', 'text-green-800');
} else if (type === 'error') {
status.classList.add('bg-red-100', 'text-red-800');
} else {
status.classList.add('bg-blue-100', 'text-blue-800');
}
status.textContent = message;
}
async function testConnection() {
showStatus('Проверка подключения...', 'info');
const formData = new FormData(document.getElementById('setup-form'));
try {
const resp = await fetch('/setup/test', {
method: 'POST',
body: formData
});
const data = await resp.json();
if (data.success) {
let msg = data.message;
if (data.can_write) {
msg += ' Права на запись: есть.';
} else {
msg += ' Права на запись: только чтение.';
}
showStatus(msg, 'success');
} else {
showStatus(data.error, 'error');
}
} catch (e) {
showStatus('Ошибка сети: ' + e.message, 'error');
}
}
document.getElementById('setup-form').addEventListener('submit', async function(e) {
e.preventDefault();
showStatus('Сохранение настроек...', 'info');
const formData = new FormData(this);
try {
const resp = await fetch('/setup', {
method: 'POST',
body: formData
});
const data = await resp.json();
if (data.success) {
showStatus(data.message + ' Перенаправление...', 'success');
setTimeout(() => {
window.location.href = '/';
}, 2000);
} else {
showStatus(data.error, 'error');
}
} catch (e) {
showStatus('Ошибка сети: ' + e.message, 'error');
}
});
</script>
</body>
</html>
{{end}}