From 5377d4f87291b6addbdbd886734e9a6995b1fb99 Mon Sep 17 00:00:00 2001 From: fomichev Date: Tue, 2 Jun 2026 16:22:44 +0300 Subject: [PATCH] =?utf8?q?ERP-389:=20is=5Factive=20=D0=B2=20city=5Fstore?= =?utf8?q?=5Fparams=20+=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B9=20=D0=B8=D0=BD?= =?utf8?q?=D1=82=D0=B5=D1=80=D1=84=D0=B5=D0=B9=D1=81=20=D1=83=D0=BF=D1=80?= =?utf8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BC=D0=B0=D0=B3?= =?utf8?q?=D0=B0=D0=B7=D0=B8=D0=BD=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit - Разделены поля 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 --- erp24/config/web.php | 6 + .../CityStoreManagementController.php | 308 ++++++++ .../controllers/CityStoreParamsController.php | 83 ++- .../m260601_100000_add_code_to_store_type.php | 51 ++ ...000_add_is_active_to_city_store_params.php | 40 + erp24/records/CityStoreParams.php | 7 +- erp24/records/StoreDynamic.php | 6 + erp24/records/StoreType.php | 6 + erp24/services/StoreService.php | 117 ++- erp24/views/city-store-management/index.php | 123 ++++ erp24/views/city-store-params/index.php | 38 +- erp24/views/layouts/store-management.php | 24 + erp24/views/store-type/create.php | 2 + erp24/views/store-type/update.php | 2 + erp24/web/css/city-store-management.css | 331 +++++++++ .../city-store-management.js | 683 ++++++++++++++++++ .../js/city-store-params/city-store-params.js | 7 +- 17 files changed, 1790 insertions(+), 44 deletions(-) create mode 100644 erp24/controllers/CityStoreManagementController.php create mode 100644 erp24/migrations/m260601_100000_add_code_to_store_type.php create mode 100644 erp24/migrations/m260601_110000_add_is_active_to_city_store_params.php create mode 100644 erp24/views/city-store-management/index.php create mode 100644 erp24/views/layouts/store-management.php create mode 100644 erp24/web/css/city-store-management.css create mode 100644 erp24/web/js/city-store-management/city-store-management.js diff --git a/erp24/config/web.php b/erp24/config/web.php index 303d94c2..6e389cf7 100644 --- a/erp24/config/web.php +++ b/erp24/config/web.php @@ -1,6 +1,7 @@ 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 index 00000000..717cf5f7 --- /dev/null +++ b/erp24/controllers/CityStoreManagementController.php @@ -0,0 +1,308 @@ +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__); + } + } +} diff --git a/erp24/controllers/CityStoreParamsController.php b/erp24/controllers/CityStoreParamsController.php index 4f19fde2..2baddb9c 100644 --- a/erp24/controllers/CityStoreParamsController.php +++ b/erp24/controllers/CityStoreParamsController.php @@ -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 } /** - * Обновление StoreDynamic для bush_chef_florist и territorial_manager + * Записывает изменение параметра магазина в store_dynamic (закрывает активную запись, создаёт новую). */ - 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 index 00000000..8ad41da2 --- /dev/null +++ b/erp24/migrations/m260601_100000_add_code_to_store_type.php @@ -0,0 +1,51 @@ +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 index 00000000..eb76242d --- /dev/null +++ b/erp24/migrations/m260601_110000_add_is_active_to_city_store_params.php @@ -0,0 +1,40 @@ +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'); + } +} diff --git a/erp24/records/CityStoreParams.php b/erp24/records/CityStoreParams.php index d43d8a0a..8149549b 100644 --- a/erp24/records/CityStoreParams.php +++ b/erp24/records/CityStoreParams.php @@ -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']); } + } diff --git a/erp24/records/StoreDynamic.php b/erp24/records/StoreDynamic.php index ce2d9981..8689b7b8 100644 --- a/erp24/records/StoreDynamic.php +++ b/erp24/records/StoreDynamic.php @@ -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} */ diff --git a/erp24/records/StoreType.php b/erp24/records/StoreType.php index 52a21a1b..d7cd19f1 100644 --- a/erp24/records/StoreType.php +++ b/erp24/records/StoreType.php @@ -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' => 'ИД редактировавшего', diff --git a/erp24/services/StoreService.php b/erp24/services/StoreService.php index 38414e08..06c2ad7d 100755 --- a/erp24/services/StoreService.php +++ b/erp24/services/StoreService.php @@ -1,14 +1,125 @@ 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 ['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 index 00000000..736b4382 --- /dev/null +++ b/erp24/views/city-store-management/index.php @@ -0,0 +1,123 @@ +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); +?> + +
+ + +
+

Магазины сети

+ +
+ + +
+
Загрузка…
+
+
+ +
+ +
+
Активность
+
Все
+
Активные
+
Неактивные
+
+
+
+ + +
+ +

Выберите магазин из списка выше для просмотра и редактирования

+
+ + + + + + + +
+ + +
+
+
+

Карточка сотрудника

+ +
+
+
+ + +
diff --git a/erp24/views/city-store-params/index.php b/erp24/views/city-store-params/index.php index 9b3c5d73..92ae3b25 100644 --- a/erp24/views/city-store-params/index.php +++ b/erp24/views/city-store-params/index.php @@ -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'
- 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', - ]) ?> + orderBy('id')->all(); + $paramsIsActiveMap = ArrayHelper::map( + CityStoreParams::find()->select(['store_id', 'is_active'])->all(), + 'store_id', + 'is_active' + ); + ?> + 'btn btn-primary w-100 mt-2 py-1', 'id' => 'edit-button']); ?>
@@ -214,6 +223,15 @@ $this->registerJsFile('/js/city-store-params/city-store-params.js', ['position' ]); ?> 'd-block mt-2 text-decoration-none', 'target' => '_blank']); ?>
+
+ + 'is-active', + 'class' => 'form-check-input me-2', + 'value' => '1', + ]) ?> + 'form-check-label']) ?> +
diff --git a/erp24/views/layouts/store-management.php b/erp24/views/layouts/store-management.php new file mode 100644 index 00000000..68ccc6a5 --- /dev/null +++ b/erp24/views/layouts/store-management.php @@ -0,0 +1,24 @@ +beginPage(); +?> + + + + + +registerCsrfMetaTags() ?> +<?= $this->title ? \yii\helpers\Html::encode($this->title) : 'Магазины' ?> + + +head() ?> + + +beginBody() ?> + +endBody() ?> + + + +endPage() ?> diff --git a/erp24/views/store-type/create.php b/erp24/views/store-type/create.php index 64453a5d..b997b7a9 100644 --- a/erp24/views/store-type/create.php +++ b/erp24/views/store-type/create.php @@ -18,6 +18,8 @@ $this->params['breadcrumbs'][] = $this->title; field($model, 'name')->textInput(['maxlength' => true]) ?> + field($model, 'code')->textInput(['maxlength' => 50, 'placeholder' => 'например: large, flagship, kiosk']) + ->hint('Машиночитаемый slug для StoreService. Только строчные латиница, цифры, дефис. Можно оставить пустым.') ?> field($model, 'sequence_number')->textInput(['maxlength' => true, 'type' => 'number']) ?>
diff --git a/erp24/views/store-type/update.php b/erp24/views/store-type/update.php index fb4d68d0..7b75da4c 100644 --- a/erp24/views/store-type/update.php +++ b/erp24/views/store-type/update.php @@ -18,6 +18,8 @@ $this->params['breadcrumbs'][] = $this->title; field($model, 'name')->textInput(['maxlength' => true]) ?> + field($model, 'code')->textInput(['maxlength' => 50, 'placeholder' => 'например: large, flagship, kiosk']) + ->hint('Машиночитаемый slug для StoreService. Только строчные латиница, цифры, дефис. Можно оставить пустым.') ?> field($model, 'sequence_number')->textInput(['maxlength' => true, 'type' => 'number']) ?>
diff --git a/erp24/web/css/city-store-management.css b/erp24/web/css/city-store-management.css new file mode 100644 index 00000000..4b9ccc3f --- /dev/null +++ b/erp24/web/css/city-store-management.css @@ -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 index 00000000..a30e4b68 --- /dev/null +++ b/erp24/web/js/city-store-management/city-store-management.js @@ -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 = '
Ничего не найдено
'; + return; + } + + var html = '
Магазины' + filtered.length + ' из ' + STORES.length + '
'; + html += filtered.map(function (s) { + var activeLbl = s.isActive ? 'Активен' : 'Неактивен'; + var activeCls = s.isActive ? 'combo-active' : 'combo-inactive'; + var typePart = s.type ? '' + esc(s.type) + '' : ''; + return '
' + + '' + s.id + '' + + '
' + esc(s.name) + '
' + + '
' + esc(s.addr || '') + '
' + + '
' + typePart + '' + activeLbl + '
' + + '
'; + }).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 = '
'; + 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 ? ' ' + esc(s.storeTypeName) + '' : '') + + (s.isActive + ? ' Активен' + : ' Неактивен'); + document.getElementById('heroAddr').innerHTML = '' + esc(s.adress || '—'); + document.getElementById('heroMeta').innerHTML = + 'ID ' + s.id + '' + + (s.openDate ? 'Открыт ' + esc(s.openDate) + '' : '') + + (s.administratorName ? 'Адм.: ' + esc(s.administratorName) + '' : ''); + document.getElementById('heroName').style.color = ''; + document.getElementById('heroAddr').style.color = ''; +} + +// ===== TAB: КАРТОЧКА (readonly) ===== +function renderCard() { + var s = D; + var h = ''; + + h += '
' + + '

Контакты и руководство

' + + '
' + + '
' + + tile('fa-user-tie', 'Администратор', + s.administratorName + ? '' + esc(s.administratorName) + '' + + (s.administratorEmail ? '' + esc(s.administratorEmail) + '' : '') + : '—') + + tile('fa-user-graduate', 'КШФ', + s.bushChefFloristName + ? '' + esc(s.bushChefFloristName) + '' + : '—') + + tile('fa-envelope', 'Email магазина', s.email ? '' + esc(s.email) + '' : '—') + + tile('fab fa-telegram', 'Telegram чат', esc(s.tgChatId || '—')) + + '
' + + (s.url || s.gis2 || s.yamap || s.googlemap ? '
' + + (s.url ? 'Сайт' : '') + + (s.gis2 ? '2GIS' : '') + + (s.yamap ? 'Я.Карты' : '') + + (s.googlemap ? 'Google' : '') + + '
' : '') + + '
'; + + h += '
' + + '

Оснащение

' + + 'редактирование — во вкладке Операционное
' + + '
' + + 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, 'м²') + + '
Техника stub
' + + eqItemStub('fa-video', 'Камера') + + eqItemStub('fa-microphone', 'Микрофон') + + eqItemStub('fa-wifi', 'Роутер') + + eqItemStub('fa-cash-register', 'Касса') + + eqItemStub('fa-globe', 'Интернет') + + '
'; + + document.getElementById('contentArea').innerHTML = h; +} + +// ===== TAB: ОПЕРАЦИОННОЕ ===== +function renderOps() { + var s = D; + var h = ''; + + // 1. Активность (CityStoreParams.is_active) + h += '
' + + '

АктивностьПараметры

' + + '
' + + '
' + + '
' + + '' + + '
' + + '
' + + 'Изменение активности записывается в историю через store_dynamic (category=4)' + + '
'; + + // 2. Тип магазина + h += '
' + + '

Тип магазинаПараметры

' + + 'store_type · FK → StoreType
' + + '
' + + '
' + + '' + + buildSelect('fStoreType', s.storeTypeArray || {}, s.storeType, 'Не задан') + + '
' + + '
' + + (s.storeTypeName ? 'Текущий: ' + esc(s.storeTypeName) + '' : 'Не задан') + + '
'; + + // 3. Оснащение (параметры из city_store_params) + h += '
' + + '

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

' + + '
' + + 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') + + '
' + + '
' + + '
' + + 'Техника (камеры, роутеры, касса) — требует миграции БД stub
' + + '
'; + + // 4. Адрес + h += '
' + + '

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

' + + '
' + + 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') + + '
' + + '' + + buildSelect('fAddressRegion', s.regionArray || {}, s.addressRegion, 'Выберите регион') + + '
' + + '
' + + '' + + buildSelect('fAddressCity', s.cityArray || {}, s.addressCity, 'Выберите город') + + '
' + + '
' + + '' + + buildSelect('fAddressDistrict', s.districtArray || {}, s.addressDistrict, 'Выберите район') + + '
' + + '
'; + + // 5. Руководство (StoreDynamic) + h += '
' + + '

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

' + + '
' + + '
' + + '' + + buildSelect('fBushChefFlorist', s.bushChefFloristArray || {}, s.bushChefFloristId, 'Не назначен') + + (s.bushChefFloristName ? '
Текущий: ' + esc(s.bushChefFloristName) + '
' : '') + + '
' + + '
' + + '' + + buildSelect('fTerritorialManager', s.territorialManagerArray || {}, s.territorialManagerId, 'Не назначен') + + (s.territorialManagerName ? '
Текущий: ' + esc(s.territorialManagerName) + '
' : '') + + '
' + + '
'; + + // 6. Общая информация (city_store) + h += '
' + + '

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

' + + '
' + + 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') + + '
' + + '' + + '
' + + (s.visible + ? 'Виден (visible = 1)' + : 'Скрыт (visible = 0)') + + '
' + + '
Синхронизируется автоматически через поле «Активен» в city_store_params
' + + '
' + + '
'; + + 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 += '
' + + '

Превью в поиске

' + + '
' + + '
Google snippet
' + + '
' + + '
erp24.ru' + esc(s.url || '') + '
' + + '
' + esc(s.seoTitle || '') + '
' + + '
' + esc(s.seoDescription || '') + '
' + + '
'; + + h += '
' + + '

ПубликацияМаркетинг

' + + '
' + + 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') + + '
'; + + h += '
' + + '

SEO мета

' + + '
' + + '
' + + '' + + '
' + (s.seoTitle ? s.seoTitle.length : 0) + '/60
' + + '
' + + '' + + '
' + (s.seoDescription ? s.seoDescription.length : 0) + '/160
' + + '
' + + '
' + + '
'; + + h += '
' + + '

Контент и изображения

' + + '
' + + '
' + + '
' + + '
' + + 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') + + '
'; + + h += '
' + + '

Карточки на картах

' + + '
' + + 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') + + '
' + + '' + + '' + + '
' + + '
'; + + 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 += '
' + + '
Сервисная вкладка — для IT-отдела. Просмотр всех полей из обеих таблиц.
'; + + 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 += '
' + + '' + + '
'; + + svcGroups.forEach(function (g) { + h += '
' + g.title + + '' + g.fields.length + ' полей
'; + g.fields.forEach(function (f) { + var val = (f.v === null || f.v === undefined || f.v === '') ? 'null' : esc(String(f.v)); + var srcCls = g.src === 'CS' ? 'src-cs' : g.src === 'CSP' ? 'src-csp' : 'src-sd'; + h += '
' + + '' + f.k + '' + + '' + g.src + '' + + '' + val + '' + + '
'; + }); + h += '
'; + }); + + 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', ''); + } +} + +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 = + '
' + av + '
' + + '
' + esc(emp.name) + '
' + + (emp.email ? '
Контакты
' : ''); + 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 = '' + 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,'"'); +} + +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 = ''; + return html; +} + +function textField(colCls, id, value, label, src) { + return '
' + + '' + + '' + + '
'; +} + +function numField(colCls, id, value, label) { + return '
' + + '' + + '' + + '
'; +} + +function tile(icon, label, content) { + return '
' + esc(label) + '
' + + '
' + content + '
'; +} + +function eqItem(icon, label, value, unit) { + var hasData = value !== null && value !== undefined; + return '
' + + '
' + esc(label) + '
' + + '
' + (hasData ? value + '' + unit + '' : '—') + '
' + + '
'; +} + +function eqItemStub(icon, label) { + return '
' + + '
' + esc(label) + ' stub
' + + '
—
' + + '
'; +} diff --git a/erp24/web/js/city-store-params/city-store-params.js b/erp24/web/js/city-store-params/city-store-params.js index a4d2bd1c..02ab430f 100644 --- a/erp24/web/js/city-store-params/city-store-params.js +++ b/erp24/web/js/city-store-params/city-store-params.js @@ -15,8 +15,10 @@ function sendData() { }, success: function (response) { if (response?.success) { - $('#selected-store').html(Object.entries(response.data).map(([key, value]) => - ``).join('')); + $('#selected-store').html(Object.entries(response.data).map(([key, value]) => { + var style = value.isActive ? '' : ' style="background-color: #ffe0b2;"'; + return ``; + }).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, 'Город'); -- 2.39.5