From 681ec15e2b1eefa8e74c21d151cf302acdeaf1cf Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Mon, 31 Aug 2026 18:08:38 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=80=D0=B5=D0=B6=D0=B8=D0=BC=20=D0=B8?= =?UTF-8?q?=D0=BD=D0=B4=D0=B8=D0=BA=D0=B0=D1=82=D0=BE=D1=80=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=B1=D0=B5=D0=B7=20=D1=86=D0=B2=D0=B5=D1=82=D0=BE=D0=B2=D0=BE?= =?UTF-8?q?=D0=B9=20=D0=BA=D0=BE=D0=B4=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B8?= =?UTF-8?q?=20+=20=D0=BF=D0=B5=D1=80=D0=B5=D1=83=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B9=D1=81=D1=82=D0=B2=D0=BE=20/setup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новая настройка app_settings.indicator_mode (color | accessible), переключается на /setup. В режиме accessible сигналы, которые раньше держались только на цвете, переходят на форму/текст: - качество цены (0-9) — 5-ступенчатый signal-meter вместо градиентной точки/числа; единый модуль web/static/price-quality.js, ветвление по window.QF_INDICATOR_MODE; - вкладка «Ценообразование»: ведущая колонка-meter вместо заливки строк, пометка W вместо амбер-подсветки world-цен, ⚠ вместо красного «загрязнённого» итога. - GET/PUT /api/settings/ui (без рестарта), регистрируется в обоих наборах роутов. /setup переустроен: две колонки одной высоты, кнопка «Вернуться в приложение», исправлен . Документация: bible-local 02/03/04 + decisions/2026-08-31-accessible-indicator-mode.md Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LbRvQgPZpM4SJTaX3iLrXk --- bible-local/02-architecture.md | 6 + bible-local/03-database.md | 6 +- bible-local/04-api.md | 11 ++ .../2026-08-31-accessible-indicator-mode.md | 63 +++++++++ cmd/qfs/main.go | 9 ++ internal/handlers/settings.go | 47 +++++++ internal/handlers/setup.go | 6 +- internal/handlers/web.go | 4 + internal/localdb/indicator_mode_test.go | 45 ++++++ internal/localdb/localdb.go | 37 +++++ web/static/app.css | 39 ++++++ web/static/price-quality.js | 53 ++++++-- web/templates/base.html | 1 + web/templates/index.html | 50 +++++-- web/templates/setup.html | 128 ++++++++++++++---- 15 files changed, 453 insertions(+), 52 deletions(-) create mode 100644 bible-local/decisions/2026-08-31-accessible-indicator-mode.md create mode 100644 internal/handlers/settings.go create mode 100644 internal/localdb/indicator_mode_test.go diff --git a/bible-local/02-architecture.md b/bible-local/02-architecture.md index 796833b..393ac23 100644 --- a/bible-local/02-architecture.md +++ b/bible-local/02-architecture.md @@ -85,6 +85,12 @@ Controls terminology: CSV export reads PN вендора, Описание, and LOT from `data-vendor-pn`, `data-desc`, `data-lot` row attributes to bypass the rowspan cell offset problem. +In the colour-optional indicator mode (`app_settings.indicator_mode = "accessible"`, see +[03-database.md](03-database.md) and [decisions/2026-08-31-accessible-indicator-mode.md](decisions/2026-08-31-accessible-indicator-mode.md)) +both tables gain a narrow leading price-quality meter column (per sub-row), the row background +tint is removed, `world`-fallback cells show a `W` text marker instead of amber, and the footer +total shows a `⚠` prefix instead of red. CSV output is unaffected. + ## Configuration versioning Configuration revisions are append-only snapshots stored in `local_configuration_versions`. diff --git a/bible-local/03-database.md b/bible-local/03-database.md index f52e733..9a338fe 100644 --- a/bible-local/03-database.md +++ b/bible-local/03-database.md @@ -17,7 +17,7 @@ Main tables: | `local_partnumber_book_items` | PN -> LOT catalog payload | | `pending_changes` | sync queue | | `connection_settings` | encrypted MariaDB connection settings | -| `app_settings` | local app state | +| `app_settings` | local app state (key→value); includes `indicator_mode` = `color` \| `accessible`, a per-client display preference set on `/setup` | | `local_schema_migrations` | applied local migration markers | | `local_qt_settings` | server-pushed configurator settings cache (from `qt_settings`) | @@ -261,7 +261,9 @@ PK: lot_name | price | decimal(12,2) NOT NULL | | | price_quality | tinyint unsigned, nullable | added by migration 033. Set by the external pricelist-building tool, 0-9, based on quote recency/count per its own per-lot pricing-period settings — QF only reads and displays it, never computes it. | -`price_quality` is synced through `LocalPricelistItem` (pricelist detail page) and, separately, through `LocalComponent`/`services.ComponentView` (`/api/components`, used by the configurator's search dropdown and item table) — both read paths go through `pricelistItemRow`/`toLocalComponent()` in `internal/localdb/components.go`. The `/api/components` path reads the component universe (`world` ∪ `estimate`, see [decisions/2026-07-24-component-universe-world-union.md](decisions/2026-07-24-component-universe-world-union.md)), where a world-only LOT is forced to `price_quality = 0` — the only place QF writes this field rather than reading it. The color scale (red 0 → yellow 5 → green 9, gradient) lives in **one shared JS module**, `web/static/price-quality.js` (loaded by `base.html` for every page) — `priceQualityColor`/`qualityDotHtml`/`qualityBadgeHtml`/`qualityRowStyle`. Do not reimplement the color scale locally in a template; add a new helper to that module instead. Used in: pricelist detail "Качество" column, configurator search dropdown (colored dot), configurator table (leftmost quality-dot column), pricing tab (row background tint). +`price_quality` is synced through `LocalPricelistItem` (pricelist detail page) and, separately, through `LocalComponent`/`services.ComponentView` (`/api/components`, used by the configurator's search dropdown and item table) — both read paths go through `pricelistItemRow`/`toLocalComponent()` in `internal/localdb/components.go`. The `/api/components` path reads the component universe (`world` ∪ `estimate`, see [decisions/2026-07-24-component-universe-world-union.md](decisions/2026-07-24-component-universe-world-union.md)), where a world-only LOT is forced to `price_quality = 0` — the only place QF writes this field rather than reading it. The visual scale lives in **one shared JS module**, `web/static/price-quality.js` (loaded by `base.html` for every page) — `priceQualityColor`/`priceQualityLevel`/`qualityMeterHtml`/`qualityDotHtml`/`qualityBadgeHtml`/`qualityRowStyle`. Do not reimplement the scale locally in a template; add a new helper to that module instead. Used in: pricelist detail "Качество" column, configurator search dropdown, configurator table (leftmost quality column), pricing tab. + +The module has **two render modes**, chosen per client by `window.QF_INDICATOR_MODE` (injected by `base.html` from `app_settings.indicator_mode`, default `color`): `color` = hue scale red 0 → yellow 5 → green 9; `accessible` = colour-blind-safe 5-step signal-strength meter (`priceQualityLevel`: `0-1/2-3/4-5/6-7/8-9`), single neutral hue. In `accessible` mode the pricing tab drops the row background tint for a leading meter column, the `world`-fallback amber cell tint becomes a `W` text marker, and the "contaminated total" red becomes a `⚠` prefix — see [decisions/2026-08-31-accessible-indicator-mode.md](decisions/2026-08-31-accessible-indicator-mode.md). Any new colour-only signal must add a branch in this module keyed on `isAccessible()`, not a local one. The real table also has `price_method`, `price_period_days`, `price_coefficient`, `manual_price`, `meta_prices` and `lead_time_weeks` columns, owned and written by the external pricing engine that maintains `qt_pricelist_items` — **keep them in the table for backward compatibility with that system; QF must never drop, rename, or write to them.** QF itself does not model, sync, or display any of them (tried once, removed as unused/dead in the client). Do not re-add them to `models.PricelistItem`/`LocalPricelistItem` without a concrete UI need. diff --git a/bible-local/04-api.md b/bible-local/04-api.md index 3aa11c0..5404d7d 100644 --- a/bible-local/04-api.md +++ b/bible-local/04-api.md @@ -29,6 +29,17 @@ `POST /api/restart` exists only in `debug` mode. +## Settings + +| Method | Path | Purpose | +| --- | --- | --- | +| `GET` | `/api/settings/ui` | read per-client UI display preferences → `{ "indicator_mode": "color" \| "accessible" }` | +| `PUT` | `/api/settings/ui` | set `indicator_mode` (body `{ "indicator_mode": ... }`); `422` on an unknown value | + +Stored in `app_settings` (SQLite); no restart. Registered in both the normal and the setup-mode +route sets. Consumed by `web/static/price-quality.js` and the pricing tab via a +`window.QF_INDICATOR_MODE` global that `base.html` renders from the same setting. + ## Reference data | Method | Path | Purpose | diff --git a/bible-local/decisions/2026-08-31-accessible-indicator-mode.md b/bible-local/decisions/2026-08-31-accessible-indicator-mode.md new file mode 100644 index 0000000..9b31277 --- /dev/null +++ b/bible-local/decisions/2026-08-31-accessible-indicator-mode.md @@ -0,0 +1,63 @@ +# Decision: `indicator_mode` — a colour-optional rendering mode for UI signals + +**Date:** 2026-08-31 +**Status:** active + +## Context + +Several UI signals were encoded by hue alone and unreadable with a colour-vision deficiency: + +- `price_quality` (0–9) as a red→yellow→green gradient — a dot in the configurator search + dropdown and inline before `lot_name`, a coloured number in the pricelist "Качество" column, + a full-row background tint on the "Ценообразование" tab; +- the amber `world`-fallback price cell tint + ([2026-07-10-world-pricelist-fallback.md](2026-07-10-world-pricelist-fallback.md)); +- the red "contaminated total" in the pricing footer + ([2026-07-24-pricing-total-world-share.md](2026-07-24-pricing-total-world-share.md)). + +The bible has no dedicated accessibility contract, but `table-management` §Icon Semantics, +`controls-selection` §Buttons, `go-code-style` §Business Logic Placement and `build-version-display` +say the same thing: meaning is never carried by one ambiguous channel — back it with +shape / number / explicit `title`+`aria-label`, and keep the indicator visually subordinate. + +## Decision + +One per-client preference, **`app_settings.indicator_mode`** = `color` (default) | `accessible`, +set on `/setup` ("Настройки") via `GET`/`PUT /api/settings/ui` (`internal/handlers/settings.go`) +— no restart. Deliberately named for the concern (how indicators are rendered), not for the +current widget, so future colour-only signals attach to the same switch. + +`internal/handlers/web.go` `render()` and `internal/handlers/setup.go` inject the mode for every +page; `base.html` publishes it as `window.QF_INDICATOR_MODE`. `web/static/price-quality.js` is +still the single source of the price-quality scale and branches on the mode internally, so its +call sites do not change. + +In `accessible` mode: + +- `price_quality` renders as a **5-step signal-strength meter** (`priceQualityLevel` = + `min(4, floor(score/2))` → `0-1 / 2-3 / 4-5 / 6-7 / 8-9`), five bars in one neutral hue, each + instance carrying `title`/`aria-label` = `Качество цены: N/9`. `qualityDotHtml` and + `qualityBadgeHtml` return the meter; +- `qualityRowStyle` returns `''` — the pricing tab instead shows the meter in a narrow leading + column (conditional `<th>` / `colspan` in `index.html`, per-sub-row `<td>`); +- the `world`-fallback amber class is dropped for a `W` superscript text marker on the price + (text, not a colour class — `applyCustomPrice`'s colour-stripping regex is a non-issue); +- `_setPricingTotal` drops `text-red-600` for a leading `⚠` glyph, on the same trigger as the + red (`worldShare > 0`). The hover popup — including the "prices for N of M positions" coverage + line — is unchanged in both modes; per 2026-07-24 there is still no colour-coded coverage-only + signal, and `accessible` mode does not add one. + +`color` mode is byte-for-byte the previous behaviour. + +## Consequences + +- Default unchanged; nothing moves until a user opts in on `/setup`. +- `web/static/price-quality.js` stays the only place that knows the 0–9 scale. Any new + colour-coded quality/price signal adds a branch there keyed on `isAccessible()`, never a local + reimplementation, and its `accessible` form must be shape/text, not another hue. +- The pricing-tab tables have a mode-dependent column count (8 / 9). `index.html` guards the + `<th>`, the empty-state `colspan`, the `tfoot` "Итого:" `colspan` and the JS row template on + `ACCESSIBLE_MODE` / `{{ eq .IndicatorMode "accessible" }}`. CSV export is untouched — it reads + `data-*` row attributes, not cell positions. +- New routes `GET`/`PUT /api/settings/ui`, registered in both the normal and setup-mode route + sets in `cmd/qfs/main.go`. `render()` nil-guards `localDB` for tests. diff --git a/cmd/qfs/main.go b/cmd/qfs/main.go index 1473129..5539d5e 100644 --- a/cmd/qfs/main.go +++ b/cmd/qfs/main.go @@ -581,6 +581,10 @@ func runSetupMode(local *localdb.LocalDB) { router.POST("/setup/test", setupHandler.TestConnection) router.GET("/setup/status", setupHandler.GetStatus) + settingsHandler := handlers.NewSettingsHandler(local) + router.GET("/api/settings/ui", settingsHandler.GetUI) + router.PUT("/api/settings/ui", settingsHandler.PutUI) + // Health check router.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ @@ -892,6 +896,11 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect router.POST("/setup/test", setupHandler.TestConnection) router.GET("/setup/status", setupHandler.GetStatus) + // Per-client UI display preferences + settingsHandler := handlers.NewSettingsHandler(local) + router.GET("/api/settings/ui", settingsHandler.GetUI) + router.PUT("/api/settings/ui", settingsHandler.PutUI) + // Web pages router.GET("/", webHandler.Index) router.GET("/configs", webHandler.Configs) diff --git a/internal/handlers/settings.go b/internal/handlers/settings.go new file mode 100644 index 0000000..365f5ed --- /dev/null +++ b/internal/handlers/settings.go @@ -0,0 +1,47 @@ +package handlers + +import ( + "net/http" + + "git.mchus.pro/mchus/quoteforge/internal/localdb" + "github.com/gin-gonic/gin" +) + +// SettingsHandler serves per-client UI display preferences stored in app_settings. +type SettingsHandler struct { + localDB *localdb.LocalDB +} + +func NewSettingsHandler(localDB *localdb.LocalDB) *SettingsHandler { + return &SettingsHandler{localDB: localDB} +} + +type uiSettingsResponse struct { + IndicatorMode string `json:"indicator_mode"` +} + +// GetUI returns the current UI display preferences. +func (h *SettingsHandler) GetUI(c *gin.Context) { + c.JSON(http.StatusOK, uiSettingsResponse{ + IndicatorMode: h.localDB.GetIndicatorMode(), + }) +} + +// PutUI updates UI display preferences. Currently only indicator_mode. +func (h *SettingsHandler) PutUI(c *gin.Context) { + req, ok := BindJSON[struct { + IndicatorMode string `json:"indicator_mode"` + }](c) + if !ok { + return + } + + if err := h.localDB.SetIndicatorMode(req.IndicatorMode); err != nil { + RespondError(c, http.StatusUnprocessableEntity, "invalid indicator_mode", err) + return + } + + c.JSON(http.StatusOK, uiSettingsResponse{ + IndicatorMode: h.localDB.GetIndicatorMode(), + }) +} diff --git a/internal/handlers/setup.go b/internal/handlers/setup.go index af6afdc..dec1f4a 100644 --- a/internal/handlers/setup.go +++ b/internal/handlers/setup.go @@ -10,6 +10,7 @@ import ( "time" qfassets "git.mchus.pro/mchus/quoteforge" + "git.mchus.pro/mchus/quoteforge/internal/appmeta" "git.mchus.pro/mchus/quoteforge/internal/db" "git.mchus.pro/mchus/quoteforge/internal/localdb" "github.com/gin-gonic/gin" @@ -56,7 +57,9 @@ func (h *SetupHandler) ShowSetup(c *gin.Context) { settings, _ := h.localDB.GetSettings() data := gin.H{ - "Settings": settings, + "Settings": settings, + "IndicatorMode": h.localDB.GetIndicatorMode(), + "AppVersion": appmeta.Version(), } tmpl := h.templates["setup.html"] @@ -207,4 +210,3 @@ func buildMySQLDSN(host string, port int, database, user, password string, timeo } return cfg.FormatDSN() } - diff --git a/internal/handlers/web.go b/internal/handlers/web.go index 81ce918..c9652de 100644 --- a/internal/handlers/web.go +++ b/internal/handlers/web.go @@ -114,6 +114,10 @@ func NewWebHandler(_ string, localDB *localdb.LocalDB) (*WebHandler, error) { func (h *WebHandler) render(c *gin.Context, name string, data gin.H) { data["AppVersion"] = appmeta.Version() + data["IndicatorMode"] = localdb.IndicatorModeColor + if h.localDB != nil { + data["IndicatorMode"] = h.localDB.GetIndicatorMode() + } c.Header("Content-Type", "text/html; charset=utf-8") tmpl, ok := h.templates[name] if !ok { diff --git a/internal/localdb/indicator_mode_test.go b/internal/localdb/indicator_mode_test.go new file mode 100644 index 0000000..932d980 --- /dev/null +++ b/internal/localdb/indicator_mode_test.go @@ -0,0 +1,45 @@ +package localdb + +import ( + "path/filepath" + "testing" +) + +func newModeDB(t *testing.T) *LocalDB { + t.Helper() + local, err := New(filepath.Join(t.TempDir(), "mode.db")) + if err != nil { + t.Fatalf("open localdb: %v", err) + } + t.Cleanup(func() { _ = local.Close() }) + return local +} + +func TestIndicatorMode(t *testing.T) { + local := newModeDB(t) + + if got := local.GetIndicatorMode(); got != IndicatorModeColor { + t.Fatalf("unset: got %q, want %q", got, IndicatorModeColor) + } + + if err := local.SetIndicatorMode(IndicatorModeAccessible); err != nil { + t.Fatalf("set accessible: %v", err) + } + if got := local.GetIndicatorMode(); got != IndicatorModeAccessible { + t.Fatalf("after set accessible: got %q, want %q", got, IndicatorModeAccessible) + } + + if err := local.SetIndicatorMode(IndicatorModeColor); err != nil { + t.Fatalf("set color: %v", err) + } + if got := local.GetIndicatorMode(); got != IndicatorModeColor { + t.Fatalf("after set color: got %q, want %q", got, IndicatorModeColor) + } + + if err := local.SetIndicatorMode("rainbow"); err == nil { + t.Fatal("expected error for invalid mode, got nil") + } + if got := local.GetIndicatorMode(); got != IndicatorModeColor { + t.Fatalf("after rejected set: got %q, want %q", got, IndicatorModeColor) + } +} diff --git a/internal/localdb/localdb.go b/internal/localdb/localdb.go index a9844ca..8077b8b 100644 --- a/internal/localdb/localdb.go +++ b/internal/localdb/localdb.go @@ -1130,6 +1130,43 @@ func (l *LocalDB) upsertAppSetting(tx *gorm.DB, key, value string, updatedAt tim `, key, value, updatedAt.Format(time.RFC3339)).Error } +// Indicator display mode: how the UI renders signals that are otherwise carried +// by colour alone (price-quality scale, world-fallback price cells, incomplete +// pricing totals). "color" (default) keeps the hue coding; "accessible" swaps it +// for shape/text so it stays readable with a colour-vision deficiency. Per-client +// preference stored in app_settings; consumed by web/static/price-quality.js and +// the pricing tab via base.html's window.QF_INDICATOR_MODE. +const ( + IndicatorModeColor = "color" + IndicatorModeAccessible = "accessible" +) + +// GetIndicatorMode returns the stored indicator display mode, falling back to +// "color" when unset or invalid. +func (l *LocalDB) GetIndicatorMode() string { + value, ok := l.getAppSettingValue("indicator_mode") + if !ok { + return IndicatorModeColor + } + switch strings.TrimSpace(value) { + case IndicatorModeAccessible: + return IndicatorModeAccessible + default: + return IndicatorModeColor + } +} + +// SetIndicatorMode stores the indicator display mode. Only the two known +// literals are accepted. +func (l *LocalDB) SetIndicatorMode(mode string) error { + mode = strings.TrimSpace(mode) + if mode != IndicatorModeColor && mode != IndicatorModeAccessible { + return fmt.Errorf("invalid indicator mode %q", mode) + } + now := time.Now() + return l.upsertAppSetting(l.db, "indicator_mode", mode, now) +} + // 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 diff --git a/web/static/app.css b/web/static/app.css index a08d7eb..01fcaf8 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -44,3 +44,42 @@ visibility: visible; opacity: 1; } + +/* Colour-blind-safe price-quality indicator (mono mode). 5-step signal meter, + single neutral hue — magnitude reads from the number of lit bars, not colour. */ +.qf-quality-meter { + display: inline-flex; + align-items: flex-end; + gap: 1px; + height: 12px; + vertical-align: middle; + line-height: 0; +} +.qf-quality-meter > i { + display: inline-block; + width: 3px; + background-color: #d1d5db; + border-radius: 1px; +} +.qf-quality-meter > i.on { + background-color: #374151; +} +.qf-quality-meter > i:nth-child(1) { height: 30%; } +.qf-quality-meter > i:nth-child(2) { height: 45%; } +.qf-quality-meter > i:nth-child(3) { height: 62%; } +.qf-quality-meter > i:nth-child(4) { height: 80%; } +.qf-quality-meter > i:nth-child(5) { height: 100%; } +.qf-quality-meter[data-size="sm"] { + height: 10px; + gap: 1px; +} +.qf-quality-meter[data-size="sm"] > i { + width: 2px; +} + +/* Leading meter column on the pricing tab tables (mono mode). */ +.pricing-quality-meter { + width: 1%; + white-space: nowrap; + text-align: center; +} diff --git a/web/static/price-quality.js b/web/static/price-quality.js index 7c9fa19..f0510a0 100644 --- a/web/static/price-quality.js +++ b/web/static/price-quality.js @@ -1,16 +1,25 @@ -// Shared LOT price-quality color scale, used everywhere price_quality is +// Shared LOT price-quality indicator, used everywhere price_quality is // displayed (pricelist detail page, configurator search dropdown, configurator -// table, pricing tab). price_quality is a 0-9 score set by the external -// pricing tool (see bible-local/03-database.md, qt_pricelist_items.price_quality) -// — QF only displays it, never computes it. Single source of the color scale -// so every view stays visually consistent. +// table, pricing tab). price_quality is a 0-9 score set by the external pricing +// tool (see bible-local/03-database.md, qt_pricelist_items.price_quality) — QF +// only displays it, never computes it. Single source of the visual scale so +// every view stays consistent. // -// Gradient: 0 = red, 5 = yellow, 9 = green. +// Two render modes, chosen per client via window.QF_INDICATOR_MODE (injected by +// base.html from app_settings, key indicator_mode): +// "color" (default) — hue scale, 0 = red, 5 = yellow, 9 = green. +// "accessible" — colour-blind-safe 5-step signal-strength meter, +// single neutral hue, magnitude encoded by bar count. +// See bible-local/decisions/2026-08-31-accessible-indicator-mode.md. (function () { const RED = [220, 38, 38]; const YELLOW = [234, 179, 8]; const GREEN = [22, 163, 74]; + function isAccessible() { + return window.QF_INDICATOR_MODE === 'accessible'; + } + function lerp(c1, c2, t) { return c1.map((v, i) => Math.round(v + (c2[i] - v) * t)); } @@ -28,27 +37,53 @@ return rgb ? `rgb(${rgb})` : null; } - // Small colored dot for compact contexts (search dropdown, configurator table cell). + // 0-9 score -> 0-4 meter level. 0-1 / 2-3 / 4-5 / 6-7 / 8-9. + function priceQualityLevel(quality) { + if (typeof quality !== 'number' || Number.isNaN(quality)) return null; + const q = Math.max(0, Math.min(9, quality)); + return Math.min(4, Math.floor(q / 2)); + } + + // Monochrome 5-step signal-strength meter. size: undefined | 'sm'. + function qualityMeterHtml(quality, opts) { + const level = priceQualityLevel(quality); + if (level === null) return ''; + const size = opts && opts.size === 'sm' ? ' data-size="sm"' : ''; + const title = `Качество цены: ${quality}/9`; + let bars = ''; + for (let i = 0; i < 5; i++) { + bars += `<i class="${i <= level ? 'on' : ''}"></i>`; + } + return `<span class="qf-quality-meter"${size} role="img" aria-label="${title}" title="${title}">${bars}</span>`; + } + + // Small indicator for compact contexts (search dropdown, configurator table cell). function qualityDotHtml(quality) { + if (isAccessible()) return qualityMeterHtml(quality, { size: 'sm' }); const color = priceQualityColor(quality); if (!color) return ''; return `<span class="inline-block w-2 h-2 rounded-full" style="background-color: ${color}" title="Качество цены: ${quality}/9"></span>`; } - // Colored number badge for a dedicated "quality" column/cell. + // Indicator for a dedicated "quality" column/cell. function qualityBadgeHtml(quality) { if (typeof quality !== 'number') return '<span class="text-gray-400">-</span>'; + if (isAccessible()) return qualityMeterHtml(quality, { size: 'sm' }); const color = priceQualityColor(quality); return `<span class="font-semibold" style="color: ${color}" title="Качество цены: ${quality}/9">${quality}</span>`; } - // Light row-tint background for table rows, e.g. the pricing tab. + // Light row-tint background for table rows, e.g. the pricing tab. Suppressed + // in accessible mode — the pricing tab carries its own leading meter column there. function qualityRowStyle(quality) { + if (isAccessible()) return ''; const rgb = priceQualityRgb(quality); return rgb ? `background-color: rgba(${rgb}, 0.10)` : ''; } window.priceQualityColor = priceQualityColor; + window.priceQualityLevel = priceQualityLevel; + window.qualityMeterHtml = qualityMeterHtml; window.qualityDotHtml = qualityDotHtml; window.qualityBadgeHtml = qualityBadgeHtml; window.qualityRowStyle = qualityRowStyle; diff --git a/web/templates/base.html b/web/templates/base.html index 3fe4878..0253e5f 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -8,6 +8,7 @@ <link rel="stylesheet" href="/static/app.css"> <script src="/static/vendor/tailwindcss.browser.js"></script> <script src="/static/vendor/htmx-1.9.10.min.js"></script> + <script>window.QF_INDICATOR_MODE = "{{ .IndicatorMode }}";</script> <script src="/static/price-quality.js"></script> <style> .htmx-request { opacity: 0.5; } diff --git a/web/templates/index.html b/web/templates/index.html index 017cb26..cc91bc9 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -222,6 +222,7 @@ <table class="w-full text-sm border-collapse"> <thead class="bg-gray-50 text-gray-700"> <tr> + {{if eq .IndicatorMode "accessible"}}<th class="px-2 py-2 border-b" title="Качество цены"><span class="sr-only">Качество цены</span></th>{{end}} <th class="px-2 py-2 text-left border-b whitespace-nowrap">PN вендора</th> <th class="px-2 py-2 text-left border-b">Описание</th> <th class="px-2 py-2 text-left border-b">LOT</th> @@ -233,11 +234,11 @@ </tr> </thead> <tbody id="pricing-body-buy"> - <tr><td colspan="8" class="px-2 py-8 text-center text-gray-400">Загрузите BOM во вкладке «BOM»</td></tr> + <tr><td colspan="{{if eq .IndicatorMode "accessible"}}9{{else}}8{{end}}" class="px-2 py-8 text-center text-gray-400">Загрузите BOM во вкладке «BOM»</td></tr> </tbody> <tfoot id="pricing-foot-buy" class="hidden bg-gray-50 font-semibold"> <tr> - <td colspan="4" class="px-2 py-2 text-right">Итого:</td> + <td colspan="{{if eq .IndicatorMode "accessible"}}5{{else}}4{{end}}" class="px-2 py-2 text-right">Итого:</td> <td class="px-2 py-2 text-right" id="pricing-total-buy-estimate">—</td> <td class="px-2 py-2 text-right stock-price-col" id="pricing-total-buy-warehouse">—</td> <td class="px-2 py-2 text-right" id="pricing-total-buy-competitor">—</td> @@ -271,6 +272,7 @@ <table class="w-full text-sm border-collapse"> <thead class="bg-gray-50 text-gray-700"> <tr> + {{if eq .IndicatorMode "accessible"}}<th class="px-2 py-2 border-b" title="Качество цены"><span class="sr-only">Качество цены</span></th>{{end}} <th class="px-2 py-2 text-left border-b whitespace-nowrap">PN вендора</th> <th class="px-2 py-2 text-left border-b">Описание</th> <th class="px-2 py-2 text-left border-b">LOT</th> @@ -282,11 +284,11 @@ </tr> </thead> <tbody id="pricing-body-sale"> - <tr><td colspan="8" class="px-2 py-8 text-center text-gray-400">Загрузите BOM во вкладке «BOM»</td></tr> + <tr><td colspan="{{if eq .IndicatorMode "accessible"}}9{{else}}8{{end}}" class="px-2 py-8 text-center text-gray-400">Загрузите BOM во вкладке «BOM»</td></tr> </tbody> <tfoot id="pricing-foot-sale" class="hidden bg-gray-50 font-semibold"> <tr> - <td colspan="4" class="px-2 py-2 text-right">Итого:</td> + <td colspan="{{if eq .IndicatorMode "accessible"}}5{{else}}4{{end}}" class="px-2 py-2 text-right">Итого:</td> <td class="px-2 py-2 text-right" id="pricing-total-sale-estimate">—</td> <td class="px-2 py-2 text-right stock-price-col" id="pricing-total-sale-warehouse">—</td> <td class="px-2 py-2 text-right" id="pricing-total-sale-competitor">—</td> @@ -4506,6 +4508,17 @@ async function renderPricingTab() { // families since applyCustomPrice() strips those on vendor-price cells via regex. const WORLD_CLS = 'bg-amber-50 text-amber-700'; + // Colour-blind-safe mode: leading signal-meter column, no row tint, and the + // amber world-fallback cell tint is replaced by a text marker. + const ACCESSIBLE_MODE = window.QF_INDICATOR_MODE === 'accessible'; + const worldCls = (w) => (!ACCESSIBLE_MODE && w) ? WORLD_CLS : ''; + const worldMark = (w) => (ACCESSIBLE_MODE && w) + ? '<sup class="text-gray-500 font-semibold" title="Цена-заглушка из прайслиста WORLD">W</sup>' + : ''; + const meterCell = (q, bt) => ACCESSIBLE_MODE + ? `<td class="pricing-quality-meter px-2 py-1.5 align-middle ${bt || ''}">${qualityMeterHtml(q)}</td>` + : ''; + // ─── Build shared row data (unit prices for display, totals for math) ──── // Each BOM row is exploded into per-LOT sub-rows; grouped by vendor PN via groupStart/groupSize. const cartQtyMap = {}; @@ -4636,7 +4649,7 @@ async function renderPricingTab() { // ─── Populate Buy table ────────────────────────────────────────────────── tbodyBuy.innerHTML = ''; if (!rowData.length) { - tbodyBuy.innerHTML = '<tr><td colspan="8" class="px-2 py-8 text-center text-gray-400">Нет данных для отображения</td></tr>'; + tbodyBuy.innerHTML = `<tr><td colspan="${ACCESSIBLE_MODE ? 9 : 8}" class="px-2 py-8 text-center text-gray-400">Нет данных для отображения</td></tr>`; tfootBuy.classList.add('hidden'); } else { let totEst = 0, totWh = 0, totComp = 0, totVendor = 0; @@ -4670,12 +4683,13 @@ async function renderPricingTab() { <td${rs} class="px-2 py-1.5 text-xs text-gray-500 truncate max-w-xs border-t border-gray-200 align-top">${escapeHtml(r.desc)}</td>`; })() : ''; tr.innerHTML = ` + ${meterCell(r.priceQuality, borderTop)} ${pnDescHtml} <td class="px-2 py-1.5 text-xs ${borderTop}">${r.lotCell}</td> <td class="px-2 py-1.5 text-right text-xs ${borderTop}">${r.qty}</td> - <td class="px-2 py-1.5 text-right text-xs ${borderTop} ${r.estWorld ? WORLD_CLS : ''}">${r.estUnit > 0 ? formatCurrency(r.estUnit) : '—'}</td> - <td class="px-2 py-1.5 text-right text-xs stock-price-col ${borderTop} ${r.whWorld ? WORLD_CLS : ''}">${r.warehouseUnit != null ? formatCurrency(r.warehouseUnit) : '—'}</td> - <td class="px-2 py-1.5 text-right text-xs ${borderTop} ${r.compWorld ? WORLD_CLS : ''}">${r.competitorUnit != null ? formatCurrency(r.competitorUnit) : '—'}</td> + <td class="px-2 py-1.5 text-right text-xs ${borderTop} ${worldCls(r.estWorld)}">${r.estUnit > 0 ? formatCurrency(r.estUnit) : '—'}${worldMark(r.estWorld)}</td> + <td class="px-2 py-1.5 text-right text-xs stock-price-col ${borderTop} ${worldCls(r.whWorld)}">${r.warehouseUnit != null ? formatCurrency(r.warehouseUnit) : '—'}${worldMark(r.whWorld)}</td> + <td class="px-2 py-1.5 text-right text-xs ${borderTop} ${worldCls(r.compWorld)}">${r.competitorUnit != null ? formatCurrency(r.competitorUnit) : '—'}${worldMark(r.compWorld)}</td> <td class="px-2 py-1.5 text-right text-xs pricing-vendor-price-buy ${borderTop} ${r.vendorOrigUnit == null ? 'text-gray-400' : ''}">${r.vendorOrigUnit != null ? formatCurrency(r.vendorOrigUnit) : '—'}</td> `; tbodyBuy.appendChild(tr); @@ -4690,7 +4704,7 @@ async function renderPricingTab() { // ─── Populate Sale table ───────────────────────────────────────────────── tbodySale.innerHTML = ''; if (!rowData.length) { - tbodySale.innerHTML = '<tr><td colspan="8" class="px-2 py-8 text-center text-gray-400">Нет данных для отображения</td></tr>'; + tbodySale.innerHTML = `<tr><td colspan="${ACCESSIBLE_MODE ? 9 : 8}" class="px-2 py-8 text-center text-gray-400">Нет данных для отображения</td></tr>`; tfootSale.classList.add('hidden'); } else { let totEst = 0, totWh = 0, totComp = 0; @@ -4727,12 +4741,13 @@ async function renderPricingTab() { <td${rs} class="px-2 py-1.5 text-xs text-gray-500 truncate max-w-xs border-t border-gray-200 align-top">${escapeHtml(r.desc)}</td>`; })() : ''; tr.innerHTML = ` + ${meterCell(r.priceQuality, borderTop)} ${pnDescHtml} <td class="px-2 py-1.5 text-xs ${borderTop}">${r.lotCell}</td> <td class="px-2 py-1.5 text-right text-xs ${borderTop}">${r.qty}</td> - <td class="px-2 py-1.5 text-right text-xs ${borderTop} ${r.estWorld ? WORLD_CLS : ''}">${saleEstUnit > 0 ? formatCurrency(saleEstUnit) : '—'}</td> - <td class="px-2 py-1.5 text-right text-xs stock-price-col ${borderTop} ${r.whWorld ? WORLD_CLS : ''}">${saleWhUnit != null ? formatCurrency(saleWhUnit) : '—'}</td> - <td class="px-2 py-1.5 text-right text-xs ${borderTop} ${r.compWorld ? WORLD_CLS : ''}">${saleCompUnit != null ? formatCurrency(saleCompUnit) : '—'}</td> + <td class="px-2 py-1.5 text-right text-xs ${borderTop} ${worldCls(r.estWorld)}">${saleEstUnit > 0 ? formatCurrency(saleEstUnit) : '—'}${worldMark(r.estWorld)}</td> + <td class="px-2 py-1.5 text-right text-xs stock-price-col ${borderTop} ${worldCls(r.whWorld)}">${saleWhUnit != null ? formatCurrency(saleWhUnit) : '—'}${worldMark(r.whWorld)}</td> + <td class="px-2 py-1.5 text-right text-xs ${borderTop} ${worldCls(r.compWorld)}">${saleCompUnit != null ? formatCurrency(saleCompUnit) : '—'}${worldMark(r.compWorld)}</td> <td class="px-2 py-1.5 text-right text-xs pricing-vendor-price-sale ${borderTop} text-gray-400">—</td> `; tbodySale.appendChild(tr); @@ -4779,10 +4794,15 @@ function _setPricingTotal(elId, has, total, worldTotal, count, totalRows) { parts.push(`Цены есть не для всех позиций: ${count} из ${totalRows}`); } - // Red is reserved for a sum that actually leans on world stand-in prices. - el.className = `${el.className} ${share > 0 ? 'text-red-600' : ''} pricing-total-tip` + // A sum that leans on world stand-in prices is flagged: by red in color + // mode, by a leading ⚠ glyph in accessible mode. The hover popup carries the + // exact share (and the coverage line, when some positions have no price) in + // both modes. + const accessible = window.QF_INDICATOR_MODE === 'accessible'; + el.className = `${el.className} ${share > 0 && !accessible ? 'text-red-600' : ''} pricing-total-tip` .replace(/\s+/g, ' ').trim(); - el.innerHTML = `${formatCurrency(total)}<span class="pricing-total-tip__body">${parts.join('<br>')}</span>`; + const mark = share > 0 && accessible ? '<span title="Часть суммы — цена-заглушка WORLD">⚠</span> ' : ''; + el.innerHTML = `${mark}${formatCurrency(total)}<span class="pricing-total-tip__body">${parts.join('<br>')}</span>`; } // One decimal, comma separator, no trailing ",0". diff --git a/web/templates/setup.html b/web/templates/setup.html index a03cd0d..5f38e1a 100644 --- a/web/templates/setup.html +++ b/web/templates/setup.html @@ -4,26 +4,39 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>OFS - Настройка подключения + QuoteForge — Настройки - -
-
-
-

QuoteForge

-

Настройка подключения к базе данных

-
- -
- - + + + {{end}} +
+

QuoteForge

+

Настройки

+
-
+
+ + + +

Важно: не закрывайте консольное окно приложения — без него программа не работает.

+
+ +
+
+

Подключение к базе данных

+ +
-
- {{if .Settings}} - - Назад - - {{end}} +
-

- QuoteForge - Конфигуратор серверов +

+

Индикаторы

+

+ Часть подсказок в интерфейсе передаётся только цветом: качество цены в спецификации + и на вкладке «Ценообразование», цены-заглушки, неполные итоги. Выберите режим без + цветовой кодировки, если цвета трудно различать. +

+ +
+ + + +
+ +
+ + +
+
+
+ +

+ QuoteForge {{.AppVersion}} — Конфигуратор серверов

@@ -159,6 +214,31 @@ } } + async function saveIndicatorMode() { + const mode = document.querySelector('input[name="indicator-mode"]:checked')?.value || 'color'; + const box = document.getElementById('indicator-status'); + box.className = 'mb-3 p-2 rounded-md text-sm bg-blue-100 text-blue-800'; + box.textContent = 'Сохранение...'; + try { + const resp = await fetch('/api/settings/ui', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ indicator_mode: mode }), + }); + const data = await resp.json(); + if (resp.ok) { + box.className = 'mb-3 p-2 rounded-md text-sm bg-green-100 text-green-800'; + box.textContent = '✓ Сохранено. Изменения появятся при следующем открытии страниц.'; + } else { + box.className = 'mb-3 p-2 rounded-md text-sm bg-red-100 text-red-800'; + box.textContent = data.error || 'Не удалось сохранить'; + } + } catch (e) { + box.className = 'mb-3 p-2 rounded-md text-sm bg-red-100 text-red-800'; + box.textContent = 'Ошибка сети: ' + e.message; + } + } + async function checkServerReady() { let attempts = 0; const maxAttempts = 30; // 30 seconds max