use yii_app\records\StoreDynamic;
use yii_app\records\AssortmentLabel;
use yii_app\records\StoreType;
+use yii\web\UploadedFile;
+use yii_app\helpers\ImageHelper;
class CityStoreManagementController extends Controller
{
'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,
+ 'cameraModel' => $params?->camera_model,
+ 'cameraCount' => $params?->camera_count,
+ 'microphoneModel' => $params?->microphone_model,
+ 'routerModel' => $params?->router_model,
+ 'internetProvider'=> $params?->internet_provider,
+ 'cashRegisterInfo'=> $params?->cash_register_info,
// store_dynamic
'bushChefFloristId' => $bushChefFloristDyn?->value_int,
$allowed = [
'store_type', 'store_area', 'showcase_volume', 'freeze_area', 'freeze_volume',
'address_region', 'address_city', 'address_district', 'is_active',
+ 'camera_model', 'camera_count', 'microphone_model', 'router_model',
+ 'internet_provider', 'cash_register_info',
];
foreach ($allowed as $field) {
Yii::error('StoreDynamic (str) save error: ' . json_encode($record->getErrors()), __CLASS__);
}
}
+
+ public function actionUploadStorePhoto(): array
+ {
+ Yii::$app->response->format = Response::FORMAT_JSON;
+
+ $storeId = (int)Yii::$app->request->post('store_id');
+ $store = $storeId ? CityStore::findOne($storeId) : null;
+ if (!$store) {
+ return ['success' => false, 'message' => 'Магазин не найден'];
+ }
+
+ $file = UploadedFile::getInstanceByName('photo');
+ if (!$file || $file->error !== UPLOAD_ERR_OK) {
+ return ['success' => false, 'message' => 'Файл не выбран или ошибка загрузки'];
+ }
+
+ $ext = strtolower($file->extension);
+ if ($ext === 'jpeg') {
+ $ext = 'jpg';
+ }
+ if (!in_array($ext, ['jpg', 'png', 'webp'], true)) {
+ return ['success' => false, 'message' => 'Допустимые форматы: jpg, png, webp'];
+ }
+
+ $adminId = Yii::$app->user->id;
+ $dateDir = date('Y') . '/' . date('m') . '/' . date('d');
+ $dir = Yii::getAlias('@uploads') . "/{$adminId}/{$dateDir}/";
+
+ if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) {
+ return ['success' => false, 'message' => 'Ошибка создания директории'];
+ }
+
+ $base = 'store_' . $storeId . '_' . time();
+ $bigFile = $dir . $base . '_big.' . $ext;
+ $smFile = $dir . $base . '_sm.jpg';
+
+ $file->saveAs($bigFile);
+ ImageHelper::resizeImage($bigFile, $smFile, 300, 200, 85);
+
+ $urlDir = '/uploads/' . $adminId . '/' . $dateDir . '/';
+ $bigUrl = $urlDir . $base . '_big.' . $ext;
+ $smUrl = $urlDir . $base . '_sm.jpg';
+
+ $store->image_big = $bigUrl;
+ $store->image_sm = $smUrl;
+ if (!$store->save(false, ['image_big', 'image_sm'])) {
+ return ['success' => false, 'message' => 'Ошибка сохранения в БД'];
+ }
+
+ return ['success' => true, 'imageSm' => $smUrl, 'imageBig' => $bigUrl];
+ }
}
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+use yii\db\Migration;
+
+/**
+ * Добавляет поля оборудования в таблицу `city_store_params` (ERP-396).
+ * Все поля текстовые, nullable — заполняются вручную оператором.
+ */
+class m260603_120000_add_equipment_to_city_store_params extends Migration
+{
+ private const TABLE = 'city_store_params';
+
+ private const COLUMNS = [
+ 'camera_model' => 'varchar(150)',
+ 'camera_count' => 'smallint',
+ 'microphone_model' => 'varchar(150)',
+ 'router_model' => 'varchar(150)',
+ 'internet_provider' => 'varchar(150)',
+ 'cash_register_info' => 'varchar(200)',
+ ];
+
+ public function safeUp(): void
+ {
+ $schema = $this->db->getTableSchema(self::TABLE, true);
+ if ($schema === null) {
+ return;
+ }
+
+ foreach (self::COLUMNS as $col => $type) {
+ if ($schema->getColumn($col) === null) {
+ $this->addColumn(self::TABLE, $col, $type . ' DEFAULT NULL');
+ }
+ }
+ }
+
+ public function safeDown(): void
+ {
+ $schema = $this->db->getTableSchema(self::TABLE, true);
+ if ($schema === null) {
+ return;
+ }
+
+ foreach (array_keys(self::COLUMNS) as $col) {
+ if ($schema->getColumn($col) !== null) {
+ $this->dropColumn(self::TABLE, $col);
+ }
+ }
+ }
+}
* @property float|null $freeze_area
* @property float|null $freeze_volume
* @property string|null $matrix_type
+ * @property string|null $camera_model
+ * @property int|null $camera_count
+ * @property string|null $microphone_model
+ * @property string|null $router_model
+ * @property string|null $internet_provider
+ * @property string|null $cash_register_info
* @property int $created_by
* @property string $created_at
* @property int|null $updated_by
[['address_city', 'address_region', 'address_district', 'matrix_type'], 'string'],
[['is_active'], 'boolean'],
[['is_active'], 'default', 'value' => true],
+ [['camera_model', 'microphone_model', 'router_model', 'internet_provider'], 'string', 'max' => 150],
+ [['cash_register_info'], 'string', 'max' => 200],
+ [['camera_count'], 'integer', 'min' => 0],
];
}
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'])) . ',
+ uploadStorePhoto: ' . json_encode(Url::to(['/city-store-management/upload-store-photo'])) . ',
assortmentLabels: ' . json_encode(Url::to(['/assortment-label/index'])) . ',
};
', \yii\web\View::POS_HEAD);
<!-- Hero — краткая шапка магазина -->
<div class="csm-hero" id="heroSection">
+ <div class="csm-hero-photo" id="heroPhoto"></div>
<div class="csm-hero-info">
<div class="csm-hero-name" id="heroName"></div>
<div class="csm-hero-addr" id="heroAddr"></div>
<!-- Toast-уведомления -->
<div class="csm-toast-w" id="toastW"></div>
+<!-- Скрытый input для загрузки фото магазина -->
+<input type="file" id="photoFileInput" accept="image/jpeg,image/png,image/webp"
+ style="display:none" onchange="handlePhotoUpload(this)">
+
+<!-- Модал: просмотр фото магазина -->
+<div class="csm-photo-modal" id="photoModal" style="display:none" onclick="closePhotoModal()">
+ <button class="csm-photo-modal-close" onclick="closePhotoModal()"><i class="fas fa-times"></i></button>
+ <img id="photoModalImg" src="" alt="Фото магазина" onclick="event.stopPropagation()">
+</div>
+
<!-- Модал: выбор лейблов ассортимента -->
<div class="lbl-modal" id="labelsModal" style="display:none" onclick="closeLabelsModal()">
<div class="lbl-modal-box" onclick="event.stopPropagation()">
border: 1px solid var(--csm-bd);
border-radius: var(--csm-r);
padding: 14px 18px;
- display: flex;
- justify-content: space-between;
- align-items: flex-start;
+ display: grid;
+ grid-template-columns: 120px 1fr auto;
+ align-items: start;
gap: 16px;
margin-bottom: 0;
}
+/* Фото в hero */
+.csm-hero-photo {
+ width: 120px; height: 90px;
+ border-radius: 4px; overflow: hidden; position: relative;
+ border: 1px solid #e0e0e0; cursor: pointer; flex-shrink: 0;
+}
+.csm-hero-photo img { width: 100%; height: 100%; object-fit: cover; display: block; }
+.csm-hero-photo-btn {
+ position: absolute; bottom: 4px; right: 4px;
+ background: rgba(0,0,0,.45); color: #fff; border: none;
+ border-radius: 3px; width: 22px; height: 22px; cursor: pointer;
+ font-size: 10px; display: flex; align-items: center; justify-content: center;
+ transition: background .15s;
+}
+.csm-hero-photo-btn:hover { background: rgba(0,0,0,.7); }
+.csm-hero-photo-empty {
+ width: 100%; height: 100%;
+ background: linear-gradient(135deg,#f5f7fa 0%,#e4e9f0 100%);
+ display: flex; flex-direction: column; align-items: center;
+ justify-content: center; gap: 5px;
+ color: #bbb; font-size: 11px; cursor: pointer;
+}
+.csm-hero-photo-empty i { font-size: 22px; }
+.csm-hero-photo-empty:hover { background: linear-gradient(135deg,#eef1f6 0%,#d8dfe9 100%); }
+/* Модал фото */
+.csm-photo-modal {
+ position: fixed; inset: 0; background: rgba(0,0,0,.82);
+ z-index: 9999; display: flex; align-items: center; justify-content: center;
+}
+.csm-photo-modal img { max-width: 90vw; max-height: 88vh; object-fit: contain; border-radius: 4px; }
+.csm-photo-modal-close {
+ position: absolute; top: 16px; right: 16px;
+ background: rgba(255,255,255,.18); color: #fff; border: none;
+ border-radius: 50%; width: 36px; height: 36px; cursor: pointer;
+ font-size: 18px; line-height: 1;
+}
+.csm-photo-modal-close:hover { background: rgba(255,255,255,.32); }
+/* eq-item текстовое значение (оборудование) */
+.eq-item-val.eq-item-text { font: 400 11px/1.4 inherit; white-space: normal; word-break: break-word; }
.csm-hero-info { flex: 1; min-width: 0; }
.csm-hero-name {
font: 700 18px/1.3 inherit;
// ===== HERO =====
function renderHero() {
var s = D;
+ // Photo area
+ var photoHtml = '';
+ if (s.imageSm) {
+ photoHtml = '<img src="' + esc(s.imageSm) + '" onclick="openPhotoModal()" title="Нажмите для просмотра">'
+ + '<button class="csm-hero-photo-btn" onclick="triggerPhotoUpload(event)" title="Заменить фото"><i class="fas fa-camera"></i></button>';
+ } else {
+ photoHtml = '<div class="csm-hero-photo-empty" onclick="triggerPhotoUpload(event)">'
+ + '<i class="fas fa-image"></i><span>Фото</span></div>';
+ }
+ document.getElementById('heroPhoto').innerHTML = photoHtml;
+
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
+ 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 class="eq-divider"><i class="fas fa-microchip"></i>Техника</div>'
+ + eqText('fa-video', 'Камера', s.cameraModel ? esc(s.cameraModel) + (s.cameraCount ? ' <small>×' + s.cameraCount + '</small>' : '') : null)
+ + eqText('fa-microphone', 'Микрофон', s.microphoneModel)
+ + eqText('fa-wifi', 'Роутер', s.routerModel)
+ + eqText('fa-globe', 'Интернет', s.internetProvider)
+ + eqText('fa-cash-register', 'Касса', s.cashRegisterInfo)
+ '</div></div></div>';
document.getElementById('contentArea').innerHTML = h;
+ 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 class="col-12" style="font:600 9px/1 inherit;text-transform:uppercase;letter-spacing:.3px;color:#888;padding-bottom:2px"><i class="fas fa-microchip me-1"></i>Техника</div>'
+ + textField('col-md-5', 'fCameraModel', s.cameraModel, 'Модель камеры')
+ + '<div class="col-md-2"><div class="form-group"><label><span class="acc-b acc-O">O</span>Кол-во камер</label>'
+ + '<input type="number" min="0" step="1" class="form-control" id="fCameraCount" value="' + esc(s.cameraCount !== null && s.cameraCount !== undefined ? s.cameraCount : '') + '" onchange="markDirty()"></div></div>'
+ + textField('col-md-5', 'fMicrophoneModel', s.microphoneModel, 'Модель микрофона')
+ + textField('col-md-4', 'fRouterModel', s.routerModel, 'Модель роутера')
+ + textField('col-md-4', 'fInternetProvider', s.internetProvider, 'Интернет-провайдер')
+ + textField('col-md-4', 'fCashRegisterInfo', s.cashRegisterInfo, 'Касса / эквайринг')
+ '</div></div></div>';
// 5. Адрес
{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},
+ {k:'camera_model',v:s.cameraModel},{k:'camera_count',v:s.cameraCount},
+ {k:'microphone_model',v:s.microphoneModel},{k:'router_model',v:s.routerModel},
+ {k:'internet_provider',v:s.internetProvider},{k:'cash_register_info',v:s.cashRegisterInfo},
]},
{ title: 'city_store_params — Адрес FK', src: 'CSP', fields: [
{k:'address_region',v:s.addressRegion},{k:'address_region_name',v:s.addressRegionName},
territorial_manager: val('fTerritorialManager'),
is_active: checked('fIsActive') ? 1 : 0,
square_store: val('fSquareStore'),
+ camera_model: val('fCameraModel'),
+ camera_count: val('fCameraCount'),
+ microphone_model: val('fMicrophoneModel'),
+ router_model: val('fRouterModel'),
+ internet_provider: val('fInternetProvider'),
+ cash_register_info: val('fCashRegisterInfo'),
label_ids: (D.labelIds || []).join(','),
};
+ '</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>'
+function eqText(icon, label, value) {
+ var hasData = value !== null && value !== undefined && value !== '';
+ 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 eq-item-text">' + (hasData ? value : '—') + '</div>'
+ '</div>';
}
+
+// ===== ФОТО МАГАЗИНА =====
+function openPhotoModal() {
+ if (!D || !D.imageBig) return;
+ document.getElementById('photoModalImg').src = D.imageBig;
+ document.getElementById('photoModal').style.display = 'flex';
+}
+
+function closePhotoModal() {
+ document.getElementById('photoModal').style.display = 'none';
+ document.getElementById('photoModalImg').src = '';
+}
+
+function triggerPhotoUpload(ev) {
+ if (ev) ev.stopPropagation();
+ document.getElementById('photoFileInput').click();
+}
+
+function handlePhotoUpload(input) {
+ var file = input.files[0];
+ if (!file || !D) return;
+ input.value = '';
+
+ var ph = document.getElementById('heroPhoto');
+ ph.innerHTML = '<div class="csm-hero-photo-empty"><i class="fas fa-spinner fa-spin"></i><span>Загрузка…</span></div>';
+
+ var fd = new FormData();
+ fd.append('photo', file);
+ fd.append('store_id', D.id);
+
+ fetch(CSM_URLS.uploadStorePhoto, {
+ method: 'POST',
+ headers: { 'X-CSRF-Token': csrfToken },
+ body: fd,
+ })
+ .then(function (r) { return r.json(); })
+ .then(function (resp) {
+ if (!resp.success) { toast(resp.message || 'Ошибка загрузки фото', 'err'); renderHero(); return; }
+ D.imageSm = resp.imageSm;
+ D.imageBig = resp.imageBig;
+ renderHero();
+ // обновить поля SEO-вкладки если они отрисованы
+ var fSm = document.getElementById('fImageSm');
+ var fBig = document.getElementById('fImageBig');
+ if (fSm) fSm.value = resp.imageSm;
+ if (fBig) fBig.value = resp.imageBig;
+ toast('Фото загружено', 'ok');
+ })
+ .catch(function () { toast('Ошибка сети при загрузке фото', 'err'); renderHero(); });
+}