From 5d58c55b11d557d6c5a2fbd128d82a64bc7e6e2e Mon Sep 17 00:00:00 2001 From: fomichev Date: Tue, 2 Jun 2026 17:53:16 +0300 Subject: [PATCH] =?utf8?q?ERP-389:=20=D0=B1=D0=BB=D0=BE=D0=BA=20=D0=9A?= =?utf8?q?=D0=B0=D0=BD=D0=B0=D0=BB=D1=8B=20(=D0=B0=D1=81=D1=81=D0=BE=D1=80?= =?utf8?q?=D1=82=D0=B8=D0=BC=D0=B5=D0=BD=D1=82=D0=BD=D1=8B=D0=B5=20=D0=BB?= =?utf8?q?=D0=B5=D0=B9=D0=B1=D0=BB=D1=8B)=20=D0=B2=20=D1=83=D0=BF=D1=80?= =?utf8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B8=20=D0=BC=D0=B0=D0=B3?= =?utf8?q?=D0=B0=D0=B7=D0=B8=D0=BD=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit - StoreDynamic: константа CATEGORY_ASSORTMENT_LABELS = 5 - CityStoreManagementController: загрузка/сохранение лейблов через StoreDynamic category 5 (value_string = "1,3,7"); recordStoreDynamicStr(); save(true, $validateFields) вместо полной валидации — фикс ошибки required для null SEO-полей при сохранении с Операционного таба - JS: панель Каналы в renderOps(), теги в renderHero(), модал выбора, openCombo() показывает полный список без фильтра по текущему тексту - CSS: стили lbl-tag, tag-add, btn-panel-link, lbl-modal-*, hero-labels - index.php: модальное окно + URL assortmentLabels Co-Authored-By: Claude Sonnet 4.6 --- .../CityStoreManagementController.php | 69 +++++++++++- erp24/records/StoreDynamic.php | 1 + erp24/views/city-store-management/index.php | 16 +++ erp24/web/css/city-store-management.css | 26 +++++ .../city-store-management.js | 103 +++++++++++++++++- 5 files changed, 207 insertions(+), 8 deletions(-) diff --git a/erp24/controllers/CityStoreManagementController.php b/erp24/controllers/CityStoreManagementController.php index 717cf5f7..c8db7fa1 100644 --- a/erp24/controllers/CityStoreManagementController.php +++ b/erp24/controllers/CityStoreManagementController.php @@ -13,6 +13,7 @@ use yii_app\records\CityStoreParams; use yii_app\records\MatrixType; use yii_app\records\StoreCityList; use yii_app\records\StoreDynamic; +use yii_app\records\AssortmentLabel; use yii_app\records\StoreType; class CityStoreManagementController extends Controller @@ -77,6 +78,23 @@ class CityStoreManagementController extends Controller ]); $terrManager = $terrManagerDyn ? Admin::findOne($terrManagerDyn->value_int) : null; + $labelsDyn = StoreDynamic::findOne([ + 'store_id' => $id, + 'active' => 1, + 'category' => StoreDynamic::CATEGORY_ASSORTMENT_LABELS, + ]); + $labelIds = []; + if ($labelsDyn && $labelsDyn->value_string !== null && $labelsDyn->value_string !== '') { + $labelIds = array_values(array_filter(array_map('intval', explode(',', $labelsDyn->value_string)))); + } + $allLabels = AssortmentLabel::findActive(); + $labelsArray = array_map(static fn(AssortmentLabel $l) => [ + 'id' => $l->id, + 'name' => $l->name, + 'color' => $l->color, + 'icon' => $l->icon, + ], $allLabels); + return [ 'success' => true, 'data' => [ @@ -149,6 +167,10 @@ class CityStoreManagementController extends Controller 'matrixTypeArray' => ArrayHelper::map(MatrixType::find()->all(), 'id', 'name'), 'bushChefFloristArray' => ArrayHelper::map(Admin::findAll(['group_id' => AdminGroup::GROUP_BUSH_CHEF_FLORIST]), 'id', 'name'), 'territorialManagerArray' => ArrayHelper::map(Admin::findAll(['group_id' => AdminGroup::GROUP_BUSH_DIRECTOR]), 'id', 'name_full'), + + // assortment labels (StoreDynamic category 5) + 'labelIds' => $labelIds, + 'labelsArray' => $labelsArray, ], ]; } @@ -173,27 +195,44 @@ class CityStoreManagementController extends Controller 'sprav_id', 'sale_plan_avg', 'visitor_day_avg', 'visitor_avg', 'square_store', ]; - // 2gis has a special key in post + // Track which fields are actually being changed — pass only these to save() + // so CityStore's required validation runs only for sent fields, + // not for null fields that weren't part of this tab's form. + $dirtyFields = []; + if (isset($post['gis2'])) { $store->{'2gis'} = $post['gis2']; + $dirtyFields[] = '2gis'; } if (isset($post['yamap'])) { $store->yamap = $post['yamap']; + $dirtyFields[] = 'yamap'; } if (isset($post['googlemap'])) { $store->googlemap = $post['googlemap']; + $dirtyFields[] = 'googlemap'; } if (isset($post['mapiframe'])) { $store->mapiframe = $post['mapiframe']; + $dirtyFields[] = 'mapiframe'; } foreach ($allowed as $field) { if (array_key_exists($field, $post)) { $store->$field = $post[$field]; + $dirtyFields[] = $field; } } - if (!$store->save()) { + // CityStore model has overly strict required rules for optional fields. + // Only validate fields that actually have a non-empty value to avoid + // false-positive errors when saving a tab that doesn't edit all fields. + $validateFields = array_values(array_filter($dirtyFields, static function (string $f) use ($store): bool { + $v = $store->$f ?? null; + return $v !== null && trim((string)$v) !== ''; + })); + + if (!$store->save(true, $validateFields ?: ['id'])) { return ['success' => false, 'message' => implode(', ', ArrayHelper::getColumn($store->errors, 0))]; } @@ -256,6 +295,10 @@ class CityStoreManagementController extends Controller $this->recordStoreDynamic($storeId, StoreDynamic::CATEGORY_TERRITORIAL_MANAGER, (int)$post['territorial_manager']); } + if (array_key_exists('label_ids', $post)) { + $this->recordStoreDynamicStr($storeId, StoreDynamic::CATEGORY_ASSORTMENT_LABELS, (string)$post['label_ids']); + } + $transaction->commit(); } catch (\Throwable $e) { $transaction->rollBack(); @@ -305,4 +348,26 @@ class CityStoreManagementController extends Controller Yii::error('StoreDynamic save error: ' . json_encode($record->getErrors()), __CLASS__); } } + + private function recordStoreDynamicStr(int $storeId, int $category, string $value): void + { + StoreDynamic::updateAll( + ['active' => 0, 'date_to' => date('Y-m-d H:i:s')], + ['active' => 1, 'category' => $category, 'store_id' => $storeId] + ); + + $record = new StoreDynamic([ + 'store_id' => $storeId, + 'value_type' => 'string', + 'value_string' => $value, + 'date_from' => date('Y-m-d H:i:s'), + 'date_to' => '2100-01-01 00:00:00', + 'active' => 1, + 'category' => $category, + ]); + + if (!$record->save()) { + Yii::error('StoreDynamic (str) save error: ' . json_encode($record->getErrors()), __CLASS__); + } + } } diff --git a/erp24/records/StoreDynamic.php b/erp24/records/StoreDynamic.php index 8689b7b8..b1a8acb8 100644 --- a/erp24/records/StoreDynamic.php +++ b/erp24/records/StoreDynamic.php @@ -25,6 +25,7 @@ class StoreDynamic extends \yii\db\ActiveRecord const CATEGORY_BUSH_CHEF_FLORIST = 2; // Кустовой шеф-флорист (value_int = admin_id) const CATEGORY_TERRITORIAL_MANAGER = 3; // Территориальный управляющий (value_int = admin_id) const CATEGORY_IS_ACTIVE = 4; // История активности магазина (value_int = 0|1) + const CATEGORY_ASSORTMENT_LABELS = 5; // Прикреплённые лейблы ассортимента (value_string = "1,3,7") /** * {@inheritdoc} diff --git a/erp24/views/city-store-management/index.php b/erp24/views/city-store-management/index.php index 736b4382..8e7b8528 100644 --- a/erp24/views/city-store-management/index.php +++ b/erp24/views/city-store-management/index.php @@ -21,6 +21,7 @@ $this->registerJs(' getStore: ' . json_encode(Url::to(['/city-store-management/get-store'])) . ', saveCityStore: ' . json_encode(Url::to(['/city-store-management/save-city-store'])) . ', saveCityStoreParams: ' . json_encode(Url::to(['/city-store-management/save-city-store-params'])) . ', + assortmentLabels: ' . json_encode(Url::to(['/assortment-label/index'])) . ', }; ', \yii\web\View::POS_HEAD); ?> @@ -121,3 +122,18 @@ $this->registerJs('
+ + + diff --git a/erp24/web/css/city-store-management.css b/erp24/web/css/city-store-management.css index 4b9ccc3f..5ebd7366 100644 --- a/erp24/web/css/city-store-management.css +++ b/erp24/web/css/city-store-management.css @@ -329,3 +329,29 @@ /* ── Заглушки (stub) ─────────────────────────────────── */ .stub-badge { display: inline-flex; align-items: center; gap: 3px; background: #ffe0b2; color: #e65100; font: 700 8px/1 inherit; padding: 2px 5px; border-radius: 3px; text-transform: uppercase; letter-spacing: .3px; margin-left: 4px; vertical-align: middle; } + +/* ── Каналы: теги ────────────────────────────────────── */ +.lbl-tag { display: inline-flex; align-items: center; gap: 5px; padding: 4px 10px; border-radius: 12px; font: 600 11px/1.2 inherit; border: 1px solid transparent; } +.lbl-tag .tag-x { cursor: pointer; opacity: .5; margin-left: 2px; font-size: 9px; padding: 1px 3px; border-radius: 50%; } +.lbl-tag .tag-x:hover { opacity: 1; background: rgba(0,0,0,.1); } +.tag-add { display: inline-flex; align-items: center; gap: 4px; padding: 4px 10px; border-radius: 12px; font: 600 11px/1.2 inherit; background: #f8f9fb; color: #666; border: 1px dashed #ccc; cursor: pointer; } +.tag-add:hover { border-color: var(--csm-ac); color: var(--csm-ac); } +.btn-panel-link { font: 500 11px/1 inherit; color: var(--csm-ac); text-decoration: none; display: inline-flex; align-items: center; gap: 4px; padding: 4px 8px; border-radius: var(--csm-r); } +.btn-panel-link:hover { color: #6a3fd8; background: #f3f0ff; } + +/* ── Модал: выбор лейблов ────────────────────────────── */ +.lbl-modal { position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 500; display: flex; align-items: center; justify-content: center; } +.lbl-modal-box { background: #fff; border-radius: var(--csm-r); width: 460px; max-height: 80vh; display: flex; flex-direction: column; box-shadow: 0 8px 32px rgba(0,0,0,.18); } +.lbl-modal-hd { padding: 14px 18px; border-bottom: 1px solid var(--csm-bd); display: flex; align-items: center; justify-content: space-between; } +.lbl-modal-hd h4 { margin: 0; font: 700 14px/1 inherit; color: var(--csm-bg); } +.lbl-modal-hd button { border: none; background: none; cursor: pointer; font-size: 16px; color: #888; line-height: 1; } +.lbl-modal-body { flex: 1; overflow-y: auto; padding: 12px 18px; display: flex; flex-direction: column; gap: 6px; } +.lbl-modal-ft { padding: 12px 18px; border-top: 1px solid var(--csm-bd); display: flex; justify-content: flex-end; gap: 8px; } +.lbl-modal-item { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border: 1px solid #e8eaed; border-radius: var(--csm-r); cursor: pointer; } +.lbl-modal-item:hover { background: #f8f9fb; border-color: var(--csm-ac); } +.lbl-modal-item input { cursor: pointer; } +.lbl-preview { display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: 10px; font: 600 11px/1.2 inherit; border: 1px solid transparent; } + +/* ── Hero: теги каналов ──────────────────────────────── */ +.hero-labels { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 4px; width: 100%; } +.hero-lbl-lbl { font: 500 10px/1 inherit; color: #888; text-transform: uppercase; letter-spacing: .3px; } diff --git a/erp24/web/js/city-store-management/city-store-management.js b/erp24/web/js/city-store-management/city-store-management.js index a30e4b68..bb8bc87e 100644 --- a/erp24/web/js/city-store-management/city-store-management.js +++ b/erp24/web/js/city-store-management/city-store-management.js @@ -73,7 +73,8 @@ function renderComboList(query) { function openCombo() { document.getElementById('storeCombo').classList.add('on'); - renderComboList(document.getElementById('storeSearchInput').value); + // Always show full list on open — filter only when user types + renderComboList(''); } function closeCombo() { @@ -138,10 +139,23 @@ function renderHero() { ? ' Активен' : ' Неактивен'); document.getElementById('heroAddr').innerHTML = '' + esc(s.adress || '—'); + var labelTags = ''; + if (s.labelIds && s.labelIds.length && s.labelsArray) { + var tagHtml = s.labelsArray + .filter(function (l) { return s.labelIds.indexOf(l.id) !== -1; }) + .map(function (l) { + var color = l.color || '#555'; + return '' + + (l.icon ? '' : '') + + esc(l.name) + ''; + }).join(''); + labelTags = '
Каналы:' + tagHtml + '
'; + } document.getElementById('heroMeta').innerHTML = 'ID ' + s.id + '' + (s.openDate ? 'Открыт ' + esc(s.openDate) + '' : '') - + (s.administratorName ? 'Адм.: ' + esc(s.administratorName) + '' : ''); + + (s.administratorName ? 'Адм.: ' + esc(s.administratorName) + '' : '') + + labelTags; document.getElementById('heroName').style.color = ''; document.getElementById('heroAddr').style.color = ''; } @@ -225,7 +239,17 @@ function renderOps() { + (s.storeTypeName ? 'Текущий: ' + esc(s.storeTypeName) + '' : 'Не задан') + ''; - // 3. Оснащение (параметры из city_store_params) + // 3. Каналы (StoreDynamic category 5 — лейблы ассортимента) + h += '
' + + '

КаналыStoreDynamic

' + + '
' + + '' + + 'Управление тегами
' + + '
' + + renderChannelsList(s.labelIds, s.labelsArray) + + '
'; + + // 4. Оснащение (параметры из city_store_params) h += '
' + '

ОснащениеПараметры

' + '
' @@ -240,7 +264,7 @@ function renderOps() { + 'Техника (камеры, роутеры, касса) — требует миграции БД stub
' + '
'; - // 4. Адрес + // 5. Адрес h += '
' + '

АдресОба источника

' + '
' @@ -261,7 +285,7 @@ function renderOps() { + '
' + '
'; - // 5. Руководство (StoreDynamic) + // 6. Руководство (StoreDynamic) h += '
' + '

РуководствоStoreDynamic

' + '
' @@ -277,7 +301,7 @@ function renderOps() { + '
' + '
'; - // 6. Общая информация (city_store) + // 7. Общая информация (city_store) h += '
' + '

Общая информацияcity_store

' + '
' @@ -508,6 +532,7 @@ function saveChanges() { territorial_manager: val('fTerritorialManager'), is_active: checked('fIsActive') ? 1 : 0, square_store: val('fSquareStore'), + label_ids: (D.labelIds || []).join(','), }; var p1 = postJson(CSM_URLS.saveCityStore, csStore); @@ -655,6 +680,72 @@ function textField(colCls, id, value, label, src) { + '
'; } +// ===== КАНАЛЫ / ЛЕЙБЛЫ ===== + +function renderChannelsList(labelIds, labelsArray) { + var ids = labelIds || []; + var all = labelsArray || []; + var h = '
'; + all.forEach(function (lbl) { + if (ids.indexOf(lbl.id) === -1) return; + var color = lbl.color || '#555'; + var bg = color + '1a'; // 10% opacity hex + h += '' + + (lbl.icon ? '' : '') + + esc(lbl.name) + + ''; + }); + h += 'Добавить'; + h += '
'; + if (!all.length) { + return 'Нет доступных тегов в справочнике ассортимента'; + } + return h; +} + +function openLabelsModal() { + if (!D || !D.labelsArray) { toast('Данные не загружены', 'warn'); return; } + var list = document.getElementById('labelsModalList'); + var ids = D.labelIds || []; + if (!D.labelsArray.length) { + list.innerHTML = '
Нет активных тегов в справочнике.
' + + 'Открыть справочник
'; + } else { + list.innerHTML = D.labelsArray.map(function (lbl) { + var color = lbl.color || '#555'; + var bg = color + '1a'; + var isChecked = ids.indexOf(lbl.id) !== -1 ? ' checked' : ''; + return ''; + }).join(''); + } + document.getElementById('labelsModal').style.display = 'flex'; +} + +function closeLabelsModal() { + document.getElementById('labelsModal').style.display = 'none'; +} + +function applyLabelsModal() { + var checkboxes = document.querySelectorAll('#labelsModalList input[type=checkbox]:checked'); + D.labelIds = Array.prototype.slice.call(checkboxes).map(function (cb) { return parseInt(cb.value, 10); }); + closeLabelsModal(); + var panel = document.getElementById('channelsPanel'); + if (panel) panel.innerHTML = renderChannelsList(D.labelIds, D.labelsArray); + markDirty(); +} + +function removeLabel(id, e) { + if (e) e.stopPropagation(); + D.labelIds = (D.labelIds || []).filter(function (x) { return x !== id; }); + var panel = document.getElementById('channelsPanel'); + if (panel) panel.innerHTML = renderChannelsList(D.labelIds, D.labelsArray); + markDirty(); +} + function numField(colCls, id, value, label) { return '
' + '' -- 2.39.5