]> gitweb.erp-flowers.ru Git - erp24_rep/yii-erp24/.git/commitdiff
ERP-389: is_active в city_store_params + новый интерфейс управления магазином
authorfomichev <vladimir.fomichev@erp-flowers.ru>
Tue, 2 Jun 2026 13:22:44 +0000 (16:22 +0300)
committerfomichev <vladimir.fomichev@erp-flowers.ru>
Tue, 2 Jun 2026 13:22:44 +0000 (16:22 +0300)
- Разделены поля visible (CityStore) и is_active (CityStoreParams):
  visible больше не меняется при смене is_active
- Неактивные магазины остаются в списке /city-store-params с оранжевым фоном
- Добавлены константы категорий в StoreDynamic (CATEGORY_IS_ACTIVE=4 и др.)
- История активности: изменение is_active записывается в store_dynamic (category=4)
- Новый интерфейс /city-store-management на основе мокапа v8:
  4 вкладки (Карточка, Операционное, SEO, Сервисная), комбобокс выбора магазина,
  AJAX-сохранение, dirty-tracking, RBAC на вкладку Сервисная (Директор + IT)
- Миграции: store_type.code (slug) и city_store_params.is_active
- StoreService зарегистрирован как singleton в DI-контейнере

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
17 files changed:
erp24/config/web.php
erp24/controllers/CityStoreManagementController.php [new file with mode: 0644]
erp24/controllers/CityStoreParamsController.php
erp24/migrations/m260601_100000_add_code_to_store_type.php [new file with mode: 0644]
erp24/migrations/m260601_110000_add_is_active_to_city_store_params.php [new file with mode: 0644]
erp24/records/CityStoreParams.php
erp24/records/StoreDynamic.php
erp24/records/StoreType.php
erp24/services/StoreService.php
erp24/views/city-store-management/index.php [new file with mode: 0644]
erp24/views/city-store-params/index.php
erp24/views/layouts/store-management.php [new file with mode: 0644]
erp24/views/store-type/create.php
erp24/views/store-type/update.php
erp24/web/css/city-store-management.css [new file with mode: 0644]
erp24/web/js/city-store-management/city-store-management.js [new file with mode: 0644]
erp24/web/js/city-store-params/city-store-params.js

index 303d94c24e7605ca0da2737424631f5f8909c29a..6e389cf7c2b4a1b9287d9cf48fe8fbf2269ee979 100644 (file)
@@ -1,6 +1,7 @@
 <?php
 
 use yii\queue\amqp_interop\Queue;
+use yii_app\services\StoreService;
 
 $params = require __DIR__ . '/params.php';
 $db = require __DIR__ . '/db.php';
@@ -145,6 +146,11 @@ $config = [
             return Yii::$app->response->redirect(['site/login']);
         },
     ],
+    'container' => [
+        'singletons' => [
+            StoreService::class => StoreService::class,
+        ],
+    ],
     'params' => $params,
     'timeZone' => 'Europe/Moscow',
 ];
diff --git a/erp24/controllers/CityStoreManagementController.php b/erp24/controllers/CityStoreManagementController.php
new file mode 100644 (file)
index 0000000..717cf5f
--- /dev/null
@@ -0,0 +1,308 @@
+<?php
+
+namespace app\controllers;
+
+use Yii;
+use yii\web\Controller;
+use yii\web\Response;
+use yii\helpers\ArrayHelper;
+use yii_app\records\Admin;
+use yii_app\records\AdminGroup;
+use yii_app\records\CityStore;
+use yii_app\records\CityStoreParams;
+use yii_app\records\MatrixType;
+use yii_app\records\StoreCityList;
+use yii_app\records\StoreDynamic;
+use yii_app\records\StoreType;
+
+class CityStoreManagementController extends Controller
+{
+    public function actionIndex(): string
+    {
+        return $this->render('index');
+    }
+
+    public function actionGetStores(): array
+    {
+        Yii::$app->response->format = Response::FORMAT_JSON;
+
+        $stores = CityStore::find()->orderBy(['id' => SORT_ASC])->all();
+
+        $paramsIsActiveMap = ArrayHelper::map(
+            CityStoreParams::find()->select(['store_id', 'is_active'])->all(),
+            'store_id',
+            'is_active'
+        );
+
+        $data = array_map(function (CityStore $store) use ($paramsIsActiveMap) {
+            $params = CityStoreParams::findOne(['store_id' => $store->id]);
+            $typeName = $params && $params->storeType ? $params->storeType->name : null;
+
+            return [
+                'id'       => $store->id,
+                'name'     => $store->name,
+                'addr'     => $store->adress,
+                'type'     => $typeName,
+                'isActive' => isset($paramsIsActiveMap[$store->id]) ? (bool)$paramsIsActiveMap[$store->id] : true,
+            ];
+        }, $stores);
+
+        return ['success' => true, 'data' => $data];
+    }
+
+    public function actionGetStore(int $id): array
+    {
+        Yii::$app->response->format = Response::FORMAT_JSON;
+
+        $store = CityStore::findOne($id);
+        if (!$store) {
+            return ['success' => false, 'message' => 'Магазин не найден'];
+        }
+
+        $params = CityStoreParams::findOne(['store_id' => $id]);
+
+        $administrator = $store->administrator_id ? Admin::findOne($store->administrator_id) : null;
+
+        $bushChefFloristDyn = StoreDynamic::findOne([
+            'store_id' => $id,
+            'active'   => 1,
+            'category' => StoreDynamic::CATEGORY_BUSH_CHEF_FLORIST,
+        ]);
+        $bushChefFlorist = $bushChefFloristDyn ? Admin::findOne($bushChefFloristDyn->value_int) : null;
+
+        $terrManagerDyn = StoreDynamic::findOne([
+            'store_id' => $id,
+            'active'   => 1,
+            'category' => StoreDynamic::CATEGORY_TERRITORIAL_MANAGER,
+        ]);
+        $terrManager = $terrManagerDyn ? Admin::findOne($terrManagerDyn->value_int) : null;
+
+        return [
+            'success' => true,
+            'data'    => [
+                // city_store
+                'id'          => $store->id,
+                'name'        => $store->name,
+                'nameFull'    => $store->name_full,
+                'adress'      => $store->adress,
+                'adressSm'    => $store->adress_sm,
+                'adressAmo'   => $store->adress_amo,
+                'gps'         => $store->gps,
+                'email'       => $store->email,
+                'tgChatId'    => $store->tg_chat_id,
+                'url'         => $store->url,
+                'seoTitle'    => $store->seo_title,
+                'seoDescription' => $store->seo_description,
+                'h1'          => $store->h1,
+                'content'     => $store->content,
+                'imageSm'     => $store->image_sm,
+                'imageBig'    => $store->image_big,
+                'image2Sm'    => $store->image2_sm,
+                'image2Big'   => $store->image2_big,
+                'images'      => $store->images,
+                'visible'     => (bool)$store->visible,
+                'spravId'     => $store->sprav_id,
+                'gis2'        => $store->{'2gis'},
+                'yamap'       => $store->yamap,
+                'googlemap'   => $store->googlemap,
+                'mapiframe'   => $store->mapiframe,
+                'posit'       => $store->posit,
+                'openDate'    => $store->open_date,
+                'salePlanAvg' => $store->sale_plan_avg,
+                'visitorDayAvg' => $store->visitor_day_avg,
+                'visitorAvg'  => $store->visitor_avg,
+                'squareStore' => $store->square_store,
+
+                // administrator
+                'administratorId'    => $store->administrator_id,
+                'administratorName'  => $administrator?->name_full ?? $administrator?->name ?? null,
+                'administratorEmail' => $administrator?->email ?? null,
+
+                // city_store_params
+                'paramsId'        => $params?->id,
+                'storeType'       => $params?->store_type,
+                'storeTypeName'   => $params?->storeType?->name,
+                'storeArea'       => $params?->store_area,
+                'showcaseVolume'  => $params?->showcase_volume,
+                'freezeArea'      => $params?->freeze_area,
+                'freezeVolume'    => $params?->freeze_volume,
+                'addressRegion'   => $params?->address_region ?: null,
+                'addressRegionName' => ($params && !empty($params->address_region)) ? $params->addressRegion?->name : null,
+                'addressCity'     => $params?->address_city ?: null,
+                'addressCityName' => ($params && !empty($params->address_city))   ? $params->addressCity?->name   : null,
+                'addressDistrict' => $params?->address_district ?: null,
+                'addressDistrictName' => ($params && !empty($params->address_district)) ? $params->addressDistrict?->name : null,
+                'matrixType'      => $params?->matrix_type ? explode(',', $params->matrix_type) : [],
+                'isActive'        => $params !== null ? (bool)$params->is_active : true,
+
+                // store_dynamic
+                'bushChefFloristId'   => $bushChefFloristDyn?->value_int,
+                'bushChefFloristName' => $bushChefFlorist?->name_full ?? $bushChefFlorist?->name ?? null,
+                'territorialManagerId'   => $terrManagerDyn?->value_int,
+                'territorialManagerName' => $terrManager?->name_full ?? $terrManager?->name ?? null,
+
+                // lookup arrays for selects
+                'storeTypeArray'         => ArrayHelper::map(StoreType::find()->all(), 'id', 'name'),
+                'regionArray'            => ArrayHelper::map(StoreCityList::findAll(['type' => StoreCityList::TYPE_REGION]), 'id', 'name'),
+                'cityArray'              => ArrayHelper::map(StoreCityList::findAll(['type' => StoreCityList::TYPE_CITY]), 'id', 'name'),
+                'districtArray'          => ArrayHelper::map(StoreCityList::findAll(['type' => StoreCityList::TYPE_DISTRICT]), 'id', 'name'),
+                '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'),
+            ],
+        ];
+    }
+
+    public function actionSaveCityStore(): array
+    {
+        Yii::$app->response->format = Response::FORMAT_JSON;
+
+        $post = Yii::$app->request->post();
+        $id = (int)($post['id'] ?? 0);
+
+        $store = $id ? CityStore::findOne($id) : null;
+        if (!$store) {
+            return ['success' => false, 'message' => 'Магазин не найден'];
+        }
+
+        $allowed = [
+            'name', 'name_full', 'adress', 'adress_sm', 'adress_amo', 'gps',
+            'email', 'tg_chat_id', 'url', 'posit',
+            'seo_title', 'seo_description', 'h1', 'content',
+            'image_sm', 'image_big', 'image2_sm', 'image2_big', 'images',
+            'sprav_id', 'sale_plan_avg', 'visitor_day_avg', 'visitor_avg', 'square_store',
+        ];
+
+        // 2gis has a special key in post
+        if (isset($post['gis2'])) {
+            $store->{'2gis'} = $post['gis2'];
+        }
+        if (isset($post['yamap'])) {
+            $store->yamap = $post['yamap'];
+        }
+        if (isset($post['googlemap'])) {
+            $store->googlemap = $post['googlemap'];
+        }
+        if (isset($post['mapiframe'])) {
+            $store->mapiframe = $post['mapiframe'];
+        }
+
+        foreach ($allowed as $field) {
+            if (array_key_exists($field, $post)) {
+                $store->$field = $post[$field];
+            }
+        }
+
+        if (!$store->save()) {
+            return ['success' => false, 'message' => implode(', ', ArrayHelper::getColumn($store->errors, 0))];
+        }
+
+        return ['success' => true];
+    }
+
+    public function actionSaveCityStoreParams(): array
+    {
+        Yii::$app->response->format = Response::FORMAT_JSON;
+
+        $post = Yii::$app->request->post();
+        $storeId = (int)($post['store_id'] ?? 0);
+
+        if (!$storeId) {
+            return ['success' => false, 'message' => 'store_id обязателен'];
+        }
+
+        $params = CityStoreParams::findOne(['store_id' => $storeId]);
+        $isNew = false;
+        if (!$params) {
+            $params = new CityStoreParams();
+            $params->store_id = $storeId;
+            $isNew = true;
+        }
+
+        $isActiveBefore = !$isNew ? (bool)$params->is_active : true;
+
+        $allowed = [
+            'store_type', 'store_area', 'showcase_volume', 'freeze_area', 'freeze_volume',
+            'address_region', 'address_city', 'address_district', 'is_active',
+        ];
+
+        foreach ($allowed as $field) {
+            if (array_key_exists($field, $post)) {
+                $params->$field = $post[$field];
+            }
+        }
+
+        if (isset($post['matrix_type'])) {
+            $params->matrix_type = is_array($post['matrix_type']) ? implode(',', $post['matrix_type']) : $post['matrix_type'];
+        }
+
+        $transaction = Yii::$app->db->beginTransaction();
+        try {
+            if (!$params->save()) {
+                $transaction->rollBack();
+                return ['success' => false, 'message' => implode(', ', ArrayHelper::getColumn($params->errors, 0))];
+            }
+
+            $isActiveNow = (bool)$params->is_active;
+            if ($isNew || $isActiveBefore !== $isActiveNow) {
+                $this->recordStoreDynamic($storeId, StoreDynamic::CATEGORY_IS_ACTIVE, (int)$isActiveNow);
+            }
+
+            if (!empty($post['bush_chef_florist'])) {
+                $this->recordStoreDynamic($storeId, StoreDynamic::CATEGORY_BUSH_CHEF_FLORIST, (int)$post['bush_chef_florist']);
+            }
+
+            if (!empty($post['territorial_manager'])) {
+                $this->recordStoreDynamic($storeId, StoreDynamic::CATEGORY_TERRITORIAL_MANAGER, (int)$post['territorial_manager']);
+            }
+
+            $transaction->commit();
+        } catch (\Throwable $e) {
+            $transaction->rollBack();
+            return ['success' => false, 'message' => $e->getMessage()];
+        }
+
+        return ['success' => true];
+    }
+
+    public function actionGetAddressData(): string
+    {
+        $params = Yii::$app->request->post('depdrop_all_params', []);
+
+        if (isset($params['region'])) {
+            $cities = StoreCityList::findAll(['parent_id' => (int)$params['region'], 'type' => StoreCityList::TYPE_CITY]);
+            $list = array_map(fn($c) => ['id' => $c->id, 'name' => $c->name], $cities);
+            return json_encode(['output' => $list, 'selected' => '']);
+        }
+
+        if (isset($params['city'])) {
+            $districts = StoreCityList::findAll(['parent_id' => (int)$params['city'], 'type' => StoreCityList::TYPE_DISTRICT]);
+            $list = array_map(fn($d) => ['id' => $d->id, 'name' => $d->name], $districts);
+            return json_encode(['output' => $list, 'selected' => '']);
+        }
+
+        return json_encode(['output' => [], 'selected' => '']);
+    }
+
+    private function recordStoreDynamic(int $storeId, int $category, int $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' => 'int',
+            'value_int'  => $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 save error: ' . json_encode($record->getErrors()), __CLASS__);
+        }
+    }
+}
index 4f19fde237c8533fe537ca1a5daf88d195757d24..2baddb9c09eadb72a51c8ebf7e2f787c5ba6d726 100644 (file)
@@ -69,10 +69,26 @@ class CityStoreParamsController extends Controller
                     $model->matrix_type = is_array($model->matrix_type) ? implode(',', $model->matrix_type) : $model->matrix_type;
                 }
 
-                if ($model->validate() && $model->save()) {
-                    $flash = 'success';
-                    $value = 'Данные успешно сохранены';
-                    $selected_store = $model->store_id;
+                if ($model->validate()) {
+                    $isNew = $model->isNewRecord;
+                    $isActiveChanged = !$isNew
+                        && (bool)$model->getOldAttribute('is_active') !== (bool)$model->is_active;
+
+                    $transaction = Yii::$app->db->beginTransaction();
+                    try {
+                        $model->save(false);
+                        if ($isNew || $isActiveChanged) {
+                            $this->updateStoreDynamic($model->store_id, 'is_active', (int)(bool)$model->is_active);
+                        }
+                        $transaction->commit();
+                        $flash = 'success';
+                        $value = 'Данные успешно сохранены';
+                        $selected_store = $model->store_id;
+                    } catch (\Throwable $e) {
+                        $transaction->rollBack();
+                        $flash = 'danger';
+                        $value = 'Ошибка при сохранении: ' . $e->getMessage();
+                    }
                 } else {
                     $flash = 'danger';
                     $value = 'Ошибка при сохранении: ' . implode('. ', array_map(fn($error) => is_array($error) ? implode(', ', $error) : $error, $model->getErrors()));
@@ -89,15 +105,19 @@ class CityStoreParamsController extends Controller
     }
 
     /**
-     * Ð\9eбновление StoreDynamic Ð´Ð»Ñ\8f bush_chef_florist Ð¸ territorial_manager
+     * Ð\97апиÑ\81Ñ\8bваеÑ\82 Ð¸Ð·Ð¼ÐµÐ½ÐµÐ½Ð¸Ðµ Ð¿Ð°Ñ\80амеÑ\82Ñ\80а Ð¼Ð°Ð³Ð°Ð·Ð¸Ð½Ð° Ð² store_dynamic (закÑ\80Ñ\8bваеÑ\82 Ð°ÐºÑ\82ивнÑ\83Ñ\8e Ð·Ð°Ð¿Ð¸Ñ\81Ñ\8c, Ñ\81оздаÑ\91Ñ\82 Ð½Ð¾Ð²Ñ\83Ñ\8e).
      */
-    private function updateStoreDynamic($storeId, $field, $value)
+    private function updateStoreDynamic(int $storeId, string $field, int $value): void
     {
-        $category = null;
-        if ($field === 'bush_chef_florist') {
-            $category = 2;
-        } else if ($field === 'territorial_manager') {
-            $category = 3;
+        $categoryMap = [
+            'bush_chef_florist'    => StoreDynamic::CATEGORY_BUSH_CHEF_FLORIST,
+            'territorial_manager'  => StoreDynamic::CATEGORY_TERRITORIAL_MANAGER,
+            'is_active'            => StoreDynamic::CATEGORY_IS_ACTIVE,
+        ];
+
+        $category = $categoryMap[$field] ?? null;
+        if ($category === null) {
+            return;
         }
 
         StoreDynamic::updateAll(
@@ -105,18 +125,18 @@ class CityStoreParamsController extends Controller
             ['active' => 1, 'category' => $category, 'store_id' => $storeId]
         );
 
-        $model = new StoreDynamic([
-            'store_id' => $storeId,
+        $record = new StoreDynamic([
+            'store_id'   => $storeId,
             'value_type' => 'int',
-            'value_int' => $value,
-            'date_from' => date('Y-m-d H:i:s'),
-            'date_to' => '2100-01-01 00:00:00',
-            'active' => 1,
-            'category' => $category,
+            'value_int'  => $value,
+            'date_from'  => date('Y-m-d H:i:s'),
+            'date_to'    => '2100-01-01 00:00:00',
+            'active'     => 1,
+            'category'   => $category,
         ]);
 
-        if (!$model->save()) {
-            var_dump($model->getErrors());
+        if (!$record->save()) {
+            Yii::error('StoreDynamic save error: ' . json_encode($record->getErrors()), __CLASS__);
         }
     }
 
@@ -144,8 +164,7 @@ class CityStoreParamsController extends Controller
         $territorialManager = $data['territorial_manager'] ?? null;
         $bushChefFlorist = $data['bush_chef_florist'] ?? null;
 
-        $query = CityStore::find()
-            ->andWhere(['visible' => CityStore::IS_VISIBLE]);
+        $query = CityStore::find();
 
         if (!empty($data['address_city'])) {
             $query->andWhere(['id' => CityStoreParams::find()
@@ -195,10 +214,19 @@ class CityStoreParamsController extends Controller
 
         $stores = $query->all();
 
+        $paramsIsActiveMap = ArrayHelper::map(
+            CityStoreParams::find()->select(['store_id', 'is_active'])->all(),
+            'store_id',
+            'is_active'
+        );
+
         return [
             'success' => true,
-            'data' => ArrayHelper::map($stores, 'id', function ($store) {
-                return $store->id . ' ' . $store->name;
+            'data' => ArrayHelper::map($stores, 'id', function ($store) use ($paramsIsActiveMap) {
+                return [
+                    'label' => $store->id . ' ' . $store->name,
+                    'isActive' => isset($paramsIsActiveMap[$store->id]) ? (bool)$paramsIsActiveMap[$store->id] : true,
+                ];
             }),
         ];
     }
@@ -219,12 +247,13 @@ class CityStoreParamsController extends Controller
                 'id' => $params->id ?? null,
                 'storeId' => $storeId,
                 'name' => $store->name,
+                'isActive' => $params !== null ? (bool)$params->is_active : true,
                 'type' => $params->store_type ?? $store->type ?? null,
                 'region' => $params->address_region ?? $store->region ?? null,
                 'city' => $params->address_city ?? $store->city ?? null,
                 'district' => $params->address_district ?? $store->district ?? null,
-                'territorialManager' => StoreDynamic::findOne(['active' => 1, 'category' => 3, 'store_id' => $storeId])->value_int ?? null,
-                'bushChefFloristId' => StoreDynamic::findOne(['store_id' => $storeId, 'active' => 1, 'category' => 2])->value_int ?? null,
+                'territorialManager' => StoreDynamic::findOne(['active' => 1, 'category' => StoreDynamic::CATEGORY_TERRITORIAL_MANAGER, 'store_id' => $storeId])->value_int ?? null,
+                'bushChefFloristId' => StoreDynamic::findOne(['store_id' => $storeId, 'active' => 1, 'category' => StoreDynamic::CATEGORY_BUSH_CHEF_FLORIST])->value_int ?? null,
                 'storeArea' => $params->store_area ?? null,
                 'showcaseVolume' => $params->showcase_volume ?? null,
                 'freezeArea' => $params->freeze_area ?? null,
@@ -233,7 +262,7 @@ class CityStoreParamsController extends Controller
                 'territorialManagerArray' => ArrayHelper::map(
                     array_merge(
                         Admin::findAll(['group_id' => AdminGroup::GROUP_BUSH_DIRECTOR]),
-                        Admin::findAll(['id' => StoreDynamic::find()->andWhere(['category' => 3, 'active' => 1])->select('value_int')->column()])
+                        Admin::findAll(['id' => StoreDynamic::find()->andWhere(['category' => StoreDynamic::CATEGORY_TERRITORIAL_MANAGER, 'active' => 1])->select('value_int')->column()])
                     ),
                     'id',
                     'name',
diff --git a/erp24/migrations/m260601_100000_add_code_to_store_type.php b/erp24/migrations/m260601_100000_add_code_to_store_type.php
new file mode 100644 (file)
index 0000000..8ad41da
--- /dev/null
@@ -0,0 +1,51 @@
+<?php
+
+declare(strict_types=1);
+
+use yii\db\Migration;
+
+/**
+ * Добавляет поле `code` (slug) в таблицу `erp24.store_type` (ERP-396).
+ *
+ * Фактические данные на 2026-06-01:
+ *   id=1 L → large
+ *   id=2 S → small
+ *   id=3 M → medium
+ *   id=4 XL → xlarge
+ *   id=5 N/A → na
+ *
+ * Новые типы задаются через CRUD /store-type/create.
+ */
+class m260601_100000_add_code_to_store_type extends Migration
+{
+    private const TABLE = 'erp24.store_type';
+    private const INDEX = 'idx_store_type_code';
+
+    public function safeUp(): void
+    {
+        $this->addColumn(
+            self::TABLE,
+            'code',
+            $this->string(50)->null()->comment('Slug-код типа магазина (large, small, medium, xlarge, na, flagship, kiosk, ...)')
+        );
+
+        $this->createIndex(self::INDEX, self::TABLE, 'code', true);
+
+        $this->execute("
+            UPDATE erp24.store_type SET code = CASE name
+                WHEN 'L'   THEN 'large'
+                WHEN 'S'   THEN 'small'
+                WHEN 'M'   THEN 'medium'
+                WHEN 'XL'  THEN 'xlarge'
+                WHEN 'N/A' THEN 'na'
+                ELSE NULL
+            END
+        ");
+    }
+
+    public function safeDown(): void
+    {
+        $this->dropIndex(self::INDEX, self::TABLE);
+        $this->dropColumn(self::TABLE, 'code');
+    }
+}
diff --git a/erp24/migrations/m260601_110000_add_is_active_to_city_store_params.php b/erp24/migrations/m260601_110000_add_is_active_to_city_store_params.php
new file mode 100644 (file)
index 0000000..eb76242
--- /dev/null
@@ -0,0 +1,40 @@
+<?php
+
+declare(strict_types=1);
+
+use yii\db\Migration;
+
+/**
+ * Добавляет поле `is_active` в таблицу `city_store_params` (ERP-396).
+ *
+ * is_active = канонический флаг активности магазина.
+ * city_store.visible синхронизируется через CityStoreParams::afterSave() (ERP-397).
+ *
+ * DEFAULT TRUE: все существующие записи считаются активными.
+ */
+class m260601_110000_add_is_active_to_city_store_params extends Migration
+{
+    private const TABLE = 'city_store_params';
+    private const INDEX = 'idx_city_store_params_inactive';
+
+    public function safeUp(): void
+    {
+        $this->addColumn(
+            self::TABLE,
+            'is_active',
+            $this->boolean()->notNull()->defaultValue(true)->comment('Активен ли магазин (TRUE=да, FALSE=нет)')
+        );
+
+        // Partial index: только неактивные магазины (меньшинство).
+        // Полный индекс на boolean-колонке PostgreSQL не использует из-за низкой кардинальности.
+        $this->execute(
+            'CREATE INDEX ' . self::INDEX . ' ON ' . self::TABLE . ' (store_id) WHERE is_active = false'
+        );
+    }
+
+    public function safeDown(): void
+    {
+        $this->execute('DROP INDEX IF EXISTS ' . self::INDEX);
+        $this->dropColumn(self::TABLE, 'is_active');
+    }
+}
index d43d8a0a175271fd53cc559eb6a166fb8a980b2e..8149549bce2145d4ff98804a121d0772dcd82015 100644 (file)
@@ -2,12 +2,10 @@
 
 namespace yii_app\records;
 
-use Yii;
 use yii\behaviors\BlameableBehavior;
 use yii\behaviors\TimestampBehavior;
 use yii\db\ActiveRecord;
 use yii\db\Expression;
-use yii\filters\VerbFilter;
 
 /**
  * This is the model class for table "{{%city_store_params}}".
@@ -15,6 +13,7 @@ use yii\filters\VerbFilter;
  * @property int $id
  * @property int $store_id
  * @property int|null $store_type
+ * @property bool $is_active
  * @property string|null $address_city
  * @property string|null $address_region
  * @property string|null $address_district
@@ -68,6 +67,8 @@ class CityStoreParams extends ActiveRecord
             [['store_area', 'showcase_volume', 'freeze_area', 'freeze_volume'], 'number'],
             [['created_at', 'updated_at'], 'safe'],
             [['address_city', 'address_region', 'address_district', 'matrix_type'], 'string'],
+            [['is_active'], 'boolean'],
+            [['is_active'], 'default', 'value' => true],
         ];
     }
 
@@ -90,6 +91,7 @@ class CityStoreParams extends ActiveRecord
             'freeze_area' => 'Площадь холодильника',
             'freeze_volume' => 'Объем холодильника',
             'matrix_type' => 'Тип матрицы',
+            'is_active' => 'Активен',
             'created_by' => 'ИД создателя',
             'created_at' => 'Дата создания',
             'updated_by' => 'ИД редактировавшего',
@@ -146,4 +148,5 @@ class CityStoreParams extends ActiveRecord
     {
         return $this->hasOne(StoreCityList::class, ['id' => 'address_city']);
     }
+
 }
index ce2d9981f8e665d06b6fec49d921e52628ff85bc..8689b7b87372d7616639e7c8b87f18212eabd65c 100644 (file)
@@ -20,6 +20,12 @@ use yii\helpers\ArrayHelper;
  */
 class StoreDynamic extends \yii\db\ActiveRecord
 {
+    // Категории записей в store_dynamic
+    const CATEGORY_CLUSTER = 1;              // Принадлежность магазина к кластеру (value_int = cluster_id)
+    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)
+
     /**
      * {@inheritdoc}
      */
index 52a21a1b2953dc3d2755ca2d14fe29471e47fb3a..d7cd19f1e9c8bc2aa246bdf7ced2a87a4c0bd3ab 100644 (file)
@@ -12,6 +12,7 @@ use yii\db\Expression;
  *
  * @property int $id
  * @property string $name
+ * @property string|null $code
  * @property int $created_by
  * @property string $created_at
  * @property int|null $updated_by
@@ -55,6 +56,10 @@ class StoreType extends \yii\db\ActiveRecord
             [['created_by', 'updated_by', 'sequence_number'], 'integer'],
             [['created_at', 'updated_at'], 'safe'],
             [['name'], 'string', 'max' => 255],
+            [['code'], 'string', 'max' => 50],
+            [['code'], 'unique'],
+            [['code'], 'match', 'pattern' => '/^[a-z0-9_-]+$/',
+                'message' => 'Код может содержать только строчные латинские буквы, цифры, дефис и подчёркивание'],
         ];
     }
 
@@ -66,6 +71,7 @@ class StoreType extends \yii\db\ActiveRecord
         return [
             'id' => 'ID',
             'name' => 'Название типа магазина',
+            'code' => 'Код типа (slug)',
             'created_by' => 'ИД создателя',
             'created_at' => 'Дата создания',
             'updated_by' => 'ИД редактировавшего',
index 38414e08a996f250d7e04a186a8bf5a856ae665b..06c2ad7dad31789f8b75576ad9c460d73338ddb4 100755 (executable)
 <?php
 
+declare(strict_types=1);
+
 namespace yii_app\services;
 
+use yii_app\records\CityStore;
+use yii_app\records\CityStoreParams;
+use yii_app\records\StoreType;
+
 class StoreService
 {
-    public static function preparedStoreName(string $storeName) : string
+    public static function preparedStoreName(string $storeName): string
     {
-        $find = array("п.", "ул.");
-        $set   = array("", "");
+        $find = ['п.', 'ул.'];
+        $set  = ['', ''];
 
         return trim(str_replace($find, $set, $storeName));
     }
+
+    /**
+     * Возвращает ID активных магазинов.
+     *
+     * LEFT JOIN обеспечивает обратную совместимость: магазины без записи в city_store_params,
+     * но с visible=1, по-прежнему включаются.
+     *
+     * @return int[]
+     */
+    public function getActiveStoreIds(): array
+    {
+        return CityStore::find()
+            ->alias('cs')
+            ->leftJoin('{{%city_store_params}} csp', 'csp.store_id = cs.id')
+            ->where(['cs.visible' => CityStore::IS_VISIBLE])
+            ->andWhere(['or', ['csp.is_active' => true], ['csp.id' => null]])
+            ->select('cs.id')
+            ->column();
+    }
+
+    /**
+     * Атомарно обновляет флаг активности магазина в обеих таблицах.
+     *
+     * city_store_params.is_active — канонический источник истины;
+     * city_store.visible синхронизируется в той же транзакции.
+     *
+     * @throws \Throwable если любое из двух обновлений завершается ошибкой
+     */
+    public function setActive(int $storeId, bool $active): void
+    {
+        $transaction = \Yii::$app->db->beginTransaction();
+        try {
+            CityStoreParams::updateAll(['is_active' => $active], ['store_id' => $storeId]);
+            CityStore::updateAll(['visible' => (int)$active], ['id' => $storeId]);
+            $transaction->commit();
+        } catch (\Throwable $e) {
+            $transaction->rollBack();
+            throw $e;
+        }
+    }
+
+    /**
+     * Возвращает записи city_store_params для магазинов с указанным типом (по slug).
+     *
+     * Примечание: возвращает ВСЕ магазины данного типа, включая неактивные.
+     * Для фильтрации только активных используйте getActiveStoresByType().
+     *
+     * @param string $code  Slug: large, small, medium, xlarge, na, flagship, kiosk, ...
+     * @return CityStoreParams[]
+     */
+    public function getStoresByType(string $code): array
+    {
+        $storeType = StoreType::findOne(['code' => $code]);
+        if ($storeType === null) {
+            return [];
+        }
+
+        return CityStoreParams::find()
+            ->where(['store_type' => $storeType->id])
+            ->all();
+    }
+
+    /**
+     * Проверяет активность конкретного магазина.
+     * Если записи city_store_params нет — fallback на city_store.visible.
+     */
+    public function isActive(int $storeId): bool
+    {
+        $params = CityStoreParams::findOne(['store_id' => $storeId]);
+        if ($params === null) {
+            $store = CityStore::findOne($storeId);
+            return $store !== null && $store->visible === CityStore::IS_VISIBLE;
+        }
+
+        return (bool)$params->is_active;
+    }
+
+    /**
+     * Возвращает активные магазины, сгруппированные по slug типа.
+     *
+     * Применяет те же критерии активности, что и getActiveStoreIds():
+     * csp.is_active=true И cs.visible=1.
+     *
+     * @return array<string, int[]>  ['large' => [1, 2], 'small' => [3], ...]
+     */
+    public function getActiveStoresByType(): array
+    {
+        $rows = CityStoreParams::find()
+            ->alias('csp')
+            ->select(['csp.store_id', 'st.code'])
+            ->innerJoin('erp24.store_type st', 'st.id = csp.store_type')
+            ->innerJoin('{{%city_store}} cs', 'cs.id = csp.store_id')
+            ->where(['csp.is_active' => true])
+            ->andWhere(['cs.visible' => CityStore::IS_VISIBLE])
+            ->andWhere(['is not', 'st.code', null])
+            ->asArray()
+            ->all();
+
+        $result = [];
+        foreach ($rows as $row) {
+            $result[$row['code']][] = (int)$row['store_id'];
+        }
+
+        return $result;
+    }
 }
diff --git a/erp24/views/city-store-management/index.php b/erp24/views/city-store-management/index.php
new file mode 100644 (file)
index 0000000..736b438
--- /dev/null
@@ -0,0 +1,123 @@
+<?php
+/** @var \yii\web\View $this */
+
+use yii\helpers\Url;
+use yii_app\records\AdminGroup;
+
+$this->title = 'Магазины сети';
+$this->params['breadcrumbs'][] = $this->title;
+
+$this->registerCssFile('https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css', ['position' => \yii\web\View::POS_HEAD]);
+$this->registerCssFile('/css/city-store-management.css', ['position' => \yii\web\View::POS_HEAD]);
+$this->registerJsFile('/js/city-store-management/city-store-management.js', ['position' => \yii\web\View::POS_END]);
+
+$userGroupId = (int)(Yii::$app->user->identity->group_id ?? 0);
+$canViewServiceTab = in_array($userGroupId, [AdminGroup::DIRECTOR, AdminGroup::GROUP_IT], true);
+
+$this->registerJs('
+  var CSM_CAN_VIEW_SERVICE_TAB = ' . ($canViewServiceTab ? 'true' : 'false') . ';
+  var CSM_URLS = {
+    getStores:           ' . json_encode(Url::to(['/city-store-management/get-stores'])) . ',
+    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'])) . ',
+  };
+', \yii\web\View::POS_HEAD);
+?>
+
+<div class="csm-wrap">
+
+  <!-- Заголовок + выбор магазина -->
+  <div class="csm-topbar">
+    <h1 class="csm-title"><i class="fas fa-store me-2"></i>Магазины сети</h1>
+
+    <div class="store-combo" id="storeCombo">
+      <input type="text" class="store-combo-input" id="storeSearchInput"
+             placeholder="Выберите магазин..."
+             onfocus="openCombo()" oninput="filterCombo(this.value)">
+      <i class="fas fa-chevron-down combo-chev"></i>
+      <div class="store-combo-dropdown" id="storeComboList">
+        <div style="padding:14px;text-align:center;color:#999;font-size:11px">Загрузка…</div>
+      </div>
+    </div>
+
+    <div class="filter-select" id="activeFilter">
+      <button class="filter-btn" onclick="toggleActiveFilter(event)">
+        <span class="fb-left"><i class="fas fa-filter"></i><span id="activeFilterLabel">Все</span></span>
+        <i class="fas fa-chevron-down fb-chevron"></i>
+      </button>
+      <div class="filter-dropdown">
+        <div class="fd-header">Активность</div>
+        <div class="fd-item on"  data-f="all"      onclick="setActiveFilter(this,'all','Все')"><span class="fd-dot" style="background:#999"></span>Все</div>
+        <div class="fd-item"     data-f="active"   onclick="setActiveFilter(this,'active','Активные')"><span class="fd-dot" style="background:var(--csm-ok)"></span>Активные</div>
+        <div class="fd-item"     data-f="inactive" onclick="setActiveFilter(this,'inactive','Неактивные')"><span class="fd-dot" style="background:var(--csm-danger)"></span>Неактивные</div>
+      </div>
+    </div>
+  </div>
+
+  <!-- Пустое состояние (до выбора магазина) -->
+  <div id="csmEmpty" class="csm-empty">
+    <i class="fas fa-store"></i>
+    <p>Выберите магазин из списка выше для просмотра и редактирования</p>
+  </div>
+
+  <!-- Карточка магазина (скрыта до выбора) -->
+  <div id="csmDetail" style="display:none">
+
+    <!-- Hero — краткая шапка магазина -->
+    <div class="csm-hero" id="heroSection">
+      <div class="csm-hero-info">
+        <div class="csm-hero-name" id="heroName"></div>
+        <div class="csm-hero-addr" id="heroAddr"></div>
+        <div class="csm-hero-meta" id="heroMeta"></div>
+      </div>
+      <div class="csm-hero-actions" id="heroActions">
+        <button class="csm-btn-save" id="saveBtn" style="display:none" onclick="saveChanges()">
+          <i class="fas fa-save"></i> Сохранить
+        </button>
+      </div>
+    </div>
+
+    <!-- Табы -->
+    <div class="csm-tabs-w">
+      <ul class="csm-nav" id="tabs">
+        <li><a class="csm-nav-link on" onclick="setTab('card',event)"><i class="fas fa-id-card"></i>Карточка <small style="opacity:.5;margin-left:3px">readonly</small></a></li>
+        <li><a class="csm-nav-link" onclick="setTab('ops',event)"><i class="fas fa-cogs"></i>Операционное</a></li>
+        <li><a class="csm-nav-link" onclick="setTab('seo',event)"><i class="fas fa-palette"></i>SEO и публикация</a></li>
+        <?php if ($canViewServiceTab): ?>
+        <li><a class="csm-nav-link csm-tab-it" onclick="setTab('svc',event)"><i class="fas fa-server"></i>Сервисная<span class="csm-tab-ic-role">IT</span></a></li>
+        <?php endif; ?>
+      </ul>
+    </div>
+
+    <!-- Контент вкладки -->
+    <div class="csm-content" id="contentArea"></div>
+
+  </div>
+
+  <!-- Footer-бар несохранённых изменений -->
+  <div class="csm-ftr hidden" id="ftrBar">
+    <div class="csm-ftr-l">
+      <span class="csm-dirty-badge" id="dirtyBadge">0 несохр.</span>
+      Есть несохранённые изменения
+    </div>
+    <div>
+      <button class="btn btn-sm btn-outline-secondary me-2" onclick="cancelChanges()">Отменить</button>
+      <button class="btn btn-sm csm-btn-ac" onclick="saveChanges()">Сохранить</button>
+    </div>
+  </div>
+
+</div>
+
+<!-- Overlay + боковая панель (карточка сотрудника) -->
+<div class="csm-overlay" id="overlay" onclick="closeSide()"></div>
+<div class="csm-side-p" id="sideEmployee">
+  <div class="csm-side-p-h">
+    <h3><i class="fas fa-user me-2"></i>Карточка сотрудника</h3>
+    <button onclick="closeSide()"><i class="fas fa-times"></i></button>
+  </div>
+  <div class="csm-side-p-b" id="employeeBody"></div>
+</div>
+
+<!-- Toast-уведомления -->
+<div class="csm-toast-w" id="toastW"></div>
index 9b3c5d73f0ec39c30042d612d872197911b7bc18..92ae3b252d9a9b5153708804605cfc159e5cd52b 100644 (file)
@@ -7,6 +7,7 @@ use yii\helpers\Url;
 use yii_app\records\Admin;
 use yii_app\records\AdminGroup;
 use yii_app\records\CityStore;
+use yii_app\records\CityStoreParams;
 use yii_app\records\StoreCityList;
 use yii_app\records\StoreType;
 use kartik\depdrop\DepDrop;
@@ -176,16 +177,24 @@ $this->registerJsFile('/js/city-store-params/city-store-params.js', ['position'
 <div class="mx-4 mt-4">
     <div class="row">
         <div class="col-md-3">
-            <?= Html::dropDownList('selected_store', $select_store,
-                ArrayHelper::map(CityStore::find()->where(['visible' => CityStore::IS_VISIBLE])->orderBy('id')->all(), 'id', function ($store) {
-                    return $store->id . ' ' . $store->name;
-                }),
-                [
-                    'multiple' => true,
-                    'size' => 16,
-                    'class' => 'form-control',
-                    'id' => 'selected-store',
-                ]) ?>
+            <?php
+            $storeList = CityStore::find()->orderBy('id')->all();
+            $paramsIsActiveMap = ArrayHelper::map(
+                CityStoreParams::find()->select(['store_id', 'is_active'])->all(),
+                'store_id',
+                'is_active'
+            );
+            ?>
+            <select name="selected_store" multiple size="16" class="form-control" id="selected-store">
+                <?php foreach ($storeList as $store): ?>
+                    <?php $isActive = isset($paramsIsActiveMap[$store->id]) ? (bool)$paramsIsActiveMap[$store->id] : true; ?>
+                    <option value="<?= Html::encode($store->id) ?>"
+                        <?= ($select_store == $store->id) ? 'selected' : '' ?>
+                        <?= !$isActive ? 'style="background-color: #ffe0b2;"' : '' ?>>
+                        <?= Html::encode($store->id . ' ' . $store->name) ?>
+                    </option>
+                <?php endforeach; ?>
+            </select>
             <?= Html::button('Редактировать', ['class' => 'btn btn-primary w-100 mt-2 py-1', 'id' => 'edit-button']); ?>
         </div>
         <div class="col-md-9 edit-form" style="pointer-events: none;opacity: 0.5;">
@@ -214,6 +223,15 @@ $this->registerJsFile('/js/city-store-params/city-store-params.js', ['position'
                         ]); ?>
                         <?= Html::a('Редактировать', [Url::to('/store-type')], ['class' => 'd-block mt-2 text-decoration-none', 'target' => '_blank']); ?>
                     </div>
+                    <div class="col-md-4 d-flex align-items-center">
+                        <?= Html::hiddenInput('CityStoreParams[is_active]', '0') ?>
+                        <?= Html::checkbox('CityStoreParams[is_active]', true, [
+                            'id' => 'is-active',
+                            'class' => 'form-check-input me-2',
+                            'value' => '1',
+                        ]) ?>
+                        <?= Html::label('Активен', 'is-active', ['class' => 'form-check-label']) ?>
+                    </div>
                 </div>
                 <div class="row">
                     <div class="col-md-2 py-6">
diff --git a/erp24/views/layouts/store-management.php b/erp24/views/layouts/store-management.php
new file mode 100644 (file)
index 0000000..68ccc6a
--- /dev/null
@@ -0,0 +1,24 @@
+<?php
+/** @var \yii\web\View $this */
+/** @var string $content */
+$this->beginPage();
+?>
+<!DOCTYPE html>
+<html lang="ru">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<?php $this->registerCsrfMetaTags() ?>
+<title><?= $this->title ? \yii\helpers\Html::encode($this->title) : 'Магазины' ?></title>
+<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
+<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
+<?php $this->head() ?>
+</head>
+<body>
+<?php $this->beginBody() ?>
+<?= $content ?>
+<?php $this->endBody() ?>
+<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
+</body>
+</html>
+<?php $this->endPage() ?>
index 64453a5dae35eef299745f03ee972505e6b7dd8b..b997b7a98a6538e65c5d7a4893edab668f8be5fd 100644 (file)
@@ -18,6 +18,8 @@ $this->params['breadcrumbs'][] = $this->title;
     <?php $form = ActiveForm::begin(); ?>
 
     <?= $form->field($model, 'name')->textInput(['maxlength' => true]) ?>
+    <?= $form->field($model, 'code')->textInput(['maxlength' => 50, 'placeholder' => 'например: large, flagship, kiosk'])
+        ->hint('Машиночитаемый slug для StoreService. Только строчные латиница, цифры, дефис. Можно оставить пустым.') ?>
     <?= $form->field($model, 'sequence_number')->textInput(['maxlength' => true, 'type' => 'number']) ?>
 
     <div class="form-group">
index fb4d68d0888c0006016c3a3df94e58355be6401f..7b75da4c091a976c9369cc18a159ab99f5ab096e 100644 (file)
@@ -18,6 +18,8 @@ $this->params['breadcrumbs'][] = $this->title;
     <?php $form = ActiveForm::begin(); ?>
 
     <?= $form->field($model, 'name')->textInput(['maxlength' => true]) ?>
+    <?= $form->field($model, 'code')->textInput(['maxlength' => 50, 'placeholder' => 'например: large, flagship, kiosk'])
+        ->hint('Машиночитаемый slug для StoreService. Только строчные латиница, цифры, дефис. Можно оставить пустым.') ?>
     <?= $form->field($model, 'sequence_number')->textInput(['maxlength' => true, 'type' => 'number']) ?>
 
     <div class="form-group">
diff --git a/erp24/web/css/city-store-management.css b/erp24/web/css/city-store-management.css
new file mode 100644 (file)
index 0000000..4b9ccc3
--- /dev/null
@@ -0,0 +1,331 @@
+/* City Store Management — вписан в ERP-контент зону */
+:root {
+  --csm-ac:      #7c4dff;
+  --csm-bg:      #1e3a5f;
+  --csm-bd:      #dee2e6;
+  --csm-r:       6px;
+  --csm-ok:      #2e7d32;
+  --csm-warn:    #f57c00;
+  --csm-danger:  #c62828;
+}
+
+/* ── Обёртка ─────────────────────────────────────────── */
+.csm-wrap {
+  position: relative;
+  padding: 0 20px 60px;
+  max-width: 1400px;
+}
+
+/* ── Topbar (заголовок + комбобокс) ─────────────────── */
+.csm-topbar {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 10px 0 14px;
+  border-bottom: 1px solid var(--csm-bd);
+  margin-bottom: 16px;
+  flex-wrap: wrap;
+}
+
+.csm-title {
+  font: 700 18px/1 inherit;
+  color: var(--csm-bg);
+  margin: 0;
+  flex-shrink: 0;
+  display: flex;
+  align-items: center;
+}
+
+/* ── Пустое состояние ────────────────────────────────── */
+.csm-empty {
+  text-align: center;
+  padding: 60px 20px;
+  color: #bbb;
+}
+.csm-empty i {
+  font-size: 48px;
+  opacity: .2;
+  margin-bottom: 16px;
+  display: block;
+}
+.csm-empty p {
+  font-size: 13px;
+  margin: 0;
+}
+
+/* ── Герой (шапка магазина) ──────────────────────────── */
+.csm-hero {
+  background: #fff;
+  border: 1px solid var(--csm-bd);
+  border-radius: var(--csm-r);
+  padding: 14px 18px;
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  gap: 16px;
+  margin-bottom: 0;
+}
+.csm-hero-info { flex: 1; min-width: 0; }
+.csm-hero-name {
+  font: 700 18px/1.3 inherit;
+  color: var(--csm-bg);
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  flex-wrap: wrap;
+  margin-bottom: 4px;
+}
+.csm-hero-addr {
+  font: 400 12px/1.3 inherit;
+  color: #666;
+  margin-bottom: 4px;
+}
+.csm-hero-addr i { color: #aaa; margin-right: 4px; font-size: 10px; }
+.csm-hero-meta {
+  font: 400 11px/1.4 inherit;
+  color: #888;
+  display: flex;
+  gap: 14px;
+  flex-wrap: wrap;
+}
+.csm-hero-meta span { display: flex; align-items: center; gap: 4px; }
+.csm-hero-meta a { color: var(--csm-ac); text-decoration: none; font-weight: 600; }
+.csm-hero-actions { flex-shrink: 0; }
+
+/* Status badges */
+.hs-bdg {
+  padding: 3px 10px;
+  border-radius: 12px;
+  font: 600 10px/1 inherit;
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+}
+.hs-active   { background: #d4edda; color: #155724; }
+.hs-inactive { background: #f8d7da; color: #842029; }
+.hs-type     { background: #e3f2fd; color: #1565c0; }
+
+/* ── Табы ────────────────────────────────────────────── */
+.csm-tabs-w {
+  background: #fff;
+  border: 1px solid var(--csm-bd);
+  border-top: none;
+  border-radius: 0 0 0 0;
+  margin-bottom: 14px;
+}
+.csm-nav {
+  display: flex;
+  flex-wrap: nowrap;
+  list-style: none;
+  padding: 0 14px;
+  margin: 0;
+  border-bottom: none;
+  overflow-x: auto;
+}
+.csm-nav-link {
+  font: 500 13px/1 inherit;
+  color: #666;
+  border: none;
+  padding: 11px 16px;
+  border-bottom: 2px solid transparent;
+  white-space: nowrap;
+  transition: .15s;
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  cursor: pointer;
+  text-decoration: none;
+  position: relative;
+  background: none;
+}
+.csm-nav-link:hover { color: var(--csm-ac); background: rgba(124,77,255,.04); }
+.csm-nav-link.on { color: var(--csm-ac); font-weight: 700; border-bottom-color: var(--csm-ac); }
+.csm-nav-link i { font-size: 11px; opacity: .7; }
+.csm-nav-link .dirty-dot { width: 7px; height: 7px; background: var(--csm-warn); border-radius: 50%; margin-left: 4px; }
+.csm-tab-it { margin-left: auto; color: #888; }
+.csm-tab-it.on { color: #455a64; border-bottom-color: #455a64; }
+.csm-tab-ic-role { background: #455a64; color: #fff; font: 700 8px/1 inherit; padding: 2px 5px; border-radius: 3px; margin-left: 5px; }
+
+/* ── Контент-область вкладки ─────────────────────────── */
+.csm-content { /* scrolling handled by ERP layout */ }
+
+/* ── Панели ──────────────────────────────────────────── */
+.panel { background: #fff; border: 1px solid var(--csm-bd); border-radius: var(--csm-r); margin-bottom: 12px; }
+.panel-heading { display: flex; justify-content: space-between; align-items: center; padding: 9px 14px; background: #fafbfc; border-bottom: 1px solid #f0f0f0; gap: 8px; border-radius: var(--csm-r) var(--csm-r) 0 0; }
+.panel-title { font: 600 12px/1 inherit; color: #333; display: flex; align-items: center; gap: 6px; margin: 0; }
+.panel-title i { color: var(--csm-ac); font-size: 11px; }
+.panel-title .owner-badge { font: 600 9px/1 inherit; padding: 2px 6px; border-radius: 3px; background: #e3f2fd; color: #1565c0; text-transform: uppercase; letter-spacing: .3px; margin-left: 6px; }
+.panel-body { padding: 14px; }
+.panel-info { font: 400 10px/1 inherit; color: #888; }
+
+/* ── Поля форм ───────────────────────────────────────── */
+.form-group { margin-bottom: 10px; }
+.form-group label { font: 500 11px/1 inherit; color: #666; margin-bottom: 4px; display: block; }
+
+/* Access badges */
+.acc-b { font: 700 7px/1 inherit; padding: 1px 4px; border-radius: 2px; color: #fff; letter-spacing: .3px; margin-right: 3px; }
+.acc-O { background: #2e7d32; }
+.acc-E { background: var(--csm-warn); }
+.acc-R { background: #adb5bd; }
+
+/* ── Store combobox ──────────────────────────────────── */
+.store-combo { position: relative; flex: 1; max-width: 400px; min-width: 240px; }
+.store-combo-input {
+  width: 100%; border: 1px solid #ced4da; border-radius: var(--csm-r);
+  padding: 7px 28px 7px 32px; font-size: 12px; height: 34px;
+  background: #fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='13' height='13' fill='%23999' viewBox='0 0 16 16'%3E%3Cpath d='M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85zm-5.242.656a5 5 0 1 1 0-10 5 5 0 0 1 0 10z'/%3E%3C/svg%3E") 9px center no-repeat;
+}
+.store-combo-input:focus { outline: none; border-color: var(--csm-ac); box-shadow: 0 0 0 3px rgba(124,77,255,.12); }
+.store-combo .combo-chev { position: absolute; right: 9px; top: 50%; transform: translateY(-50%); color: #999; font-size: 10px; pointer-events: none; }
+.store-combo-dropdown {
+  display: none; position: absolute; top: calc(100% + 3px); left: 0; right: 0;
+  background: #fff; border: 1px solid var(--csm-bd); border-radius: var(--csm-r);
+  box-shadow: 0 6px 20px rgba(0,0,0,.12); z-index: 200; max-height: 380px; overflow-y: auto;
+}
+.store-combo.on .store-combo-dropdown { display: block; }
+.combo-hdr { padding: 5px 12px; font: 700 9px/1 inherit; color: #888; text-transform: uppercase; letter-spacing: .3px; background: #fafbfc; display: flex; justify-content: space-between; border-bottom: 1px solid #f0f0f0; }
+.combo-hdr .cnt { color: #666; font-weight: 500; font-size: 10px; text-transform: none; letter-spacing: 0; }
+.combo-item { padding: 8px 12px; border-bottom: 1px solid #f4f4f4; cursor: pointer; display: flex; gap: 8px; align-items: center; }
+.combo-item:hover { background: #f8f9fb; }
+.combo-item.on { background: #eef0ff; border-left: 3px solid var(--csm-ac); padding-left: 9px; }
+.combo-id { font: 700 10px/1 monospace; color: #999; width: 24px; text-align: right; flex-shrink: 0; }
+.combo-info { flex: 1; min-width: 0; }
+.combo-name { font: 600 12px/1.3 inherit; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.combo-addr { font: 400 10px/1.2 inherit; color: #888; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; }
+.combo-badges { display: flex; gap: 4px; flex-shrink: 0; }
+.combo-bdg { font: 700 8px/1 inherit; padding: 2px 6px; border-radius: 3px; }
+.combo-active   { background: #d4edda; color: #155724; }
+.combo-inactive { background: #f8d7da; color: #842029; }
+.combo-type     { background: #e8f5e9; color: #2e7d32; }
+
+/* ── Фильтр-дропдаун ─────────────────────────────────── */
+.filter-select { position: relative; min-width: 150px; }
+.filter-btn {
+  width: 100%; border: 1px solid #ced4da; background: #fff; border-radius: var(--csm-r);
+  padding: 7px 28px 7px 10px; font: 500 12px/1 inherit; color: #333; cursor: pointer;
+  text-align: left; display: flex; align-items: center; height: 34px;
+}
+.filter-btn:hover { border-color: var(--csm-ac); }
+.filter-btn.has-filter { border-color: var(--csm-ac); background: #faf9ff; color: var(--csm-ac); }
+.filter-btn .fb-left { display: flex; align-items: center; gap: 6px; }
+.filter-btn .fb-left i { font-size: 10px; }
+.filter-btn .fb-chevron { position: absolute; right: 9px; top: 50%; transform: translateY(-50%); color: #999; font-size: 9px; transition: transform .15s; }
+.filter-select.on .fb-chevron { transform: translateY(-50%) rotate(180deg); }
+.filter-dropdown {
+  display: none; position: absolute; top: calc(100% + 3px); left: 0; min-width: 170px;
+  background: #fff; border: 1px solid var(--csm-bd); border-radius: var(--csm-r);
+  box-shadow: 0 4px 12px rgba(0,0,0,.1); z-index: 200; overflow: hidden;
+}
+.filter-select.on .filter-dropdown { display: block; }
+.fd-item { display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: pointer; font-size: 12px; border-bottom: 1px solid #f0f0f0; }
+.fd-item:last-child { border-bottom: none; }
+.fd-item:hover { background: #fafbfc; }
+.fd-item.on { background: #eef0ff; color: var(--csm-ac); font-weight: 600; }
+.fd-item .fd-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
+.fd-header { padding: 5px 12px; font: 700 9px/1 inherit; color: #888; text-transform: uppercase; letter-spacing: .3px; background: #fafbfc; }
+
+/* ── Контакты / плиточная сетка ──────────────────────── */
+.cnt-grid { display: grid; grid-template-columns: repeat(4,1fr); gap: 10px; }
+.cnt-tile { background: #fafbfc; border: 1px solid #e8eaed; border-radius: var(--csm-r); padding: 10px 12px; }
+.cnt-lbl { font: 500 9px/1 inherit; color: #888; text-transform: uppercase; letter-spacing: .3px; margin-bottom: 4px; display: flex; align-items: center; gap: 5px; }
+.cnt-lbl i { color: var(--csm-ac); font-size: 10px; }
+.cnt-val { font: 500 12px/1.3 inherit; color: #333; word-break: break-all; }
+.cnt-val a { color: var(--csm-ac); text-decoration: none; }
+.cnt-val .cnt-sub { display: block; font: 400 10px/1.3 inherit; color: #888; margin-top: 2px; }
+
+.maps-inline { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
+.map-ico { display: inline-flex; align-items: center; gap: 5px; padding: 4px 10px; border: 1px solid var(--csm-bd); border-radius: 4px; text-decoration: none; color: #333; font: 500 11px/1 inherit; background: #fff; }
+.map-ico:hover { border-color: var(--csm-ac); color: var(--csm-ac); }
+
+/* ── Оснащение ───────────────────────────────────────── */
+.eq-grid { display: grid; grid-template-columns: repeat(5,1fr); gap: 8px; }
+.eq-item { background: #fafbfc; border: 1px solid #e8eaed; border-radius: 4px; padding: 8px 10px; }
+.eq-item-lbl { font: 500 8px/1 inherit; color: #888; text-transform: uppercase; letter-spacing: .2px; margin-bottom: 4px; display: flex; align-items: center; gap: 4px; }
+.eq-item-lbl i { color: #999; font-size: 9px; }
+.eq-item-val { font: 700 14px/1 inherit; color: var(--csm-bg); }
+.eq-item-val small { font: 400 9px/1 inherit; color: #888; margin-left: 1px; }
+.eq-item.no-data .eq-item-val { color: #bbb; font-weight: 400; font-style: italic; font-size: 11px; }
+.eq-divider { grid-column: 1/-1; border-top: 1px dashed #ddd; margin: 4px 0 0; padding-top: 6px; font: 700 9px/1 inherit; color: #888; text-transform: uppercase; letter-spacing: .3px; display: flex; align-items: center; gap: 6px; }
+.eq-divider i { color: var(--csm-ac); }
+
+/* ── Toggle switch ───────────────────────────────────── */
+.toggle-switch { position: relative; display: inline-flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; }
+.toggle-switch input { position: absolute; opacity: 0; pointer-events: none; }
+.toggle-track { width: 36px; height: 20px; background: #ccc; border-radius: 10px; position: relative; transition: .2s; }
+.toggle-track::after { content: ''; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; background: #fff; border-radius: 50%; transition: .2s; box-shadow: 0 1px 3px rgba(0,0,0,.2); }
+.toggle-switch input:checked + .toggle-track { background: var(--csm-ok); }
+.toggle-switch input:checked + .toggle-track::after { left: 18px; }
+.toggle-lbl { font: 500 12px/1 inherit; color: #333; }
+
+/* ── SEO ─────────────────────────────────────────────── */
+.seo-preview { background: #f9f9f9; border: 1px solid #e5e5e5; border-radius: var(--csm-r); padding: 12px 14px; }
+.seo-preview-hd { font: 600 9px/1 inherit; color: #888; text-transform: uppercase; letter-spacing: .3px; margin-bottom: 6px; display: flex; align-items: center; gap: 5px; }
+.seo-preview-g .sp-url { color: #006621; font-size: 12px; }
+.seo-preview-g .sp-title { color: #1a0dab; font-size: 15px; font-weight: 400; margin: 3px 0; }
+.seo-preview-g .sp-desc { color: #4d5156; font-size: 12px; line-height: 1.5; }
+.char-cnt { font: 400 9px/1 inherit; color: #888; margin-top: 2px; text-align: right; }
+.char-cnt.warn { color: var(--csm-warn); }
+.char-cnt.err  { color: var(--csm-danger); font-weight: 600; }
+
+/* ── Сервисная вкладка ───────────────────────────────── */
+.svc-grp { background: #fff; border: 1px solid var(--csm-bd); border-radius: var(--csm-r); margin-bottom: 10px; overflow: hidden; }
+.svc-grp-h { padding: 7px 12px; background: #fafbfc; border-bottom: 1px solid #f0f0f0; font: 600 11px/1 inherit; color: #455a64; display: flex; align-items: center; gap: 8px; }
+.svc-grp-h i { font-size: 10px; }
+.svc-grp-count { margin-left: auto; color: #888; font-weight: 400; font-size: 10px; }
+.svc-field { display: grid; grid-template-columns: 180px 46px 1fr; gap: 10px; padding: 5px 12px; border-bottom: 1px solid #f4f4f4; font-size: 11px; align-items: center; }
+.svc-field:last-child { border-bottom: none; }
+.svc-field:hover { background: #fafbfc; }
+.svc-field code { font-family: monospace; font-size: 10px; background: #f3f4f6; padding: 1px 5px; border-radius: 3px; color: #455a64; }
+.svc-field .src-tag { font: 700 8px/1 inherit; padding: 2px 5px; border-radius: 2px; width: fit-content; }
+.svc-field .src-cs  { background: #dbeafe; color: #1e40af; }
+.svc-field .src-csp { background: #ede9fe; color: #5b21b6; }
+.svc-field .src-sd  { background: #fce7f3; color: #9f1239; }
+.svc-field .svc-val  { color: #333; word-break: break-all; font-family: monospace; font-size: 11px; }
+.svc-field .svc-null { color: #bbb; font-style: italic; }
+.it-warning { background: #fff3cd; border-left: 3px solid var(--csm-warn); padding: 9px 12px; border-radius: 4px; margin-bottom: 12px; font-size: 11px; color: #856404; display: flex; align-items: flex-start; gap: 8px; }
+.it-warning i { color: var(--csm-warn); margin-top: 1px; }
+
+/* ── Кнопки ──────────────────────────────────────────── */
+.csm-btn-save { background: var(--csm-ac); border: none; color: #fff; padding: 6px 14px; border-radius: var(--csm-r); font: 600 12px/1 inherit; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; }
+.csm-btn-save:hover { background: #6a3fd8; }
+.csm-btn-ac { background: var(--csm-ac); border-color: var(--csm-ac); color: #fff; }
+.csm-btn-ac:hover { background: #6a3fd8; }
+
+/* ── Footer-бар изменений ────────────────────────────── */
+.csm-ftr {
+  position: fixed; bottom: 0; right: 0; left: 0;
+  height: 50px; background: #fff; border-top: 1px solid var(--csm-bd);
+  display: flex; align-items: center; justify-content: space-between; padding: 0 24px;
+  z-index: 100; transition: transform .2s;
+}
+.csm-ftr.hidden { display: none; }
+.csm-ftr-l { font-size: 11px; color: #666; display: flex; align-items: center; gap: 8px; }
+.csm-dirty-badge { background: var(--csm-warn); color: #fff; padding: 2px 8px; border-radius: 10px; font: 600 10px/1 inherit; }
+
+/* ── Side-панель сотрудника ──────────────────────────── */
+.csm-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.3); z-index: 390; display: none; }
+.csm-overlay.on { display: block; }
+.csm-side-p { position: fixed; top: 0; right: -480px; width: 440px; height: 100vh; background: #fff; box-shadow: -4px 0 16px rgba(0,0,0,.1); transition: right .25s; z-index: 400; display: flex; flex-direction: column; }
+.csm-side-p.on { right: 0; }
+.csm-side-p-h { padding: 12px 18px; background: var(--csm-ac); color: #fff; display: flex; justify-content: space-between; align-items: center; }
+.csm-side-p-h h3 { font: 700 14px/1 inherit; margin: 0; }
+.csm-side-p-h button { background: rgba(255,255,255,.2); border: none; color: #fff; width: 26px; height: 26px; border-radius: 50%; cursor: pointer; font-size: 12px; }
+.csm-side-p-b { flex: 1; overflow-y: auto; padding: 16px 18px; }
+
+.emp-head { text-align: center; padding: 14px 0; border-bottom: 1px solid #f0f0f0; }
+.emp-av { width: 64px; height: 64px; border-radius: 50%; background: var(--csm-ac); color: #fff; font: 700 22px/1 inherit; display: flex; align-items: center; justify-content: center; margin: 0 auto 10px; }
+.emp-name { font: 700 15px/1 inherit; color: var(--csm-bg); margin-bottom: 3px; }
+.emp-pos  { font: 500 11px/1 inherit; color: #666; }
+.emp-section { padding: 10px 0; border-bottom: 1px solid #f0f0f0; }
+.emp-section h5 { font: 600 10px/1 inherit; color: #888; text-transform: uppercase; letter-spacing: .3px; margin-bottom: 6px; }
+
+/* ── Toast ───────────────────────────────────────────── */
+.csm-toast-w { position: fixed; bottom: 60px; right: 20px; z-index: 500; max-width: 360px; }
+.toast-m { padding: 10px 14px; border-radius: var(--csm-r); font: 500 12px/1.4 inherit; color: #fff; margin-bottom: 6px; box-shadow: 0 4px 12px rgba(0,0,0,.15); display: flex; align-items: center; gap: 8px; animation: tsmIn .25s; }
+.toast-ok   { background: var(--csm-ok); }
+.toast-warn { background: var(--csm-warn); }
+.toast-err  { background: var(--csm-danger); }
+@keyframes tsmIn { from { opacity: 0; transform: translateX(16px); } to { opacity: 1; transform: translateX(0); } }
+
+/* ── Заглушки (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; }
diff --git a/erp24/web/js/city-store-management/city-store-management.js b/erp24/web/js/city-store-management/city-store-management.js
new file mode 100644 (file)
index 0000000..a30e4b6
--- /dev/null
@@ -0,0 +1,683 @@
+/* ============================================================
+   City Store Management — JS
+   Все данные: через AJAX, рендеринг: через JS-функции (как в макете).
+   CSM_URLS инициализируется в PHP-шаблоне.
+   ============================================================ */
+
+var D = null;          // текущие данные выбранного магазина
+var STORES = [];       // список всех магазинов
+var currentTab = 'card';
+var currentActiveFilter = 'all';
+var currentStoreId = null;
+var dirtyCount = 0;
+var csrfToken = document.querySelector('meta[name="csrf-token"]') ? document.querySelector('meta[name="csrf-token"]').content : '';
+
+// ===== INIT =====
+document.addEventListener('DOMContentLoaded', function () {
+    loadStores();
+
+    document.addEventListener('click', function (e) {
+        if (!e.target.closest('#storeCombo'))  closeCombo();
+        if (!e.target.closest('#activeFilter')) document.getElementById('activeFilter').classList.remove('on');
+    });
+});
+
+function loadStores() {
+    fetch(CSM_URLS.getStores)
+        .then(function (r) { return r.json(); })
+        .then(function (resp) {
+            if (resp.success) {
+                STORES = resp.data;
+                renderComboList('');
+                // Авто-выбор первого активного магазина
+                var first = STORES.find(function (s) { return s.isActive; }) || STORES[0];
+                if (first) selectStore(first.id);
+            }
+        })
+        .catch(function () { toast('Ошибка загрузки магазинов', 'err'); });
+}
+
+// ===== STORE COMBOBOX =====
+function renderComboList(query) {
+    var list = document.getElementById('storeComboList');
+    var q = query.toLowerCase();
+    var filtered = STORES.filter(function (s) {
+        if (currentActiveFilter === 'active' && !s.isActive) return false;
+        if (currentActiveFilter === 'inactive' && s.isActive) return false;
+        if (!q) return true;
+        return s.name.toLowerCase().indexOf(q) !== -1
+            || (s.addr && s.addr.toLowerCase().indexOf(q) !== -1)
+            || String(s.id).indexOf(q) !== -1;
+    });
+
+    if (!filtered.length) {
+        list.innerHTML = '<div style="padding:16px;text-align:center;color:#999;font-size:11px;font-style:italic">Ничего не найдено</div>';
+        return;
+    }
+
+    var html = '<div class="combo-hdr"><span>Магазины</span><span class="cnt">' + filtered.length + ' из ' + STORES.length + '</span></div>';
+    html += filtered.map(function (s) {
+        var activeLbl = s.isActive ? 'Активен' : 'Неактивен';
+        var activeCls = s.isActive ? 'combo-active' : 'combo-inactive';
+        var typePart = s.type ? '<span class="combo-bdg combo-type">' + esc(s.type) + '</span>' : '';
+        return '<div class="combo-item' + (s.id === currentStoreId ? ' on' : '') + '" onclick="selectStore(' + s.id + ')">'
+            + '<span class="combo-id">' + s.id + '</span>'
+            + '<div class="combo-info"><div class="combo-name">' + esc(s.name) + '</div>'
+            + '<div class="combo-addr">' + esc(s.addr || '') + '</div></div>'
+            + '<div class="combo-badges">' + typePart + '<span class="combo-bdg ' + activeCls + '">' + activeLbl + '</span></div>'
+            + '</div>';
+    }).join('');
+
+    list.innerHTML = html;
+}
+
+function openCombo() {
+    document.getElementById('storeCombo').classList.add('on');
+    renderComboList(document.getElementById('storeSearchInput').value);
+}
+
+function closeCombo() {
+    document.getElementById('storeCombo').classList.remove('on');
+}
+
+function filterCombo(q) {
+    openCombo();
+    renderComboList(q);
+}
+
+function selectStore(id) {
+    currentStoreId = id;
+    var s = STORES.find(function (x) { return x.id === id; });
+    if (s) document.getElementById('storeSearchInput').value = s.id + ' — ' + s.name;
+    closeCombo();
+    loadStore(id);
+}
+
+function toggleActiveFilter(ev) {
+    if (ev) ev.stopPropagation();
+    document.getElementById('activeFilter').classList.toggle('on');
+}
+
+function setActiveFilter(el, f, label) {
+    el.parentElement.querySelectorAll('.fd-item').forEach(function (x) { x.classList.remove('on'); });
+    el.classList.add('on');
+    currentActiveFilter = f;
+    document.getElementById('activeFilterLabel').textContent = label;
+    document.querySelector('#activeFilter .filter-btn').classList.toggle('has-filter', f !== 'all');
+    document.getElementById('activeFilter').classList.remove('on');
+    renderComboList(document.getElementById('storeSearchInput').value);
+}
+
+// ===== LOAD STORE DATA =====
+function loadStore(id) {
+    document.getElementById('contentArea').innerHTML = '<div style="text-align:center;padding:40px;color:#999"><i class="fas fa-spinner fa-spin fa-2x"></i></div>';
+    document.getElementById('csmDetail').style.display = '';
+    document.getElementById('csmEmpty').style.display = 'none';
+
+    fetch(CSM_URLS.getStore + '?id=' + id)
+        .then(function (r) { return r.json(); })
+        .then(function (resp) {
+            if (!resp.success) { toast(resp.message || 'Ошибка', 'err'); return; }
+            D = resp.data;
+            renderHero();
+            currentTab = 'card';
+            document.querySelectorAll('#tabs .csm-nav-link').forEach(function (a) { a.classList.remove('on'); });
+            document.querySelector('#tabs .csm-nav-link').classList.add('on');
+            document.getElementById('saveBtn').style.display = 'none';
+            renderCard();
+        })
+        .catch(function () { toast('Ошибка загрузки данных магазина', 'err'); });
+}
+
+// ===== HERO =====
+function renderHero() {
+    var s = D;
+    document.getElementById('heroName').innerHTML = esc(s.name)
+        + (s.storeTypeName ? ' <span class="hs-bdg hs-type"><i class="fas fa-store"></i>' + esc(s.storeTypeName) + '</span>' : '')
+        + (s.isActive
+            ? ' <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 || '—');
+    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>' : '');
+    document.getElementById('heroName').style.color = '';
+    document.getElementById('heroAddr').style.color = '';
+}
+
+// ===== TAB: КАРТОЧКА (readonly) =====
+function renderCard() {
+    var s = D;
+    var h = '';
+
+    h += '<div class="panel">'
+        + '<div class="panel-heading"><h3 class="panel-title"><i class="fas fa-address-book"></i>Контакты и руководство</h3></div>'
+        + '<div class="panel-body">'
+        + '<div class="cnt-grid">'
+        + tile('fa-user-tie', 'Администратор',
+            s.administratorName
+                ? '<a href="#" onclick="openEmployee(\'adm\');return false">' + esc(s.administratorName) + '</a>'
+                  + (s.administratorEmail ? '<span class="cnt-sub"><a href="mailto:' + esc(s.administratorEmail) + '">' + esc(s.administratorEmail) + '</a></span>' : '')
+                : '<span style="color:#bbb">—</span>')
+        + tile('fa-user-graduate', 'КШФ',
+            s.bushChefFloristName
+                ? '<a href="#" onclick="openEmployee(\'ksf\');return false">' + esc(s.bushChefFloristName) + '</a>'
+                : '<span style="color:#bbb">—</span>')
+        + tile('fa-envelope', 'Email магазина', s.email ? '<a href="mailto:' + esc(s.email) + '">' + esc(s.email) + '</a>' : '—')
+        + tile('fab fa-telegram', 'Telegram чат', esc(s.tgChatId || '—'))
+        + '</div>'
+        + (s.url || s.gis2 || s.yamap || s.googlemap ? '<div class="maps-inline">'
+            + (s.url ? '<a href="' + esc(s.url) + '" target="_blank" class="map-ico"><i class="fas fa-at"></i>Сайт</a>' : '')
+            + (s.gis2 ? '<a href="' + esc(s.gis2) + '" target="_blank" class="map-ico"><i class="fas fa-map-marked-alt"></i>2GIS</a>' : '')
+            + (s.yamap ? '<a href="' + esc(s.yamap) + '" target="_blank" class="map-ico"><i class="fas fa-map-marked-alt"></i>Я.Карты</a>' : '')
+            + (s.googlemap ? '<a href="' + esc(s.googlemap) + '" target="_blank" class="map-ico"><i class="fas fa-map-marked-alt"></i>Google</a>' : '')
+            + '</div>' : '')
+        + '</div></div>';
+
+    h += '<div class="panel">'
+        + '<div class="panel-heading"><h3 class="panel-title"><i class="fas fa-cubes"></i>Оснащение</h3>'
+        + '<span class="panel-info">редактирование — во вкладке Операционное</span></div>'
+        + '<div class="panel-body"><div class="eq-grid">'
+        + eqItem('fa-vector-square', 'Торг. пл.', s.storeArea, 'м²')
+        + eqItem('fa-store', 'Витрина', s.showcaseVolume, 'м³')
+        + eqItem('fa-snowflake', 'Хол. пл.', s.freezeArea, 'м²')
+        + eqItem('fa-snowflake', 'Хол. об.', s.freezeVolume, 'м³')
+        + eqItem('fa-file-contract', 'Договор', s.squareStore, 'м²')
+        + '<div class="eq-divider"><i class="fas fa-microchip"></i>Техника <span class="stub-badge">stub</span></div>'
+        + eqItemStub('fa-video', 'Камера')
+        + eqItemStub('fa-microphone', 'Микрофон')
+        + eqItemStub('fa-wifi', 'Роутер')
+        + eqItemStub('fa-cash-register', 'Касса')
+        + eqItemStub('fa-globe', 'Интернет')
+        + '</div></div></div>';
+
+    document.getElementById('contentArea').innerHTML = h;
+}
+
+// ===== TAB: ОПЕРАЦИОННОЕ =====
+function renderOps() {
+    var s = D;
+    var h = '';
+
+    // 1. Активность (CityStoreParams.is_active)
+    h += '<div class="panel" style="border-left:4px solid var(--ok)">'
+        + '<div class="panel-heading"><h3 class="panel-title"><i class="fas fa-power-off"></i>Активность<span class="owner-badge">Параметры</span></h3></div>'
+        + '<div class="panel-body">'
+        + '<div class="row g-3 align-items-center">'
+        + '<div class="col-md-4">'
+        + '<label class="toggle-switch"><input type="checkbox" id="fIsActive" ' + (s.isActive ? 'checked' : '') + ' onchange="markDirty()"><span class="toggle-track"></span><span class="toggle-lbl">Магазин активен</span></label>'
+        + '</div>'
+        + '<div class="col-md-8" style="font-size:11px;color:#666"><i class="fas fa-info-circle me-1" style="color:var(--ac)"></i>'
+        + 'Изменение активности записывается в историю через <code>store_dynamic</code> (category=4)'
+        + '</div></div></div></div>';
+
+    // 2. Тип магазина
+    h += '<div class="panel" style="border-left:4px solid var(--ac)">'
+        + '<div class="panel-heading"><h3 class="panel-title"><i class="fas fa-store"></i>Тип магазина<span class="owner-badge">Параметры</span></h3>'
+        + '<span class="panel-info">store_type · FK → StoreType</span></div>'
+        + '<div class="panel-body"><div class="row g-3" style="align-items:end">'
+        + '<div class="col-md-5"><div class="form-group" style="margin-bottom:0">'
+        + '<label><span class="acc-b acc-O">O</span>Тип магазина</label>'
+        + buildSelect('fStoreType', s.storeTypeArray || {}, s.storeType, 'Не задан')
+        + '</div></div>'
+        + '<div class="col-md-7" style="color:#666;font-size:11px">'
+        + (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)
+    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">'
+        + numField('col-md-4', 'fStoreArea', s.storeArea, 'Торг. площадь (store_area, м²)')
+        + numField('col-md-4', 'fShowcaseVolume', s.showcaseVolume, 'Витрина (showcase_volume, м³)')
+        + numField('col-md-4', 'fFreezeArea', s.freezeArea, 'Хол. площадь (freeze_area, м²)')
+        + numField('col-md-4', 'fFreezeVolume', s.freezeVolume, 'Хол. объём (freeze_volume, м³)')
+        + numField('col-md-4', 'fSquareStore', s.squareStore, 'Договор (square_store, м²)', 'city_store')
+        + '</div>'
+        + '<div class="row g-3 mt-2">'
+        + '<div class="col-12"><div style="padding:8px 12px;background:#fff3e0;border-left:3px solid var(--warn);border-radius:4px;font-size:11px;color:#856404">'
+        + '<i class="fas fa-exclamation-triangle me-1"></i>Техника (камеры, роутеры, касса) — требует миграции БД <span class="stub-badge">stub</span></div></div>'
+        + '</div></div></div>';
+
+    // 4. Адрес
+    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">'
+        + textField('col-md-8', 'fAdress', s.adress, 'Адрес (city_store.adress)', 'city_store')
+        + textField('col-md-4', 'fGps', s.gps, 'GPS (city_store.gps)', 'city_store')
+        + textField('col-md-12', 'fAdressAmo', s.adressAmo, 'Адрес для самовывоза (city_store.adress_amo)', 'city_store')
+        + '<div class="col-md-4"><div class="form-group">'
+        + '<label><span class="acc-b acc-O">O</span>Регион (city_store_params)</label>'
+        + buildSelect('fAddressRegion', s.regionArray || {}, s.addressRegion, 'Выберите регион')
+        + '</div></div>'
+        + '<div class="col-md-4"><div class="form-group">'
+        + '<label><span class="acc-b acc-O">O</span>Город (city_store_params)</label>'
+        + buildSelect('fAddressCity', s.cityArray || {}, s.addressCity, 'Выберите город')
+        + '</div></div>'
+        + '<div class="col-md-4"><div class="form-group">'
+        + '<label><span class="acc-b acc-O">O</span>Район (city_store_params)</label>'
+        + buildSelect('fAddressDistrict', s.districtArray || {}, s.addressDistrict, 'Выберите район')
+        + '</div></div>'
+        + '</div></div></div>';
+
+    // 5. Руководство (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 class="col-md-6"><div class="form-group">'
+        + '<label><span class="acc-b acc-O">O</span>Кустовой шеф-флорист</label>'
+        + buildSelect('fBushChefFlorist', s.bushChefFloristArray || {}, s.bushChefFloristId, 'Не назначен')
+        + (s.bushChefFloristName ? '<div style="font-size:10px;color:#888;margin-top:3px">Текущий: ' + esc(s.bushChefFloristName) + '</div>' : '')
+        + '</div></div>'
+        + '<div class="col-md-6"><div class="form-group">'
+        + '<label><span class="acc-b acc-O">O</span>Территориальный управляющий</label>'
+        + buildSelect('fTerritorialManager', s.territorialManagerArray || {}, s.territorialManagerId, 'Не назначен')
+        + (s.territorialManagerName ? '<div style="font-size:10px;color:#888;margin-top:3px">Текущий: ' + esc(s.territorialManagerName) + '</div>' : '')
+        + '</div></div>'
+        + '</div></div></div>';
+
+    // 6. Общая информация (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">'
+        + textField('col-md-6', 'fName', s.name, 'Название (name)', 'city_store')
+        + textField('col-md-6', 'fAdressSm', s.adressSm, 'Краткий адрес (adress_sm)', 'city_store')
+        + textField('col-md-6', 'fSpravId', s.spravId, 'ID в 1С (sprav_id)', 'city_store')
+        + textField('col-md-6', 'fEmail', s.email, 'Email (email)', 'city_store')
+        + textField('col-md-6', 'fTgChatId', s.tgChatId, 'Telegram чат ID (tg_chat_id)', 'city_store')
+        + '<div class="col-md-6"><div class="form-group">'
+        + '<label>Видимость на сайте (visible)</label>'
+        + '<div style="padding:6px 10px;background:#f3f4f6;border:1px solid #e0e0e0;border-radius:4px;font-size:12px;color:#555;display:inline-flex;align-items:center;gap:8px">'
+        + (s.visible
+            ? '<i class="fas fa-eye" style="color:var(--csm-ok)"></i><span>Виден (visible = 1)</span>'
+            : '<i class="fas fa-eye-slash" style="color:var(--csm-danger)"></i><span>Скрыт (visible = 0)</span>')
+        + '</div>'
+        + '<div style="font-size:10px;color:#999;margin-top:4px"><i class="fas fa-info-circle"></i> Синхронизируется автоматически через поле «Активен» в city_store_params</div>'
+        + '</div></div>'
+        + '</div></div></div>';
+
+    document.getElementById('contentArea').innerHTML = h;
+
+    // навешиваем onchange на все инпуты/селекты панели
+    document.querySelectorAll('#contentArea input, #contentArea select, #contentArea textarea').forEach(function (el) {
+        el.addEventListener('change', markDirty);
+    });
+}
+
+// ===== TAB: SEO =====
+function renderSeo() {
+    var s = D;
+    var h = '';
+
+    h += '<div class="panel">'
+        + '<div class="panel-heading"><h3 class="panel-title"><i class="fas fa-eye"></i>Превью в поиске</h3></div>'
+        + '<div class="panel-body"><div class="seo-preview">'
+        + '<div class="seo-preview-hd"><i class="fab fa-google"></i>Google snippet</div>'
+        + '<div class="seo-preview-g">'
+        + '<div class="sp-url">erp24.ru' + esc(s.url || '') + '</div>'
+        + '<div class="sp-title">' + esc(s.seoTitle || '') + '</div>'
+        + '<div class="sp-desc">' + esc(s.seoDescription || '') + '</div>'
+        + '</div></div></div></div>';
+
+    h += '<div class="panel">'
+        + '<div class="panel-heading"><h3 class="panel-title"><i class="fas fa-globe"></i>Публикация<span class="owner-badge" style="background:#f3e5f5;color:#6a1b9a">Маркетинг</span></h3></div>'
+        + '<div class="panel-body"><div class="row g-3">'
+        + textField('col-md-12', 'fNameFull', s.nameFull, 'Полное название (name_full)', 'city_store')
+        + textField('col-md-4', 'fUrl', s.url, 'URL страницы (url)', 'city_store')
+        + numField('col-md-4', 'fPosit', s.posit, 'Позиция (posit)', 'city_store')
+        + '</div></div></div>';
+
+    h += '<div class="panel">'
+        + '<div class="panel-heading"><h3 class="panel-title"><i class="fas fa-search"></i>SEO мета</h3></div>'
+        + '<div class="panel-body">'
+        + '<div class="form-group"><label><span class="acc-b acc-O">O</span>SEO заголовок (seo_title)</label>'
+        + '<input type="text" class="form-control" id="fSeoTitle" value="' + esc(s.seoTitle || '') + '" oninput="updateSeoPreview();updateCharCnt(\'fSeoTitle\',\'cntTitle\',60)" onchange="markDirty()">'
+        + '<div class="char-cnt' + charWarnCls(s.seoTitle, 60) + '" id="cntTitle">' + (s.seoTitle ? s.seoTitle.length : 0) + '/60</div></div>'
+        + '<div class="form-group"><label><span class="acc-b acc-O">O</span>SEO описание (seo_description)</label>'
+        + '<textarea class="form-control" id="fSeoDesc" oninput="updateSeoPreview();updateCharCnt(\'fSeoDesc\',\'cntDesc\',160)" onchange="markDirty()">' + esc(s.seoDescription || '') + '</textarea>'
+        + '<div class="char-cnt' + charWarnCls(s.seoDescription, 160) + '" id="cntDesc">' + (s.seoDescription ? s.seoDescription.length : 0) + '/160</div></div>'
+        + '<div class="form-group"><label><span class="acc-b acc-O">O</span>H1 (h1)</label>'
+        + '<input type="text" class="form-control" id="fH1" value="' + esc(s.h1 || '') + '" onchange="markDirty()"></div>'
+        + '</div></div>';
+
+    h += '<div class="panel">'
+        + '<div class="panel-heading"><h3 class="panel-title"><i class="fas fa-align-left"></i>Контент и изображения</h3></div>'
+        + '<div class="panel-body">'
+        + '<div class="form-group"><label><span class="acc-b acc-O">O</span>Контент (content)</label>'
+        + '<textarea class="form-control" id="fContent" style="min-height:80px" onchange="markDirty()">' + esc(s.content || '') + '</textarea></div>'
+        + '<div class="row g-3">'
+        + textField('col-md-6', 'fImageSm', s.imageSm, 'Фото маленькое (image_sm)', 'city_store')
+        + textField('col-md-6', 'fImageBig', s.imageBig, 'Фото большое (image_big)', 'city_store')
+        + textField('col-md-6', 'fImage2Sm', s.image2Sm, 'Фото 2 маленькое (image2_sm)', 'city_store')
+        + textField('col-md-6', 'fImage2Big', s.image2Big, 'Фото 2 большое (image2_big)', 'city_store')
+        + textField('col-md-12', 'fImages', s.images, 'Изображения (images)', 'city_store')
+        + '</div></div></div>';
+
+    h += '<div class="panel">'
+        + '<div class="panel-heading"><h3 class="panel-title"><i class="fas fa-map-marked-alt"></i>Карточки на картах</h3></div>'
+        + '<div class="panel-body"><div class="row g-3">'
+        + textField('col-md-4', 'fGis2', s.gis2, '2GIS (2gis)', 'city_store')
+        + textField('col-md-4', 'fYamap', s.yamap, 'Яндекс.Карты (yamap)', 'city_store')
+        + textField('col-md-4', 'fGooglemap', s.googlemap, 'Google Maps (googlemap)', 'city_store')
+        + '<div class="col-md-12"><div class="form-group">'
+        + '<label><span class="acc-b acc-O">O</span>HTML iframe (mapiframe)</label>'
+        + '<textarea class="form-control" id="fMapiframe" style="font-family:monospace;font-size:11px" onchange="markDirty()">' + esc(s.mapiframe || '') + '</textarea>'
+        + '</div></div>'
+        + '</div></div></div>';
+
+    document.getElementById('contentArea').innerHTML = h;
+
+    document.querySelectorAll('#contentArea input, #contentArea select, #contentArea textarea').forEach(function (el) {
+        el.addEventListener('change', markDirty);
+    });
+}
+
+// ===== TAB: СЕРВИСНАЯ =====
+function renderSvc() {
+    var s = D;
+    var h = '';
+
+    h += '<div class="it-warning"><i class="fas fa-server"></i>'
+        + '<div><strong>Сервисная вкладка — для IT-отдела.</strong> Просмотр всех полей из обеих таблиц.</div></div>';
+
+    var svcGroups = [
+        { title: 'city_store — Идентификация', src: 'CS', fields: [
+            {k:'id',v:s.id},{k:'name',v:s.name},{k:'name_full',v:s.nameFull},
+            {k:'sprav_id',v:s.spravId},{k:'posit',v:s.posit},{k:'visible',v:s.visible?'1':'0'},
+        ]},
+        { title: 'city_store — Адрес', src: 'CS', fields: [
+            {k:'adress',v:s.adress},{k:'adress_sm',v:s.adressSm},{k:'adress_amo',v:s.adressAmo},{k:'gps',v:s.gps},
+        ]},
+        { title: 'city_store — Контакты', src: 'CS', fields: [
+            {k:'email',v:s.email},{k:'tg_chat_id',v:s.tgChatId},
+            {k:'2gis',v:s.gis2},{k:'yamap',v:s.yamap},{k:'googlemap',v:s.googlemap},
+        ]},
+        { title: 'city_store — SEO', src: 'CS', fields: [
+            {k:'url',v:s.url},{k:'seo_title',v:s.seoTitle},{k:'seo_description',v:s.seoDescription},
+            {k:'h1',v:s.h1},{k:'image_sm',v:s.imageSm},{k:'image_big',v:s.imageBig},
+        ]},
+        { title: 'city_store — Прочее', src: 'CS', fields: [
+            {k:'open_date',v:s.openDate},{k:'sale_plan_avg',v:s.salePlanAvg},
+            {k:'visitor_day_avg',v:s.visitorDayAvg},{k:'visitor_avg',v:s.visitorAvg},
+            {k:'square_store',v:s.squareStore},{k:'administrator_id',v:s.administratorId},
+        ]},
+        { title: 'city_store_params — Параметры', src: 'CSP', fields: [
+            {k:'store_type',v:s.storeType},{k:'store_type_name',v:s.storeTypeName},
+            {k:'store_area',v:s.storeArea},{k:'showcase_volume',v:s.showcaseVolume},
+            {k:'freeze_area',v:s.freezeArea},{k:'freeze_volume',v:s.freezeVolume},
+            {k:'is_active',v:s.isActive?'true':'false'},
+            {k:'matrix_type',v:s.matrixType ? s.matrixType.join(',') : null},
+        ]},
+        { title: 'city_store_params — Адрес FK', src: 'CSP', fields: [
+            {k:'address_region',v:s.addressRegion},{k:'address_region_name',v:s.addressRegionName},
+            {k:'address_city',v:s.addressCity},{k:'address_city_name',v:s.addressCityName},
+            {k:'address_district',v:s.addressDistrict},{k:'address_district_name',v:s.addressDistrictName},
+        ]},
+        { title: 'store_dynamic — Назначения', src: 'SD', fields: [
+            {k:'bush_chef_florist_id',v:s.bushChefFloristId},{k:'bush_chef_florist_name',v:s.bushChefFloristName},
+            {k:'territorial_manager_id',v:s.territorialManagerId},{k:'territorial_manager_name',v:s.territorialManagerName},
+        ]},
+    ];
+
+    h += '<div style="margin:0 0 10px 0">'
+        + '<input type="text" class="form-control" placeholder="Поиск по имени поля или значению..." oninput="filterSvc(this.value)">'
+        + '</div>';
+
+    svcGroups.forEach(function (g) {
+        h += '<div class="svc-grp"><div class="svc-grp-h"><i class="fas fa-table"></i>' + g.title
+            + '<span class="svc-grp-count">' + g.fields.length + ' полей</span></div><div>';
+        g.fields.forEach(function (f) {
+            var val = (f.v === null || f.v === undefined || f.v === '') ? '<span class="svc-null">null</span>' : esc(String(f.v));
+            var srcCls = g.src === 'CS' ? 'src-cs' : g.src === 'CSP' ? 'src-csp' : 'src-sd';
+            h += '<div class="svc-field" data-sch="' + esc(f.k + ' ' + (f.v || '')) + '">'
+                + '<code>' + f.k + '</code>'
+                + '<span class="src-tag ' + srcCls + '">' + g.src + '</span>'
+                + '<span class="svc-val">' + val + '</span>'
+                + '</div>';
+        });
+        h += '</div></div>';
+    });
+
+    document.getElementById('contentArea').innerHTML = h;
+}
+
+function filterSvc(q) {
+    q = q.toLowerCase();
+    document.querySelectorAll('.svc-field').forEach(function (el) {
+        el.style.display = el.dataset.sch.toLowerCase().indexOf(q) !== -1 ? '' : 'none';
+    });
+}
+
+// ===== TAB SWITCHING =====
+function setTab(k, ev) {
+    currentTab = k;
+    document.querySelectorAll('#tabs .csm-nav-link').forEach(function (a) { a.classList.remove('on'); });
+    if (ev && ev.target) ev.target.closest('.csm-nav-link').classList.add('on');
+    document.getElementById('contentArea').scrollTop = 0;
+    document.getElementById('saveBtn').style.display = (k === 'card' || k === 'svc') ? 'none' : 'inline-flex';
+    if (k === 'card') renderCard();
+    else if (k === 'ops') renderOps();
+    else if (k === 'seo') renderSeo();
+    else renderSvc();
+}
+
+// ===== SAVE =====
+function saveChanges() {
+    if (!D) return;
+
+    var csStore = {
+        id:        D.id,
+        name:      val('fName'),
+        name_full: val('fNameFull'),
+        adress:    val('fAdress'),
+        adress_sm: val('fAdressSm'),
+        adress_amo:val('fAdressAmo'),
+        gps:       val('fGps'),
+        email:     val('fEmail'),
+        tg_chat_id:val('fTgChatId'),
+        url:       val('fUrl'),
+        posit:     val('fPosit'),
+        seo_title: val('fSeoTitle'),
+        seo_description: val('fSeoDesc'),
+        h1:        val('fH1'),
+        content:   val('fContent'),
+        image_sm:  val('fImageSm'),
+        image_big: val('fImageBig'),
+        image2_sm: val('fImage2Sm'),
+        image2_big:val('fImage2Big'),
+        images:    val('fImages'),
+        gis2:      val('fGis2'),
+        yamap:     val('fYamap'),
+        googlemap: val('fGooglemap'),
+        mapiframe: val('fMapiframe'),
+        sprav_id:  val('fSpravId'),
+    };
+
+    var csParams = {
+        store_id:           D.id,
+        store_type:         val('fStoreType'),
+        store_area:         val('fStoreArea'),
+        showcase_volume:    val('fShowcaseVolume'),
+        freeze_area:        val('fFreezeArea'),
+        freeze_volume:      val('fFreezeVolume'),
+        address_region:     val('fAddressRegion'),
+        address_city:       val('fAddressCity'),
+        address_district:   val('fAddressDistrict'),
+        bush_chef_florist:  val('fBushChefFlorist'),
+        territorial_manager: val('fTerritorialManager'),
+        is_active:          checked('fIsActive') ? 1 : 0,
+        square_store:       val('fSquareStore'),
+    };
+
+    var p1 = postJson(CSM_URLS.saveCityStore, csStore);
+    var p2 = postJson(CSM_URLS.saveCityStoreParams, csParams);
+
+    Promise.all([p1, p2]).then(function (results) {
+        var allOk = results.every(function (r) { return r && r.success; });
+        if (allOk) {
+            toast('Сохранено', 'ok');
+            clearDirty();
+            loadStore(D.id);
+        } else {
+            var errs = results.filter(function (r) { return r && !r.success; }).map(function (r) { return r.message; }).join('; ');
+            toast('Ошибка: ' + errs, 'err');
+        }
+    }).catch(function () { toast('Ошибка сети', 'err'); });
+}
+
+function cancelChanges() {
+    clearDirty();
+    if (D) setTab(currentTab, null);
+    toast('Отменено', 'warn');
+}
+
+// ===== DIRTY STATE =====
+function markDirty() {
+    dirtyCount++;
+    document.getElementById('ftrBar').classList.remove('hidden');
+    document.getElementById('dirtyBadge').textContent = dirtyCount + ' несохр.';
+    document.getElementById('saveBtn').style.display = 'inline-flex';
+    var tabLink = document.querySelector('#tabs .csm-nav-link.on');
+    if (tabLink && !tabLink.querySelector('.dirty-dot')) {
+        tabLink.insertAdjacentHTML('beforeend', '<span class="dirty-dot"></span>');
+    }
+}
+
+function clearDirty() {
+    dirtyCount = 0;
+    document.getElementById('ftrBar').classList.add('hidden');
+    document.querySelectorAll('.dirty-dot').forEach(function (d) { d.remove(); });
+    document.getElementById('saveBtn').style.display = 'none';
+}
+
+// ===== EMPLOYEE SIDE PANEL =====
+function openEmployee(type) {
+    var emp = (type === 'adm') ? {name: D.administratorName, email: D.administratorEmail} : {name: D.bushChefFloristName};
+    if (!emp || !emp.name) { toast('Данные не загружены', 'warn'); return; }
+    var av = emp.name.split(' ').map(function (w) { return w[0]; }).join('').slice(0, 2).toUpperCase();
+    document.getElementById('employeeBody').innerHTML =
+        '<div class="emp-head"><div class="emp-av">' + av + '</div>'
+        + '<div class="emp-name">' + esc(emp.name) + '</div></div>'
+        + (emp.email ? '<div class="emp-section"><h5>Контакты</h5><div><i class="fas fa-envelope me-2" style="color:#999"></i><a href="mailto:' + esc(emp.email) + '" style="color:var(--ac)">' + esc(emp.email) + '</a></div></div>' : '');
+    document.getElementById('sideEmployee').classList.add('on');
+    document.getElementById('overlay').classList.add('on');
+}
+
+function closeSide() {
+    document.querySelectorAll('.side-p').forEach(function (p) { p.classList.remove('on'); });
+    document.getElementById('overlay').classList.remove('on');
+}
+
+function showMoreMenu() { toast('В разработке', 'warn'); }
+
+// ===== SEO preview =====
+function updateSeoPreview() {
+    var t = document.getElementById('fSeoTitle');
+    var d = document.getElementById('fSeoDesc');
+    var previewTitle = document.querySelector('.sp-title');
+    var previewDesc = document.querySelector('.sp-desc');
+    if (t && previewTitle) previewTitle.textContent = t.value;
+    if (d && previewDesc) previewDesc.textContent = d.value;
+}
+
+function updateCharCnt(inpId, cntId, max) {
+    var el = document.getElementById(inpId);
+    var cnt = document.getElementById(cntId);
+    if (!el || !cnt) return;
+    var len = el.value.length;
+    cnt.textContent = len + '/' + max;
+    cnt.className = 'char-cnt ' + (len > max ? 'err' : len > max * 0.85 ? 'warn' : '');
+}
+
+function charWarnCls(str, max) {
+    if (!str) return '';
+    var len = str.length;
+    if (len > max) return ' err';
+    if (len > max * 0.85) return ' warn';
+    return '';
+}
+
+// ===== TOAST =====
+function toast(msg, type) {
+    var d = document.createElement('div');
+    d.className = 'toast-m toast-' + (type || 'ok');
+    d.innerHTML = '<i class="fas fa-' + (type === 'ok' ? 'check-circle' : type === 'err' ? 'times-circle' : 'exclamation-circle') + '"></i>' + msg;
+    document.getElementById('toastW').appendChild(d);
+    setTimeout(function () { d.remove(); }, 3500);
+}
+
+// ===== HELPERS =====
+function esc(s) {
+    if (s === null || s === undefined) return '';
+    return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
+}
+
+function val(id) {
+    var el = document.getElementById(id);
+    return el ? el.value : null;
+}
+
+function checked(id) {
+    var el = document.getElementById(id);
+    return el ? el.checked : false;
+}
+
+function postJson(url, data) {
+    return fetch(url, {
+        method: 'POST',
+        headers: {
+            'Content-Type': 'application/x-www-form-urlencoded',
+            'X-CSRF-Token': csrfToken,
+        },
+        body: Object.entries(data)
+            .filter(function (e) { return e[1] !== null && e[1] !== undefined; })
+            .map(function (e) { return encodeURIComponent(e[0]) + '=' + encodeURIComponent(e[1]); })
+            .join('&'),
+    }).then(function (r) { return r.json(); });
+}
+
+function buildSelect(id, options, selected, placeholder) {
+    var html = '<select class="form-select" id="' + id + '" onchange="markDirty()">';
+    html += '<option value="">' + esc(placeholder) + '</option>';
+    Object.entries(options).forEach(function (entry) {
+        var k = entry[0], v = entry[1];
+        html += '<option value="' + esc(k) + '"' + (String(selected) === String(k) ? ' selected' : '') + '>' + esc(v) + '</option>';
+    });
+    html += '</select>';
+    return html;
+}
+
+function textField(colCls, id, value, label, src) {
+    return '<div class="' + colCls + '"><div class="form-group">'
+        + '<label><span class="acc-b acc-O">O</span>' + esc(label) + '</label>'
+        + '<input type="text" class="form-control" id="' + id + '" value="' + esc(value || '') + '" onchange="markDirty()">'
+        + '</div></div>';
+}
+
+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>'
+        + '<input type="number" step="0.1" class="form-control" id="' + id + '" value="' + esc(value !== null && value !== undefined ? value : '') + '" onchange="markDirty()">'
+        + '</div></div>';
+}
+
+function tile(icon, label, content) {
+    return '<div class="cnt-tile"><div class="cnt-lbl"><i class="fas ' + icon + '"></i>' + esc(label) + '</div>'
+        + '<div class="cnt-val">' + content + '</div></div>';
+}
+
+function eqItem(icon, label, value, unit) {
+    var hasData = value !== null && value !== undefined;
+    return '<div class="eq-item' + (!hasData ? ' no-data' : '') + '">'
+        + '<div class="eq-item-lbl"><i class="fas ' + icon + '"></i>' + esc(label) + '</div>'
+        + '<div class="eq-item-val">' + (hasData ? value + '<small>' + unit + '</small>' : '—') + '</div>'
+        + '</div>';
+}
+
+function eqItemStub(icon, label) {
+    return '<div class="eq-item no-data">'
+        + '<div class="eq-item-lbl"><i class="fas ' + icon + '"></i>' + esc(label) + ' <span class="stub-badge">stub</span></div>'
+        + '<div class="eq-item-val">—</div>'
+        + '</div>';
+}
index a4d2bd1c7956f9e473c0fa138030f6f99413a6cf..02ab430f621cc124951a488334d40401af85112b 100644 (file)
@@ -15,8 +15,10 @@ function sendData() {
         },
         success: function (response) {
             if (response?.success) {
-                $('#selected-store').html(Object.entries(response.data).map(([key, value]) =>
-                    `<option value="${key}">${value}</option>`).join(''));
+                $('#selected-store').html(Object.entries(response.data).map(([key, value]) => {
+                    var style = value.isActive ? '' : ' style="background-color: #ffe0b2;"';
+                    return `<option value="${key}"${style}>${value.label}</option>`;
+                }).join(''));
             } else {
                 console.error("Ошибка: Невозможно получить данные.");
             }
@@ -64,6 +66,7 @@ function getData() {
             $('#district').val(data.district);
             $('#territorial-manager').text(data.territorialManager);
 
+            $('#is-active').prop('checked', data.isActive === true);
             populateSelect('#store-type', data.storeTypeArray, data.type, 'Тип магазина');
             populateSelect('#region', data.regionArray, data.region, 'Регион');
             populateSelect('#city', data.cityArray, data.city, 'Город');