]> gitweb.erp-flowers.ru Git - erp24_rep/yii-erp24/.git/commitdiff
ERP-389: блок Каналы (ассортиментные лейблы) в управлении магазином
authorfomichev <vladimir.fomichev@erp-flowers.ru>
Tue, 2 Jun 2026 14:53:16 +0000 (17:53 +0300)
committerfomichev <vladimir.fomichev@erp-flowers.ru>
Tue, 2 Jun 2026 14:53:16 +0000 (17:53 +0300)
- 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 <noreply@anthropic.com>
erp24/controllers/CityStoreManagementController.php
erp24/records/StoreDynamic.php
erp24/views/city-store-management/index.php
erp24/web/css/city-store-management.css
erp24/web/js/city-store-management/city-store-management.js

index 717cf5f76b286e3d133fc0e559cae50f38128a20..c8db7fa1854ff86a94cdd880acd15963e7a76557 100644 (file)
@@ -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__);
+        }
+    }
 }
index 8689b7b87372d7616639e7c8b87f18212eabd65c..b1a8acb8e784765d6a47aec5b8f1d802b2557639 100644 (file)
@@ -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}
index 736b4382b09a1e7d188597b69c471f6d11712ee0..8e7b85286ea73e029e63b6551fc0b8f815e21ec6 100644 (file)
@@ -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('
 
 <!-- Toast-уведомления -->
 <div class="csm-toast-w" id="toastW"></div>
+
+<!-- Модал: выбор лейблов ассортимента -->
+<div class="lbl-modal" id="labelsModal" style="display:none" onclick="closeLabelsModal()">
+  <div class="lbl-modal-box" onclick="event.stopPropagation()">
+    <div class="lbl-modal-hd">
+      <h4><i class="fas fa-hashtag me-2"></i>Выбор каналов</h4>
+      <button onclick="closeLabelsModal()"><i class="fas fa-times"></i></button>
+    </div>
+    <div class="lbl-modal-body" id="labelsModalList"></div>
+    <div class="lbl-modal-ft">
+      <button class="btn btn-sm btn-outline-secondary" onclick="closeLabelsModal()">Отмена</button>
+      <button class="btn btn-sm csm-btn-ac" onclick="applyLabelsModal()"><i class="fas fa-check me-1"></i>Применить</button>
+    </div>
+  </div>
+</div>
index 4b9ccc3f49ba2ddd5cf1cb037ccb5f3aaee0ca14..5ebd73668036ad778b1d9def826a3642a173c044 100644 (file)
 
 /* ── Заглушки (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; }
index a30e4b68a960f8a227d875b71ac4f1cdf1616b91..bb8bc87e80e23922053d0926c45c82c5098015f1 100644 (file)
@@ -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() {
             ? ' <span class="hs-bdg hs-active"><i class="fas fa-circle" style="font-size:5px"></i>Активен</span>'
             : ' <span class="hs-bdg hs-inactive"><i class="fas fa-circle" style="font-size:5px"></i>Неактивен</span>');
     document.getElementById('heroAddr').innerHTML = '<i class="fas fa-map-marker-alt"></i>' + 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 '<span class="lbl-tag" style="background:' + color + '1a;color:' + color + ';border-color:' + color + ';font-size:10px;padding:2px 7px">'
+                    + (l.icon ? '<i class="fas ' + esc(l.icon) + ' me-1"></i>' : '')
+                    + esc(l.name) + '</span>';
+            }).join('');
+        labelTags = '<div class="hero-labels"><span class="hero-lbl-lbl">Каналы:</span>' + tagHtml + '</div>';
+    }
     document.getElementById('heroMeta').innerHTML =
         '<span><i class="fas fa-hashtag"></i>ID ' + s.id + '</span>'
         + (s.openDate ? '<span><i class="fas fa-calendar"></i>Открыт ' + esc(s.openDate) + '</span>' : '')
-        + (s.administratorName ? '<span><i class="fas fa-user"></i>Адм.: <a href="#" onclick="openEmployee(\'adm\');return false">' + esc(s.administratorName) + '</a></span>' : '');
+        + (s.administratorName ? '<span><i class="fas fa-user"></i>Адм.: <a href="#" onclick="openEmployee(\'adm\');return false">' + esc(s.administratorName) + '</a></span>' : '')
+        + labelTags;
     document.getElementById('heroName').style.color = '';
     document.getElementById('heroAddr').style.color = '';
 }
@@ -225,7 +239,17 @@ function renderOps() {
         + (s.storeTypeName ? '<i class="fas fa-info-circle me-1" style="color:var(--ac)"></i>Текущий: <strong style="font-size:14px;color:var(--ac)">' + esc(s.storeTypeName) + '</strong>' : '<span style="color:#bbb">Не задан</span>')
         + '</div></div></div></div>';
 
-    // 3. Оснащение (параметры из city_store_params)
+    // 3. Каналы (StoreDynamic category 5 — лейблы ассортимента)
+    h += '<div class="panel">'
+        + '<div class="panel-heading"><h3 class="panel-title"><i class="fas fa-hashtag"></i>Каналы<span class="owner-badge">StoreDynamic</span></h3>'
+        + '<div style="margin-left:auto">'
+        + '<a href="' + esc(CSM_URLS.assortmentLabels) + '" target="_blank" class="btn-panel-link">'
+        + '<i class="fas fa-cog"></i>Управление тегами</a></div></div>'
+        + '<div class="panel-body" id="channelsPanel">'
+        + renderChannelsList(s.labelIds, s.labelsArray)
+        + '</div></div>';
+
+    // 4. Оснащение (параметры из city_store_params)
     h += '<div class="panel">'
         + '<div class="panel-heading"><h3 class="panel-title"><i class="fas fa-cubes"></i>Оснащение<span class="owner-badge">Параметры</span></h3></div>'
         + '<div class="panel-body"><div class="row g-3">'
@@ -240,7 +264,7 @@ function renderOps() {
         + '<i class="fas fa-exclamation-triangle me-1"></i>Техника (камеры, роутеры, касса) — требует миграции БД <span class="stub-badge">stub</span></div></div>'
         + '</div></div></div>';
 
-    // 4. Адрес
+    // 5. Адрес
     h += '<div class="panel">'
         + '<div class="panel-heading"><h3 class="panel-title"><i class="fas fa-map-marker-alt"></i>Адрес<span class="owner-badge">Оба источника</span></h3></div>'
         + '<div class="panel-body"><div class="row g-3">'
@@ -261,7 +285,7 @@ function renderOps() {
         + '</div></div>'
         + '</div></div></div>';
 
-    // 5. Руководство (StoreDynamic)
+    // 6. Руководство (StoreDynamic)
     h += '<div class="panel">'
         + '<div class="panel-heading"><h3 class="panel-title"><i class="fas fa-users"></i>Руководство<span class="owner-badge">StoreDynamic</span></h3></div>'
         + '<div class="panel-body"><div class="row g-3">'
@@ -277,7 +301,7 @@ function renderOps() {
         + '</div></div>'
         + '</div></div></div>';
 
-    // 6. Общая информация (city_store)
+    // 7. Общая информация (city_store)
     h += '<div class="panel">'
         + '<div class="panel-heading"><h3 class="panel-title"><i class="fas fa-info-circle"></i>Общая информация<span class="owner-badge">city_store</span></h3></div>'
         + '<div class="panel-body"><div class="row g-3">'
@@ -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) {
         + '</div></div>';
 }
 
+// ===== КАНАЛЫ / ЛЕЙБЛЫ =====
+
+function renderChannelsList(labelIds, labelsArray) {
+    var ids = labelIds || [];
+    var all = labelsArray || [];
+    var h = '<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center">';
+    all.forEach(function (lbl) {
+        if (ids.indexOf(lbl.id) === -1) return;
+        var color = lbl.color || '#555';
+        var bg = color + '1a';  // 10% opacity hex
+        h += '<span class="lbl-tag" style="background:' + bg + ';color:' + color + ';border-color:' + color + '">'
+            + (lbl.icon ? '<i class="fas ' + esc(lbl.icon) + ' me-1"></i>' : '')
+            + esc(lbl.name)
+            + '<span class="tag-x" onclick="removeLabel(' + lbl.id + ',event)"><i class="fas fa-times"></i></span></span>';
+    });
+    h += '<span class="tag-add" onclick="openLabelsModal()"><i class="fas fa-plus me-1"></i>Добавить</span>';
+    h += '</div>';
+    if (!all.length) {
+        return '<span style="color:#bbb;font-size:11px">Нет доступных тегов в справочнике ассортимента</span>';
+    }
+    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 = '<div style="color:#bbb;text-align:center;padding:20px;font-size:12px">Нет активных тегов в справочнике.<br>'
+            + '<a href="' + esc(CSM_URLS.assortmentLabels) + '" target="_blank" style="color:var(--csm-ac)">Открыть справочник</a></div>';
+    } 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 '<label class="lbl-modal-item">'
+                + '<input type="checkbox" name="lbl" value="' + lbl.id + '"' + isChecked + '>'
+                + '<span class="lbl-preview" style="background:' + bg + ';color:' + color + ';border-color:' + color + '">'
+                + (lbl.icon ? '<i class="fas ' + esc(lbl.icon) + ' me-1"></i>' : '')
+                + esc(lbl.name) + '</span></label>';
+        }).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 '<div class="' + colCls + '"><div class="form-group">'
         + '<label><span class="acc-b acc-O">O</span>' + esc(label) + '</label>'