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
]);
$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' => [
'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,
],
];
}
'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))];
}
$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();
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__);
+ }
+ }
}
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}
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);
?>
<!-- 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>
/* ── Заглушки (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; }
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() {
? ' <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 = '';
}
+ (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">'
+ '<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">'
+ '</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">'
+ '</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">'
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);
+ '</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>'