Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
677b5d898f | ||
|
|
b3cab3477b | ||
|
|
6d4a37df8b | ||
|
|
7cc101d24d | ||
|
|
4900cd073c | ||
|
|
c0588e9710 | ||
|
|
0cd4f99b46 |
@@ -116,6 +116,28 @@ Rules:
|
||||
- storage configurations use the same vendor_spec + PN→LOT + pricing flow as server configurations;
|
||||
- storage component categories map to existing tabs: `ENC`/`DKC`/`CTL` → Base, `HIC` → PCI (HIC-карты СХД; `HBA`/`NIC` — серверные, не смешивать), `SSD`/`HDD` → Storage (используют существующие серверные LOT), `ACC` → Accessories (используют существующие серверные LOT), `SW` → SW.
|
||||
- `DKC` = контроллерная полка (модель СХД + тип дисков + кол-во слотов + кол-во контроллеров); `CTL` = контроллер (кэш + встроенные порты); `ENC` = дисковая полка без контроллера.
|
||||
- the available config types and their localized names flow from `qt_settings.config_types` on the server;
|
||||
QF falls back to hardcoded "server/Сервер" and "storage/СХД" when the setting is absent.
|
||||
|
||||
## Server-driven configurator settings (`qt_settings`)
|
||||
|
||||
QF reads four settings from `qt_settings` (MariaDB) and caches them in `local_qt_settings` (SQLite).
|
||||
They are synced during every component sync. See `bible-local/server-contract-qt-settings.md` for the
|
||||
full contract and JSON schemas.
|
||||
|
||||
| Setting key | Effect in QF |
|
||||
|-------------|-------------|
|
||||
| `config_types` | New-config modal buttons; category allowlist per config type |
|
||||
| `tab_config` | Configurator tab structure, sections, singleSelect |
|
||||
| `always_visible_tabs` | Which tabs are shown even when empty |
|
||||
| `required_categories` | Per-config-type badge on tabs with unfilled required categories |
|
||||
|
||||
Rules:
|
||||
- sync runs after `SyncComponents`; failure is non-fatal (Warn log only);
|
||||
- `local_qt_settings` is a read-only cache — never written by user actions;
|
||||
- absent or unparseable settings: QF uses hardcoded fallbacks for that key only;
|
||||
- `config_types[].categories` is an allowlist: a category absent from all types is shown everywhere;
|
||||
- `qt_categories.name` and `qt_categories.name_ru` are not used by QF runtime; do not depend on them.
|
||||
|
||||
## Vendor BOM contract
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ Main tables:
|
||||
| `connection_settings` | encrypted MariaDB connection settings |
|
||||
| `app_settings` | local app state |
|
||||
| `local_schema_migrations` | applied local migration markers |
|
||||
| `local_qt_settings` | server-pushed configurator settings cache (from `qt_settings`) |
|
||||
|
||||
Rules:
|
||||
- cache tables may be rebuilt if local migration recovery requires it;
|
||||
@@ -34,12 +35,13 @@ MariaDB is the central sync database (`RFQ_LOG`). Final schema as of 2026-04-15.
|
||||
### QuoteForge tables (qt_*)
|
||||
|
||||
Runtime read:
|
||||
- `qt_categories` — pricelist categories
|
||||
- `qt_categories` — pricelist categories (note: `name`/`name_ru` columns being removed; QF does not use them)
|
||||
- `qt_lot_metadata` — component metadata, price settings
|
||||
- `qt_pricelists` — pricelist headers (source: estimate / warehouse / competitor)
|
||||
- `qt_pricelist_items` — pricelist rows
|
||||
- `qt_partnumber_books` — partnumber book headers
|
||||
- `qt_partnumber_book_items` — PN→LOT catalog payload
|
||||
- `qt_settings` — server-pushed configurator settings; schema managed by server-side agent (see `bible-local/server-contract-qt-settings.md`)
|
||||
|
||||
Runtime read/write:
|
||||
- `qt_projects` — projects
|
||||
@@ -91,11 +93,26 @@ Full column reference as of 2026-03-21 (`RFQ_LOG` final schema).
|
||||
|--------|------|-------|
|
||||
| id | bigint UNSIGNED PK AUTO_INCREMENT | |
|
||||
| code | varchar(20) UNIQUE NOT NULL | |
|
||||
| name | varchar(100) NOT NULL | |
|
||||
| name_ru | varchar(100) | |
|
||||
| name | varchar(100) NOT NULL | being removed; QF does not use at runtime |
|
||||
| name_ru | varchar(100) | being removed; QF does not use at runtime |
|
||||
| display_order | bigint DEFAULT 0 | |
|
||||
| is_required | tinyint(1) DEFAULT 0 | |
|
||||
|
||||
### qt_settings
|
||||
Managed by the server-side agent. QF has SELECT-only access.
|
||||
See `bible-local/server-contract-qt-settings.md` for full schema and value formats.
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| name | varchar(100) PK | setting key |
|
||||
| value | TEXT NOT NULL | JSON-encoded value |
|
||||
|
||||
### local_qt_settings (SQLite)
|
||||
Read-only cache of `qt_settings`. Synced during component sync.
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| name | text PK | setting key |
|
||||
| value | text | JSON value as-is from server |
|
||||
|
||||
### qt_client_schema_state
|
||||
PK: (username, hostname)
|
||||
| Column | Type | Notes |
|
||||
|
||||
165
bible-local/server-contract-qt-settings.md
Normal file
165
bible-local/server-contract-qt-settings.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# Server contract: qt_settings
|
||||
|
||||
## Purpose
|
||||
|
||||
`qt_settings` is a general-purpose key→JSON-value table that the price management
|
||||
application uses to push configuration into QuoteForge clients. QF reads it during
|
||||
component sync and caches the result in `local_qt_settings` (SQLite).
|
||||
|
||||
## Required MariaDB changes (implemented by server-side agent)
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS qt_settings (
|
||||
name VARCHAR(100) NOT NULL PRIMARY KEY,
|
||||
value TEXT NOT NULL -- JSON-encoded value
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
GRANT SELECT ON RFQ_LOG.qt_settings TO 'qfs_user'@'%';
|
||||
```
|
||||
|
||||
## Settings consumed by QuoteForge
|
||||
|
||||
All values are JSON. Missing or unparseable entries are silently skipped; QF
|
||||
falls back to hardcoded defaults for each missing key.
|
||||
|
||||
---
|
||||
|
||||
### `config_types`
|
||||
|
||||
Defines the available device configuration types, their localized names, and the
|
||||
category codes that are allowed for each type. QF uses this for:
|
||||
- the new-config modal (button list + labels);
|
||||
- the configurator's category filter per `config_type`.
|
||||
|
||||
**Value format:** JSON array of objects.
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"code": "server",
|
||||
"name_ru": "Сервер",
|
||||
"display_order": 10,
|
||||
"categories": [
|
||||
"MB","CPU","MEM","RAID",
|
||||
"SSD","HDD","M2","EDSFF","HHHL",
|
||||
"GPU","NIC","HCA","DPU","HBA",
|
||||
"PSU","PS","ACC","RISERS","CARD","BB"
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "storage",
|
||||
"name_ru": "СХД",
|
||||
"display_order": 20,
|
||||
"categories": [
|
||||
"DKC","CPU","MEM","PS",
|
||||
"SSD","HDD","M2","EDSFF","HHHL",
|
||||
"NIC","HBA","HCA","ACC","CARD"
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Fields:
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `code` | string | Identifier stored on `qt_configurations.config_type`. Must be stable. |
|
||||
| `name_ru` | string | Display name in Russian for the QF UI. |
|
||||
| `display_order` | int | Sort order for the modal button list. |
|
||||
| `categories` | string[] | Allowlist of LOT category codes visible in this config type. A category absent from ALL entries is visible in all types. |
|
||||
|
||||
---
|
||||
|
||||
### `tab_config`
|
||||
|
||||
Defines the configurator tab layout: which tabs exist, which categories each tab
|
||||
contains, optional sub-sections within a tab, and whether the tab uses
|
||||
single-select mode.
|
||||
|
||||
**Value format:** JSON array of tab objects (ordered — defines tab bar order).
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"key": "base",
|
||||
"label": "Base",
|
||||
"single_select": true,
|
||||
"categories": ["MB","CPU","MEM","ENC","DKC","CTL"],
|
||||
"sections": null
|
||||
},
|
||||
{
|
||||
"key": "storage",
|
||||
"label": "Storage",
|
||||
"single_select": false,
|
||||
"categories": ["RAID","M2","SSD","HDD","EDSFF","HHHL"],
|
||||
"sections": [
|
||||
{ "title": "RAID Контроллеры", "categories": ["RAID"] },
|
||||
{ "title": "Диски", "categories": ["M2","SSD","HDD","EDSFF","HHHL"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "pci",
|
||||
"label": "PCI",
|
||||
"single_select": false,
|
||||
"categories": ["GPU","DPU","NIC","HCA","HBA","HIC"],
|
||||
"sections": [
|
||||
{ "title": "GPU / DPU", "categories": ["GPU","DPU"] },
|
||||
{ "title": "NIC / HCA", "categories": ["NIC","HCA"] },
|
||||
{ "title": "HBA", "categories": ["HBA"] },
|
||||
{ "title": "HIC", "categories": ["HIC"] }
|
||||
]
|
||||
},
|
||||
{ "key": "power", "label": "Power", "single_select": false, "categories": ["PS","PSU"] },
|
||||
{ "key": "accessories", "label": "Accessories", "single_select": false, "categories": ["ACC","CARD"] },
|
||||
{ "key": "sw", "label": "SW", "single_select": false, "categories": ["SW"] }
|
||||
]
|
||||
```
|
||||
|
||||
The QF frontend always appends an "other" tab for any categories not listed here.
|
||||
|
||||
---
|
||||
|
||||
### `always_visible_tabs`
|
||||
|
||||
Tab keys that are always shown in the configurator regardless of whether they
|
||||
contain any items. Other tabs are hidden when empty.
|
||||
|
||||
**Value format:** JSON string array.
|
||||
|
||||
```json
|
||||
["base", "storage", "pci"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `required_categories`
|
||||
|
||||
Category codes that must have at least one LOT selected for a configuration to
|
||||
be considered complete. Keyed by `config_type` code. QF uses this to show a
|
||||
badge on the tab label when required categories are missing.
|
||||
|
||||
**Value format:** JSON object mapping config_type code → string array.
|
||||
|
||||
```json
|
||||
{
|
||||
"server": ["CPU", "MEM", "BB"],
|
||||
"storage": ["DKC", "CPU", "MEM"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backward compatibility
|
||||
|
||||
- If `qt_settings` does not exist (old server): QF logs `Warn` during sync and
|
||||
leaves `local_qt_settings` empty. The frontend falls back to hardcoded defaults
|
||||
for all four settings. No crash, no data loss.
|
||||
- If a specific key is absent from `qt_settings`: QF falls back to the hardcoded
|
||||
default for that key only.
|
||||
- Old QF clients that do not know about `local_qt_settings` continue to use their
|
||||
hardcoded JS constants unchanged.
|
||||
|
||||
## Note on `qt_categories`
|
||||
|
||||
`qt_categories.name` and `qt_categories.name_ru` are being removed.
|
||||
QF runtime does not depend on them — `GetCategories` derives `Name` from the
|
||||
category code string stored in `local_components`.
|
||||
@@ -894,6 +894,27 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
router.GET("/pricelists/:id", webHandler.PricelistDetail)
|
||||
router.GET("/partnumber-books", webHandler.PartnumberBooks)
|
||||
|
||||
// Short project URLs: /:code → main variant, /:code/:variant → named variant
|
||||
router.GET("/:code", func(c *gin.Context) {
|
||||
code := c.Param("code")
|
||||
project, err := projectService.GetByCode(code)
|
||||
if err != nil {
|
||||
c.Redirect(http.StatusFound, "/projects")
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/projects/"+project.UUID)
|
||||
})
|
||||
router.GET("/:code/:variant", func(c *gin.Context) {
|
||||
code := c.Param("code")
|
||||
variant := c.Param("variant")
|
||||
project, err := projectService.GetByCodeAndVariant(code, variant)
|
||||
if err != nil {
|
||||
c.Redirect(http.StatusFound, "/projects")
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/projects/"+project.UUID)
|
||||
})
|
||||
|
||||
// htmx partials
|
||||
partials := router.Group("/partials")
|
||||
{
|
||||
@@ -919,6 +940,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
|
||||
// Categories (public)
|
||||
api.GET("/categories", componentHandler.GetCategories)
|
||||
api.GET("/configurator-settings", componentHandler.GetConfiguratorSettings)
|
||||
|
||||
// Quote (public)
|
||||
quote := api.Group("/quote")
|
||||
@@ -1147,6 +1169,15 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
c.JSON(http.StatusOK, config)
|
||||
})
|
||||
|
||||
configs.POST("/:uuid/snapshot", func(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
if err := configService.SnapshotCurrentState(uuid); err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "internal server error", err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
configs.PATCH("/:uuid/project", func(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
var req struct {
|
||||
@@ -1516,7 +1547,9 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
project, err := projectService.Create(dbUsername, &req)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrReservedMainVariant):
|
||||
case errors.Is(err, services.ErrReservedMainVariant),
|
||||
errors.Is(err, services.ErrProjectCodeInvalidChars),
|
||||
errors.Is(err, services.ErrProjectVariantInvalidChars):
|
||||
respondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
case errors.Is(err, services.ErrProjectCodeExists):
|
||||
respondError(c, http.StatusConflict, "conflict detected", err)
|
||||
@@ -1554,7 +1587,9 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrReservedMainVariant),
|
||||
errors.Is(err, services.ErrCannotRenameMainVariant):
|
||||
errors.Is(err, services.ErrCannotRenameMainVariant),
|
||||
errors.Is(err, services.ErrProjectCodeInvalidChars),
|
||||
errors.Is(err, services.ErrProjectVariantInvalidChars):
|
||||
respondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
case errors.Is(err, services.ErrProjectCodeExists):
|
||||
respondError(c, http.StatusConflict, "conflict detected", err)
|
||||
|
||||
@@ -125,3 +125,102 @@ func (h *ComponentHandler) GetCategories(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, models.DefaultCategories)
|
||||
}
|
||||
|
||||
func (h *ComponentHandler) GetConfiguratorSettings(c *gin.Context) {
|
||||
s, _ := h.localDB.GetConfiguratorSettings()
|
||||
if s == nil {
|
||||
s = &localdb.ConfiguratorSettings{}
|
||||
}
|
||||
|
||||
if len(s.ConfigTypes) == 0 {
|
||||
s.ConfigTypes = defaultConfigTypes()
|
||||
}
|
||||
if len(s.TabConfig) == 0 {
|
||||
s.TabConfig = defaultTabConfig()
|
||||
}
|
||||
if len(s.AlwaysVisibleTabs) == 0 {
|
||||
s.AlwaysVisibleTabs = []string{"base", "storage", "pci"}
|
||||
}
|
||||
if len(s.RequiredCategories) == 0 {
|
||||
s.RequiredCategories = map[string][]string{"server": {"CPU", "MEM", "BB"}}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, s)
|
||||
}
|
||||
|
||||
func defaultConfigTypes() []localdb.ConfigTypeDef {
|
||||
return []localdb.ConfigTypeDef{
|
||||
{
|
||||
Code: "server",
|
||||
NameRu: "Сервер",
|
||||
DisplayOrder: 10,
|
||||
Categories: []string{
|
||||
"MB", "CPU", "MEM", "RAID",
|
||||
"SSD", "HDD", "M2", "EDSFF", "HHHL",
|
||||
"GPU", "NIC", "HCA", "DPU", "HBA",
|
||||
"PSU", "PS", "ACC", "RISERS", "CARD", "BB",
|
||||
},
|
||||
},
|
||||
{
|
||||
Code: "storage",
|
||||
NameRu: "СХД",
|
||||
DisplayOrder: 20,
|
||||
Categories: []string{
|
||||
"DKC", "CPU", "MEM", "PS",
|
||||
"SSD", "HDD", "M2", "EDSFF", "HHHL",
|
||||
"NIC", "HBA", "HCA", "ACC", "CARD",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func defaultTabConfig() []localdb.TabDef {
|
||||
return []localdb.TabDef{
|
||||
{
|
||||
Key: "base",
|
||||
Label: "Base",
|
||||
SingleSelect: true,
|
||||
Categories: []string{"MB", "CPU", "MEM", "ENC", "DKC", "CTL"},
|
||||
},
|
||||
{
|
||||
Key: "storage",
|
||||
Label: "Storage",
|
||||
SingleSelect: false,
|
||||
Categories: []string{"RAID", "M2", "SSD", "HDD", "EDSFF", "HHHL"},
|
||||
Sections: []localdb.TabSection{
|
||||
{Title: "RAID Контроллеры", Categories: []string{"RAID"}},
|
||||
{Title: "Диски", Categories: []string{"M2", "SSD", "HDD", "EDSFF", "HHHL"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: "pci",
|
||||
Label: "PCI",
|
||||
SingleSelect: false,
|
||||
Categories: []string{"GPU", "DPU", "NIC", "HCA", "HBA", "HIC"},
|
||||
Sections: []localdb.TabSection{
|
||||
{Title: "GPU / DPU", Categories: []string{"GPU", "DPU"}},
|
||||
{Title: "NIC / HCA", Categories: []string{"NIC", "HCA"}},
|
||||
{Title: "HBA", Categories: []string{"HBA"}},
|
||||
{Title: "HIC", Categories: []string{"HIC"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: "power",
|
||||
Label: "Power",
|
||||
SingleSelect: false,
|
||||
Categories: []string{"PS", "PSU"},
|
||||
},
|
||||
{
|
||||
Key: "accessories",
|
||||
Label: "Accessories",
|
||||
SingleSelect: false,
|
||||
Categories: []string{"ACC", "CARD"},
|
||||
},
|
||||
{
|
||||
Key: "sw",
|
||||
Label: "SW",
|
||||
SingleSelect: false,
|
||||
Categories: []string{"SW"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,6 +203,10 @@ func (h *SyncHandler) SyncComponents(c *gin.Context) {
|
||||
_ = h.localDB.SetComponentSyncResult("ok", "", now)
|
||||
h.localDB.AppendSyncLog("components", "ok", "", result.TotalSynced, now, result.Duration.Milliseconds())
|
||||
|
||||
if err := h.localDB.SyncQtSettings(mariaDB); err != nil {
|
||||
slog.Warn("qt_settings sync failed", "error", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SyncResultResponse{
|
||||
Success: true,
|
||||
Message: "Components synced successfully",
|
||||
@@ -339,6 +343,10 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
|
||||
h.localDB.AppendSyncLog("components", "ok", "", compResult.TotalSynced, compNow, compResult.Duration.Milliseconds())
|
||||
componentsSynced = compResult.TotalSynced
|
||||
|
||||
if err := h.localDB.SyncQtSettings(mariaDB); err != nil {
|
||||
slog.Warn("qt_settings sync failed", "error", err)
|
||||
}
|
||||
|
||||
// Sync pricelists
|
||||
plNow := time.Now()
|
||||
pricelistsSynced, err = h.syncService.SyncPricelists()
|
||||
|
||||
@@ -230,6 +230,7 @@ func autoMigrateLocalSchema(db *gorm.DB) error {
|
||||
&PendingChange{},
|
||||
&LocalPartnumberBook{},
|
||||
&SyncLogEntry{},
|
||||
&LocalQtSetting{},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -691,6 +692,22 @@ func (l *LocalDB) GetProjectByUUID(uuid string) (*LocalProject, error) {
|
||||
return &project, nil
|
||||
}
|
||||
|
||||
func (l *LocalDB) GetProjectByCode(code string) (*LocalProject, error) {
|
||||
var project LocalProject
|
||||
if err := l.db.Where("LOWER(code) = LOWER(?) AND variant = ''", code).First(&project).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &project, nil
|
||||
}
|
||||
|
||||
func (l *LocalDB) GetProjectByCodeAndVariant(code, variant string) (*LocalProject, error) {
|
||||
var project LocalProject
|
||||
if err := l.db.Where("LOWER(code) = LOWER(?) AND LOWER(variant) = LOWER(?)", code, variant).First(&project).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &project, nil
|
||||
}
|
||||
|
||||
func (l *LocalDB) GetProjectByName(ownerUsername, name string) (*LocalProject, error) {
|
||||
var project LocalProject
|
||||
if err := l.db.Where("owner_username = ? AND name = ?", ownerUsername, name).First(&project).Error; err != nil {
|
||||
|
||||
@@ -356,3 +356,12 @@ func (v *VendorSpec) Scan(value interface{}) error {
|
||||
}
|
||||
return json.Unmarshal(bytes, v)
|
||||
}
|
||||
|
||||
// LocalQtSetting caches server-pushed settings from qt_settings (MariaDB) into local SQLite.
|
||||
// Synced during component sync. Each row is a JSON-valued setting identified by name.
|
||||
type LocalQtSetting struct {
|
||||
Name string `gorm:"primaryKey;size:100"`
|
||||
Value string `gorm:"type:text"`
|
||||
}
|
||||
|
||||
func (LocalQtSetting) TableName() string { return "local_qt_settings" }
|
||||
|
||||
122
internal/localdb/qt_settings.go
Normal file
122
internal/localdb/qt_settings.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package localdb
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ConfigTypeDef describes one device configuration type as synced from qt_settings.
|
||||
type ConfigTypeDef struct {
|
||||
Code string `json:"code"`
|
||||
NameRu string `json:"name_ru"`
|
||||
DisplayOrder int `json:"display_order"`
|
||||
Categories []string `json:"categories"`
|
||||
}
|
||||
|
||||
// TabSection is a named sub-group of categories within a configurator tab.
|
||||
type TabSection struct {
|
||||
Title string `json:"title"`
|
||||
Categories []string `json:"categories"`
|
||||
}
|
||||
|
||||
// TabDef describes one tab in the configurator as synced from qt_settings.
|
||||
type TabDef struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
SingleSelect bool `json:"single_select"`
|
||||
Categories []string `json:"categories"`
|
||||
Sections []TabSection `json:"sections,omitempty"`
|
||||
}
|
||||
|
||||
// ConfiguratorSettings holds all four server-driven settings consumed by the configurator.
|
||||
// Fields are nil/empty when the corresponding qt_settings key is absent or unparseable;
|
||||
// callers are expected to apply hardcoded fallbacks in that case.
|
||||
type ConfiguratorSettings struct {
|
||||
ConfigTypes []ConfigTypeDef `json:"config_types"`
|
||||
TabConfig []TabDef `json:"tab_config"`
|
||||
AlwaysVisibleTabs []string `json:"always_visible_tabs"`
|
||||
RequiredCategories map[string][]string `json:"required_categories"`
|
||||
}
|
||||
|
||||
// SyncQtSettings reads all rows from qt_settings on MariaDB and replaces the
|
||||
// local_qt_settings cache in a single SQLite transaction. Returns an error if
|
||||
// the qt_settings table doesn't exist on the server (old server without the
|
||||
// table) or on any query/write failure.
|
||||
func (l *LocalDB) SyncQtSettings(mariaDB *gorm.DB) error {
|
||||
var rows []LocalQtSetting
|
||||
if err := mariaDB.
|
||||
Table("qt_settings").
|
||||
Select("name, value").
|
||||
Find(&rows).Error; err != nil {
|
||||
return fmt.Errorf("reading qt_settings from MariaDB: %w", err)
|
||||
}
|
||||
|
||||
return l.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Exec("DELETE FROM local_qt_settings").Error; err != nil {
|
||||
return fmt.Errorf("clearing local_qt_settings: %w", err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := tx.Create(&rows).Error; err != nil {
|
||||
return fmt.Errorf("inserting local_qt_settings: %w", err)
|
||||
}
|
||||
slog.Info("qt_settings synced", "count", len(rows))
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// GetQtSetting returns the raw JSON value for a named setting.
|
||||
// found is false when the key does not exist.
|
||||
func (l *LocalDB) GetQtSetting(name string) (value string, found bool, err error) {
|
||||
var row LocalQtSetting
|
||||
res := l.db.Where("name = ?", name).First(&row)
|
||||
if res.Error != nil {
|
||||
if res.Error == gorm.ErrRecordNotFound {
|
||||
return "", false, nil
|
||||
}
|
||||
return "", false, res.Error
|
||||
}
|
||||
return row.Value, true, nil
|
||||
}
|
||||
|
||||
// GetConfiguratorSettings reads all four known settings from local_qt_settings and
|
||||
// parses them. Any missing or unparseable key is left as nil/zero in the result;
|
||||
// the caller must apply fallbacks.
|
||||
func (l *LocalDB) GetConfiguratorSettings() (*ConfiguratorSettings, error) {
|
||||
out := &ConfiguratorSettings{}
|
||||
|
||||
keys := []string{"config_types", "tab_config", "always_visible_tabs", "required_categories"}
|
||||
for _, key := range keys {
|
||||
raw, found, err := l.GetQtSetting(key)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("reading setting %q: %w", key, err)
|
||||
}
|
||||
if !found || raw == "" {
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "config_types":
|
||||
if err := json.Unmarshal([]byte(raw), &out.ConfigTypes); err != nil {
|
||||
slog.Warn("failed to parse config_types setting", "error", err)
|
||||
}
|
||||
case "tab_config":
|
||||
if err := json.Unmarshal([]byte(raw), &out.TabConfig); err != nil {
|
||||
slog.Warn("failed to parse tab_config setting", "error", err)
|
||||
}
|
||||
case "always_visible_tabs":
|
||||
if err := json.Unmarshal([]byte(raw), &out.AlwaysVisibleTabs); err != nil {
|
||||
slog.Warn("failed to parse always_visible_tabs setting", "error", err)
|
||||
}
|
||||
case "required_categories":
|
||||
if err := json.Unmarshal([]byte(raw), &out.RequiredCategories); err != nil {
|
||||
slog.Warn("failed to parse required_categories setting", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
10
internal/models/qt_setting.go
Normal file
10
internal/models/qt_setting.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package models
|
||||
|
||||
// QtSetting is the MariaDB-side model for qt_settings.
|
||||
// The table is managed by the server-side agent; QF only reads from it.
|
||||
type QtSetting struct {
|
||||
Name string `gorm:"primaryKey;size:100" json:"name"`
|
||||
Value string `gorm:"type:text" json:"value"`
|
||||
}
|
||||
|
||||
func (QtSetting) TableName() string { return "qt_settings" }
|
||||
@@ -423,6 +423,13 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
|
||||
}
|
||||
}
|
||||
|
||||
// Capture fingerprint of the current state before any mutations.
|
||||
preRefreshFP, err := localdb.BuildConfigurationSpecPriceFingerprint(localCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build pre-refresh fingerprint: %w", err)
|
||||
}
|
||||
preRefreshCfg := *localCfg
|
||||
|
||||
// Update prices for all items from pricelist
|
||||
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
||||
for i, item := range localCfg.Items {
|
||||
@@ -462,6 +469,18 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
|
||||
localCfg.UpdatedAt = now
|
||||
localCfg.SyncStatus = "pending"
|
||||
|
||||
// Before saving the new prices, snapshot the pre-refresh state so the revision
|
||||
// history shows a clear before/after for every price update.
|
||||
postRefreshFP, err := localdb.BuildConfigurationSpecPriceFingerprint(localCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build post-refresh fingerprint: %w", err)
|
||||
}
|
||||
if preRefreshFP != postRefreshFP {
|
||||
if err := s.snapshotPreRefreshTx(&preRefreshCfg, ownerUsername); err != nil {
|
||||
return nil, fmt.Errorf("snapshot pre-refresh state: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
cfg, err := s.saveWithVersionAndPending(localCfg, "update", ownerUsername)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("refresh prices with version: %w", err)
|
||||
@@ -820,6 +839,13 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string, pricelistSe
|
||||
}
|
||||
}
|
||||
|
||||
// Capture fingerprint of the current state before any mutations.
|
||||
preRefreshFP, err := localdb.BuildConfigurationSpecPriceFingerprint(localCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build pre-refresh fingerprint: %w", err)
|
||||
}
|
||||
preRefreshCfg := *localCfg
|
||||
|
||||
// Update prices for all items from pricelist
|
||||
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
||||
for i, item := range localCfg.Items {
|
||||
@@ -859,6 +885,18 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string, pricelistSe
|
||||
localCfg.UpdatedAt = now
|
||||
localCfg.SyncStatus = "pending"
|
||||
|
||||
// Before saving the new prices, snapshot the pre-refresh state so the revision
|
||||
// history shows a clear before/after for every price update.
|
||||
postRefreshFP, err := localdb.BuildConfigurationSpecPriceFingerprint(localCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build post-refresh fingerprint: %w", err)
|
||||
}
|
||||
if preRefreshFP != postRefreshFP {
|
||||
if err := s.snapshotPreRefreshTx(&preRefreshCfg, ""); err != nil {
|
||||
return nil, fmt.Errorf("snapshot pre-refresh state: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
cfg, err := s.saveWithVersionAndPending(localCfg, "update", "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("refresh prices without auth with version: %w", err)
|
||||
@@ -866,6 +904,16 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string, pricelistSe
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SnapshotCurrentState creates a revision of the current configuration state without modifying it.
|
||||
// Called before a client-side price refresh so the revision history has a clear before/after.
|
||||
func (s *LocalConfigurationService) SnapshotCurrentState(uuid string) error {
|
||||
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
||||
if err != nil {
|
||||
return ErrConfigNotFound
|
||||
}
|
||||
return s.snapshotPreRefreshTx(localCfg, "")
|
||||
}
|
||||
|
||||
// UpdateServerCount updates server count and recalculates total price without creating a new version.
|
||||
func (s *LocalConfigurationService) UpdateServerCount(configUUID string, serverCount int) (*models.Configuration, error) {
|
||||
if serverCount < 1 {
|
||||
@@ -1432,12 +1480,25 @@ func (s *LocalConfigurationService) appendVersionTx(
|
||||
localCfg *localdb.LocalConfiguration,
|
||||
operation string,
|
||||
createdBy string,
|
||||
) (*localdb.LocalConfigurationVersion, error) {
|
||||
return s.appendVersionTxNote(tx, localCfg, operation, createdBy, "")
|
||||
}
|
||||
|
||||
func (s *LocalConfigurationService) appendVersionTxNote(
|
||||
tx *gorm.DB,
|
||||
localCfg *localdb.LocalConfiguration,
|
||||
operation string,
|
||||
createdBy string,
|
||||
noteOverride string,
|
||||
) (*localdb.LocalConfigurationVersion, error) {
|
||||
snapshot, err := s.buildConfigurationSnapshot(localCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build snapshot: %w", err)
|
||||
}
|
||||
changeNote := fmt.Sprintf("%s via local-first flow", operation)
|
||||
if noteOverride != "" {
|
||||
changeNote = noteOverride
|
||||
}
|
||||
|
||||
var createdByPtr *string
|
||||
if createdBy != "" {
|
||||
@@ -1478,6 +1539,35 @@ func (s *LocalConfigurationService) appendVersionTx(
|
||||
return nil, fmt.Errorf("%w: exceeded retries for %s", ErrVersionConflict, localCfg.UUID)
|
||||
}
|
||||
|
||||
// snapshotPreRefreshTx creates a revision of the current configuration state before a price
|
||||
// refresh so the history clearly shows what existed before prices were updated.
|
||||
// Called only when prices are about to change (fingerprints differ).
|
||||
func (s *LocalConfigurationService) snapshotPreRefreshTx(localCfg *localdb.LocalConfiguration, createdBy string) error {
|
||||
return s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
||||
var locked localdb.LocalConfiguration
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("uuid = ?", localCfg.UUID).
|
||||
First(&locked).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrConfigNotFound
|
||||
}
|
||||
return fmt.Errorf("lock row for pre-refresh snapshot: %w", err)
|
||||
}
|
||||
|
||||
version, err := s.appendVersionTxNote(tx, localCfg, "update", createdBy, "до обновления цен")
|
||||
if err != nil {
|
||||
return fmt.Errorf("append pre-refresh version: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Model(&localdb.LocalConfiguration{}).
|
||||
Where("uuid = ?", localCfg.UUID).
|
||||
Update("current_version_id", version.ID).Error; err != nil {
|
||||
return fmt.Errorf("set current_version_id for pre-refresh snapshot: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *LocalConfigurationService) buildConfigurationSnapshot(localCfg *localdb.LocalConfiguration) (string, error) {
|
||||
return localdb.BuildConfigurationSnapshot(localCfg)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -16,14 +17,19 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrProjectNotFound = errors.New("project not found")
|
||||
ErrProjectForbidden = errors.New("access to project forbidden")
|
||||
ErrProjectCodeExists = errors.New("project code and variant already exist")
|
||||
ErrCannotDeleteMainVariant = errors.New("cannot delete main variant")
|
||||
ErrReservedMainVariant = errors.New("variant name 'main' is reserved")
|
||||
ErrCannotRenameMainVariant = errors.New("cannot rename main variant")
|
||||
ErrProjectNotFound = errors.New("project not found")
|
||||
ErrProjectForbidden = errors.New("access to project forbidden")
|
||||
ErrProjectCodeExists = errors.New("project code and variant already exist")
|
||||
ErrCannotDeleteMainVariant = errors.New("cannot delete main variant")
|
||||
ErrReservedMainVariant = errors.New("variant name 'main' is reserved")
|
||||
ErrCannotRenameMainVariant = errors.New("cannot rename main variant")
|
||||
ErrProjectCodeInvalidChars = errors.New("код опти содержит недопустимые символы (разрешены: буквы, цифры, дефис, точка, подчёркивание)")
|
||||
ErrProjectVariantInvalidChars = errors.New("имя варианта содержит недопустимые символы (разрешены: буквы, цифры, дефис, точка, подчёркивание)")
|
||||
)
|
||||
|
||||
// projectCodeRe allows only URL-path-safe characters so project codes can appear directly in URLs.
|
||||
var projectCodeRe = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
||||
|
||||
type ProjectService struct {
|
||||
localDB *localdb.LocalDB
|
||||
}
|
||||
@@ -64,6 +70,9 @@ func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest)
|
||||
if code == "" {
|
||||
return nil, fmt.Errorf("project code is required")
|
||||
}
|
||||
if !projectCodeRe.MatchString(code) {
|
||||
return nil, ErrProjectCodeInvalidChars
|
||||
}
|
||||
variant := strings.TrimSpace(req.Variant)
|
||||
if err := validateProjectVariantName(variant); err != nil {
|
||||
return nil, err
|
||||
@@ -106,6 +115,9 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr
|
||||
if code == "" {
|
||||
return nil, fmt.Errorf("project code is required")
|
||||
}
|
||||
if !projectCodeRe.MatchString(code) {
|
||||
return nil, ErrProjectCodeInvalidChars
|
||||
}
|
||||
localProject.Code = code
|
||||
}
|
||||
if req.Variant != nil {
|
||||
@@ -183,6 +195,9 @@ func validateProjectVariantName(variant string) error {
|
||||
if normalizeProjectVariant(variant) == "main" {
|
||||
return ErrReservedMainVariant
|
||||
}
|
||||
if variant != "" && !projectCodeRe.MatchString(variant) {
|
||||
return ErrProjectVariantInvalidChars
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -282,6 +297,24 @@ func (s *ProjectService) GetByUUID(projectUUID, ownerUsername string) (*models.P
|
||||
return localdb.LocalToProject(localProject), nil
|
||||
}
|
||||
|
||||
// GetByCode finds the main variant of a project by its code (case-insensitive).
|
||||
func (s *ProjectService) GetByCode(code string) (*models.Project, error) {
|
||||
localProject, err := s.localDB.GetProjectByCode(code)
|
||||
if err != nil {
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
return localdb.LocalToProject(localProject), nil
|
||||
}
|
||||
|
||||
// GetByCodeAndVariant finds a project by code + variant (both case-insensitive).
|
||||
func (s *ProjectService) GetByCodeAndVariant(code, variant string) (*models.Project, error) {
|
||||
localProject, err := s.localDB.GetProjectByCodeAndVariant(code, variant)
|
||||
if err != nil {
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
return localdb.LocalToProject(localProject), nil
|
||||
}
|
||||
|
||||
func (s *ProjectService) ListConfigurations(projectUUID, ownerUsername, status string) (*ProjectConfigurationsResult, error) {
|
||||
project, err := s.GetByUUID(projectUUID, ownerUsername)
|
||||
if err != nil {
|
||||
|
||||
20
releases/v1.18/RELEASE_NOTES.md
Normal file
20
releases/v1.18/RELEASE_NOTES.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# QuoteForge v1.18
|
||||
|
||||
Дата релиза: 2026-06-18
|
||||
Тег: `v1.18`
|
||||
|
||||
Предыдущий релиз: `v1.17`
|
||||
|
||||
## Ключевые изменения
|
||||
|
||||
- BOM: поддержка формата `<qty>x <description>` при импорте Nx-спецификаций;
|
||||
- BOM: приоритет cart-LOT в дропдауне, корректный qtyMismatch при lot_qty_per_pn > 1;
|
||||
- CSV экспорт: bundle (1 PN → N LOT) разворачивается в отдельные строки;
|
||||
- ценообразование: ручная цена (buy/sale) сохраняется и экспортируется в CSV;
|
||||
- ценообразование: таблица использует qty из корзины как источник истины;
|
||||
- ценообразование: правильный порядок строк (MB→CPU→MEM→…) в pricing CSV и вкладке Ценообразование при отсутствии BOM;
|
||||
|
||||
## Запуск на macOS
|
||||
|
||||
Снимите карантинный атрибут через терминал: `xattr -d com.apple.quarantine /path/to/qfs-darwin-arm64`
|
||||
После этого бинарник запустится без предупреждения Gatekeeper.
|
||||
35
releases/v2.19/RELEASE_NOTES.md
Normal file
35
releases/v2.19/RELEASE_NOTES.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# QuoteForge v2.19
|
||||
|
||||
Дата релиза: 2026-06-23
|
||||
Тег: `v2.19`
|
||||
|
||||
## Что нового
|
||||
|
||||
### Серверно-управляемые настройки конфигуратора
|
||||
|
||||
Типы устройств, структура вкладок и фильтры категорий теперь приезжают с сервера вместо жёстко заданных JS-констант.
|
||||
|
||||
- новая таблица `qt_settings` на стороне сервера (контракт в `bible-local/server-contract-qt-settings.md`);
|
||||
- QF синхронизирует `qt_settings` → `local_qt_settings` (SQLite) после каждой синхронизации компонентов;
|
||||
- новый endpoint `GET /api/configurator-settings` отдаёт четыре настройки: `config_types`, `tab_config`, `always_visible_tabs`, `required_categories`;
|
||||
- при недоступности сервера или отсутствии таблицы QF автоматически использует прежние захардкоженные значения — поведение не меняется.
|
||||
|
||||
### Динамический выбор типа оборудования
|
||||
|
||||
- модальное окно «Новая конфигурация» загружает типы устройств с сервера: названия и количество кнопок определяются в `qt_settings.config_types`;
|
||||
- добавление новых типов устройств не требует обновления QF.
|
||||
|
||||
### Серверно-управляемая фильтрация категорий
|
||||
|
||||
- конфигуратор фильтрует LOT-категории по списку из `qt_settings.config_types[].categories`;
|
||||
- структура вкладок обновляется из `qt_settings.tab_config` (порядок вкладок, подразделы, single-select режим);
|
||||
- бейдж на вкладке при незаполненных обязательных категориях (`qt_settings.required_categories`).
|
||||
|
||||
### Прочее
|
||||
|
||||
- тайтлы страниц переименованы с OFS на QFS.
|
||||
|
||||
## Запуск на macOS
|
||||
|
||||
Снимите карантинный атрибут через терминал: `xattr -d com.apple.quarantine /path/to/qfs-darwin-arm64`
|
||||
После этого бинарник запустится без предупреждения Gatekeeper.
|
||||
@@ -53,45 +53,34 @@ mkdir -p "${RELEASE_DIR}"
|
||||
# Create release notes template only when missing.
|
||||
ensure_release_notes "${RELEASE_DIR}/RELEASE_NOTES.md"
|
||||
|
||||
# Build for all platforms
|
||||
# Build binaries
|
||||
echo -e "${YELLOW}→ Building binaries...${NC}"
|
||||
make build-all
|
||||
|
||||
LDFLAGS="-s -w -X main.Version=${VERSION}"
|
||||
|
||||
echo "Building qfs for macOS (Apple Silicon)..."
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="${LDFLAGS}" -o bin/qfs-darwin-arm64 ./cmd/qfs
|
||||
echo "✓ Built: bin/qfs-darwin-arm64"
|
||||
|
||||
echo "Building qfs for Windows..."
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="${LDFLAGS}" -o bin/qfs-windows-amd64.exe ./cmd/qfs
|
||||
echo "✓ Built: bin/qfs-windows-amd64.exe"
|
||||
|
||||
# Package binaries with checksums
|
||||
echo ""
|
||||
echo -e "${YELLOW}→ Creating release packages...${NC}"
|
||||
|
||||
# Linux AMD64
|
||||
if [ -f "bin/qfs-linux-amd64" ]; then
|
||||
cd bin
|
||||
tar -czf "../${RELEASE_DIR}/qfs-${VERSION}-linux-amd64.tar.gz" qfs-linux-amd64
|
||||
cd ..
|
||||
echo -e "${GREEN} ✓ qfs-${VERSION}-linux-amd64.tar.gz${NC}"
|
||||
fi
|
||||
|
||||
# macOS Intel
|
||||
if [ -f "bin/qfs-darwin-amd64" ]; then
|
||||
cd bin
|
||||
tar -czf "../${RELEASE_DIR}/qfs-${VERSION}-darwin-amd64.tar.gz" qfs-darwin-amd64
|
||||
cd ..
|
||||
echo -e "${GREEN} ✓ qfs-${VERSION}-darwin-amd64.tar.gz${NC}"
|
||||
fi
|
||||
|
||||
# macOS Apple Silicon
|
||||
if [ -f "bin/qfs-darwin-arm64" ]; then
|
||||
cd bin
|
||||
tar -czf "../${RELEASE_DIR}/qfs-${VERSION}-darwin-arm64.tar.gz" qfs-darwin-arm64
|
||||
cd ..
|
||||
echo -e "${GREEN} ✓ qfs-${VERSION}-darwin-arm64.tar.gz${NC}"
|
||||
fi
|
||||
cd bin
|
||||
tar -czf "../${RELEASE_DIR}/qfs-${VERSION}-darwin-arm64.tar.gz" qfs-darwin-arm64
|
||||
cd ..
|
||||
echo -e "${GREEN} ✓ qfs-${VERSION}-darwin-arm64.tar.gz${NC}"
|
||||
|
||||
# Windows AMD64
|
||||
if [ -f "bin/qfs-windows-amd64.exe" ]; then
|
||||
cd bin
|
||||
zip -q "../${RELEASE_DIR}/qfs-${VERSION}-windows-amd64.zip" qfs-windows-amd64.exe
|
||||
cd ..
|
||||
echo -e "${GREEN} ✓ qfs-${VERSION}-windows-amd64.zip${NC}"
|
||||
fi
|
||||
cd bin
|
||||
zip -q "../${RELEASE_DIR}/qfs-${VERSION}-windows-amd64.zip" qfs-windows-amd64.exe
|
||||
cd ..
|
||||
echo -e "${GREEN} ✓ qfs-${VERSION}-windows-amd64.zip${NC}"
|
||||
|
||||
# Generate checksums
|
||||
echo ""
|
||||
|
||||
@@ -629,11 +629,13 @@
|
||||
|
||||
const totalColor = totalDelta > 0 ? 'text-red-600' : totalDelta < 0 ? 'text-green-600' : 'text-gray-600';
|
||||
const totalArrow = _fmtArrow(r.prevTotal || 0, r.newTotal || 0);
|
||||
const totalPrevHtml = totalDelta !== 0
|
||||
? `<span class="text-gray-400 line-through text-xs mr-1">${_fmtMoneyDiff(r.prevTotal || 0)}</span>`
|
||||
: '';
|
||||
html += `<div class="flex justify-between items-center text-sm bg-gray-50 rounded px-3 py-2 mb-1">
|
||||
<span class="text-gray-600 font-medium">Итог конфигурации</span>
|
||||
<span>
|
||||
<span class="text-gray-400 line-through text-xs mr-1">${_fmtMoneyDiff(r.prevTotal || 0)}</span>
|
||||
<span class="${totalColor} font-semibold">${_fmtMoneyDiff(r.newTotal || 0)}</span>${totalArrow}
|
||||
${totalPrevHtml}<span class="${totalColor} font-semibold">${_fmtMoneyDiff(r.newTotal || 0)}</span>${totalArrow}
|
||||
</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "title"}}Ревизии - OFS{{end}}
|
||||
{{define "title"}}QFS Ревизии{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-4">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "title"}}Мои конфигурации - OFS{{end}}
|
||||
{{define "title"}}QFS Мои конфигурации{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-4">
|
||||
@@ -55,12 +55,12 @@
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Тип оборудования</label>
|
||||
<div class="inline-flex rounded-lg border border-gray-200 overflow-hidden w-full">
|
||||
<button type="button" id="type-server-btn" onclick="setCreateType('server')"
|
||||
<div id="config-type-buttons" class="inline-flex rounded-lg border border-gray-200 overflow-hidden w-full">
|
||||
<button type="button" data-type="server" onclick="setCreateType('server')"
|
||||
class="flex-1 py-2 text-sm font-medium bg-blue-600 text-white">
|
||||
Сервер
|
||||
</button>
|
||||
<button type="button" id="type-storage-btn" onclick="setCreateType('storage')"
|
||||
<button type="button" data-type="storage" onclick="setCreateType('storage')"
|
||||
class="flex-1 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200">
|
||||
СХД
|
||||
</button>
|
||||
@@ -532,18 +532,51 @@ async function cloneConfig() {
|
||||
}
|
||||
|
||||
let createConfigType = 'server';
|
||||
let _cfgSettings = null;
|
||||
|
||||
async function loadCfgSettings() {
|
||||
if (_cfgSettings) return _cfgSettings;
|
||||
try {
|
||||
const r = await fetch('/api/configurator-settings');
|
||||
if (r.ok) _cfgSettings = await r.json();
|
||||
} catch(e) { /* use hardcoded fallback */ }
|
||||
return _cfgSettings;
|
||||
}
|
||||
|
||||
function renderConfigTypeButtons(types) {
|
||||
if (!types || !types.length) return;
|
||||
const el = document.getElementById('config-type-buttons');
|
||||
if (!el) return;
|
||||
el.innerHTML = types
|
||||
.sort((a, b) => (a.display_order || 0) - (b.display_order || 0))
|
||||
.map((t, i) => {
|
||||
const borderClass = i > 0 ? 'border-l border-gray-200' : '';
|
||||
return `<button type="button" data-type="${t.code}" onclick="setCreateType('${t.code}')"
|
||||
class="flex-1 py-2 text-sm font-medium ${borderClass} bg-white text-gray-700 hover:bg-gray-50">
|
||||
${t.name_ru || t.code}
|
||||
</button>`;
|
||||
}).join('');
|
||||
// activate first type
|
||||
const firstCode = types[0].code;
|
||||
createConfigType = firstCode;
|
||||
setCreateType(firstCode);
|
||||
}
|
||||
|
||||
function setCreateType(type) {
|
||||
createConfigType = type;
|
||||
document.getElementById('type-server-btn').className = 'flex-1 py-2 text-sm font-medium ' +
|
||||
(type === 'server' ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200');
|
||||
document.getElementById('type-storage-btn').className = 'flex-1 py-2 text-sm font-medium border-l border-gray-200 ' +
|
||||
(type === 'storage' ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50');
|
||||
document.querySelectorAll('#config-type-buttons button').forEach(btn => {
|
||||
const active = btn.dataset.type === type;
|
||||
btn.className = 'flex-1 py-2 text-sm font-medium ' +
|
||||
(active
|
||||
? 'bg-blue-600 text-white border-l border-gray-200'
|
||||
: 'bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200');
|
||||
});
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
createConfigType = 'server';
|
||||
setCreateType('server');
|
||||
loadCfgSettings().then(s => renderConfigTypeButtons(s && s.config_types));
|
||||
document.getElementById('opportunity-number').value = '';
|
||||
document.getElementById('create-project-input').value = '';
|
||||
document.getElementById('create-modal').classList.remove('hidden');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "title"}}OFS - Конфигуратор{{end}}
|
||||
{{define "title"}}QFS Конфигуратор{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-4">
|
||||
@@ -488,7 +488,7 @@ function updateConfigBreadcrumbs() {
|
||||
configEl.textContent = truncateBreadcrumbSpecName(fullConfigName);
|
||||
configEl.title = fullConfigName;
|
||||
versionEl.textContent = 'main';
|
||||
document.title = code + ' / ' + variant + ' / ' + fullConfigName + ' — OFS';
|
||||
document.title = code + ' / ' + variant + ' / ' + fullConfigName + ' — QFS';
|
||||
const configNameLinkEl = document.getElementById('breadcrumb-config-name-link');
|
||||
if (configNameLinkEl && configUUID) {
|
||||
configNameLinkEl.href = '/configs/' + configUUID + '/revisions';
|
||||
@@ -504,6 +504,9 @@ let currentTab = 'base';
|
||||
let allComponents = [];
|
||||
let cart = [];
|
||||
let categoryOrderMap = {}; // Category code -> display_order mapping
|
||||
let configTypeCategoryMap = {}; // configTypeCode → Set<UPPER_CODE> of allowed categories (from server)
|
||||
let alwaysVisibleTabsSet = null; // Set<tabKey> — null means use hardcoded fallback
|
||||
let requiredCategoriesMap = {}; // configTypeCode → Set<UPPER_CODE> of required categories
|
||||
let autoSaveTimeout = null; // Timeout for debounced autosave
|
||||
let hasUnsavedChanges = false;
|
||||
let exitSaveStarted = false;
|
||||
@@ -783,6 +786,95 @@ async function loadCategoriesFromAPI() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCfgSettings() {
|
||||
if (typeof _cfgSettings !== 'undefined' && _cfgSettings) return _cfgSettings;
|
||||
try {
|
||||
const r = await fetch('/api/configurator-settings');
|
||||
if (r.ok) {
|
||||
window._cfgSettings = await r.json();
|
||||
return window._cfgSettings;
|
||||
}
|
||||
} catch(e) { /* fallback to hardcoded */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyServerSettings(settings) {
|
||||
if (!settings) return;
|
||||
|
||||
// config_types → category allowlist map
|
||||
if (Array.isArray(settings.config_types) && settings.config_types.length) {
|
||||
configTypeCategoryMap = {};
|
||||
settings.config_types.forEach(ct => {
|
||||
if (ct.code && Array.isArray(ct.categories)) {
|
||||
configTypeCategoryMap[ct.code] = new Set(ct.categories.map(c => c.toUpperCase()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// tab_config → update TAB_CONFIG (preserve .other)
|
||||
if (Array.isArray(settings.tab_config) && settings.tab_config.length) {
|
||||
const otherTab = TAB_CONFIG.other;
|
||||
TAB_CONFIG = {};
|
||||
settings.tab_config.forEach(tab => {
|
||||
TAB_CONFIG[tab.key] = {
|
||||
categories: Array.isArray(tab.categories) ? tab.categories : [],
|
||||
singleSelect: !!tab.single_select,
|
||||
label: tab.label || tab.key,
|
||||
sections: tab.sections || undefined
|
||||
};
|
||||
});
|
||||
TAB_CONFIG.other = otherTab || { categories: [], singleSelect: false, label: 'Other' };
|
||||
ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG).flatMap(t => t.categories).map(c => c.toUpperCase());
|
||||
}
|
||||
|
||||
// always_visible_tabs
|
||||
if (Array.isArray(settings.always_visible_tabs) && settings.always_visible_tabs.length) {
|
||||
alwaysVisibleTabsSet = new Set(settings.always_visible_tabs);
|
||||
}
|
||||
|
||||
// required_categories
|
||||
if (settings.required_categories && typeof settings.required_categories === 'object') {
|
||||
requiredCategoriesMap = {};
|
||||
Object.entries(settings.required_categories).forEach(([ct, codes]) => {
|
||||
if (Array.isArray(codes)) {
|
||||
requiredCategoriesMap[ct] = new Set(codes.map(c => c.toUpperCase()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
applyConfigTypeToTabs();
|
||||
updateTabVisibility();
|
||||
updateRequiredCategoryBadges();
|
||||
}
|
||||
|
||||
function updateRequiredCategoryBadges() {
|
||||
const required = requiredCategoriesMap[configType];
|
||||
if (!required || !required.size) return;
|
||||
|
||||
// Build set of categories that have at least one cart item
|
||||
const filledCategories = new Set(
|
||||
cart.map(item => (item.category || getCategoryFromLotName(item.lot_name) || '').toUpperCase())
|
||||
);
|
||||
|
||||
// For each tab, check if it contains any required-but-unfilled category
|
||||
Object.entries(TAB_CONFIG).forEach(([tabKey, tabCfg]) => {
|
||||
const btn = document.querySelector(`[data-tab="${tabKey}"]`);
|
||||
if (!btn) return;
|
||||
const tabCategories = (tabCfg.categories || []).map(c => c.toUpperCase());
|
||||
const hasUnfilled = tabCategories.some(cat => required.has(cat) && !filledCategories.has(cat));
|
||||
const badge = btn.querySelector('.required-badge');
|
||||
if (hasUnfilled) {
|
||||
if (!badge) {
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'required-badge inline-block w-1.5 h-1.5 bg-orange-400 rounded-full ml-1 align-middle';
|
||||
btn.appendChild(dot);
|
||||
}
|
||||
} else if (badge) {
|
||||
badge.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
// RBAC disabled - no token check required
|
||||
@@ -791,8 +883,9 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Load categories in background (defaults are usable immediately).
|
||||
// Load categories and configurator settings in background (defaults are usable immediately).
|
||||
const categoriesPromise = loadCategoriesFromAPI().catch(() => {});
|
||||
loadCfgSettings().then(s => applyServerSettings(s)).catch(() => {});
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/configs/' + configUUID);
|
||||
@@ -1160,60 +1253,60 @@ function switchTab(tab) {
|
||||
renderTab();
|
||||
}
|
||||
|
||||
// Hardcoded fallback constants — used only when server has not provided config_types data
|
||||
const ALWAYS_VISIBLE_TABS = new Set(['base', 'storage', 'pci']);
|
||||
|
||||
// Storage-only categories — hidden for server configs
|
||||
const STORAGE_ONLY_BASE_CATEGORIES = ['DKC', 'CTL', 'ENC'];
|
||||
// Server-only categories — hidden for storage configs
|
||||
const SERVER_ONLY_BASE_CATEGORIES = ['MB', 'CPU', 'MEM'];
|
||||
const SERVER_ONLY_BASE_CATEGORIES = ['MB', 'CPU', 'MEM'];
|
||||
const STORAGE_HIDDEN_STORAGE_CATEGORIES = ['RAID'];
|
||||
const STORAGE_HIDDEN_PCI_CATEGORIES = ['GPU', 'DPU'];
|
||||
const STORAGE_HIDDEN_POWER_CATEGORIES = ['PS', 'PSU'];
|
||||
|
||||
function isCategoryVisibleForConfigType(code, cfgType) {
|
||||
const allowed = configTypeCategoryMap[cfgType];
|
||||
if (!allowed || allowed.size === 0) return _hardcodedCategoryVisible(code, cfgType);
|
||||
return allowed.has(code.toUpperCase());
|
||||
}
|
||||
|
||||
function _hardcodedCategoryVisible(code, cfgType) {
|
||||
if (cfgType === 'storage') {
|
||||
if (STORAGE_ONLY_BASE_CATEGORIES.includes(code)) return true;
|
||||
if (SERVER_ONLY_BASE_CATEGORIES.includes(code)) return false;
|
||||
if (STORAGE_HIDDEN_STORAGE_CATEGORIES.includes(code)) return false;
|
||||
if (STORAGE_HIDDEN_PCI_CATEGORIES.includes(code)) return false;
|
||||
if (STORAGE_HIDDEN_POWER_CATEGORIES.includes(code)) return false;
|
||||
} else {
|
||||
if (STORAGE_ONLY_BASE_CATEGORIES.includes(code)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function _effectiveAlwaysVisibleTabs() {
|
||||
return alwaysVisibleTabsSet || ALWAYS_VISIBLE_TABS;
|
||||
}
|
||||
|
||||
function applyConfigTypeToTabs() {
|
||||
const baseCategories = ['MB', 'CPU', 'MEM', 'DKC', 'CTL', 'ENC'];
|
||||
const storageCategories = ['RAID', 'M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'];
|
||||
const storageSections = [
|
||||
{ title: 'RAID Контроллеры', categories: ['RAID'] },
|
||||
{ title: 'Диски', categories: ['M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'] }
|
||||
];
|
||||
const pciCategories = ['GPU', 'DPU', 'NIC', 'HCA', 'HBA', 'HIC'];
|
||||
const pciSections = [
|
||||
{ title: 'GPU / DPU', categories: ['GPU', 'DPU'] },
|
||||
{ title: 'NIC / HCA', categories: ['NIC', 'HCA'] },
|
||||
{ title: 'HBA', categories: ['HBA'] },
|
||||
{ title: 'HIC', categories: ['HIC'] }
|
||||
];
|
||||
const powerCategories = ['PS', 'PSU'];
|
||||
// Filter each tab's categories by visibility for current configType.
|
||||
// Uses server-driven allowlists when available; falls back to hardcoded constants.
|
||||
Object.keys(TAB_CONFIG).forEach(tabKey => {
|
||||
if (tabKey === 'other') return;
|
||||
const tab = TAB_CONFIG[tabKey];
|
||||
if (!tab || !Array.isArray(tab.categories)) return;
|
||||
|
||||
TAB_CONFIG.base.categories = baseCategories.filter(c => {
|
||||
if (configType === 'storage') {
|
||||
return !SERVER_ONLY_BASE_CATEGORIES.includes(c);
|
||||
}
|
||||
return !STORAGE_ONLY_BASE_CATEGORIES.includes(c);
|
||||
});
|
||||
// Snapshot the full category list for this tab (stored in _allCategories if not yet saved)
|
||||
if (!tab._allCategories) tab._allCategories = [...tab.categories];
|
||||
|
||||
TAB_CONFIG.storage.categories = storageCategories.filter(c => {
|
||||
return configType === 'storage' ? !STORAGE_HIDDEN_STORAGE_CATEGORIES.includes(c) : true;
|
||||
});
|
||||
TAB_CONFIG.storage.sections = storageSections.filter(section => {
|
||||
if (configType === 'storage') {
|
||||
return !section.categories.every(cat => STORAGE_HIDDEN_STORAGE_CATEGORIES.includes(cat));
|
||||
}
|
||||
return true;
|
||||
});
|
||||
tab.categories = tab._allCategories.filter(c => isCategoryVisibleForConfigType(c, configType));
|
||||
|
||||
TAB_CONFIG.pci.categories = pciCategories.filter(c => {
|
||||
return configType === 'storage' ? !STORAGE_HIDDEN_PCI_CATEGORIES.includes(c) : c !== 'HIC';
|
||||
});
|
||||
TAB_CONFIG.pci.sections = pciSections.filter(section => {
|
||||
if (configType === 'storage') {
|
||||
return !section.categories.every(cat => STORAGE_HIDDEN_PCI_CATEGORIES.includes(cat));
|
||||
if (Array.isArray(tab._allSections || tab.sections)) {
|
||||
const allSections = tab._allSections || tab.sections;
|
||||
if (!tab._allSections) tab._allSections = allSections.map(s => ({ ...s, categories: [...s.categories] }));
|
||||
tab.sections = tab._allSections
|
||||
.map(section => ({
|
||||
...section,
|
||||
categories: section.categories.filter(c => isCategoryVisibleForConfigType(c, configType))
|
||||
}))
|
||||
.filter(section => section.categories.length > 0);
|
||||
}
|
||||
return section.title !== 'HIC';
|
||||
});
|
||||
TAB_CONFIG.power.categories = powerCategories.filter(c => {
|
||||
return configType === 'storage' ? !STORAGE_HIDDEN_POWER_CATEGORIES.includes(c) : true;
|
||||
});
|
||||
|
||||
// Rebuild assigned categories index
|
||||
@@ -1223,8 +1316,9 @@ function applyConfigTypeToTabs() {
|
||||
}
|
||||
|
||||
function updateTabVisibility() {
|
||||
const visibleTabs = _effectiveAlwaysVisibleTabs();
|
||||
for (const tabId of Object.keys(TAB_CONFIG)) {
|
||||
if (ALWAYS_VISIBLE_TABS.has(tabId)) continue;
|
||||
if (visibleTabs.has(tabId)) continue;
|
||||
const btn = document.querySelector(`[data-tab="${tabId}"]`);
|
||||
if (!btn) continue;
|
||||
const hasComponents = getComponentsForTab(tabId).length > 0;
|
||||
@@ -2151,6 +2245,7 @@ function removeFromCart(lotName) {
|
||||
|
||||
function updateCartUI() {
|
||||
updateTabVisibility();
|
||||
updateRequiredCategoryBadges();
|
||||
window._currentCart = cart; // expose for BOM/Pricing tabs
|
||||
const total = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0);
|
||||
document.getElementById('cart-total').textContent = formatMoney(total);
|
||||
@@ -2880,6 +2975,15 @@ async function refreshPrices() {
|
||||
}
|
||||
beforeTotal *= serverCount;
|
||||
|
||||
// Create a revision of the current state before prices are updated
|
||||
if (configUUID) {
|
||||
try {
|
||||
await fetch('/api/configs/' + configUUID + '/snapshot', { method: 'POST' });
|
||||
} catch (e) {
|
||||
console.warn('pre-refresh snapshot failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
await saveConfig(false);
|
||||
await refreshPriceLevels({ force: true, noCache: true });
|
||||
renderTab();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "title"}}OFS - Партномера{{end}}
|
||||
{{define "title"}}QFS Партномера{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-4">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "title"}}Прайслист - OFS{{end}}
|
||||
{{define "title"}}QFS Прайслист{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-6">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "title"}}Прайслисты - OFS{{end}}
|
||||
{{define "title"}}QFS Прайслисты{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-6">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "title"}}Проект - OFS{{end}}
|
||||
{{define "title"}}QFS Проект{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-4">
|
||||
@@ -207,9 +207,11 @@
|
||||
</div>
|
||||
<div>
|
||||
<label for="new-variant-value" class="block text-sm font-medium text-gray-700 mb-1">Вариант</label>
|
||||
<input id="new-variant-value" type="text" placeholder="Например: Lenovo"
|
||||
<input id="new-variant-value" type="text" placeholder="Например: B200"
|
||||
pattern="[A-Za-z0-9._-]+"
|
||||
title="Только буквы, цифры, дефис, точка, подчёркивание"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<div class="text-xs text-gray-500 mt-1">Оставьте пустым для main нельзя — нужно уникальное значение.</div>
|
||||
<div class="text-xs text-gray-500 mt-1">Буквы, цифры, дефис, точка, подчёркивание. Используется в URL: /КОД/Вариант.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end gap-2">
|
||||
@@ -842,6 +844,10 @@ async function createNewVariant() {
|
||||
showToast('Укажите вариант', 'error');
|
||||
return;
|
||||
}
|
||||
if (!/^[A-Za-z0-9._-]+$/.test(variant)) {
|
||||
showToast('Имя варианта содержит недопустимые символы. Разрешены: буквы, цифры, дефис, точка, подчёркивание.', 'error');
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
code: code,
|
||||
variant: variant,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "title"}}Мои проекты - OFS{{end}}
|
||||
{{define "title"}}QFS Мои проекты{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-4">
|
||||
@@ -39,12 +39,18 @@
|
||||
<div>
|
||||
<label for="create-project-code" class="block text-sm font-medium text-gray-700 mb-1">Код проекта</label>
|
||||
<input id="create-project-code" type="text" placeholder="Например: OPS-123"
|
||||
pattern="[A-Za-z0-9._-]+"
|
||||
title="Только буквы, цифры, дефис, точка, подчёркивание"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<p class="text-xs text-gray-400 mt-1">Буквы, цифры, дефис, точка, подчёркивание. Код используется в URL.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="create-project-variant" class="block text-sm font-medium text-gray-700 mb-1">Вариант (необязательно)</label>
|
||||
<input id="create-project-variant" type="text" placeholder="Например: Lenovo"
|
||||
<input id="create-project-variant" type="text" placeholder="Например: B200"
|
||||
pattern="[A-Za-z0-9._-]*"
|
||||
title="Только буквы, цифры, дефис, точка, подчёркивание"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<p class="text-xs text-gray-400 mt-1">Используется в URL: /КОД/Вариант</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="create-project-tracker-url" class="block text-sm font-medium text-gray-700 mb-1">Ссылка на трекер</label>
|
||||
@@ -396,6 +402,14 @@ async function createProject() {
|
||||
alert('Введите код проекта');
|
||||
return;
|
||||
}
|
||||
if (!/^[A-Za-z0-9._-]+$/.test(code)) {
|
||||
alert('Код проекта содержит недопустимые символы.\nРазрешены: буквы, цифры, дефис, точка, подчёркивание.');
|
||||
return;
|
||||
}
|
||||
if (variant && !/^[A-Za-z0-9._-]+$/.test(variant)) {
|
||||
alert('Имя варианта содержит недопустимые символы.\nРазрешены: буквы, цифры, дефис, точка, подчёркивание.');
|
||||
return;
|
||||
}
|
||||
const resp = await fetch('/api/projects', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
@@ -411,6 +425,11 @@ async function createProject() {
|
||||
alert('Проект с таким кодом и вариантом уже существует');
|
||||
return;
|
||||
}
|
||||
if (resp.status === 400) {
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
alert(body.error || 'Некорректный запрос');
|
||||
return;
|
||||
}
|
||||
alert('Не удалось создать проект');
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user