# Admin panel — pattern de implementare (Yii2)

> **Ce este acest document.** Specificația completă a panoului de administrare din
> proiectul `cinova`, scrisă ca să poată fi **reprodusă într-un proiect nou**.
> Fluxul pentru care e gândit: tu creezi baza de date și spui ce entități există
> și cum trebuie să se comporte → adminul primește paginile de gestiune (listă,
> creare, editare, vizualizare, traduceri) → apoi se scriu controllerele de
> frontend care trag datele în pagini.
>
> Cititorul-țintă poate fi un dev sau un agent AI (Claude Code). De aceea totul e
> dat ca **contracte și șabloane concrete**: copiezi fișierul, înlocuiești
> `{Entity}` / `{entity}` / `{entities}` și merge.
>
> Convenții de nume folosite peste tot mai jos:
> - `{Entity}` — clasa, ca în DB: `Services`, `Blog`, `PriceSteps`
> - `{entity}` — namespace-ul de model, camelCase: `services`, `blog`, `priceSteps`
> - `{entity-key}` — cheia din URL și din registru, kebab-case: `services`, `price-steps`
> - `{entities}` — numele tabelului, snake_case plural: `services`, `price_steps`

**Cuprins**

1. [Stack și premise](#1-stack-și-premise)
2. [Convenții de bază de date](#2-convenții-de-bază-de-date)
3. [Infrastructura care se copiază o singură dată](#3-infrastructura-care-se-copiază-o-singură-dată)
4. [Nucleul CRUD](#4-nucleul-crud)
5. [Anatomia unei entități](#5-anatomia-unei-entități--fișierele-de-creat)
6. [Contractele widget-urilor și ale JS-ului](#6-contractele-widget-urilor-și-ale-js-ului)
7. [Ecranele de infrastructură](#7-ecranele-de-infrastructură)
8. [i18n — interfața adminului](#8-i18n--interfața-adminului)
9. [Autentificare și RBAC](#9-autentificare-și-rbac)
10. [Rețeta: de la tabel la pagină publică](#10-rețeta-de-la-tabel-la-pagină-publică)
11. [Frontend: cum se trag datele în pagini](#11-frontend-cum-se-trag-datele-în-pagini)
12. [Funcționalul obligatoriu — listă de recepție](#12-funcționalul-obligatoriu--listă-de-recepție)
13. [Capcane și defecte de nu-copiat](#13-capcane-și-defecte-de-nu-copiat)
14. [Ordinea de bootstrap a unui proiect nou](#14-ordinea-de-bootstrap-a-unui-proiect-nou)

---

## 1. Stack și premise

| Element | Valoare |
|---|---|
| Framework | Yii2 basic (`yiisoft/yii2-app-basic`) |
| DB | MySQL / MariaDB, tabele `utf8mb4`, conexiune `charset => utf8mb4` |
| Admin | modul Yii (`modules/admin`), rută `/admin/...`, layout propriu |
| Multilingv | tabel `languages` + tabele `*_translations`; URL cu prefix de limbă (`codemix/yii2-localeurls`) |
| Autentificare | `app\models\users\Users` (IdentityInterface) + RBAC `yii\rbac\DbManager` |
| Media | `mihaildev/yii2-elfinder` |
| Frontend | controllere Yii + view-uri PHP, fără SPA |

Adminul **nu** folosește GridView / DetailView din Yii și nici `ActiveDataProvider`.
Are widget-uri proprii (`ListViewWidget`, `DetailViewWidget`) și paginare proprie
(`DataProviderHelper`). Motivul: markup-ul e sub control total și listele se
reîncarcă prin AJAX fără să depindă de asset-urile Yii.

---

## 2. Convenții de bază de date

Regula de aur: **o entitate = două tabele** — `{entities}` (date netraductibile)
și `{entities}_translations` (textele). Tot ce e text vizibil utilizatorului stă
în tabelul de traduceri, chiar dacă azi există o singură limbă.

### 2.1 Tabelul principal

```php
$this->createTable('{{%{entities}}}', [
    'id'         => $this->primaryKey(),
    'alias'      => $this->string(255)->notNull(),   // identificator intern, folosit ca titlu în admin
    'slug'       => $this->string(255)->null(),      // doar dacă entitatea are pagină de detaliu
    'image'      => $this->string(255)->null(),      // doar dacă are imagine
    'sort_order' => $this->integer(11)->notNull()->defaultValue(0),
    '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)->null(),
    'updated_by' => $this->integer(11)->null(),
    'created_at' => $this->dateTime(),
    'updated_at' => $this->dateTime(),
    'deleted_at' => $this->dateTime()->null(),       // soft delete
    'deleted_by' => $this->integer(11)->null(),
]);
```

Obligatoriu în aceeași migrație: indexuri pe `alias`, `status`, `sort_order`,
`deleted_at` și pe fiecare FK; FK spre `users` pentru `created_by` / `updated_by`
/ `deleted_by` cu `ON DELETE SET NULL`; FK spre părinte (dacă e copil) cu
`ON DELETE CASCADE`.

Nume: indexuri `idx_{entities}_{coloana}`, chei străine `fk_{entities}_{coloana}`.

### 2.2 Tabelul de traduceri

```php
$this->createTable('{{%{entities}_translations}}', [
    'id'          => $this->primaryKey(),
    'entity_id'   => $this->integer(11)->null(),
    'language_id' => $this->integer(11)->null(),
    // coloanele de text: name, desc_min, content_text, seo_title, ...
    'notice'      => $this->string(500)->null()->comment('Notice for admin'),
    'created_by'  => $this->integer(11)->null(),
    'updated_by'  => $this->integer(11)->null(),
    'created_at'  => $this->dateTime(),
    'updated_at'  => $this->dateTime(),
]);

$this->createIndex('idx_{entities}_translations_entity_language',
    '{{%{entities}_translations}}', ['entity_id', 'language_id'], true); // UNIC
```

- Coloana de legătură se numește **întotdeauna `entity_id`** (nu `service_id`).
  Tot CRUD-ul de traduceri se bazează pe asta.
- Indexul unic `(entity_id, language_id)` e obligatoriu.
- Tabelele-copil (traduceri, galerii) **nu** primesc soft delete — dispar odată
  cu părintele, prin `ON DELETE CASCADE` sau prin `purgeItem()`.

### 2.3 Statusuri — niciodată scrise de mână

Toate valorile ENUM vin din `helpers/EnumHelper.php`, un registru central de
constante-string:

```php
use app\helpers\EnumHelper as Enum;
'status' => Enum::generateQuery([Enum::STATUS_ACTIVE, Enum::STATUS_INACTIVE], "not null default 'INACTIVE'"),
```

`generateQuery(array $values, string $condition = '')` produce
`ENUM('ACTIVE','INACTIVE') not null default 'INACTIVE'` — atenție, aplică
`strtoupper` pe listă. Set standard pentru conținut: `ACTIVE`, `INACTIVE`,
`HIDDEN`, `DRAFT`, `DELETED`. Doar `ACTIVE` ajunge pe site.

### 2.4 Tabele de sistem, în ordinea migrațiilor

1. **`users`** — `initials`, `email`, `password_hash`, `auth_key`, `access_token`,
   `email_token`, `reset_token`, `status` (ENUM `USER_ACTIVE` / `USER_BAN` /
   `USER_INACTIVE` / `USER_DELETED`), audit.
2. **`languages`** — `code` (2 litere, UPPERCASE), `name`, `status`, `sort_order`.
3. **`pages`** + **`pages_translations`** — `name` e identificatorul de rută
   (`services/index`), plus `slug`, `seo_og_image`; traducerile țin
   `banner_title`, `banner_breadcrumb`, `seo_title`, `seo_description`,
   `seo_keywords`, `seo_og_title`, `seo_og_description`, `seo_og_type`.
4. **RBAC**: `php yii migrate --migrationPath=@yii/rbac/migrations`, apoi migrația
   proprie care creează rolurile.
5. **`activity_history`** — vezi §7.3.
6. Seed utilizator admin.
7. Abia apoi entitățile de conținut.

---

## 3. Infrastructura care se copiază o singură dată

| Fișier | Rol |
|---|---|
| `helpers/EnumHelper.php` | registrul de constante ENUM + `generateQuery()` |
| `helpers/DataProviderHelper.php` | paginarea proprie (§6.3) |
| `helpers/RecordHelper.php` | `getStatusEnum()`, `dateToTime()`, `getSubstr()`, `getSum()` |
| `helpers/FileHelper.php` | upload / înlocuire / ștergere fișiere (§6.4) |
| `helpers/SeoHelper.php` | `applyModel()` — title + meta + OpenGraph |
| `helpers/UrlHelper.php` | slug transliterat, `normalizeSlug()` |
| `traits/TranslationTrait.php` | relațiile de traducere + `t()` cu fallback |
| `traits/SoftDeleteTrait.php` | `softDelete()`, `restore()`, `forceDelete()`, `find()` |
| `models/query/SoftDeleteQuery.php` | `notDeleted()` / `onlyDeleted()` / `withDeleted()` |
| `validators/ActiveTranslationValidator.php` | blochează publicarea unei înregistrări netraduse |
| `behaviors/ActivityLogBehavior.php` | jurnalul de modificări |
| `modules/admin/controllers/base/BaseAdminCrudController.php` | clasa de bază (§4.1) |
| `modules/admin/controllers/traits/MainCrudTrait.php` | acțiunile CRUD (§4.2) |
| `modules/admin/components/*` + `components/views/*` | widget-urile (§6.1–6.2) |
| `modules/admin/components/AdminEntityRegistry.php` | registrul de entități (§3.3) |
| `modules/admin/messages/{en,ro,ru}/*.php` | textele adminului (§8) |
| `modules/admin/web/{css,js}` + `assets/AdminAsset.php` | stilurile și JS-ul (§6.2) |
| `modules/admin/views/layouts/admin.php` | layout: aside + header + content |
| `modules/admin/controllers/{Default,Trash,ActivityHistory,Search,Translations,Media,Profile,Error}Controller.php` | ecranele de infrastructură (§7) |

### 3.1 `TranslationTrait`

Modelul declară `public $translationClass = {Entity}Translations::class;` și primește:

- `getTranslations()` — toate traducerile (hasMany pe `entity_id`);
- `getTranslationsObj()` — traducerea pentru limba curentă;
- `getTranslationFallback()` — traducerea în `sourceLanguage`;
- `t('camp', $default)` — limba curentă → fallback → default.

**Regulă de performanță:** orice query care randează liste face
`->with(['translationsObj', 'translationFallback'])`. Fără el, fiecare rând costă
două query-uri în plus.

### 3.2 Soft delete

`SoftDeleteTrait::find()` întoarce `SoftDeleteQuery`, care **exclude implicit**
rândurile din coș. Contează: o înregistrare ștearsă păstrează `status = ACTIVE`,
deci dacă filtrul ar fi opt-in, site-ul public ar continua să o afișeze.
Condiția se aplică în `prepare()`, nu în `init()`, ca modificatorii să poată
schimba modul după `find()`. Coloana e calificată cu tabelul/aliasul, ca scope-ul
să supraviețuiască unui join.

Modelele cu soft delete **nu își declară `find()` propriu** — ar rupe scope-urile.

### 3.3 Registrul de entități

`AdminEntityRegistry::all()` e singurul loc care știe ce entități are adminul.
Din el se alimentează dashboard-ul, căutarea globală, coșul, panoul de traduceri,
selecția în masă și reordonarea.

```php
'{entity-key}' => [
    'model'        => \app\models\{entity}\{Entity}::class,
    'label'        => '{Entity}',            // cheie în messages/*/menu.php
    'icon'         => 'category',            // Material Symbols
    'group'        => self::GROUP_CONTENT,   // GROUP_CONTENT | GROUP_BLOCKS | GROUP_SETTINGS | GROUP_USERS
    'search'       => ['alias', 'slug'],     // coloane scanate de căutarea globală
    'translations' => \app\models\{entity}\{Entity}Translations::class, // sau null
    'sortable'     => true,                  // are sort_order → activează drag & drop
    'trashable'    => true,                  // participă la coș
    'frontend'     => '{entity-key}/{id}/{slug}', // link „vezi pe site", sau null
],
```

API: `all()`, `trashable()`, `translatable()`, `sortable()`, `get($key)`,
`keyForModel($model)` (urcă pe `get_parent_class`, ca `{Entity}Search extends {Entity}`
să se rezolve), `label($key)`, `icon($key)`, `frontendUrl($key, $model)`.

---

## 4. Nucleul CRUD

### 4.1 `BaseAdminCrudController`

Clasă abstractă; fiecare controller de entitate îi spune cu ce clase lucrează:

```php
abstract protected function modelClass(): string;
abstract protected function formClass(): string;
abstract protected function searchClass(): string;
abstract protected function entityLabel(): string;         // eticheta plural (admin/menu)
abstract protected function entitySingleLabel(): string;    // eticheta singular ({Entity}_single)
abstract protected function imageFields(): array;           // câmpurile cu upload, [] dacă nu are
abstract protected function filePath(): string;             // director de upload, '' dacă nu are
```

Opțional (implicit `null`): `translationClass()`, `translationFormClass()`,
`galleryModelClass()`, `galleryFormClass()`, `faqModelClass()`, `faqFormClass()`,
`faqTranslationModelClass()`, `faqTranslationFormClass()`.

Oferă gratuit:

- `behaviors()` cu `VerbFilter`: `delete`, `delete-row`, `bulk`, `reorder` și
  variantele de copii sunt **POST-only**;
- `setPage($title, $breadcrumbs, $includeBaseEntity = true)` — scrie
  `view->params['title']` și `['breadcrumbs']`, cu Dashboard + entitatea în față;
- `findModel($id)` / `findLanguage($id)` — cu `NotFoundHttpException`;
- pipeline-ul de upload: `prepareUploadedFiles()`, `restoreTempMarkers()`,
  `savePendingToTemp()`, `processAllUploads()`, `deleteModelFiles()`.

**Pipeline-ul de upload** rezolvă o problemă reală: dacă validarea pică, fișierul
deja urcat nu trebuie pierdut. Ordinea în acțiune:

```
prepareUploadedFiles()      // UploadedFile::getInstance pe fiecare câmp
restoreTempMarkers()        // marchează 'temp:...' din submit-ul anterior (DUPĂ getInstance!)
validate()
 ├─ ok   → processAllUploads()   // mută în final, șterge fișierul vechi
 └─ fail → savePendingToTemp()   // salvează în temp cât timp fișierul PHP mai există
```

### 4.2 `MainCrudTrait` — acțiunile gata făcute

| Acțiune | Rută | Ce face |
|---|---|---|
| `actionIndex()` / `actionSearch()` | `index`, `search` | listă + filtre; pe AJAX randează doar `table_data`, fără layout |
| `actionView($id)` | `view` | detaliu |
| `actionCreate()` | `create` | formular + upload cu fișiere temporare; redirect la `view` |
| `actionUpdate($id)` | `update` | idem, cu ștergerea fișierului vechi |
| `actionTranslations($id)` | `translations` | lista limbilor cu nivelul de traducere |
| `actionTranslationView($lang_id, $entity_id)` | `translation-view` | traducerea pe o limbă |
| `actionTranslationUpdate($lang_id, $entity_id)` | `translation-update` | editarea traducerii |
| `actionDelete($id)` | `delete` (POST) | mută în coș; redirect la `index` |
| `actionDeleteRow($id)` | `delete-row` (POST) | idem, răspuns JSON `['success' => bool]` |
| `actionBulk()` | `bulk` (POST) | `activate` / `deactivate` / `delete` pe rândurile bifate |
| `actionReorder()` | `reorder` (POST) | salvează ordinea, JSON |

Comportamente de păstrat exact:

- `actionBulk()` salvează **fiecare model separat**
  (`$model->save(false, ['status','updated_at','updated_by'])`), nu prin
  `updateAll()` — altfel nu se declanșează behaviors (audit log, `updated_by`).
- `actionReorder()` rescrie `sort_order` pornind de la **minimul existent între
  id-urile trimise**, ca reordonarea din pagina 2 să nu sară în capul listei;
  totul în tranzacție.
- `deleteItem()` face soft delete dacă modelul are `softDelete()`, altfel cade pe
  `purgeItem()` — care șterge în tranzacție traducerile, copiii (FAQ, galerie) și
  fișierele, apoi înregistrarea.
- `renderIndex()` forțează `notDeleted()` când query-ul e `SoftDeleteQuery`.

### 4.3 Controllerul unei entități — șablon complet

```php
<?php

namespace app\modules\admin\controllers;

use Yii;
use app\constants\EntityConstants;
use app\models\{entity}\{Entity};
use app\models\{entity}\{Entity}Translations;
use app\models\{entity}\forms\{Entity}Form;
use app\models\{entity}\forms\{Entity}TranslationsForm;
use app\models\{entity}\search\{Entity}Search;
use app\modules\admin\controllers\base\BaseAdminCrudController;
use app\modules\admin\controllers\traits\MainCrudTrait;

class {Entity}Controller extends BaseAdminCrudController
{
    use MainCrudTrait;

    public string $baseEntity = EntityConstants::{ENTITY};
    public string $translationEntity = EntityConstants::{ENTITY}_TRANSLATIONS;

    protected function modelClass(): string { return {Entity}::class; }
    protected function formClass(): string { return {Entity}Form::class; }
    protected function searchClass(): string { return {Entity}Search::class; }
    protected function translationClass(): string { return {Entity}Translations::class; }
    protected function translationFormClass(): string { return {Entity}TranslationsForm::class; }

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

    protected function imageFields(): array { return ['image']; }         // [] dacă nu are
    protected function filePath(): string { return {Entity}::FILE_PATH; }  // '' dacă nu are
}
```

Atât — ~25 de linii, și entitatea are tot CRUD-ul.

---

## 5. Anatomia unei entități — fișierele de creat

```
migrations/mYYMMDD_HHMMSS_create_{entities}_table.php
migrations/mYYMMDD_HHMMSS_create_{entities}_translations_table.php

models/{entity}/{Entity}.php
models/{entity}/{Entity}Translations.php
models/{entity}/forms/{Entity}Form.php
models/{entity}/forms/{Entity}TranslationsForm.php
models/{entity}/search/{Entity}Search.php

modules/admin/controllers/{Entity}Controller.php
modules/admin/views/{entity-key}/{index,table_data,_search_form,_form,create,update,view}.php
modules/admin/views/{entity-key}/translations/{index,table_data,view,update,_form}.php
```

Plus trei modificări în fișiere existente: `constants/EntityConstants.php`,
`AdminEntityRegistry::all()`, `modules/admin/components/views/aside.php`.

### 5.1 Modelul principal

`SoftDeleteTrait` **înainte** de `TranslationTrait`, `$translationClass`,
constantele de status, `behaviors()` cu activity log + blameable + timestamp,
`optsStatusCreate()` / `optsStatusUpdate()` și `findActive()` pentru frontend:

```php
class {Entity} extends ActiveRecord
{
    use SoftDeleteTrait;
    public $translationClass = {Entity}Translations::class;
    use TranslationTrait;

    public const STATUS_ACTIVE = 'ACTIVE';
    public const STATUS_HIDDEN = 'HIDDEN';
    public const STATUS_DRAFT = 'DRAFT';
    public const STATUS_INACTIVE = 'INACTIVE';

    public const FILE_PATH = 'core/uploads/images/{entity-key}/'; // TREBUIE să se termine cu /

    public static function tableName(): string { return '{{%{entities}}}'; }

    public function behaviors(): array
    {
        return [
            'activityLog' => ['class' => ActivityLogBehavior::class],
            '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'),   // coloane dateTime, nu timestamp UNIX
            ],
        ];
    }

    // rules(), attributeLabels(), optsStatus(), optsStatusCreate(), optsStatusUpdate()

    public static function findActive(): SoftDeleteQuery
    {
        return static::find()
            ->notDeleted()
            ->where(['status' => self::STATUS_ACTIVE])
            ->with(['translationsObj', 'translationFallback'])
            ->orderBy(['sort_order' => SORT_ASC, 'id' => SORT_ASC]);
    }
}
```

`optsStatusCreate()` întoarce **doar** `INACTIVE`: o înregistrare nouă nu poate fi
publicată direct, pentru că încă nu are traduceri.

### 5.2 Form objects

Formularele **nu** sunt ActiveRecord, ci `yii\base\Model` care primesc modelul în
constructor:

```php
class {Entity}Form extends Model
{
    private {Entity} $model;

    public ?string $alias = null;
    public ?int $sort_order = null;
    public ?string $status = null;

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

    private function formAttributes(): array { return ['alias', 'sort_order', 'status']; }

    public function rules(): array
    {
        return [
            [['alias'], 'required'],
            [['alias'], 'string', 'min' => 2, 'max' => 255],
            [['sort_order'], 'integer'],
            ['status', 'default', 'value' => {Entity}::STATUS_INACTIVE, 'on' => 'create'],
            ['status', 'required', 'on' => 'update'],
            ['status', 'in', 'range' => array_keys(self::optsStatusCreate()), 'on' => 'create'],
            ['status', 'in', 'range' => array_keys(self::optsStatusUpdate()), 'on' => 'update'],
            ['status', ActiveTranslationValidator::class,
                'activeStatus' => {Entity}::STATUS_ACTIVE,
                'translationClass' => {Entity}Translations::class,
                'on' => 'update'],
        ];
    }

    public function setDefaultValues(): void { /* status + sort_order implicite */ }

    public function saveRecord(): {Entity}|false
    {
        foreach ($this->formAttributes() as $attribute) {
            $this->model->{$attribute} = $this->{$attribute};
        }
        return $this->model->save(false) ? $this->model : false;
    }

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

Formularul de traduceri e la fel, dar `saveRecord(array $data)` primește
`['entity_id' => ..., 'language_id' => ...]`, le setează **doar la creare** și
întoarce `null` dacă nu s-a schimbat nimic (`getDirtyAttributes()` gol), ca să nu
raporteze fals „actualizat". `beforeValidate()` face `strip_tags()` pe câmpurile
simple și doar `trim()` pe cele care au voie să conțină HTML.

`ActiveTranslationValidator` verifică, la trecerea pe `ACTIVE`, că există rând de
traducere pentru **fiecare limbă activă**; altfel adaugă eroarea
`admin/errors: translation_activate` cu lista limbilor lipsă.

### 5.3 Modelul de căutare

Extinde modelul, `formName(): ''` (parametri curați în URL) și `search($params)`
care întoarce **query-ul**, nu un data provider:

```php
class {Entity}Search extends {Entity}
{
    public $sort = 'sort_order_asc';

    public function rules(): array { return [[['alias', 'status', 'sort'], 'safe']]; }
    public function formName(): string { return ''; }

    public function search($params)
    {
        $query = {Entity}::find()->notDeleted();
        $this->load($params);
        if (!$this->validate()) { return $query->orderBy(['sort_order' => SORT_ASC]); }

        $query->andFilterWhere(['like', 'alias', $this->alias]);
        $query->andFilterWhere(['status' => $this->status]);

        switch ($this->sort) { /* sort_order_asc|desc, updated_at_asc|desc */ }
        return $query;
    }

    public static function optsSort(): array { /* etichete pentru dropdown */ }
}
```

### 5.4 View-urile

Toate sunt subțiri — configurează widget-uri:

- **`index.php`** — `BreadcrumbsWidget` + titlu + `ActionsButtonsWidget` (Create)
  + `_search_form` + `table_data` + `ConfirmModalWidget` cu
  `confirmClass => 'delete-row-table'`.
- **`table_data.php`** — un singur `ListViewWidget::widget([...])`. Fișierul e
  randat și singur, pentru reîncărcarea AJAX (JS-ul îi înlocuiește conținutul în `.table-area`).
- **`_search_form.php`** — `ActiveForm` cu `method => 'get'`, `action => $searchAction`,
  clasa `search-form-admin` (JS-ul o interceptează).
- **`_form.php`** — câmpurile entității; blocul de status **doar** pe scenariul `update`.
- **`create.php` / `update.php`** — wrapper peste `_form`.
- **`view.php`** — `DetailViewWidget` + butoane + `ConfirmModalWidget` cu
  `confirmClass => 'delete-record-single'`.
- **`translations/*`** — lista limbilor + view/update per limbă.

Coloana standard „nivel de traducere":

```php
'translation_level' => [
    'label' => Yii::t('admin/transaltion', 'Translation Level'),
    'format' => 'raw',
    'value' => static function ($data) {
        $translationClass = $data->getTranslations()->modelClass;
        $langs = Languages::find()->where(['status' => Languages::STATUS_ACTIVE])->all();
        $cnt = 0;
        foreach ($langs as $l) {
            if ($translationClass::find()->where(['entity_id' => $data->id, 'language_id' => $l->id])->exists()) $cnt++;
        }
        return Html::tag('span', "{$cnt}/" . count($langs), ['class' => $cnt === count($langs) ? 'valid' : 'warning']);
    },
],
```

### 5.5 Entități-copil (galerie, FAQ, opțiuni)

Copiii gestionați din interiorul părintelui **nu** primesc controller separat: se
scrie un trait `Has{Children}Trait` cu acțiunile `action{Child}s`,
`action{Child}Create`, `action{Child}Update`, `action{Child}Translations`,
`action{Child}TranslationView`, `action{Child}TranslationUpdate`,
`action{Child}Delete`, `action{Child}DeleteRow`, iar controllerul părinte îl
folosește alături de `MainCrudTrait`. View-urile stau în
`modules/admin/views/{entity-key}/{children}/`. Copilul se leagă prin FK propriu
(`step_id`, `service_id`), traducerile lui tot prin `entity_id`.

---

## 6. Contractele widget-urilor și ale JS-ului

### 6.1 `ListViewWidget`

Proprietăți: `$dataProvider` (un `DataProviderHelper`), `$attributes` (array sau
`Closure($model)`), `$buttons` (array sau `Closure($model)`), `$options`,
`$paginationView` (orice valoare non-null **suprimă** paginarea).

Chei acceptate în `options` — fiecare poate fi dată ca valoare în listă sau ca
cheie asociativă:

| Cheie | Formă | Efect |
|---|---|---|
| `numbering` | `['label' => '№', 'sequential']` | coloană de numerotare; `'sequential'` numerotează continuu peste pagini |
| `results_summary` | flag | „result {from} - {to} from {total}" |
| `pages_summary` | flag | „page {n} from {total}" |
| `search_summary` | flag | listează filtrele active din `$_GET` (sare peste `page`, `limit`, `admin-language`) |
| `table_type` | `'min'` | tabel compact |
| `totals` / `averages` | array de coloane | rânduri de sumar sub tabel |
| `no_bulk` | flag | dezactivează selecția în masă |
| `no_reorder` | flag | dezactivează drag & drop |

`attributes`: fie string simplu (`'alias'`), fie
`'cheie' => ['label' => ..., 'format' => 'text'|'raw', 'value' => scalar|Closure($model)]`.
`text` face `Html::encode()`, `raw` afișează brut; valoarea goală devine
`<span class="danger no-data-symb">N/A</span>`.

`buttons`: `['label' => HTML, 'action' => array|string|Closure($model), 'options' => array|Closure($model), 'encodeLabel' => bool]`.
Butonul de ștergere primește `['class' => 'delete-record-modal', 'confirm' => '...']`
și pointează spre `delete-row`.

Selecția în masă și reordonarea **nu se configurează per tabel** — se rezolvă din
`AdminEntityRegistry` (`sortable`, existența coloanei `sort_order`, existența
entității în registru). Markup-ul emite: `table[data-bulk-table][data-reorder-table]`,
`tr[data-id]`, `input[data-bulk-master]`, `input[data-bulk-item]`,
`[data-reorder-toggle]`, `[data-reorder-save][data-url]`, `[data-bulk-form]`,
`[data-bulk-count]`.

### 6.2 Restul widget-urilor și JS-ul

| Widget | Opțiuni | Observații |
|---|---|---|
| `DetailViewWidget` | `$model`, `$attributes` | **ignoră `format`**: dacă `value` e setat, se afișează brut |
| `BreadcrumbsWidget` | `$links` = `[['label','url'?]]` | etichetele ≥ 25 caractere sunt trunchiate |
| `ActionsButtonsWidget` | `$buttons` = `key => ['label','icon','action','options']` | **toate cele 4 chei sunt obligatorii** |
| `ConfirmModalWidget` | `$heading`, `$heading_icon`, `$confirmClass`, `$data_method` | `delete-row-table` pe liste, `delete-record-single` pe detaliu |
| `PaginationWidget` | `$page`, `$pagination`, `$distance` | ancorele au clasa `page-change` (JS-ul le interceptează) |
| `HeaderWidget` / `AsideWidget` | fără opțiuni | header-ul publică `window.ADMIN_SEARCH_URL`; aside-ul e HTML scris de mână |

JS (`modules/admin/web/js/`):

- **`admin-tools.js`** — `initGlobalSearch()` (paletă Ctrl/Cmd+K, fetch pe
  `ADMIN_SEARCH_URL?q=`, min 2 caractere, debounce 180 ms), `initBulkActions()`,
  `initReorder()` (drag & drop, POST `ids[]` la `data-url`, reload la succes),
  `initSeoMeters()` (contoare de lungime pe câmpurile SEO).
- **`main.js`** — reîncărcarea AJAX a listei (`.table-area`), `deleteRowTable()`,
  `deleteRecord()` (citește header-ul `X-Redirect`), `changePage()`,
  `confirmActionModal()`, `actionSearch()` pe `form.search-form-admin`, temă
  dark, previzualizare imagini, ascunderea flash-urilor după 7 s.
- **`ajax.js`** — `sendAjax()` / `serialize()`; trimite mereu `X-CSRF-Token`.

Convenția de loader: elementul `.loader-ajax` în interiorul `.table-area` /
`.it-wrp`; ambele primesc clasa `active` cât durează cererea.

`AdminAsset` (`sourcePath = '@app/modules/admin/web'`, `forceCopy`) încarcă:
Bootstrap 5.3, bootstrap-icons, Montserrat + Material Symbols, SunEditor, Swiper,
Flatpickr, Choices.js, Chart.js, SweetAlert2, apoi `ajax.js`, `global.js`,
`main.js`, `admin-tools.js`.

### 6.3 `DataProviderHelper`

`new DataProviderHelper(['query' => $query, 'limit' => 20])`. Citește din GET
`page` și `limit`. Proprietăți publice folosite de view-uri: `model`, `query`,
`page`, `limit`, `data`, `dataCountAll`, `dataCountPage`, `pagination`.
Fără `limit` nu paginează deloc. Redirecționează singur când pagina cerută e în
afara intervalului.

⚠️ În implementarea actuală constructorul rulează `query->all()` **și** `count()`
în plus față de query-ul paginat — 3 interogări per listă. Merită optimizat în
proiectul nou.

### 6.4 `FileHelper`

- `getInstance([['model' => $form, 'property' => 'image']])` — ia `UploadedFile`;
- `uploadFile([['model','property','uploadedFile','filePath','oldFile'?]], $deletePrevious = true)`;
- `fileDestroy([['filePath' => ..., 'file' => ...]])`;
- `getFile(['filePath' => ..., 'file' => ...])` — URL absolut, cu fallback pe
  `/core/images/no-file.png`.

În DB se salvează **doar numele** fișierului (`md5(uniqid()).ext`), calea vine din
constanta `FILE_PATH` a modelului. `filePath` trebuie să se termine cu `/`.

---

## 7. Ecranele de infrastructură

### 7.1 Dashboard (`DefaultController::actionIndex`)

Patru statistici (total înregistrări, cereri noi de contact, traduceri lipsă,
draft-uri), acțiuni rapide, „Necesită atenție" (entități cu traduceri lipsă),
„Activitate recentă" (ultimele 8 intrări din jurnal) și „Content overview" —
o grilă cu numărul de înregistrări per entitate, generată **din registru**.

Detaliu de implementare: pentru „traduceri lipsă" se face **un singur** query
grupat per entitate (`COUNT(DISTINCT language_id) GROUP BY entity_id`), nu unul
per înregistrare. Fiecare entitate e învelită în try/catch, ca o entitate stricată
să nu doboare dashboard-ul.

### 7.2 Coș de gunoi (`TrashController`)

Rute: `index` (tab per entitate `trashable`, cu numărul de rânduri șterse),
`restore`, `purge`, `empty`, `restore-all` — toate POST.

Detaliul care merită copiat: ștergerea definitivă **reutilizează cascada
entității**, nu o duplică — `TrashController` instanțiază controllerul entității
(`Yii::$app->createController('/admin/' . $entity)`) și îi apelează
`purgeItem()` printr-un `Closure::bind` (metoda e `protected`).

### 7.3 Jurnal de activitate

Tabel `activity_history`: `entity`, `entity_id`, `entity_label`, `action`,
`changes` (JSON `{atribut: [vechi, nou]}`), `user_id`, `ip`, `created_at`.
FK spre `users` cu `ON DELETE SET NULL` — ștergerea unui operator nu șterge urma.

`ActivityLogBehavior` se atașează pe fiecare model și scrie la insert / update /
delete. Reguli:

- ignoră `created_at`, `updated_at`, `created_by`, `updated_by` și toate câmpurile
  de securitate (`password_hash`, `auth_key`, `access_token`, ...);
- dacă s-a schimbat `deleted_at` → acțiune `deleted` sau `restored` (deci soft
  delete-ul nu are cale proprie de logare);
- dacă singurul câmp schimbat e `status` → `status_changed`;
- eticheta înregistrării (`alias`, `name`, `slug`, `email`…) se captează **la
  momentul scrierii**, ca rândurile șterse definitiv să rămână lizibile;
- totul într-un try/catch: auditul nu are voie să rupă un save.

### 7.4 Căutare globală (`SearchController`)

`/admin/search?q=` → JSON `['query', 'sections', 'records']`. Caută în etichetele
de meniu (secțiuni) și în coloanele declarate în registru la `search`, maximum 5
rezultate per entitate și 30 în total, cu `notDeleted()` acolo unde există.

### 7.5 Panou de traduceri (`TranslationsController`)

Pentru fiecare entitate traductibilă: câte înregistrări sunt complete și câte
limbi lipsesc, cu bară de acoperire și lista rândurilor incomplete. Folosește tot
o singură interogare per entitate (`entity_id`, `language_id`, `asArray()`).

### 7.6 Bibliotecă media

elFinder, înregistrat în `config/web.php` → `controllerMap`, în două instanțe:
una multi-root pentru ecranul „Media Library", alta (`PathController`) ca
selector de imagini pentru editor.

### 7.7 Profil și autentificare

`/auth/login` (layout separat `auth`) + `/auth/logout`; `AccessControl` pe
`login` (doar guest) și `logout` (doar autentificat). `ProfileController` are
schimbarea parolei (`ChangePasswordForm`: parola veche + regex de complexitate +
confirmare).

---

## 8. i18n — interfața adminului

Textele adminului sunt separate de conținut și se înregistrează în
`config/web.php`:

```php
'admin/*' => [
    'class' => 'yii\i18n\PhpMessageSource',
    'basePath' => '@app/modules/admin/messages',
    'forceTranslation' => true,           // OBLIGATORIU când sourceLanguage === 'en'
    'fileMap' => [
        'admin/buttons' => 'buttons.php',   'admin/list-table'  => 'list-table.php',
        'admin/history' => 'history.php',   'admin/flash'       => 'flash.php',
        'admin/status'  => 'status.php',    'admin/transaltion' => 'transaltion.php',
        'admin/headings'=> 'headings.php',  'admin/menu'        => 'menu.php',
        'admin/labels'  => 'labels.php',    'admin/errors'      => 'errors.php',
    ],
],
```

Categoriile și ce conțin:

| Categorie | Conținut |
|---|---|
| `admin/menu` | numele entităților, pereche `{Entity}` + `{Entity}_single` |
| `admin/labels` | etichetele de câmpuri și de secțiuni de formular |
| `admin/headings` | titluri cu placeholder: `create`, `update`, `view`, `index`, `translation_title`, `gallery_title`, `faqs_title`, `options_title` |
| `admin/buttons` | Create, Save, Search, Reset, Reorder, Save order… |
| `admin/list-table` | anteturi și acțiuni de tabel, `no_results`, `page`, `result` |
| `admin/flash` | `created`, `updated`, `deleted`, `restored`, `order_saved`, `bulk_done`, `nothing_selected`, `key_delete_warning` |
| `admin/errors` | `not_found`, `translation_activate`, `save_before_activate`, `current_password_wrong` |
| `admin/status` | etichetele valorilor ENUM |
| `admin/history` | etichetele jurnalului de activitate |
| `admin/transaltion` | ecranul de traduceri (**numele fișierului e scris greșit în original — categoria trebuie să se potrivească exact cu fișierul; în proiect nou scrie-l corect `translation`**) |

Limba interfeței de admin se schimbă cu parametrul GET `admin-language`, se ține
în sesiune (`admin.language`) și se aplică în `Module::init()`. Motivul: rutele
`/admin/*` sunt excluse din prefixarea pe limbi (`ignoreLanguageUrlPatterns`),
deci limba nu poate veni din URL ca pe site.

---

## 9. Autentificare și RBAC

Roluri (create într-o migrație proprie, după migrațiile RBAC din Yii):
`dev` > `admin` > `contentManager`, legate prin `addChild()`. Seed-ul creează un
utilizator inițial și îi atribuie `dev`.

Accesul se controlează **într-un singur loc**, în `modules/admin/Module::behaviors()`:

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

> ⚠️ **În cinova blocul acesta este comentat**, deci panoul e accesibil fără
> autentificare. Într-un proiect nou el trebuie activat de la început, iar odată
> cu el se revin la valori sigure: `access => ['@']` pe cele două instanțe
> elFinder din `controllerMap` și eliminarea fallback-ului „primul user din tabel"
> din `ProfileController::currentUser()`.

---

## 10. Rețeta: de la tabel la pagină publică

1. **Migrații** — tabel principal + tabel de traduceri (§2). `php yii migrate`.
2. **Constante** — `{ENTITY}` și `{ENTITY}_TRANSLATIONS` în `constants/EntityConstants.php`.
3. **Modele** — `{Entity}`, `{Entity}Translations` (§5.1).
4. **Forms + Search** — patru fișiere (§5.2, §5.3).
5. **Controller admin** — ~25 de linii (§4.3).
6. **View-uri admin** — copiate de la o entitate similară, cu câmpurile schimbate (§5.4).
7. **Registru + meniu** — intrare în `AdminEntityRegistry::all()` și în `aside.php`.
8. **Traduceri de admin** — `{Entity}` și `{Entity}_single` în `menu.php`, câmpurile noi în `labels.php`, în toate limbile.
9. **Verificare admin** — listă, creare, editare, traduceri, reordonare, ștergere în coș, restaurare, căutare globală.
10. **Frontend** — controller + view (§11).
11. **Rutare** — regulă în `config/web.php` dacă entitatea are pagină proprie + rând în `pages` pentru SEO.
12. **Seed** (opțional) — migrație separată care populează conținutul inițial în toate limbile.

---

## 11. Frontend: cum se trag datele în pagini

Regula: **niciun query în view**.

```php
class {Entity}Controller extends Controller
{
    public function actionIndex()
    {
        $page = Pages::findOne(['name' => '{entity-key}/index']);  // SEO + banner din admin
        $items = {Entity}::findActive()->all();                     // notDeleted + ACTIVE + with()

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

    public function actionView(int $id, string $slug)
    {
        $item = {Entity}::findActive()->andWhere(['id' => $id])->one();
        if ($item === null) { throw new NotFoundHttpException(); }

        if ($slug !== UrlHelper::normalizeSlug((string) $item->slug)) {
            return $this->redirect($item->getUrl(), 301);   // slug greșit → 301 spre cel corect
        }

        return $this->render('view', [
            'page' => Pages::findOne(['name' => '{entity-key}/view']),
            'item' => $item,
        ]);
    }
}
```

În view:

```php
use app\helpers\SeoHelper;

SeoHelper::applyModel($this, $item ?? $page);   // title + meta + OpenGraph, cu fallback pe limbă

<h2><?= Html::encode($item->t('name', '')) ?></h2>
<p><?= Html::encode($item->t('desc_min', '')) ?></p>
```

Reguli:

- textele traduse se citesc **doar** prin `t('camp', $default)`;
- SEO-ul paginilor de listă vine din `pages` + `pages_translations`, cel al
  paginilor de detaliu din traducerile entității;
- rutele frumoase în `config/web.php` → `urlManager.rules`, ex.
  `'{entity-key}/<id:\d+>/<slug:[a-zA-Z0-9\-]+>' => '{entity-key}/view'`;
- prefixul de limbă îl adaugă `codemix\localeurls\UrlManager`, alimentat la
  bootstrap din tabelul `languages` — nu hardcoda lista de limbi;
- `/admin/*` se exclude din prefixare prin `ignoreLanguageUrlPatterns`.

---

## 12. Funcționalul obligatoriu — listă de recepție

**Pe fiecare entitate**
- [ ] listă paginată, cu numerotare, sumar de rezultate și reîncărcare AJAX;
- [ ] căutare/filtrare pe câmpurile relevante + sortare;
- [ ] creare / editare cu validare pe scenarii (`create` / `update`);
- [ ] status editabil doar la editare, cu blocarea publicării fără traduceri complete;
- [ ] pagină de detaliu cu toate câmpurile + audit (cine/când);
- [ ] traduceri: listă de limbi cu nivel de completare, editare per limbă;
- [ ] upload de imagini care supraviețuiește unei validări eșuate;
- [ ] reordonare prin drag & drop (dacă are `sort_order`);
- [ ] acțiuni în masă (activare, dezactivare, ștergere);
- [ ] ștergere în coș + restaurare + ștergere definitivă;
- [ ] link „vezi pe site" acolo unde entitatea are pagină publică.

**Global**
- [ ] dashboard cu statistici, „necesită atenție" și activitate recentă;
- [ ] căutare globală (Ctrl+K) peste entitățile din registru;
- [ ] panou de traduceri (ce lipsește, pe entități și limbi);
- [ ] coș de gunoi comun;
- [ ] istoric de activitate;
- [ ] bibliotecă media;
- [ ] gestiunea limbilor, paginilor (SEO), setărilor și utilizatorilor;
- [ ] autentificare + RBAC activ;
- [ ] interfața adminului tradusă separat de conținut;
- [ ] mesaje flash, breadcrumbs, confirmare la ștergere, loader pe AJAX.

---

## 13. Capcane și defecte de nu-copiat

**Capcane de arhitectură**

| Capcană | Simptom | Prevenție |
|---|---|---|
| Coloana de legătură numită `service_id` în traduceri | `t()` întoarce mereu default-ul | `entity_id`, mereu |
| Lipsa `with(['translationsObj','translationFallback'])` | 20 de rânduri → 40+ query-uri | pune-l în `findActive()` |
| `updateAll()` în acțiunile în masă | nu se scrie `updated_by`, nu intră în jurnal | salvează model cu model |
| Publicare fără traduceri | câmpuri goale pe alte limbi | `ActiveTranslationValidator` pe `status` |
| `find()` propriu în model cu soft delete | coșul nu mai filtrează | nu declara `find()` |
| Fișier încărcat + validare eșuată | utilizatorul reîncarcă imaginea la fiecare eroare | pipeline-ul cu fișiere temporare |
| ENUM scris de mână în migrație | valorile diverg de constante | `EnumHelper::generateQuery()` |
| Reordonare din pagina 2 | rândurile sar în capul listei | `sort_order` rescris de la minimul paginii |
| Cache de schemă activ în dev | modificările de coloane par să nu existe | `enableSchemaCache => !YII_DEBUG` |
| `sourceLanguage === 'en'` fără `forceTranslation` | adminul afișează cheile brute | `forceTranslation => true` |
| Apostrof în cheile de traducere PHP | fișier de mesaje cu eroare de sintaxă | scapă `\'` sau evită apostroful în cheie |

**Defecte prezente în cinova — de reparat, nu de copiat**

- `Module::behaviors()` cu `AccessControl` este **comentat** → adminul e public.
- `TrashController` are `restore-all` în afara listei `VerbFilter` (nu e POST-only).
- Două mecanisme paralele de limbă pentru admin (`AdminLanguageHelper` cu cheia
  `adminLanguage` vs. `Module` cu `admin.language`) — păstrează unul singur.
- `DataProviderHelper` face 3 interogări per listă.
- `DetailViewWidget` acceptă `format` dar îl ignoră.
- Cod mort: `tableCheckbox()` din `main.js`, categoria `admin/dashboard`,
  `helpers/EntityHelper.php` (înlocuit de `AdminEntityRegistry`).
- Logout e POST-only, dar meniul îl leagă printr-un `<a>` simplu.
- Fișierul de mesaje `transaltion.php` are numele scris greșit, iar categoria
  trebuie să se potrivească cu el.
- `aside.php` e HTML scris de mână, deși registrul ar putea genera meniul.

---

## 14. Ordinea de bootstrap a unui proiect nou

1. Yii2 basic + `composer require codemix/yii2-localeurls mihaildev/yii2-elfinder`.
2. Copiază infrastructura din §3.
3. Migrații de sistem (§2.4), inclusiv RBAC, apoi `php yii migrate`.
4. `config/web.php`: `i18n` (cu `fileMap`), `user` + `authManager`, `urlManager`
   (bootstrap-ul de limbi + `ignoreLanguageUrlPatterns` pentru `/admin/`),
   `assetManager.appendTimestamp`, `controllerMap` pentru elFinder, modulul `admin`
   cu `layout => 'admin'`.
5. **Activează `AccessControl` pe modulul admin** (§9) înainte de orice altceva.
6. Ecranele de infrastructură (§7) — funcționează generic, prin registru.
7. Prima entitate, cap-coadă, după §10 — apoi restul, copiind-o.
8. Frontend: `pages` populat pentru fiecare rută + controllerele publice.

*Document scris pe baza implementării reale din proiectul cinova (2026-08-21).
Fiecare regulă are în spate cod funcțional; secțiunea 13 listează explicit ce
anume din implementarea existentă NU trebuie copiat.*
