3 Commits

Author SHA1 Message Date
Mikhail Chusavitin
aea6bf91ab fix: abbreviate GPU architecture suffixes in article token
Ampere, Hopper, Blackwell now produce AMP/HOP/BWL suffixes (like ADA)
so RTX cards across generations are distinguishable: RTX6000ADA vs
RTX6000BWL. LOVELACE remains a skip token as it duplicates ADA info.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 15:08:47 +03:00
Mikhail Chusavitin
d58d52c5e7 fix: include model number and ADA suffix in GPU article token
RTX 6000 ADA and A6000 are distinct cards — RTX_4000_ADA_SFF now
produces RTX4000ADA instead of RTX, avoiding visual ambiguity with
the segment separator (10xRTX4000ADA vs 10xRTX-1x…).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 15:07:25 +03:00
Mikhail Chusavitin
7a628deb8a feat: add СХД configuration type with storage-specific tabs and LOT catalog guide
- Add config_type field ("server"|"storage") to Configuration and LocalConfiguration
- Create modal: Сервер/СХД segmented control in configs.html and project_detail.html
- Configurator: ENC/DKC/CTL categories in Base tab, HIC section in PCI tab hidden for server configs
- Add SW tab (categories: SW) to configurator, visible only when components present
- TAB_CONFIG.pci: add HIC section for storage HIC adapters (separate from server HBA/NIC)
- Migration 029: ALTER TABLE qt_configurations ADD COLUMN config_type
- Fix: skip Error 1833 (Cannot change column used in FK) in GORM AutoMigrate
- Operator guide: docs/storage-components-guide.md with LOT naming rules and DE4000H catalog template

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 18:01:23 +03:00
14 changed files with 385 additions and 12 deletions

View File

@@ -106,6 +106,17 @@ Rules:
- copy checkboxes and copy modals must prefill `_копия`, not ` (копия)`;
- the literal variant name `main` is reserved and must not be allowed for non-main variants.
## Configuration types
Configurations have a `config_type` field: `"server"` (default) or `"storage"`.
Rules:
- `config_type` defaults to `"server"` for all existing and new configurations unless explicitly set;
- the configurator page is shared for both types; the SW tab is always visible regardless of type;
- 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` = дисковая полка без контроллера.
## Vendor BOM contract
Vendor BOM is stored in `vendor_spec` on the configuration row.

Binary file not shown.

View File

@@ -0,0 +1,213 @@
# Руководство по составлению каталога лотов СХД
## Что такое LOT и зачем он нужен
LOT — это внутренний идентификатор типа компонента в системе QuoteForge.
Каждый LOT представляет одну рыночную позицию и хранит **средневзвешенную рыночную цену**, рассчитанную по историческим данным от поставщиков. Это позволяет получать актуальную оценку стоимости независимо от конкретного поставщика или прайс-листа.
Партномера вендора (Part Number, Feature Code) сами по себе не имеют цены в системе — они **переводятся в LOT** через книгу партномеров. Именно через LOT происходит расценка конфигурации.
**Пример:** Feature Code `B4B9` и Part Number `4C57A14368` — это два разных обозначения одной и той же HIC-карты от Lenovo. Оба маппируются на один LOT `HIC_4pFC32`, у которого есть рыночная цена.
---
## Категории и вкладки конфигуратора
Категория LOT определяет, в какой вкладке конфигуратора он появится.
| Код категории | Название | Вкладка | Что сюда относится |
|---|---|---|---|
| `ENC` | Storage Enclosure | **Base** | Дисковая полка без контроллера |
| `DKC` | Disk/Controller Enclosure | **Base** | Контроллерная полка: модель СХД + тип дисков + кол-во слотов + кол-во контроллеров |
| `CTL` | Storage Controller | **Base** | Контроллер СХД: объём кэша + встроенные хост-порты |
| `HIC` | Host Interface Card | **PCI** | HIC-карты СХД: интерфейсы подключения (FC, iSCSI, SAS) |
| `HDD` | HDD | **Storage** | Жёсткие диски (HDD) |
| `SSD` | SSD | **Storage** | Твердотельные диски (SSD, NVMe) |
| `ACC` | Accessories | **Accessories** | Кабели подключения, кабели питания |
| `SW` | Software | **SW** | Программные лицензии |
| *(прочее)* | — | **Other** | Гарантийные опции, инсталляция |
---
## Правила именования LOT
Формат: `КАТЕГОРИЯ_МОДЕЛЬСХД_СПЕЦИФИКА`
- только латиница, цифры и знак `_`
- регистр — ВЕРХНИЙ
- без пробелов, дефисов, точек
- каждый LOT уникален — два разных компонента не могут иметь одинаковое имя
### DKC — контроллерная полка
Специфика: `ТИПДИСКА_СЛОТЫ_NCTRL`
| Пример | Расшифровка |
|---|---|
| `DKC_DE4000H_SFF_24_2CTRL` | DE4000H, 24 слота SFF (2.5"), 2 контроллера |
| `DKC_DE4000H_LFF_12_2CTRL` | DE4000H, 12 слотов LFF (3.5"), 2 контроллера |
| `DKC_DE4000H_SFF_24_1CTRL` | DE4000H, 24 слота SFF, 1 контроллер (симплекс) |
Обозначения типа диска: `SFF` — 2.5", `LFF` — 3.5", `NVMe` — U.2/U.3.
### CTL — контроллер
Специфика: `КЭШГБ_ПОРТЫТИП` (если встроенные порты есть) или `КЭШГБ_BASE` (если без портов, добавляются через HIC)
| Пример | Расшифровка |
|---|---|
| `CTL_DE4000H_32GB_BASE` | 32GB кэш, без встроенных хост-портов |
| `CTL_DE4000H_8GB_BASE` | 8GB кэш, без встроенных хост-портов |
| `CTL_MSA2060_8GB_ISCSI10G_4P` | 8GB кэш, встроенные 4× iSCSI 10GbE |
### HIC — HIC-карты (интерфейс подключения)
Специфика: `NpПРОТОКОЛ` — без привязки к модели СХД, по аналогии с серверными `HBA_2pFC16`, `HBA_4pFC32_Gen6`.
| Пример | Расшифровка |
|---|---|
| `HIC_4pFC32` | 4 порта FC 32Gb |
| `HIC_4pFC16` | 4 порта FC 16G/10GbE |
| `HIC_4p25G_iSCSI` | 4 порта 25G iSCSI |
| `HIC_4p12G_SAS` | 4 порта SAS 12Gb |
| `HIC_2p10G_BaseT` | 2 порта 10G Base-T |
### HDD / SSD / NVMe — диски
Диски **не привязываются к модели СХД** — используются существующие LOT из серверного каталога (`HDD_...`, `SSD_...`, `NVME_...`). Новые LOT для дисков СХД не создаются; партномера дисков маппируются на уже существующие серверные LOT.
### ACC — кабели
Кабели **не привязываются к модели СХД**. Формат: `ACC_CABLE_{ТИП}_{ДЛИНА}` — универсальные LOT, одинаковые для серверов и СХД.
| Пример | Расшифровка |
|---|---|
| `ACC_CABLE_CAT6_10M` | Кабель CAT6 10м |
| `ACC_CABLE_FC_OM4_3M` | Кабель FC LC-LC OM4 до 3м |
| `ACC_CABLE_PWR_C13C14_15M` | Кабель питания C13C14 1.5м |
### SW — программные лицензии
Специфика: краткое название функции.
| Пример | Расшифровка |
|---|---|
| `SW_DE4000H_ASYNC_MIRROR` | Async Mirroring |
| `SW_DE4000H_SNAPSHOT_512` | Snapshot 512 |
---
## Таблица лотов: DE4000H (пример заполнения)
### DKC — контроллерная полка
| lot_name | vendor | model | description | disk_slots | disk_type | controllers |
|---|---|---|---|---|---|---|
| `DKC_DE4000H_SFF_24_2CTRL` | Lenovo | DE4000H 2U24 | DE4000H, 24× SFF, 2 контроллера | 24 | SFF | 2 |
| `DKC_DE4000H_LFF_12_2CTRL` | Lenovo | DE4000H 2U12 | DE4000H, 12× LFF, 2 контроллера | 12 | LFF | 2 |
### CTL — контроллер
| lot_name | vendor | model | description | cache_gb | host_ports |
|---|---|---|---|---|---|
| `CTL_DE4000H_32GB_BASE` | Lenovo | DE4000 Controller 32GB Gen2 | Контроллер DE4000, 32GB кэш, без встроенных портов | 32 | — |
| `CTL_DE4000H_8GB_BASE` | Lenovo | DE4000 Controller 8GB Gen2 | Контроллер DE4000, 8GB кэш, без встроенных портов | 8 | — |
### HIC — HIC-карты
| lot_name | vendor | model | description |
|---|---|---|---|
| `HIC_2p10G_BaseT` | Lenovo | HIC 10GBASE-T 2-Ports | HIC 10GBASE-T, 2 порта |
| `HIC_4p25G_iSCSI` | Lenovo | HIC 10/25GbE iSCSI 4-ports | HIC iSCSI 10/25GbE, 4 порта |
| `HIC_4p12G_SAS` | Lenovo | HIC 12Gb SAS 4-ports | HIC SAS 12Gb, 4 порта |
| `HIC_4pFC32` | Lenovo | HIC 32Gb FC 4-ports | HIC FC 32Gb, 4 порта |
| `HIC_4pFC16` | Lenovo | HIC 16G FC/10GbE 4-ports | HIC FC 16G/10GbE, 4 порта |
### HDD / SSD / NVMe / ACC — диски и кабели
Для дисков и кабелей новые LOT не создаются. Партномера маппируются на существующие серверные LOT из каталога.
### SW — программные лицензии
| lot_name | vendor | model | description |
|---|---|---|---|
| `SW_DE4000H_ASYNC_MIRROR` | Lenovo | DE4000H Asynchronous Mirroring | Лицензия Async Mirroring |
| `SW_DE4000H_SNAPSHOT_512` | Lenovo | DE4000H Snapshot Upgrade 512 | Лицензия Snapshot 512 |
| `SW_DE4000H_SYNC_MIRROR` | Lenovo | DE4000 Synchronous Mirroring | Лицензия Sync Mirroring |
---
## Таблица партномеров: DE4000H (пример заполнения)
Каждый Feature Code и Part Number должен быть привязан к своему LOT.
Если у компонента есть оба — добавить две строки.
| partnumber | lot_name | описание |
|---|---|---|
| `BEY7` | `ENC_2U24_CHASSIS` | Lenovo ThinkSystem Storage 2U24 Chassis |
| `BQA0` | `CTL_DE4000H_32GB_BASE` | DE4000 Controller 32GB Gen2 |
| `BQ9Z` | `CTL_DE4000H_8GB_BASE` | DE4000 Controller 8GB Gen2 |
| `B4B1` | `HIC_2p10G_BaseT` | HIC 10GBASE-T 2-Ports |
| `4C57A14376` | `HIC_2p10G_BaseT` | HIC 10GBASE-T 2-Ports |
| `B4BA` | `HIC_4p25G_iSCSI` | HIC 10/25GbE iSCSI 4-ports |
| `4C57A14369` | `HIC_4p25G_iSCSI` | HIC 10/25GbE iSCSI 4-ports |
| `B4B8` | `HIC_4p12G_SAS` | HIC 12Gb SAS 4-ports |
| `4C57A14367` | `HIC_4p12G_SAS` | HIC 12Gb SAS 4-ports |
| `B4B9` | `HIC_4pFC32` | HIC 32Gb FC 4-ports |
| `4C57A14368` | `HIC_4pFC32` | HIC 32Gb FC 4-ports |
| `B4B7` | `HIC_4pFC16` | HIC 16G FC/10GbE 4-ports |
| `4C57A14366` | `HIC_4pFC16` | HIC 16G FC/10GbE 4-ports |
| `BW12` | `HDD_SAS_02.4TB` | 2.4TB 10K 2.5" HDD 2U24 |
| `4XB7A88046` | `HDD_SAS_02.4TB` | 2.4TB 10K 2.5" HDD 2U24 |
| `B4C0` | `HDD_SAS_01.8TB` | 1.8TB 10K 2.5" HDD SED FIPS |
| `4XB7A14114` | `HDD_SAS_01.8TB` | 1.8TB 10K 2.5" HDD SED FIPS |
| `BW13` | `HDD_SAS_02.4TB` | 2.4TB 10K 2.5" HDD FIPS |
| `4XB7A88048` | `HDD_SAS_02.4TB` | 2.4TB 10K 2.5" HDD FIPS |
| `BKUQ` | `SSD_SAS_0.960T` | 960GB 1DWD 2.5" SSD |
| `4XB7A74948` | `SSD_SAS_0.960T` | 960GB 1DWD 2.5" SSD |
| `BKUT` | `SSD_SAS_01.92T` | 1.92TB 1DWD 2.5" SSD |
| `4XB7A74951` | `SSD_SAS_01.92T` | 1.92TB 1DWD 2.5" SSD |
| `BKUK` | `SSD_SAS_03.84T` | 3.84TB 1DWD 2.5" SSD |
| `4XB7A74955` | `SSD_SAS_03.84T` | 3.84TB 1DWD 2.5" SSD |
| `B4RY` | `SSD_SAS_07.68T` | 7.68TB 1DWD 2.5" SSD |
| `4XB7A14176` | `SSD_SAS_07.68T` | 7.68TB 1DWD 2.5" SSD |
| `B4CD` | `SSD_SAS_15.36T` | 15.36TB 1DWD 2.5" SSD |
| `4XB7A14110` | `SSD_SAS_15.36T` | 15.36TB 1DWD 2.5" SSD |
| `BWCJ` | `SSD_SAS_03.84T` | 3.84TB 1DWD 2.5" SSD FIPS |
| `4XB7A88469` | `SSD_SAS_03.84T` | 3.84TB 1DWD 2.5" SSD FIPS |
| `BW2B` | `SSD_SAS_15.36T` | 15.36TB 1DWD 2.5" SSD SED |
| `4XB7A88466` | `SSD_SAS_15.36T` | 15.36TB 1DWD 2.5" SSD SED |
| `AVFW` | `ACC_CABLE_CAT6_1M` | CAT6 0.75-1.5m |
| `A1MT` | `ACC_CABLE_CAT6_10M` | CAT6 10m |
| `90Y3718` | `ACC_CABLE_CAT6_10M` | CAT6 10m |
| `A1MW` | `ACC_CABLE_CAT6_25M` | CAT6 25m |
| `90Y3727` | `ACC_CABLE_CAT6_25M` | CAT6 25m |
| `39Y7937` | `ACC_CABLE_PWR_C13C14_15M` | C13C14 1.5m |
| `39Y7938` | `ACC_CABLE_PWR_C13C20_28M` | C13C20 2.8m |
| `4L67A08371` | `ACC_CABLE_PWR_C13C14_43M` | C13C14 4.3m |
| `C932` | `SW_DE4000H_ASYNC_MIRROR` | DE4000H Asynchronous Mirroring |
| `00WE123` | `SW_DE4000H_ASYNC_MIRROR` | DE4000H Asynchronous Mirroring |
| `C930` | `SW_DE4000H_SNAPSHOT_512` | DE4000H Snapshot Upgrade 512 |
| `C931` | `SW_DE4000H_SYNC_MIRROR` | DE4000 Synchronous Mirroring |
---
## Шаблон для новых моделей СХД
```
DKC_МОДЕЛЬ_ТИПДИСКА_СЛОТЫ_NCTRL — контроллерная полка
CTL_МОДЕЛЬ_КЭШГБ_ПОРТЫ — контроллер
HIC_МОДЕЛЬРОТОКОЛ_СКОРОСТЬОРТЫ — HIC-карта (интерфейс подключения)
SW_МОДЕЛЬУНКЦИЯ — лицензия
```
Диски (HDD/SSD/NVMe) и кабели (ACC) — маппируются на существующие серверные LOT, новые не создаются.
Пример для HPE MSA 2060:
```
DKC_MSA2060_SFF_24_2CTRL
CTL_MSA2060_8GB_ISCSI10G_4P
HIC_MSA2060_FC32G_2P
SW_MSA2060_REMOTE_SNAP
```

View File

@@ -329,33 +329,60 @@ func parseGPUModel(lotName string) string {
}
parts := strings.Split(upper, "_")
model := ""
numSuffix := ""
mem := ""
for i, p := range parts {
if p == "" {
continue
}
switch p {
case "NV", "NVIDIA", "INTEL", "AMD", "RADEON", "PCIE", "PCI", "SXM", "SXMX":
case "NV", "NVIDIA", "INTEL", "AMD", "RADEON", "PCIE", "PCI", "SXM", "SXMX", "SFF", "LOVELACE":
continue
case "ADA", "AMPERE", "HOPPER", "BLACKWELL":
if model != "" {
archAbbr := map[string]string{
"ADA": "ADA", "AMPERE": "AMP", "HOPPER": "HOP", "BLACKWELL": "BWL",
}
numSuffix += archAbbr[p]
}
continue
default:
if strings.Contains(p, "GB") {
mem = p
continue
}
if model == "" && (i > 0) {
if model == "" && i > 0 {
model = p
} else if model != "" && numSuffix == "" && isNumeric(p) {
numSuffix = p
}
}
}
if model != "" && mem != "" {
return model + "_" + mem
full := model
if numSuffix != "" {
full = model + numSuffix
}
if model != "" {
return model
if full != "" && mem != "" {
return full + "_" + mem
}
if full != "" {
return full
}
return normalizeModelToken(lotName)
}
func isNumeric(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return true
}
func parseMemGiB(lotName string) int {
if m := reMemTiB.FindStringSubmatch(lotName); len(m) == 3 {
return atoi(m[1]) * 1024

View File

@@ -34,6 +34,7 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
PricelistID: cfg.PricelistID,
WarehousePricelistID: cfg.WarehousePricelistID,
CompetitorPricelistID: cfg.CompetitorPricelistID,
ConfigType: cfg.ConfigType,
VendorSpec: modelVendorSpecToLocal(cfg.VendorSpec),
DisablePriceRefresh: cfg.DisablePriceRefresh,
OnlyInStock: cfg.OnlyInStock,
@@ -82,6 +83,7 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
PricelistID: local.PricelistID,
WarehousePricelistID: local.WarehousePricelistID,
CompetitorPricelistID: local.CompetitorPricelistID,
ConfigType: local.ConfigType,
VendorSpec: localVendorSpecToModel(local.VendorSpec),
DisablePriceRefresh: local.DisablePriceRefresh,
OnlyInStock: local.OnlyInStock,

View File

@@ -110,6 +110,7 @@ type LocalConfiguration struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
SyncedAt *time.Time `json:"synced_at"`
ConfigType string `gorm:"default:server" json:"config_type"` // "server" | "storage"
SyncStatus string `gorm:"default:'local'" json:"sync_status"` // 'local', 'synced', 'modified'
OriginalUserID uint `json:"original_user_id"` // UserID from MariaDB for reference
OriginalUsername string `gorm:"not null;default:'';index" json:"original_username"`

View File

@@ -111,6 +111,7 @@ type Configuration struct {
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
VendorSpec VendorSpec `gorm:"type:json" json:"vendor_spec,omitempty"`
ConfigType string `gorm:"size:20;default:server" json:"config_type"` // "server" | "storage"
DisablePriceRefresh bool `gorm:"default:false" json:"disable_price_refresh"`
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
Line int `gorm:"column:line_no;index" json:"line"`

View File

@@ -31,7 +31,9 @@ func Migrate(db *gorm.DB) error {
errStr := err.Error()
if strings.Contains(errStr, "Can't DROP") ||
strings.Contains(errStr, "Duplicate key name") ||
strings.Contains(errStr, "check that it exists") {
strings.Contains(errStr, "check that it exists") ||
strings.Contains(errStr, "Cannot change column") ||
strings.Contains(errStr, "used in a foreign key constraint") {
slog.Warn("migration warning (skipped)", "model", model, "error", errStr)
continue
}

View File

@@ -58,6 +58,7 @@ type CreateConfigRequest struct {
PricelistID *uint `json:"pricelist_id,omitempty"`
WarehousePricelistID *uint `json:"warehouse_pricelist_id,omitempty"`
CompetitorPricelistID *uint `json:"competitor_pricelist_id,omitempty"`
ConfigType string `json:"config_type,omitempty"` // "server" | "storage"
DisablePriceRefresh bool `json:"disable_price_refresh"`
OnlyInStock bool `json:"only_in_stock"`
}
@@ -103,9 +104,13 @@ func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigReq
PricelistID: pricelistID,
WarehousePricelistID: req.WarehousePricelistID,
CompetitorPricelistID: req.CompetitorPricelistID,
ConfigType: req.ConfigType,
DisablePriceRefresh: req.DisablePriceRefresh,
OnlyInStock: req.OnlyInStock,
}
if config.ConfigType == "" {
config.ConfigType = "server"
}
if err := s.configRepo.Create(config); err != nil {
return nil, err

View File

@@ -101,10 +101,14 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
PricelistID: pricelistID,
WarehousePricelistID: req.WarehousePricelistID,
CompetitorPricelistID: req.CompetitorPricelistID,
ConfigType: req.ConfigType,
DisablePriceRefresh: req.DisablePriceRefresh,
OnlyInStock: req.OnlyInStock,
CreatedAt: time.Now(),
}
if cfg.ConfigType == "" {
cfg.ConfigType = "server"
}
// Convert to local model
localCfg := localdb.ConfigurationToLocal(cfg)

View File

@@ -0,0 +1,2 @@
ALTER TABLE qt_configurations
ADD COLUMN config_type VARCHAR(20) NOT NULL DEFAULT 'server';

View File

@@ -53,6 +53,19 @@
<h2 class="text-xl font-semibold mb-4">Новая конфигурация</h2>
<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')"
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')"
class="flex-1 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200">
СХД
</button>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Название конфигурации</label>
<input type="text" id="opportunity-number" placeholder="Например: Сервер для проекта X"
@@ -518,7 +531,19 @@ async function cloneConfig() {
}
}
let createConfigType = 'server';
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');
}
function openCreateModal() {
createConfigType = 'server';
setCreateType('server');
document.getElementById('opportunity-number').value = '';
document.getElementById('create-project-input').value = '';
document.getElementById('create-modal').classList.remove('hidden');
@@ -573,7 +598,8 @@ async function createConfigWithProject(name, projectUUID) {
items: [],
notes: '',
server_count: 1,
project_uuid: projectUUID || null
project_uuid: projectUUID || null,
config_type: createConfigType
})
});

View File

@@ -108,6 +108,10 @@
class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 whitespace-nowrap">
Accessories
</button>
<button onclick="switchTab('sw')" data-tab="sw"
class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 whitespace-nowrap">
SW
</button>
<button onclick="switchTab('other')" data-tab="other"
class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 whitespace-nowrap">
Other
@@ -365,7 +369,7 @@
// Tab configuration - will be populated dynamically
let TAB_CONFIG = {
base: {
categories: ['MB', 'CPU', 'MEM'],
categories: ['MB', 'CPU', 'MEM', 'ENC', 'DKC', 'CTL'],
singleSelect: true,
label: 'Base'
},
@@ -379,13 +383,14 @@ let TAB_CONFIG = {
]
},
pci: {
categories: ['GPU', 'DPU', 'NIC', 'HCA', 'HBA'],
categories: ['GPU', 'DPU', 'NIC', 'HCA', 'HBA', 'HIC'],
singleSelect: false,
label: 'PCI',
sections: [
{ title: 'GPU / DPU', categories: ['GPU', 'DPU'] },
{ title: 'NIC / HCA', categories: ['NIC', 'HCA'] },
{ title: 'HBA', categories: ['HBA'] }
{ title: 'HBA', categories: ['HBA'] },
{ title: 'HIC', categories: ['HIC'] }
]
},
power: {
@@ -398,6 +403,11 @@ let TAB_CONFIG = {
singleSelect: false,
label: 'Accessories'
},
sw: {
categories: ['SW'],
singleSelect: false,
label: 'SW'
},
other: {
categories: [],
singleSelect: false,
@@ -411,6 +421,7 @@ let ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
// State
let configUUID = '{{.ConfigUUID}}';
let configType = 'server';
let configName = '';
let currentVersionNo = 1;
let projectUUID = '';
@@ -793,6 +804,8 @@ document.addEventListener('DOMContentLoaded', async function() {
const config = await resp.json();
configName = config.name;
configType = config.config_type || 'server';
applyConfigTypeToTabs();
currentVersionNo = config.current_version_no || 1;
projectUUID = config.project_uuid || '';
await loadProjectIndex();
@@ -1139,6 +1152,45 @@ function switchTab(tab) {
renderTab();
}
const ALWAYS_VISIBLE_TABS = new Set(['base', 'storage', 'pci']);
// Storage-only categories — hidden for server configs
const STORAGE_ONLY_BASE_CATEGORIES = ['ENC', 'DKC', 'CTL'];
function applyConfigTypeToTabs() {
if (configType === 'storage') return; // storage sees everything
// Remove ENC/DKC/CTL from Base
TAB_CONFIG.base.categories = TAB_CONFIG.base.categories.filter(
c => !STORAGE_ONLY_BASE_CATEGORIES.includes(c)
);
// Remove HIC from PCI tab
TAB_CONFIG.pci.categories = TAB_CONFIG.pci.categories.filter(c => c !== 'HIC');
TAB_CONFIG.pci.sections = TAB_CONFIG.pci.sections.filter(s => s.title !== 'HIC');
// Rebuild assigned categories index
ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
.flatMap(t => t.categories)
.map(c => c.toUpperCase());
}
function updateTabVisibility() {
for (const tabId of Object.keys(TAB_CONFIG)) {
if (ALWAYS_VISIBLE_TABS.has(tabId)) continue;
const btn = document.querySelector(`[data-tab="${tabId}"]`);
if (!btn) continue;
const hasComponents = getComponentsForTab(tabId).length > 0;
const hasCartItems = cart.some(item => {
const cat = (item.category || getCategoryFromLotName(item.lot_name) || '').toUpperCase();
return getTabForCategory(cat) === tabId;
});
const visible = hasComponents || hasCartItems;
btn.classList.toggle('hidden', !visible);
// If the current tab just got hidden, fall back to base
if (!visible && currentTab === tabId) {
switchTab('base');
}
}
}
function getComponentsForTab(tab) {
const config = TAB_CONFIG[tab];
return allComponents.filter(comp => {
@@ -1870,12 +1922,14 @@ function updateMultiQuantity(lotName, value) {
function removeFromCart(lotName) {
cart = cart.filter(i => i.lot_name !== lotName);
updateTabVisibility();
renderTab();
updateCartUI();
triggerAutoSave();
}
function updateCartUI() {
updateTabVisibility();
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);

View File

@@ -77,6 +77,19 @@
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
<h2 class="text-xl font-semibold mb-4">Новая конфигурация в проекте</h2>
<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="pd-type-server-btn" onclick="pdSetCreateType('server')"
class="flex-1 py-2 text-sm font-medium bg-blue-600 text-white">
Сервер
</button>
<button type="button" id="pd-type-storage-btn" onclick="pdSetCreateType('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>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Название конфигурации</label>
<input type="text" id="create-name" placeholder="Например: OPP-2026-001"
@@ -576,7 +589,19 @@ async function loadConfigs() {
}
}
let pdCreateConfigType = 'server';
function pdSetCreateType(type) {
pdCreateConfigType = type;
document.getElementById('pd-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');
document.getElementById('pd-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');
}
function openCreateModal() {
pdCreateConfigType = 'server';
pdSetCreateType('server');
document.getElementById('create-name').value = '';
document.getElementById('create-modal').classList.remove('hidden');
document.getElementById('create-modal').classList.add('flex');
@@ -934,7 +959,7 @@ async function createConfig() {
const resp = await fetch('/api/projects/' + projectUUID + '/configs', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: name, items: [], notes: '', server_count: 1})
body: JSON.stringify({name: name, items: [], notes: '', server_count: 1, config_type: pdCreateConfigType})
});
if (!resp.ok) {
alert('Не удалось создать конфигурацию');