# Etapa 0 — Pattern-ul de administrare a conținutului din `ecaterina`

**Sursă analizată:** `C:\OpenServer\domains\localhost\ecaterina` (Yii2 2.0.45, template *basic*, DB `eca`)
**Document normativ:** `C:\OpenServer\domains\localhost\YII2-ARHITECTURA-ECATERINA.md`
**Scop:** replicarea mecanismului în `C:\OpenServer\domains\localhost\cinova`.

> Ordinea de prioritate impusă de client: (1) documentul `.md`, (2) codul real din ecaterina,
> (3) preferințe proprii. Acest raport marchează explicit locurile unde codul real **deviază**
> de la document — în acele locuri se urmează documentul.

> **Regula de aur (din .md, rândurile 12-14):** din ecaterina se copiază **structura**,
> niciodată numele de coloane. `alias_mini_string`, `price_from` etc. există doar acolo.
> cinova are deja convenția proprie, cu sufixele eliminate (`alias`, `slug`, `name`, `seo_title`).

---

## 1. ADMIN — unde e, cum e organizat, layout, meniu, login, acces

### 1.1 Amplasare

Admin-ul e **modul în aceeași aplicație**, nu aplicație separată.
Înregistrat în `ecaterina/config/web.php:213-221`:

```php
'modules' => [
    'admin' => [
        'layout' => 'admin',
        'class'  => 'app\modules\admin\Module',
    ],
    'editmode' => [
        'layout' => 'editmode',
        'class'  => 'app\modules\editmode\Module',
    ],
],
```

Structura modulului:

```
modules/admin/
    Module.php
    controllers/
        base/BaseAdminCrudController.php        <- abstract, contractul + logica de fișiere
        traits/MainCrudTrait.php                <- index/search/view/create/update/translations/delete
        traits/HasGalleryTrait.php              <- sub-CRUD galerie
        traits/HasFaqsTrait.php                 <- sub-CRUD FAQ
        <Entitate>Controller.php                <- ~40 linii, doar configurație
    components/                                 <- widget-uri admin + components/views/
        AsideWidget, HeaderWidget, ListViewWidget, DetailViewWidget, PaginationWidget,
        BreadcrumbsWidget, ActionsButtonsWidget, ConfirmModalWidget, StatusBadgeWidget,
        StatisticCardWidget, ChartWidget, ActivityHistoryWidget, CalendarPreviewWidget,
        QuickActionsWidget, RecentRecordsWidget
    views/<entitate>/{index,table_data,create,update,view,_form,_search_form}.php
                     + translations/{index,table_data,view,update,_form}.php
                     + gallery/{...}, faqs/{...}
    views/layouts/admin.php
    messages/{ro,en,ru}/{buttons,labels,flash,status,errors,menu,headings,list-table,transaltion,history,dashboard}.php
    web/{css,js}                                <- publicate prin AdminAsset
```

### 1.2 Module.php — acces + limbă

`ecaterina/modules/admin/Module.php:17-39`:

```php
public function behaviors()
{
    return [
        'access' => [
            'class' => \yii\filters\AccessControl::class,
            'rules' => [
                ['allow' => true, 'roles' => ['admin', 'contentManager', 'superAdmin', 'dev']],
            ],
        ],
    ];
}

public function init()
{
    Yii::$app->language = 'ro_RO';   // adminul e monolingv, indiferent de limba site-ului
    parent::init();
}
```

**Atenție:** protecția stă pe **Module**, nu pe controllere. `BaseAdminCrudController` NU are
behavior `access` — doar `verbs`. Deci e suficient un singur punct de control.

⚠️ Rolul `superAdmin` e referit în 4 locuri dar **nu există** în `auth_item`. Inofensiv, dar de
nu-l copia.

### 1.3 RBAC

Migrarea `ecaterina/migrations/m260429_180501_create_rbac_data.php:16-30`:

```php
$dev            = $auth->createRole('dev');
$admin          = $auth->createRole('admin');
$contentManager = $auth->createRole('contentManager');
$auth->addChild($admin, $contentManager);
$auth->addChild($dev, $admin);
```

Ierarhia: `dev → admin → contentManager`. `authManager` = `yii\rbac\DbManager` (`config/web.php`).
Tabelele RBAC vin din migrările standard Yii (`m140506_102106_rbac_init` etc.).

> **De verificat înainte de activare în cinova:** trebuie să existe cel puțin un rând în
> `auth_assignment`, altfel te încui pe dinafară (avertisment explicit în `.md` §14).

### 1.4 Login

Login-ul e **pe frontend**, nu în modulul admin.

`ecaterina/config/web.php:73-77`:

```php
'user' => [
    'identityClass'   => 'app\models\users\Users',
    'loginUrl'        => '/auth/login',
    'enableAutoLogin' => true,
],
```

`ecaterina/controllers/AuthController.php` — `actionLogin`, `actionLogout`, `actionConfirmEmail`,
`actionSendPasswordReset`, `actionResetPassword`. Form objects în `models/users/forms/`:
`LoginForm`, `RegisterForm`, `SendPasswordRequestForm`, `ResetPasswordForm`, `ActivatePasswordForm`
— toate `extends Model`.

`ignoreLanguageUrlPatterns` scoate `/auth/` și `/editmode/` din prefixele de limbă
(`config/web.php:136-140`).

### 1.5 Layout admin

`ecaterina/modules/admin/views/layouts/admin.php` — 78 de linii:

```php
use app\assets\AdminAsset;
use app\modules\admin\components\AsideWidget as Aside;
use app\modules\admin\components\HeaderWidget as Header;

AdminAsset::register($this);
$this->registerCsrfMetaTags();
// meta charset / viewport / description / keywords / favicon
?>
<body>
  <div class="wrapper d-flex flex-row">
    <?= Aside::widget() ?>
    <div class="main-content d-flex flex-column">
      <?= Header::widget() ?>
      <main id="app-content"><?= $content ?></main>
    </div>
  </div>
  <!-- .modal-photo-wrapper  (lightbox pentru imaginile din liste) -->
  <!-- .modal-error-wrapper  (modal de eroare pentru AJAX) -->
</body>
```

### 1.6 AdminAsset — cu `sourcePath`

`ecaterina/assets/AdminAsset.php`:

```php
class AdminAsset extends AssetBundle
{
    public $sourcePath = '@app/modules/admin/web';
    public $publishOptions = ['forceCopy' => true];

    public $css = [
        'https://fonts.googleapis.com/css2?family=Montserrat:...',
        'https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:...',
        'https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css',
        'https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css',
        'https://cdn.jsdelivr.net/npm/suneditor@latest/dist/css/suneditor.min.css',
        'https://cdn.jsdelivr.net/npm/swiper@8/swiper-bundle.min.css',
        'https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css',
        'https://cdn.jsdelivr.net/npm/choices.js/public/assets/styles/choices.min.css',
        'css/style.css',
    ];
    public $js = [ /* bootstrap, chart.js, swiper, suneditor, flatpickr, choices, sweetalert2 */
                   'js/ajax.js', 'js/global.js', 'js/main.js' ];
    public $depends = ['yii\web\YiiAsset'];
}
```

Diferența față de `AppAsset` (frontend): frontend-ul **nu** are `sourcePath`, fișierele stau în
`web/core/**` și se referă relativ la `@web`, nimic nu se publică.

⚠️ SunEditor e încărcat în AdminAsset dar **nu e folosit în niciun view**. WYSIWYG-ul real e
**CKEditor** (vezi §4.3). Nu copia SunEditor în cinova.

### 1.7 Meniul lateral

`ecaterina/modules/admin/components/views/aside.php`. Nu e configurație, e HTML scris de mână,
grupat pe secțiuni, cu evidențierea activului pe `controller->id`:

```php
$currentController = Yii::$app->controller->id;
$isActiveController = static function (array $controllers) use ($currentController): string {
    return in_array($currentController, $controllers, true) ? 'active' : '';
};
?>
<aside class="admin-aside">
  <div class="admin-aside-brand">...</div>
  <nav class="admin-aside-nav">
    <div class="admin-aside-section">                    <!-- DASHBOARD -->
      <ul><li class="<?= $isActiveController(['dashboard']) ?>">
        <a class="aside" href="<?= Url::to(['/admin/dashboard/index']); ?>">
          <span class="material-symbols-outlined">speed</span>
          <span><?= Yii::t('admin/menu', 'Dashboard') ?></span>
        </a>
      </li></ul>
    </div>
    <div class="admin-aside-section">                    <!-- CONTENT -->
      <div class="admin-aside-section-title"><?= Yii::t('admin/menu', 'Content') ?></div>
      <ul>
        <li class="<?= $isActiveController(['portfolio']) ?>">
          <a class="aside" href="<?= Url::to(['/admin/portfolio']); ?>">
            <span class="material-symbols-outlined">work</span>
            <span><?= Yii::t('admin/menu', 'Portfolio') ?></span>
          </a>
        </li>
        <!-- blog / rooms / packages / events / ... -->
      </ul>
    </div>
  </nav>
</aside>
```

Iconițele sunt **Material Symbols Outlined** (text în interiorul span-ului).
**Adăugarea unei entități noi = un `<li>` nou aici.** Pasul 8 din checklist-ul `.md` §12.

---

## 2. CONTROLLERE ADMIN

### 2.1 Contractul — `BaseAdminCrudController`

`ecaterina/modules/admin/controllers/base/BaseAdminCrudController.php` (190 linii, abstract).

Metode abstracte (obligatorii în fiecare controller concret):

```php
abstract protected function modelClass(): string;
abstract protected function formClass(): string;
abstract protected function searchClass(): string;
abstract protected function entityLabel(): string;
abstract protected function entitySingleLabel(): string;
abstract protected function imageFields(): array;
abstract protected function filePath(): string;
```

Metode opționale (implicit `null`, se suprascriu doar dacă entitatea le are):

```php
protected function translationClass(): ?string          { return null; }
protected function translationFormClass(): ?string      { return null; }
protected function galleryModelClass(): ?string         { return null; }
protected function galleryFormClass(): ?string          { return null; }
protected function faqModelClass(): ?string             { return null; }
protected function faqFormClass(): ?string              { return null; }
protected function faqTranslationModelClass(): ?string  { return null; }
protected function faqTranslationFormClass(): ?string   { return null; }
protected function parentForeignKey(): string           { return 'service_id'; }
protected function parentRelationName(): string         { return 'service'; }
```

Behaviors — **doar verbs, fără access** (accesul e pe Module), liniile 35-50:

```php
public function behaviors(): array
{
    return array_merge(parent::behaviors(), [
        'verbs' => [
            'class' => VerbFilter::class,
            'actions' => [
                'delete' => ['POST'], 'delete-row' => ['POST'],
                'faq-delete' => ['POST'], 'faq-delete-row' => ['POST'],
                'gallery-delete' => ['POST'], 'gallery-delete-row' => ['POST'],
            ],
        ],
    ]);
}
```

Alte metode utile din bază:

```php
protected function setPage(string $title, array $breadcrumbs = [], bool $includeBaseEntity = true): void
protected function prepareUploadedFiles(FileHelper $fileUpload, object $form): void
protected function restoreTempMarkers(object $form, array $postTemp): array
protected function savePendingToTemp(object $form, array $existingTemps): array
protected function processAllUploads(object $form, array &$temps, ?object $oldModel = null): void
protected function deleteModelFiles(object $model): void
protected function findModel($id)                 // NotFoundHttpException dacă lipsește
protected function findLanguage($id): Languages   // acceptă ACTIVE + DRAFT
```

`findModel()`, liniile 173-179:

```php
protected function findModel($id)
{
    $class = $this->modelClass();
    $model = $class::findOne(['id' => $id]);
    if ($model !== null) return $model;
    throw new NotFoundHttpException(Yii::t('admin/errors', 'not_found'));
}
```

### 2.2 Controller concret — exemplu integral

`ecaterina/modules/admin/controllers/RoomsController.php` (liniile 18-32 + restul):

```php
class RoomsController extends BaseAdminCrudController
{
    use MainCrudTrait;
    use HasGalleryTrait;

    public string $baseEntity        = EntityConstants::ROOMS;
    public string $translationEntity = EntityConstants::ROOMS_TRANSLATIONS;

    protected function modelClass(): string           { return Rooms::class; }
    protected function formClass(): string            { return RoomsForm::class; }
    protected function searchClass(): string          { return RoomsSearch::class; }
    protected function translationClass(): string     { return RoomsTranslations::class; }
    protected function translationFormClass(): string { return RoomsTranslationsForm::class; }
    protected function galleryModelClass(): string    { return RoomsGallery::class; }
    protected function galleryFormClass(): string     { return RoomsGalleryForm::class; }

    protected function entityLabel(): string       { return Yii::t('admin/menu', 'Rooms'); }
    protected function entitySingleLabel(): string { return Yii::t('admin/menu', 'Rooms_single'); }

    protected function imageFields(): array { return ['main_image', 'bg_image', 'seo_og_image']; }
    protected function filePath(): string   { return Rooms::FILE_PATH; }

    protected function parentForeignKey(): string   { return 'room_id'; }
    protected function parentRelationName(): string { return 'room'; }
}
```

Alte controllere identice ca formă: `BlogController` (fără galerie), `EventsController`,
`PackagesController`, `PortfolioController` (cu galerie), `GastronomyController`, `TeamController`,
`FaqController`, `MainBannerController`, `MainGalleryController`, `GeneralEventsGalleryController`.

### 2.3 Acțiunile — `MainCrudTrait`

`ecaterina/modules/admin/controllers/traits/MainCrudTrait.php` (296 linii).
Acțiuni oferite: `index`, `search`, `view`, `create`, `update`, `translations`,
`translation-view`, `translation-update`, `delete`, `delete-row`.

**Index cu ramură AJAX** (liniile 13-48) — aceeași metodă servește pagina și refresh-ul de tabel:

```php
private function renderIndex()
{
    $searchClass = $this->searchClass();
    $searchModel = new $searchClass();

    $title = Yii::t('admin/headings', 'index', ['model' => $this->entityLabel()]);
    $this->setPage($title, [['label' => $title]], false);

    $dataProvider = new DataProviderHelper([
        'query' => $searchModel->search($this->request->queryParams),
        'limit' => 20,
    ]);

    if (Yii::$app->request->isAjax) {
        $this->layout = false;
        return $this->render('table_data', ['dataProvider' => $dataProvider, 'search' => $searchModel]);
    }

    return $this->render('index', [
        'search'       => $searchModel,
        'dataProvider' => $dataProvider,
        'searchAction' => '/admin/' . $this->baseEntity . '/search',
    ]);
}

public function actionIndex()  { return $this->renderIndex(); }
public function actionSearch() { return $this->renderIndex(); }
```

**Create** (liniile 58-97) — ordinea de upload e obligatorie:

```php
public function actionCreate()
{
    $modelClass = $this->modelClass();
    $formClass  = $this->formClass();

    $model = new $modelClass();
    $form  = new $formClass($model);
    $form->scenario = 'create';
    $tempFiles = [];

    $title = Yii::t('admin/headings', 'create', ['model' => $this->entitySingleLabel()]);
    $this->setPage($title, [['label' => $title]]);

    if ($this->request->isPost) {
        $form->load($this->request->post());
        $fileUpload = new FileHelper();
        $this->prepareUploadedFiles($fileUpload, $form);                     // 1
        $postTemp  = $this->request->post('temp_files', []);
        $tempFiles = $this->restoreTempMarkers($form, $postTemp);            // 2 (DUPĂ 1)

        if ($form->validate()) {
            $this->processAllUploads($form, $tempFiles);                     // 3a
            $record = $form->saveRecord();
            if ($record) {
                Yii::$app->session->setFlash('success-create', Yii::t('admin/flash', 'created'));
                return $this->redirect(['view', 'id' => $record->id]);
            }
        }

        $tempFiles = $this->savePendingToTemp($form, $tempFiles);            // 3b
        Yii::$app->session->setFlash('error', Yii::t('admin/flash', 'error'));
    }

    return $this->render('create', [
        'model' => $model, 'form' => $form, 'addForm' => $form, 'tempFiles' => $tempFiles,
    ]);
}
```

`actionUpdate($id)` e identic, cu trei diferențe: `$oldModel = clone $model;`,
`$form->scenario = 'update';`, și `processAllUploads($form, $tempFiles, $oldModel)`.

**Traducerile** (liniile 143-222) — o limbă pe pagină, **fără tab-uri, fără `loadMultiple()`**:

```php
public function actionTranslations($id)                          // listă de LIMBI cu badge
public function actionTranslationView($lang_id, $entity_id)
public function actionTranslationUpdate($lang_id, $entity_id)
```

În `actionTranslationUpdate`, `null` de la `saveRecord()` înseamnă „nimic de schimbat" = succes:

```php
$record = $form->saveRecord(['entity_id' => $entity_id, 'language_id' => $lang_id]);
if ($record !== false) {                                          // NU `if ($record)`
    Yii::$app->session->setFlash('success-create', Yii::t('admin/flash', 'updated'));
    return $this->redirect(['translation-view', 'lang_id' => $lang_id, 'entity_id' => $entity_id]);
}
```

**Ștergerea — singura acțiune tranzacțională** (liniile 224-283):

```php
public function actionDelete($id)
{
    if ($this->deleteItem($id)) {
        Yii::$app->session->setFlash('success-create', Yii::t('admin/flash', 'deleted'));
    } else {
        Yii::$app->session->setFlash('error', Yii::t('admin/flash', 'error'));
    }
    return $this->redirect(['index']);
}

public function actionDeleteRow($id): array          // varianta AJAX
{
    Yii::$app->response->format = Response::FORMAT_JSON;
    return ['success' => $this->deleteItem($id)];
}

protected function deleteItem($id): bool
{
    $model = $this->findModel($id);
    $transaction = Yii::$app->db->beginTransaction();
    $galleryFiles = [];
    try {
        if ($this->translationClass() !== null) {
            foreach ($model->translations as $translation) {
                if (!$translation->delete()) throw new \RuntimeException('Could not delete translation.');
            }
        }
        if ($this->faqModelClass() !== null) { /* ... */ }
        if ($this->galleryModelClass() !== null) {
            $galleryClass = $this->galleryModelClass();
            $items = $galleryClass::find()->where([$this->parentForeignKey() => $model->id])->all();
            foreach ($items as $galleryItem) {
                $galleryFiles[] = ['file' => $galleryItem->image, 'filePath' => $galleryClass::FILE_PATH];
                if (!$galleryItem->delete()) throw new \RuntimeException('Could not delete gallery item.');
            }
        }
        if (!$model->delete()) throw new \RuntimeException('Could not delete item.');

        $transaction->commit();

        $this->deleteModelFiles($model);                       // fișierele DUPĂ commit
        if ($galleryFiles) (new FileHelper())->fileDestroy($galleryFiles);
        return true;
    } catch (\Throwable $e) {
        $transaction->rollBack();
        Yii::error($e->getMessage(), __METHOD__);
        return false;
    }
}
```

⚠️ **Ștergerea e FIZICĂ** (`$model->delete()`), deși modelele definesc `STATUS_DELETED` și
search-urile îl exclud. `setStatusToDeleted()` **nu e apelat niciodată** în tot proiectul.
Jumătate din infrastructura de soft-delete e cod mort. **De decis explicit în cinova.**

### 2.4 Flash-uri — doar două chei

| Cheie de sesiune | Când | Mesaj |
|---|---|---|
| `success-create` | create / update / delete reușit | `Yii::t('admin/flash', 'created'\|'updated'\|'deleted')` |
| `error` | validare eșuată sau salvare eșuată | `Yii::t('admin/flash', 'error')` |
| `not-updated` | folosit doar în view-urile de traducere | — |

### 2.5 Search models

`ecaterina/models/rooms/search/RoomsSearch.php` — extind AR-ul, `formName(): ''` (parametrii GET
nu sunt prefixați: `?status=ACTIVE`), și **întorc query, nu DataProvider**:

```php
class RoomsSearch extends Rooms
{
    public $alias_mini_string, $status, $sort = 'latest';

    public function formName(): string { return ''; }

    public function search($params)
    {
        $query = Rooms::find()->where(['<>', 'status', Rooms::STATUS_DELETED]);
        $this->load($params);
        if (!$this->validate()) return $query->orderBy(['updated_at' => SORT_DESC]);

        $query->andFilterWhere(['like', 'alias_mini_string', $this->alias_mini_string]);
        $query->andFilterWhere(['status' => $this->status]);
        $query->orderBy(['updated_at' => $this->sort === 'oldest' ? SORT_ASC : SORT_DESC]);
        return $query;
    }

    public static function optsSort(): array { return ['latest' => '...', 'oldest' => '...']; }
}
```

### 2.6 Paginare — `DataProviderHelper`

`ecaterina/helpers/DataProviderHelper.php`. **NU** e `yii\data\ActiveDataProvider`.
Constructor: `new DataProviderHelper(['query' => $query, 'limit' => 20])`.
Citește `?page=` și `?limit=` din query string, expune `->data`, `->dataCountAll`,
`->dataCountPage`, `->pagination`, `->model`. Redirecționează când pagina e în afara intervalului.

---

## 3. MODELE

### 3.1 ActiveRecord de entitate

`ecaterina/models/rooms/Rooms.php`:

```php
class Rooms extends \yii\db\ActiveRecord
{
    public $translationClass = RoomsTranslations::class;   // citit de trait
    use TranslationTrait;

    const STATUS_ACTIVE = 'ACTIVE';
    const STATUS_DELETED = 'DELETED';
    const STATUS_HIDDEN = 'HIDDEN';
    const STATUS_DRAFT = 'DRAFT';
    const STATUS_INACTIVE = 'INACTIVE';
    const FILE_PATH = 'core/uploads/images/rooms/';        // models/rooms/Rooms.php:42

    public function behaviors(): array
    {
        return [
            'user' => [
                'class' => BlameableBehavior::class,
                'createdByAttribute' => 'created_by',
                'updatedByAttribute' => 'updated_by',
            ],
            'timestamp' => [
                'class' => TimestampBehavior::class,
                'createdAtAttribute' => 'created_at',
                'updatedAtAttribute' => 'updated_at',
                'value' => date('Y-m-d H:i:s'),            // DATETIME string, nu unix timestamp
            ],
        ];
    }

    // Getteri virtuali — baza CRUD generică spune $model->alias / $model->slug
    // indiferent de numele real al coloanei
    public function getAlias(): ?string { return $this->alias_mini_string; }
    public function getSlug(): ?string  { return $this->slug_big_string; }
    public function getUrl(): string    { return UrlHelper::entityUrl('room', $this->id, $this->slug); }

    public static function optsStatus(): array       { /* toate */ }
    public static function optsStatusCreate(): array { return [self::STATUS_INACTIVE => self::STATUS_INACTIVE]; }
    public static function optsStatusUpdate(): array { /* ACTIVE|HIDDEN|DRAFT|INACTIVE */ }

    public function isStatusActive(): bool    { return $this->status === self::STATUS_ACTIVE; }
    public function setStatusToActive(): void { $this->status = self::STATUS_ACTIVE; }
    // câte o pereche is*/setTo* per status
}
```

**Getterii virtuali `getAlias()`/`getSlug()` sunt obligatorii** — `MainCrudTrait` îi folosește
în titluri și breadcrumbs (`$model->alias`, `$model->slug`) fără să știe numele coloanei.

**Regula de publicare:** o înregistrare nouă se creează întotdeauna `INACTIVE`; `ACTIVE` e
accesibil doar la *update* și doar dacă toate limbile active au traducere.

### 3.2 Form objects — regula centrală

**Modelul AR nu e populat niciodată direct din POST.** Fiecare acțiune trece printr-un obiect
formular care înfășoară AR-ul.

Varianta corectă (36 din 40 de form-uri din ecaterina): **`extends Model`**.
`ecaterina/models/rooms/forms/RoomsForm.php` (integral, 140 linii):

```php
class RoomsForm extends Model
{
    private Rooms $model;

    public $alias_mini_string, $notice_big_string, $main_image, $bg_image, $seo_og_image,
           $slug_big_string, $price_from, $min_capacity, $max_capacity, $area_size, $status;

    const FILE_PATH = 'core/uploads/images/rooms/';

    public function __construct(Rooms $model, $config = [])
    {
        $this->model = $model;
        parent::__construct($config);
        foreach ($this->formAttributes() as $attribute) {
            $this->{$attribute} = $model->{$attribute};
        }
    }

    private function formAttributes(): array
    {
        return ['alias_mini_string', 'notice_big_string', 'main_image', 'bg_image',
                'seo_og_image', 'slug_big_string', 'price_from', 'min_capacity',
                'max_capacity', 'area_size', 'status'];
    }

    public function rules(): array
    {
        return [
            [['alias_mini_string', 'slug_big_string'], 'required'],
            [['alias_mini_string', 'notice_big_string', 'slug_big_string'], 'trim'],
            [['alias_mini_string', 'slug_big_string'], 'string', 'min' => 2, 'max' => 255],
            [['notice_big_string'], 'string', 'max' => 500],
            [['price_from', 'area_size'], 'number', 'min' => 0],
            [['min_capacity', 'max_capacity'], 'integer', 'min' => 0],

            [['main_image', 'bg_image', 'seo_og_image'], 'file',
                'extensions' => ['jpg','jpeg','png','gif','bmp','svg','webp','avif'],
                'mimeTypes'  => ['image/jpeg','image/png','image/gif','image/bmp',
                                 'image/svg+xml','image/webp','image/avif'],
                'maxSize'    => 1024 * 1024 * 2,
                'skipOnEmpty' => true],
            [['main_image', 'bg_image'], 'required', 'on' => 'create'],

            ['status', 'default', 'value' => Rooms::STATUS_INACTIVE, 'on' => 'create'],
            ['status', 'required', 'on' => 'update'],
            ['status', 'in', 'range' => array_keys(Rooms::optsStatusCreate()), 'on' => 'create'],
            ['status', 'in', 'range' => array_keys(Rooms::optsStatusUpdate()), 'on' => 'update'],
            ['status', ActiveTranslationValidator::class, 'on' => 'update',
                'activeStatus'     => Rooms::STATUS_ACTIVE,
                'translationClass' => RoomsTranslations::class],
        ];
    }

    public function attributeLabels(): array { /* Yii::t('admin/labels', '...') per câmp */ }

    public function beforeValidate(): bool
    {
        if (!parent::beforeValidate()) return false;
        $this->alias_mini_string = trim(strip_tags((string) $this->alias_mini_string));
        $this->notice_big_string = trim(strip_tags((string) $this->notice_big_string));
        $this->slug_big_string   = trim(strip_tags((string) $this->slug_big_string));
        return true;
    }

    public function saveRecord(): Rooms|false
    {
        foreach ($this->formAttributes() as $attribute) {
            $this->model->{$attribute} = $this->{$attribute};
        }
        return $this->model->save(false) ? $this->model : false;   // form-ul a validat deja
    }

    public function getModel(): Rooms { return $this->model; }      // cerut de ActiveTranslationValidator
}
```

> ⚠️ **CRITIC — câmp în formular fără regulă = nu se salvează niciodată.**
> `Model::load()` atribuie doar atributele **safe**, iar un atribut e safe doar dacă apare
> într-o regulă de validare. O proprietate publică fără nicio regulă nu primește niciodată
> valoarea din POST. Verificare obligatorie: fiecare câmp randat în `_form.php` trebuie să fie
> (a) proprietate publică, (b) în `formAttributes()`, (c) acoperit de o regulă.
> Am auditat automat toate cele 32 de `_form.php` din ecaterina: 0 câmpuri fără proprietate,
> 0 câmpuri fără regulă. Pattern-ul e curat — de respectat.

**Varianta B — de NU copiat.** 4 form-uri din ecaterina folosesc `extends <AR>` + `$__model` +
`ModelHelper` prin reflecție: `PagesForm`, `PagesTranslationsForm`, `LanguagesForm`,
`UpdateUserForm`. Documentul `.md` §7 spune explicit „nu amesteca cele două". Varianta B a produs
în ecaterina două bug-uri reale:

* `models/pages/forms/PagesTranslationsForm.php:62` generează `max => 255` pentru toate câmpurile
  `*_big_string`, dar coloana `pages_translations.seo_description_big_string` e `varchar(160)`
  și regula AR impune 160. Cum `saveRecord()` (linia 120) apelează `$this->__model->save()` **cu**
  validare, un text de 161-255 caractere trece de formular, pică pe AR, și utilizatorul vede doar
  flash-ul generic „error", **fără niciun mesaj pe câmp** (erorile sunt pe `__model`, nu pe form).
* `linia 58`: `[$stringAll, 'required']` face **toate** cele 6 câmpuri SEO obligatorii.

### 3.3 Formularul de traducere

`ecaterina/models/rooms/forms/RoomsTranslationsForm.php` — identic ca formă, cu `saveRecord(array $data)`:

```php
public function saveRecord(array $data): RoomsTranslations|null|false
{
    if (!$this->validate()) return false;

    if ($this->model->isNewRecord) {
        $this->model->entity_id   = $data['entity_id'] ?? null;
        $this->model->language_id = $data['language_id'] ?? null;
    }
    foreach ($this->formAttributes() as $attribute) {
        $this->model->{$attribute} = $this->{$attribute};
    }
    if (empty($this->model->getDirtyAttributes())) return null;    // „fără modificări" = succes
    return $this->model->save(false) ? $this->model : false;
}

public function getModel(): RoomsTranslations { return $this->model; }
```

`beforeValidate()` trece toate câmpurile prin `trim(strip_tags(...))` **cu excepția**
`content_text` (câmpul CKEditor, care trebuie să rămână HTML).

### 3.4 Blocarea publicării fără traduceri

`ecaterina/validators/ActiveTranslationValidator.php`, atașat pe `status`, scenariul `update`:

```php
class ActiveTranslationValidator extends Validator
{
    public string $activeStatus;
    public string $translationClass;
    public string $entityIdAttribute = 'id';
    public string $translationEntityAttribute = 'entity_id';
    public string $translationLanguageAttribute = 'language_id';

    public function validateAttribute($model, $attribute): void
    {
        if ($model->{$attribute} !== $this->activeStatus) return;
        if (!method_exists($model, 'getModel')) { /* eroare */ return; }

        $entityModel = $model->getModel();
        if ($entityModel->isNewRecord) {
            $this->addError($model, $attribute, Yii::t('admin/errors', 'save_before_activate'));
            return;
        }
        // compară limbile ACTIVE cu traducerile existente, adună lipsurile,
        // raportează Yii::t('admin/errors', 'translation_activate', ['list' => '...'])
    }
}
```

### 3.5 Upload de imagini

`ecaterina/helpers/FileHelper.php`. Două căi: upload direct și **supraviețuirea validării eșuate**.

API complet:

```php
public static function saveTempUpload(UploadedFile $file): ?string
public static function tempExists(string $name): bool          // gardă anti-path-traversal
public static function moveTempToFinal(string $tempName, string $filePath): ?string
public static function deleteTempFile(string $name): void
public static function tempFileUrl(string $name): string
public function getInstance($files): void                       // UploadedFile::getInstance
public function getInstances($files): void
public function uploadFile($uploadedFiles, $deletePreviousFile = true): void
public function fileDestroy($uploadedFiles): void
public static function getFile(array $options): string          // ['file' => ..., 'filePath' => ...]
```

Detalii:
* `const TEMP_PATH = 'core/uploads/temp/';`
* numele final e `md5(uniqid()) . '.' . extensie` (`FileHelper.php:103`)
* în DB se ține **doar numele fișierului**; calea vine din `<Model>::FILE_PATH`
* `getFile()` întoarce `/core/images/no-file.png` când fișierul lipsește
* `tempExists()` validează cu `/^tmp_[a-f0-9]+\.[a-z0-9]+$/i`
* directoarele se creează automat (`is_dir()` + `mkdir($dir, 0777, true)`)

În `_form.php`, fiecare input de fișier are un hidden care cară numele temporar:

```php
<?= Html::hiddenInput('temp_files[' . $attribute . ']', $tempFile ?? '') ?>
```

> ⚠️ **BUG DE NU COPIAT.** `FileHelper` folosește `Yii::getAlias('@web')` — care e un alias de
> **URL** — ca prefix de **cale de fișier**, la liniile 18, 31, 36, 42, 68, 73, 75, 112. Corect e
> `@webroot`. În ecaterina merge accidental pentru că `@web` e `''` (aplicație la rădăcina
> domeniului) iar CWD-ul PHP e `web/`. **În cinova aplicația stă în subfolder** (`/cinova/web/`),
> deci `@web` = `/cinova/web` și `is_dir('/cinova/web/core/...')` eșuează. **La portare se
> înlocuiește obligatoriu cu `@webroot`.**

---

## 4. VIEW-URI ADMIN

Set standard per entitate: `index.php`, `table_data.php`, `create.php`, `update.php`, `view.php`,
`_form.php`, `_search_form.php`, plus `translations/{index,table_data,view,update,_form}.php`
(+ `gallery/`, `faqs/` unde e cazul).

### 4.1 `index.php`

`ecaterina/modules/admin/views/rooms/index.php` (integral, 49 linii):

```php
use yii\helpers\Html;
use app\modules\admin\components\BreadcrumbsWidget;
use app\modules\admin\components\ActionsButtonsWidget;
use app\modules\admin\components\ConfirmModalWidget as ConfirmModal;

$this->title = Html::encode($this->params['title'] ?? Yii::t('admin/menu', 'Rooms'));
?>
<div class="grid-wrapper it-wrp">
    <div class="loader-ajax"></div>

    <?= BreadcrumbsWidget::widget(['links' => $this->params['breadcrumbs'] ?? []]) ?>

    <div class="page-header d-flex flex-column align-items-start mb-3 w-100">
        <h2 class="page-title mb-2"><?= Html::encode($this->title) ?></h2>
        <div class="page-actions d-flex flex-wrap gap-2">
            <?= ActionsButtonsWidget::widget(['buttons' => [
                'create' => [
                    'label'   => Yii::t('admin/buttons', 'Create'),
                    'action'  => ['create'],
                    'icon'    => Html::tag('span', 'add', ['class' => 'material-symbols-outlined']),
                    'options' => ['class' => 'btn-action btn-success'],
                ],
            ]]) ?>
        </div>
    </div>

    <div class="search-area mb-3 w-100">
        <?= $this->render('_search_form', ['search' => $search, 'searchAction' => $searchAction]) ?>
    </div>

    <div class="table-area">
        <?= $this->render('table_data', ['dataProvider' => $dataProvider]) ?>
    </div>

    <?= ConfirmModal::widget([
        'heading'      => Yii::t('admin/flash', 'key_confirm_action'),
        'heading_icon' => Html::tag('span', 'error', ['class' => 'material-symbols-outlined']),
        'confirmClass' => 'delete-row-table',
        'data_method'  => 'POST',
    ]) ?>
</div>
```

### 4.2 `table_data.php` — **NU se folosește `yii\grid\GridView`**

Listele sunt `ListViewWidget` (widget propriu) alimentat de `DataProviderHelper`.
`ecaterina/modules/admin/views/rooms/table_data.php` (integral, 97 linii):

```php
use app\modules\admin\components\ListViewWidget as ListView;
use app\helpers\RecordHelper as DataHelper;
use app\helpers\FileHelper as File;

$dataHelper = new DataHelper();
$baseEntity = $this->context->baseEntity;
?>
<div class="loader-ajax"></div>

<?= ListView::widget([
    'dataProvider' => $dataProvider,

    'options' => [
        'numbering' => ['label' => '№', 'sequential'],
        'results_summary', 'search_summary', 'pages_summary',
    ],

    'attributes' => [
        'alias_mini_string',

        'main_image' => [
            'format' => 'raw',
            'value'  => static function ($data) {
                return Html::tag('div', Html::img(
                    Url::to(File::getFile(['filePath' => $data::FILE_PATH, 'file' => $data->main_image])),
                    ['class' => 'modal-img-open', 'loading' => 'lazy',
                     'alt' => Html::encode($data->alias_mini_string)]
                ), ['class' => 'list-wrapper-img']);
            },
        ],

        'price_from',

        'translation_level' => [                       // badge „2/3", verde când e complet
            'label'  => Yii::t('admin/transaltion', 'Translation Level'),
            'format' => 'raw',
            'value'  => static function ($data) {
                $translationClass = $data->getTranslations()->modelClass;
                $activeLanguages  = Languages::find()->where(['status' => Languages::STATUS_ACTIVE])->all();
                $activeLanguagesCount = count($activeLanguages);
                $translatedCount = 0;
                foreach ($activeLanguages as $language) {
                    if ($translationClass::find()
                            ->where(['entity_id' => $data->id, 'language_id' => $language->id])
                            ->exists()) {
                        $translatedCount++;
                    }
                }
                $class = $translatedCount === $activeLanguagesCount ? 'valid' : 'warning';
                return Html::tag('span', "{$translatedCount}/{$activeLanguagesCount}", ['class' => $class]);
            },
        ],

        'status' => [
            'format' => 'raw',
            'value'  => static fn($data) => $dataHelper->getStatusEnum(Html::encode($data->status)),
        ],

        'updated_at' => [
            'format' => 'raw',
            'value'  => static function ($data) use ($dataHelper) {
                return Html::tag('b', Html::encode($data->updated_at)) . '<br>'
                     . Html::tag('p', Html::encode($dataHelper->dateToTime($data->updated_at) . ' ago'),
                                 ['class' => 'show-ago']);
            },
        ],
    ],

    'buttons' => [
        'gallery' => [
            'label'  => Html::tag('span', 'photo_library', ['tooltip' => Yii::t('admin/list-table', 'Gallery'), 'class' => 'ttp material-symbols-outlined']),
            'action' => static fn($data) => ['gallery', 'id' => $data->id],
        ],
        'translation' => [
            'label'  => Html::tag('span', 'language', ['tooltip' => Yii::t('admin/list-table', 'Translations'), 'class' => 'ttp material-symbols-outlined']),
            'action' => static fn($data) => ['translations', 'id' => $data->id],
        ],
        'update' => [
            'label'  => Html::tag('span', 'edit', [...]),
            'action' => static fn($data) => ['update', 'id' => $data->id],
        ],
        'view' => [
            'label'  => Html::tag('span', 'visibility', [...]),
            'action' => static fn($data) => ['view', 'id' => $data->id],
        ],
        'delete' => [
            'label'   => Html::tag('span', 'delete', [...]),
            'action'  => static fn($data) => ['delete-row', 'id' => $data->id],
            'options' => static fn($data) => ['class' => 'delete-record-modal',
                'confirm' => Yii::t('admin/flash', 'key_delete_warning') . ' '
                           . Html::encode($data->alias_mini_string) . ' ?'],
        ],
    ],
]) ?>
```

Filtrele sunt **formular GET separat** (`_search_form.php`) randat deasupra tabelului, cu `action`
pe `/admin/<entitate>/search` — nu celule de filtru în tabel.

### 4.3 `_form.php` — ActiveForm + upload + WYSIWYG

Antetul standard (`ecaterina/modules/admin/views/rooms/_form.php:54`):

```php
$formModel = $form ?? $addForm;     // controllerul pasează ambele chei
$activeForm = ActiveForm::begin([
    'options' => ['enctype' => 'multipart/form-data', 'class' => 'w-100'],
    'errorCssClass'   => '_error-form',
    'successCssClass' => '_success-form',
]);
```

Câmp simplu:

```php
<?= $activeForm->field($formModel, 'alias_mini_string')->textInput([
        'maxlength' => true,
        'placeholder' => $formModel->getAttributeLabel('alias_mini_string')]) ?>
```

Câmp numeric:

```php
<?= $activeForm->field($formModel, 'price_from')->textInput([
        'type' => 'number', 'min' => 0, 'step' => '0.01',
        'placeholder' => $formModel->getAttributeLabel('price_from')]) ?>
```

Status — **randat doar pe scenariul `update`** (`rooms/_form.php:118`):

```php
<?= $activeForm->field($formModel, 'status')->dropDownList($statusOptions,
        ['prompt' => Yii::t('admin/labels', 'Select Status')]) ?>
```

Upload de fișier — closure reutilizabil definit în capul view-ului (`rooms/_form.php:13-50`),
cu hidden-ul `temp_files[...]`, preview-ul fișierului existent și `drp-area`:

```php
$renderFileInput = static function (ActiveForm $activeForm, $formModel, $model, string $attribute,
                                    string $inputPrefix, array $tempFiles = []): string {
    $inputId = uniqid($inputPrefix . '_');
    /* ... */
};
// în interior:
<?= Html::hiddenInput('temp_files[' . $attribute . ']', $tempFile ?? '') ?>
<img class="request_img modal-img-open"
     src="<?= \app\helpers\FileHelper::getFile(['file' => $existingFile, 'filePath' => $model::FILE_PATH]) ?>">
<?= $activeForm->field($formModel, $attribute)
        ->fileInput(['multiple' => false, 'id' => $inputId, 'class' => 'attachment file-input-client'])
        ->label(false) ?>
```

**WYSIWYG = CKEditor + elFinder** (nu SunEditor). Apare **doar în formularele de traducere**, pe
câmpul `content_text`. `ecaterina/modules/admin/views/rooms/translations/_form.php:40-48`:

```php
use mihaildev\ckeditor\CKEditor;
use mihaildev\elfinder\ElFinder;

$ckeditorOptions = ElFinder::ckeditorOptions('elfinder', ['preset' => 'full', 'inline' => false]);
?>
<?= $activeForm->field($formModel, 'content_text')->widget(CKEditor::class,
        ['editorOptions' => $ckeditorOptions]) ?>
```

elFinder e înregistrat în `config/web.php:159-165` prin `controllerMap`:

```php
'elfinder' => [
    'class'  => 'mihaildev\elfinder\PathController',
    'access' => ['@'],
    'root'   => ['path' => 'uploads/global', 'name' => 'Global'],
],
```

Structura formularului de traducere (`rooms/translations/_form.php`, 150 linii) — patru blocuri
separate prin `<hr class="w-100">`:
1. câmpurile de conținut (`name_small_string` text, `desc_min_big_string` textarea,
   `content_text` CKEditor, restul textarea)
2. `<h4>SEO</h4>` — `seo_title_small_string`, `seo_keywords_small_string`, `seo_description_big_string`
3. `<h4>Open Graph</h4>` — `seo_og_title_small_string`, `seo_og_type_mini_string`, `seo_og_description_big_string`
4. `<h4>Admin Info</h4>` — `notice_big_string`

Flash-urile `error` și `not-updated` se afișează în capul formularului.

### 4.4 `view.php` / `update.php`

Antetul tipic:

```php
<?= BreadcrumbsWidget::widget(['links' => $this->params['breadcrumbs'] ?? []]) ?>
<?= ActionsButtonsWidget::widget(['buttons' => [
        'create' => [...], 'update' => [...], 'translations' => [...], 'delete' => [...],
]]) ?>
<?= ConfirmModal::widget(['confirmClass' => 'delete-record-single', 'data_method' => 'POST']) ?>
```

`translations/index.php` e o listă de **limbi** (nu de traduceri), alimentată de
`$languageProvider`, cu badge de nivel și buton spre `translation-update`.

---

## 5. MULTILINGV

### 5.1 Mecanismul

**Tabel separat de traduceri per entitate**, NU coloane per limbă, NU behavior.

* `<entitate>` — câmpuri netraductibile (imagini, prețuri, slug, status, sort)
* `<entitate>_translations` — `entity_id` + `language_id` + câmpurile traductibile
* `languages` — limbile ca rânduri, `code` pe 2 caractere stocat **UPPERCASE**, cu `status`

### 5.2 `TranslationTrait` — varianta din ecaterina (minimală)

`ecaterina/traits/TranslationTrait.php`:

```php
trait TranslationTrait
{
    private $_currentLanguageId;

    public function getTranslations()
    { return $this->hasMany($this->translationClass, ['entity_id' => 'id']); }

    public function getCurrentLanguageId()
    {
        if ($this->_currentLanguageId === null) {
            $this->_currentLanguageId = Languages::find()->select('id')
                ->where(['code' => strtoupper(Yii::$app->language)])->scalar();
        }
        return $this->_currentLanguageId;
    }

    public function getTranslationsObj()
    {
        return $this->hasOne($this->translationClass, ['entity_id' => 'id'])
            ->andWhere(['language_id' => $this->currentLanguageId]);
    }
}
```

> ⚠️ **DE NU COPIAT ca atare.** Documentul `.md` §4 (rândurile 167-194) semnalează trei probleme
> reale și cere varianta îmbunătățită: **fallback** la limba sursă (relația `translationFallback`
> + metoda `t($attribute, $default = null)`), **cache static** per request pentru id-ul limbii
> (altfel N+1 pe orice listă), și **`andOnCondition()` în loc de `andWhere()`** (altfel relația se
> strică prin `joinWith()`). Plus normalizarea `ro-RO → RO`.
>
> **`cinova/traits/TranslationTrait.php` conține DEJA varianta îmbunătățită** — nu o înlocui.
> Semnături disponibile acolo: `getTranslations()`, `getTranslationsObj()`,
> `getTranslationFallback()`, `getCurrentLanguageId(): ?int`, `getFallbackLanguageId(): ?int`,
> `t(string $attribute, $default = null)`.

Consumatorii trebuie să încarce eager **ambele** relații:

```php
Entitate::find()->with(['translationsObj', 'translationFallback'])->all();
```

### 5.3 Cum se salvează toate limbile din admin

**Câte o limbă pe pagină.** Nu există tab-uri, nu `loadMultiple()`, nu buclă peste limbi
într-o singură acțiune. Fluxul:

```
/admin/<entitate>                        → listă, coloana „translation_level" arată 1/3
   → buton „language"
/admin/<entitate>/translations?id=5      → listă de LIMBI (ACTIVE + DRAFT), badge per limbă
   → buton pe rândul limbii
/admin/<entitate>/translation-update?lang_id=2&entity_id=5   → formularul acelei limbi
   → salvare → redirect la translation-view
```

`findTranslationModel()` întoarce rândul existent sau o instanță nouă goală:

```php
protected function findTranslationModel($lang_id, $entity_id)
{
    $class = $this->translationClass();
    return $class::findOne(['entity_id' => $entity_id, 'language_id' => $lang_id]) ?? new $class();
}
```

### 5.3.1 DECIZIA PENTRU CINOVA — dropdown de limbi (confirmată de client)

Cerința inițială a Etapei 4 spunea „taburi pentru limbi". **Clientul a decis altfel:**

> **Un `<select>` (dropdown) cu limbile, în pagina formularului de traducere. Limba selectată
> în dropdown este SINGURA limbă afișată pe pagină.**

Ce înseamnă concret:

* **Modelul de salvare din ecaterina rămâne NESCHIMBAT.** O limbă pe request, un singur
  `saveRecord(['entity_id' => ..., 'language_id' => ...])`. **Fără `loadMultiple()`,
  fără taburi, fără array de limbi în POST.** Formularul conține câmpurile unei singure limbi.
* **Se schimbă doar navigarea.** În loc de pagina intermediară `translations/index.php`
  (listă de limbi cu butoane), formularul are în antet un dropdown care comută limba.
* Ruta rămâne `translation-update?lang_id=<id>&entity_id=<id>` — dropdown-ul doar
  redirecționează la aceeași rută cu alt `lang_id`.
* `findTranslationModel($lang_id, $entity_id)` rămâne identic: întoarce rândul existent sau
  o instanță nouă goală, deci comutarea pe o limbă netradusă deschide un formular gol.
* Badge-ul de nivel de traducere (`translation_level`, „2/3") din `table_data.php` rămâne —
  e singurul loc unde se vede acoperirea per entitate.
* `translations/index.php` + `translations/table_data.php` devin **opționale**. Se pot păstra
  ca pagină de ansamblu, dar nu mai sunt calea principală de acces.

Implicație pentru implementare (Etapa 4): dropdown-ul se populează din
`Languages::find()->where(['status' => [Languages::STATUS_ACTIVE, Languages::STATUS_DRAFT]])`,
iar comutarea se face cu un `onchange` care încarcă URL-ul noii limbi. Atenție: dacă
utilizatorul a modificat câmpuri și comută limba fără să salveze, modificările se pierd —
de avertizat sau de blocat la implementare.

### 5.4 Cum se citesc în frontend

`SeoHelper::apply($view, $translationModel)` mapează câmpurile SEO ale traducerii pe `$view->title`
și meta-taguri, sărind peste valorile goale.
`ecaterina/helpers/SeoHelper.php:12-27` — semnătura reală:

```php
public static function apply(View $view, $pageInfo)          // citește $pageInfo->seo_title_*
public static function applyImg(View $view, $image, $filePath)
```

⚠️ În ecaterina citește `seo_title_small_string` etc. **În cinova, `SeoHelper` citește deja
numele scurte** (`seo_title`, `seo_description`, `seo_keywords`, `seo_og_title`,
`seo_og_description`, `seo_og_type`) — coerent cu `services_translations` / `blog_translations`
existente. Nu-l modifica.

### 5.5 i18n și URL-uri

`ecaterina/config/web.php`:

```php
'language'       => 'ro',
'sourceLanguage' => 'ro',
'defaultRoute'   => 'home/index',
'bootstrap' => [
    'log',
    function () {                       // rulează după încărcarea Yii, deci clasele există
        $languages = Languages::find()
            ->select(['code' => 'LOWER(code)'])
            ->where(['status' => Languages::STATUS_ACTIVE])->column();
        Yii::$app->urlManager->languages = $languages;
    },
],
'urlManager' => [
    'class' => 'codemix\localeurls\UrlManager',
    'languages' => ['ro'],                    // suprascris de closure
    'enableLanguageDetection' => false,
    'enableDefaultLanguageUrlCode' => false,
    'enableLanguagePersistence' => true,
    'languageCookieName' => 'language',
    'enablePrettyUrl' => true,
    'showScriptName' => false,
    'rules' => [
        'faqs'           => 'faq/index',
        'privacy-policy' => 'info/privacy-policy',
        'terms-of-use'   => 'info/terms-of-use',
        'portfolio/<id:\d+>/<slug:[a-zA-Z0-9\-]+>' => 'portfolio/view',
        'room/<id:\d+>/<slug:[a-zA-Z0-9\-]+>'      => 'room/view',
        'blog/<id:\d+>/<slug:[a-zA-Z0-9\-]+>'      => 'blog/view',
        /* events, packages, gastronomy identic */
    ],
    'ignoreLanguageUrlPatterns' => [
        '#^/editmode/#' => '#^/editmode/#', '#^editmode/#' => '#^editmode/#',
        '#^/auth/#' => '#^/auth/#',
    ],
],
```

Categoriile de mesaje admin sunt mapate explicit prin `fileMap`: `admin/buttons`, `admin/labels`,
`admin/flash`, `admin/status`, `admin/errors`, `admin/menu`, `admin/headings`, `admin/list-table`,
`admin/transaltion` *(typo păstrat intenționat în ambele proiecte — scrie-l așa)*, `admin/history`.

**Stare curentă în `eca`:** o singură limbă e `ACTIVE` (`RO`). Restul sunt inactive.

---

## 6. FRONTEND

### 6.1 Controller public — subțire, fără `findModel()`

`ecaterina/controllers/RoomController.php` (integral, 57 linii):

```php
class RoomController extends Controller
{
    public function actionIndex()
    {
        $page = Pages::findOne(['name_big_string' => 'room/index']);

        $rooms = Rooms::find()
            ->where(['status' => Rooms::STATUS_ACTIVE])
            ->with(['translationsObj'])
            ->orderBy(['created_at' => SORT_DESC])
            ->all();

        return $this->render('index', ['page' => $page, 'rooms' => $rooms]);
    }

    public function actionView(int $id, string $slug)
    {
        $room = Rooms::find()
            ->where(['id' => $id, 'status' => Rooms::STATUS_ACTIVE])
            ->with(['translationsObj'])
            ->one();

        if ($room === null) throw new NotFoundHttpException();

        if ($slug !== UrlHelper::normalizeSlug($room->slug)) {
            return $this->redirect($room->getUrl(), 301);        // slug canonic
        }

        $gallery = RoomsGallery::find()
            ->where(['room_id' => $room->id, 'status' => RoomsGallery::STATUS_ACTIVE])
            ->orderBy(['sort_order' => SORT_ASC, 'id' => SORT_DESC])
            ->all();

        return $this->render('view', ['room' => $room, 'gallery' => $gallery]);
    }
}
```

**Condițiile ca un rând să apară pe site:**

| Condiție | Unde |
|---|---|
| `status = 'ACTIVE'` | query-ul controllerului |
| traduceri complete pentru toate limbile ACTIVE | impus indirect de `ActiveTranslationValidator` la publicare |
| `slug` nenul | `views/room/components/_room_grid.php:19` face `continue` altfel |
| slug canonic în URL | `RoomController.php:44` → 301 |
| pentru galerie: `RoomsGallery.status = ACTIVE` | `RoomController.php:49` |
| sortare | `sort_order ASC, id DESC` la galerie; `created_at DESC` la entitate |

⚠️ Entitatea principală se sortează după `created_at DESC`, **nu** după `sort_order` — ecaterina
nu are `sort_order` pe entitățile principale, doar pe galerii. Cerința cinova (Etapa 4:
„sort_order editabil") **extinde** pattern-ul: `sort_order` intră pe entitatea principală și
sortarea frontend devine `['sort_order' => SORT_ASC, 'id' => SORT_ASC]`.

### 6.2 View-urile

Layout-ul frontend (`ecaterina/views/layouts/main.php`) ≈ 45 de linii: `AppAsset`, meta din
`$this->params['meta_description'|'meta_keywords']`, apoi `HeaderWidget` + `$content` + `FooterWidget`.
Header/footer sunt **widget-uri** (`app\components\widgets\`), nu partial-uri — pot face propriile
query-uri fără să polueze controllerele.

Card-urile sunt partial-uri în `views/<entitate>/components/_*.php`, randate cu parametri expliciți.
Randarea unui câmp tradus (`ecaterina/views/room/components/_room_grid.php:20-32`):

```php
<?php $t = $room->translationsObj; ?>
<h2><?= Html::encode($t ? ($t->name_small_string ?? '') : '') ?></h2>
<?php if ($t && !empty($t->desc_min_big_string)): ?>
    <p><?= Html::encode($t->desc_min_big_string) ?></p>
<?php endif; ?>
<img src="<?= FileHelper::getFile(['file' => $room->main_image, 'filePath' => Rooms::FILE_PATH]) ?>">
```

SEO în view (`ecaterina/views/room/view.php:16-23`):

```php
SeoHelper::apply($this, $t);
SeoHelper::applyImg($this, $ogImage, Rooms::FILE_PATH);
```

### 6.3 Cache

**NU EXISTĂ.** Am căutat `Yii::$app->cache`, `->cache(`, `beginCache`, `Dependency` în
`controllers/`, `views/`, `helpers/`, `components/` — zero rezultate. Fiecare request lovește DB-ul
direct, fără paginare pe frontend (`->all()`). Nu există nimic de invalidat.
`.md` §14 confirmă: paginarea pe frontend lipsește, `DataProviderHelper` e folosit doar în admin.

### 6.4 Al doilea canal: editmode (informativ)

ecaterina are un al doilea mecanism, **independent** de CRUD-ul de entități: `RenderHelper` +
tabelele `elements` / `element_content` / `element_content_draft` / `element_content_history`,
plus `modules/editmode`. Permite editarea inline a textelor „din design" (etichete, titluri de
secțiune) marcate cu clasa `.editable` sau apelate explicit prin
`RenderHelper::editableText($pageKey, $dataKey, $default, $tag, $attrs)`.

⚠️ `RenderHelper::processEditableBlocks()` (`helpers/RenderHelper.php:145`) citește fișierul de
view cu `file_get_contents`, îl parsează cu `DOMDocument` și **rescrie fișierul sursă pe disc**
(`file_put_contents`, liniile 371-378 și 418-425) ca să persiste `data-key`-ul generat.
Consecințe: `views/` trebuie scriptibil în producție, deploy read-only pică, requesturi simultane
pot genera chei duplicate, fiecare inițializare produce diff în git.

**Recomandare pentru cinova: NU se portează în această fază.** Obiectivul declarat e
„tabele → CRUD complet în admin → afișare pe paginile publice", ceea ce e canalul 1. Dacă se
dorește ulterior editare inline, cheile se generează printr-o comandă de consolă rulată la deploy,
nu la runtime în request-ul utilizatorului.

---

## 7. MIGRAȚII

### 7.1 Stil

* Nume: `mYYMMDD_HHMMSS_create_<tabel>_table.php` (timestamp-uri grupate logic:
  `m260524_100001`, `m260524_100002`, …)
* `safeUp()` / `safeDown()`, nu `up()`/`down()`
* nume de tabel cu prefix: `'{{%rooms}}'`
* ENUM-urile se generează prin `EnumHelper`, **niciodată scrise de mână**
* `down()` face rollback complet: drop FK → drop index → drop table, în ordine inversă

### 7.2 Tabel principal — exemplu integral

`ecaterina/migrations/m260429_173950_create_rooms_table.php`:

```php
use yii\db\Migration;
use app\helpers\EnumHelper as Enum;

class m260429_173950_create_rooms_table extends Migration
{
    public function safeUp()
    {
        $this->createTable('{{%rooms}}', [
            'id'                => $this->primaryKey(),
            'alias_mini_string' => $this->string(255),
            'notice_big_string' => $this->string(500),
            'main_image'        => $this->string(255),
            'bg_image'          => $this->string(255),
            'price_from'        => $this->decimal(10, 2)->null(),
            'min_capacity'      => $this->integer()->null(),
            'max_capacity'      => $this->integer()->null(),
            'area_size'         => $this->decimal(10, 2)->null()->comment('Square meters'),
            'seo_og_image'      => $this->string(255),
            'views_integer'     => $this->integer(11),
            'slug_big_string'   => $this->string(255)->comment('SEO-friendly URL slug'),
            'status'            => Enum::generateQuery([
                                       Enum::STATUS_ACTIVE, Enum::STATUS_DELETED,
                                       Enum::STATUS_HIDDEN, Enum::STATUS_DRAFT,
                                       Enum::STATUS_INACTIVE,
                                   ], "not null default '" . Enum::STATUS_INACTIVE . "'"),
            'created_by'        => $this->integer(11),
            'updated_by'        => $this->integer(11),
            'created_at'        => $this->dateTime(),
            'updated_at'        => $this->dateTime(),
        ]);

        $this->addForeignKey('fk_users_rooms_created_by', '{{%rooms}}', 'created_by',
                             '{{%users}}', 'id', 'SET NULL', 'RESTRICT');
        $this->addForeignKey('fk_users_rooms_updated_by', '{{%rooms}}', 'updated_by',
                             '{{%users}}', 'id', 'SET NULL', 'RESTRICT');

        $this->createIndex('idx_created_by', '{{%rooms}}', 'created_by');
        $this->createIndex('idx_updated_by', '{{%rooms}}', 'updated_by');
    }

    public function safeDown()
    {
        $this->dropForeignKey('fk_users_rooms_created_by', '{{%rooms}}');
        $this->dropForeignKey('fk_users_rooms_updated_by', '{{%rooms}}');
        $this->dropIndex('idx_created_by', '{{%rooms}}');
        $this->dropIndex('idx_updated_by', '{{%rooms}}');
        $this->dropTable('{{%rooms}}');
    }
}
```

> Nota: în fișierul original linia 29 are parantezele puse greșit
> (`... . Enum::STATUS_INACTIVE) . "'"`) — rezultatul e același string, dar e o scăpare.
> Am scris varianta corectă mai sus. Migrațiile existente din cinova o scriu deja corect.

`EnumHelper::generateQuery()` (`ecaterina/helpers/EnumHelper.php:196-203`):

```php
public static function generateQuery(array $enumArray, string $condition = '')
{
    $enumString = '';
    foreach ($enumArray as $key) { $enumString .= "'" . $key . "'" . ','; }
    return "ENUM(" . strtoupper(trim($enumString, ",")) . ") " . $condition;
}
```

⚠️ **uppercase-uiește toate valorile** — `'desktop'` devine `'DESKTOP'` în tabel.

### 7.3 Tabel de traduceri — exemplu

`ecaterina/migrations/m260429_174040_create_rooms_translations_table.php`:

```php
$this->createTable('{{%rooms_translations}}', [
    'id'          => $this->primaryKey(),
    'entity_id'   => $this->integer(11),
    'language_id' => $this->integer(11),

    'name_small_string'    => $this->string(255),
    'desc_min_big_string'  => $this->string(355),
    'content_text'         => $this->text(),
    'equipment_big_string' => $this->string(355),
    'options_big_string'   => $this->string(355),

    'seo_title_small_string'        => $this->string(255)->comment('SEO Title'),
    'seo_description_big_string'    => $this->string(160)->comment('Meta Description'),
    'seo_keywords_small_string'     => $this->string(255)->comment('Meta Keywords'),
    'seo_og_title_small_string'     => $this->string(255),
    'seo_og_description_big_string' => $this->string(160),
    'seo_og_type_mini_string'       => $this->string(50)->defaultValue('website'),

    'notice_big_string' => $this->string(500)->comment('Notice for admin'),

    'created_by' => $this->integer(11),
    'updated_by' => $this->integer(11),
    'created_at' => $this->dateTime(),
    'updated_at' => $this->dateTime(),
]);

$this->addForeignKey('fk_users_rooms_translations_created_by', '{{%rooms_translations}}',
                     'created_by', '{{%users}}', 'id', 'SET NULL', 'RESTRICT');
$this->addForeignKey('fk_users_rooms_translations_updated_by', '{{%rooms_translations}}',
                     'updated_by', '{{%users}}', 'id', 'SET NULL', 'RESTRICT');
$this->addForeignKey('fk_rooms_rooms_translations_entity_id', '{{%rooms_translations}}',
                     'entity_id', '{{%rooms}}', 'id', 'SET NULL', 'RESTRICT');

$this->createIndex('idx_created_by', '{{%rooms_translations}}', 'created_by');
$this->createIndex('idx_updated_by', '{{%rooms_translations}}', 'updated_by');
$this->createIndex('idx_entity_id',  '{{%rooms_translations}}', 'entity_id');
```

> ⚠️ **Trei devieri față de document, de CORECTAT la portare** (`.md` §12, rândul 723):
> 1. **Lipsește indexul unic `(entity_id, language_id)`.** Din 16 tabele de traduceri din
>    ecaterina, doar `main_banner_translations` îl are
>    (`m260525_160000_create_main_banner_translations_table.php:32`). Fără el, nimic nu împiedică
>    două traduceri pentru aceeași limbă.
> 2. **Lipsește FK-ul pe `language_id`** spre `{{%languages}}`.
> 3. **`entity_id` are `SET NULL` în loc de `CASCADE`.** Documentul cere `CASCADE` spre entitate
>    și limbi, `SET NULL` doar spre `users`.
>
> Migrațiile existente din cinova (`m260728_2000*`) le fac deja corect — se continuă acel stil.

---

## 8. CE SE APLICĂ ÎN CINOVA — sinteză operațională

### 8.1 Ce se copiază (structura)

| Element | Fișier-sursă din ecaterina |
|---|---|
| Contract CRUD abstract | `modules/admin/controllers/base/BaseAdminCrudController.php` |
| Acțiunile CRUD | `modules/admin/controllers/traits/MainCrudTrait.php` |
| Sub-CRUD galerie / FAQ | `modules/admin/controllers/traits/HasGalleryTrait.php`, `HasFaqsTrait.php` |
| Controller concret (~40 linii) | `modules/admin/controllers/RoomsController.php` |
| Form object entitate | `models/rooms/forms/RoomsForm.php` |
| Form object traducere | `models/rooms/forms/RoomsTranslationsForm.php` |
| Search model | `models/rooms/search/RoomsSearch.php` |
| Validator publicare | `validators/ActiveTranslationValidator.php` |
| View index | `modules/admin/views/rooms/index.php` |
| View listă | `modules/admin/views/rooms/table_data.php` |
| View formular + upload | `modules/admin/views/rooms/_form.php` |
| View formular traducere + CKEditor | `modules/admin/views/rooms/translations/_form.php` |
| Meniu lateral | `modules/admin/components/views/aside.php` |
| Layout admin | `modules/admin/views/layouts/admin.php` |
| Asset admin | `assets/AdminAsset.php` |
| Migrație entitate | `migrations/m260429_173950_create_rooms_table.php` |
| Migrație traduceri | `migrations/m260429_174040_create_rooms_translations_table.php` |
| Controller public | `controllers/RoomController.php` |
| Partial de card | `views/room/components/_room_grid.php` |

### 8.2 Ce se corectează la portare (devieri obligatorii)

| # | Problema în ecaterina | Corecția în cinova | Sursă |
|---|---|---|---|
| 1 | `TranslationTrait` fără fallback, cache per instanță, `andWhere` | varianta îmbunătățită — **deja există în cinova** | `.md` §4, rândurile 167-194 |
| 2 | Lipsă index unic `(entity_id, language_id)`, lipsă FK pe `language_id`, `SET NULL` pe `entity_id` | unic + FK `CASCADE` | `.md` §12, rândul 723 |
| 3 | `FileHelper` folosește `@web` (alias URL) ca și cale de fișier | `@webroot` — **obligatoriu**, cinova stă în subfolder | `helpers/FileHelper.php:18,31,36,42,68,73,75,112` |
| 4 | Form objects varianta B (`extends AR` + `ModelHelper`) în 4 fișiere | exclusiv varianta A (`extends Model`) | `.md` §7, rândul 428 |
| 5 | `STATUS_DELETED` declarat dar ștergerea e fizică; `setStatusToDeleted()` mort | **NU se corectează** — clientul a cerut „fă ca în ecaterina": ștergere fizică, constanta rămâne declarată și exclusă în search (vezi §9.2) | `MainCrudTrait.php:269` |
| 6 | Rolul `superAdmin` referit dar inexistent în `auth_item` | se omite | `modules/admin/Module.php:25` |
| 7 | SunEditor încărcat în AdminAsset dar nefolosit | nu se copiază | `assets/AdminAsset.php` |
| 8 | Fără `sort_order` pe entitățile principale | se adaugă (cerință explicită Etapa 4) | cerința clientului |
| 9 | Fără cache, fără paginare pe frontend | de discutat separat, nu blochează | `.md` §14 |
| 10 | `RenderHelper` rescrie view-urile pe disc la runtime | editmode **nu se portează** în această fază | `helpers/RenderHelper.php:371-378` |

### 8.3 Nume de coloane — traducerea convențiilor

cinova a eliminat deja sufixele. Corespondența de folosit:

| ecaterina | cinova |
|---|---|
| `alias_mini_string` | `alias` |
| `slug_big_string` | `slug` |
| `name_small_string` | `name` (sau `title` la blog) |
| `desc_min_big_string` | `desc_min` (sau `description`) |
| `content_text` | `content_text` (neschimbat) |
| `notice_big_string` | `notice` |
| `seo_title_small_string` | `seo_title` |
| `seo_description_big_string` | `seo_description` |
| `seo_keywords_small_string` | `seo_keywords` |
| `seo_og_title_small_string` | `seo_og_title` |
| `seo_og_description_big_string` | `seo_og_description` |
| `seo_og_type_mini_string` | `seo_og_type` |
| `views_integer` | `views_integer` (neschimbat) |

`cinova/helpers/SeoHelper.php` citește deja formele scurte. `EnumHelper`, `FileHelper`,
`DataProviderHelper`, `RecordHelper`, `UrlHelper`, `ModelHelper`, `PageHelper`, `EntityHelper`
există deja în cinova, portate anterior.

### 8.4 Checklist pentru o entitate nouă (`.md` §12, confirmat în cod)

1. Migrare: tabel principal + `<entitate>_translations` (`utf8mb4_unicode_ci`, InnoDB,
   unic `(entity_id, language_id)`, FK `CASCADE` spre entitate și limbi, `SET NULL` spre `users`)
2. `constants/EntityConstants.php` — slug-urile de entitate
3. Modele AR: entitatea (cu `TranslationTrait`, `FILE_PATH`, constante de status, `opts*`,
   getteri virtuali `getAlias()`/`getSlug()`/`getUrl()`) + traducerea (relații
   `getEntity()`/`getLanguage()`, validatori `exist`)
4. Form objects: `<E>Form` + `<E>TranslationsForm` (ambele cu `getModel()`)
5. Search model: `formName(): ''`, exclude `STATUS_DELETED`, întoarce query
6. Controller admin: extinde `BaseAdminCrudController` + `use MainCrudTrait`
7. Cele 7 view-uri + folderul `translations/`
8. Intrare în `modules/admin/components/views/aside.php`
9. Chei noi în `modules/admin/messages/{ro,en,ru}/{menu,labels,errors,headings}.php`
10. Frontend: controller subțire + view-uri + `SeoHelper::apply()`
11. Verificare: migrarea rulează, toate ecranele admin randează, comutarea de limbă merge,
    `php -l` curat

### 8.5 Rularea PHP pe această stație

`php.ini`-ul din CLI are placeholdere nerezolvate, deci extensiile sunt dezactivate și
`php yii` moare cu „could not find driver". Invocarea care funcționează:

```bash
C:/OpenServer/modules/php/PHP_8.1/php.exe \
  -d extension_dir=C:/OpenServer/modules/php/PHP_8.1/ext \
  -d extension=php_pdo_mysql.dll -d extension=php_mbstring.dll \
  -d extension=php_openssl.dll -d extension=php_curl.dll \
  -d extension=php_fileinfo.dll -d extension=php_intl.dll -d extension=php_gd.dll \
  yii migrate
```

---

## 9. ÎNTREBĂRI DESCHISE — de clarificat înainte de Etapa 2

1. ~~**Taburi pentru limbi.**~~ **REZOLVAT — vezi §5.3.1.** Clientul a decis: **dropdown de limbi
   în pagina formularului, o singură limbă pe pagină** (cea selectată). Modelul de salvare din
   ecaterina rămâne neschimbat — un `saveRecord()` per limbă, fără `loadMultiple()`, fără taburi.
   Se schimbă doar navigarea: `<select>` în antetul formularului în loc de pagina intermediară
   cu lista de limbi.
2. ~~**Soft-delete sau hard-delete.**~~ **REZOLVAT.** Clientul a decis: **„fă ca în ecaterina"**,
   adică **ștergere FIZICĂ** — `deleteItem()` din `MainCrudTrait.php:240-283` reprodus identic:
   tranzacție → șterge traducerile → șterge galeria/FAQ-urile (colectând căile fișierelor) →
   `$model->delete()` → `commit()` → **abia apoi** `deleteModelFiles()` + `fileDestroy()`.
   Fără soft-delete, fără filtrare pe `status = DELETED` la ștergere.

   Corolar, tot „ca în ecaterina": constanta `STATUS_DELETED` **se declară** în modele și
   search-urile o exclud (`->where(['<>', 'status', X::STATUS_DELETED])`), chiar dacă nimic
   n-o setează vreodată. Am semnalat că e cod mort; clientul a cerut explicit replicarea
   ecaterina, deci se păstrează așa.

   ⚠️ **Tensiune de semnalat la Etapa 2:** entitățile care există DEJA în cinova
   (`blog`, `portfolio`, `services`, `projects`, `faq`) au enum-ul de status **fără** `DELETED`
   (`ACTIVE|HIDDEN|DRAFT|INACTIVE`). Dacă entitățile noi îl includ, cele două seturi vor
   diferi. Presupunerea implicită pentru Etapa 2: **entitățile noi urmează ecaterina (5 statusuri,
   cu `DELETED`), cele existente rămân neatinse.** Dacă se dorește uniformitate, e o singură
   migrare de `ALTER` — de spus înainte de Etapa 2.
3. **`views/site/*` și `SiteController` din cinova** sunt cod-template rămas (referă
   `app\models\ContactForm` și `LoginForm`, care nu există ca fișiere). Se șterg în Etapa 6 sau
   se lasă?
4. **Limbi active în cinova:** `RO`, `EN`, `RU` sunt ACTIVE, dar tot conținutul seed-at anterior
   e doar EN. `ActiveTranslationValidator` va bloca publicarea până există toate trei. Se
   dezactivează temporar RO/RU, sau se acceptă că entitățile rămân INACTIVE până la traducere?
