<?php
use yii\queue\amqp_interop\Queue;
+use yii_app\services\StoreService;
$params = require __DIR__ . '/params.php';
$db = require __DIR__ . '/db.php';
return Yii::$app->response->redirect(['site/login']);
},
],
+ 'container' => [
+ 'singletons' => [
+ StoreService::class => StoreService::class,
+ ],
+ ],
'params' => $params,
'timeZone' => 'Europe/Moscow',
];
--- /dev/null
+<?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__);
+ }
+ }
+}
$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()));
}
/**
- * Ð\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(
['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__);
}
}
$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()
$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,
+ ];
}),
];
}
'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,
'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',
--- /dev/null
+<?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');
+ }
+}
--- /dev/null
+<?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');
+ }
+}
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}}".
* @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
[['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],
];
}
'freeze_area' => 'Площадь холодильника',
'freeze_volume' => 'Объем холодильника',
'matrix_type' => 'Тип матрицы',
+ 'is_active' => 'Активен',
'created_by' => 'ИД создателя',
'created_at' => 'Дата создания',
'updated_by' => 'ИД редактировавшего',
{
return $this->hasOne(StoreCityList::class, ['id' => 'address_city']);
}
+
}
*/
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}
*/
*
* @property int $id
* @property string $name
+ * @property string|null $code
* @property int $created_by
* @property string $created_at
* @property int|null $updated_by
[['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' => 'Код может содержать только строчные латинские буквы, цифры, дефис и подчёркивание'],
];
}
return [
'id' => 'ID',
'name' => 'Название типа магазина',
+ 'code' => 'Код типа (slug)',
'created_by' => 'ИД создателя',
'created_at' => 'Дата создания',
'updated_by' => 'ИД редактировавшего',
<?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;
+ }
}
--- /dev/null
+<?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>
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;
<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;">
]); ?>
<?= 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">
--- /dev/null
+<?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() ?>
<?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">
<?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">
--- /dev/null
+/* 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; }
--- /dev/null
+/* ============================================================
+ 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
+}
+
+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>';
+}
},
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("Ошибка: Невозможно получить данные.");
}
$('#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, 'Город');