<!--
	covers: modules/admin config/modules/admin.php resources/js/admin
	verified: 3c56e1b
-->
# Admin module

[`modules/admin/`](../../modules/admin) — 152 files, 14 010 LOC, nearly a third of the codebase. A homegrown
declarative CRUD framework: a screen is **one controller declaring `Field` objects**, and the generic actions
do the rest.

## Anatomy of a screen

[`modules/admin/controllers/ArticlesController.php`](../../modules/admin/controllers/ArticlesController.php)
is the whole of the Articles backend:

```php
class ArticlesController extends BaseController {

    public ?string $modelClass = Article::class;
    public Article|ActiveRecord|null $model = null;
    protected array $submodules = [ Photo::class ];

    public function getFields() : array {
        return [ ...parent::getFields(),
            DatetimeField::make( 'createdAt' )->defaultValue( date( 'Y-m-d H:i:00' ) )->colClasses( 'd-none d-md-table-cell' ),
            TextField::make( 'name' )->colClasses( 'w-100' ),
            TextareaField::make( 'lead' ),
            CkEditorField::make( 'body' )->height( '450px' ),
            CheckboxField::make( 'isPublished' )->defaultValue( true ),
            InlineGridField::makeForModel( Photo::class )->rowRenderer( fn( Photo $p ) => $p->inlineGridRow )
        ];
    }

    protected function getFormLayout() : string {
        return '<div class="row">
                    <div class="col-12 col-lg-8">{name}{body}</div>
                    <div class="col-12 col-lg-4">{createdAt}{lead}{isPublished}{photos}</div>
                </div>';
    }

}
```

That produces a filterable, sortable, paginated grid, create/edit forms, delete, toggle and an inline photo
sub-grid. **No view files, no action methods.**

## `BaseController`

[`modules/admin/controllers/BaseController.php`](../../modules/admin/controllers/BaseController.php),
758 lines. Extends the *frontend* `app\controllers\Controller`.

### Configuration surface

| Member | Default | Meaning |
|--------|---------|---------|
| `$modelClass` | — | the entity |
| `$submodules` | `[]` | child models reachable from a row |
| `$gridSortable` | `false` | drag-and-drop `orderNumber` ordering |
| `$gridOrderBy` / `$gridOrderDir` / `$gridPageSize` | derived | see below |
| `$headerButtons` | `[]` | extra buttons above the grid |
| `$flushCacheAfterSave` / `AfterDelete` / `AfterSort` | `true` | cache invalidation per action |
| `getFields()` | `[]` | **the declaration** |
| `getFormLayout()` / `getCreateLayout()` / `getEditLayout()` | `''` | form arrangement |
| `getRowClass( $model )` | `''` | per-row CSS class in the grid |

`init()` derives the grid defaults from `$gridSortable`:

```
sortable   → orderBy 'orderNumber' ASC,  pageSize 10000
otherwise  → orderBy 'id' DESC,          pageSize 25
```

**A sortable grid pages at 10 000 rows** — drag-and-drop needs the whole set on one page. That is a real
memory ceiling on a large table.

### Generic actions

`actions()` maps seven action ids to `Action` classes; `sort` is removed unless `$gridSortable`:

| Id | Class | Cache flush |
|----|-------|-------------|
| `index` | `Grid` | — |
| `create` | `Create` | `$flushCacheAfterSave` |
| `edit` | `Edit` | `$flushCacheAfterSave` |
| `delete` | `Delete` | `$flushCacheAfterDelete` |
| `toggle` | `Toggle` | `$flushCacheAfterSave` |
| `sort` | `Sort` | `$flushCacheAfterSort` |
| `inline-grid` | `InlineGrid` | — |

A controller adds screens by extending `actions()` — this is how `DashboardController` gets
`delete-cache`, and how the overview and document-generating screens are wired.

The 21 action classes in [`modules/admin/action/`](../../modules/admin/action): `Action` (base), `Grid`,
`Create`, `Edit`, `Delete`, `Toggle`, `Sort`, `InlineGrid`, `Form`, `View`, `Rights`, `Dashboard`,
`DeleteCache`, `Generate`, `GenerateOffer`, `GenerateRentDocument`, `CreateRent`, `OverviewTable` (781 LOC),
`OverviewCalendar`, `OverviewFlow`, `Viber`.

### Rights

Derived from controller + action, never stored as a list
([`BaseController::getRequiredRights()`](../../modules/admin/controllers/BaseController.php)):

```php
$right  = strtoupper( Inflector::camel2id( str_replace( 'Controller', '', <ClassBaseName> ), '_' ) );
$suffix = $action === 'index' ? '' : '_' . strtoupper( str_replace( '-', '_', $action ) );
$suffix = in_array( $action, [ 'inline-grid', 'toggle' ] ) ? '_EDIT' : $suffix;
```

`ProposalRequestsController` + `edit` → `PROPOSAL_REQUESTS_EDIT`. `inline-grid` and `toggle` both collapse to
`_EDIT`, so there is no separate right for them.

`checkRights()` hard-codes one exception: `dashboard/index` is always allowed.
[`User::hasRight()`](../../components/User.php) requires `admin`, then either `superAdmin` or membership in
the user's right list, and strips a leading `DASHBOARD_`.

### Labels come from convention

`initModelLabels()` / `initModelHints()` build the keys as:

```
bt( '<models><Attribute>' )       ?: bt( '<attribute>' )          // label
bt( '<models><Attribute>Hint' )   ?: bt( '<attribute>Hint' )      // hint
bt( '<models><Attribute>Grid' )                                   // grid header, in getGridColumns()
```

`<models>` is the model's base name pluralized and lower-camelised — `Article` → `articles`, so
`articlesName`, `articlesNameHint`, `articlesNameGrid`. All in `messages/hu/admin.php`, Hungarian only.
Because `bt()` returns `''` on a miss, the `?:` chain works and a missing key falls back to the generic
attribute key, then to Yii's generated label.

### Propagates and expand

Two request-scoped mechanisms that make sub-grids work, both read from POST-then-GET and memoized in a
`static`:

- **`propagates`** — a JSON object of `attribute => value`. `getGridQuery()` applies each as a `WHERE`, and
  `filterFields()` **removes those attributes from the field list** so the form cannot change them.
  `getUrl()` re-encodes them into every generated link. This is how "photos of article 42" is a filtered
  Photos screen rather than its own controller.
- **`expand`** — a flag that suppresses the back button and switches the layout into the popup form used by
  submodule windows.

### Submodules

`protected array $submodules = [ Photo::class ]` — or `[ Photo::class => 'articleId' ]` when the foreign key
cannot be inferred. Each becomes a
[`SubModuleItem`](../../modules/admin/models/SubModuleItem.php), rendered as an extra grid column of icon
links that open the child screen in a new window with `propagates` pre-set.

The column only appears if the administrator has the right for at least one child model.

## Fields

[`modules/admin/models/fields/`](../../modules/admin/models/fields) — `Field` (335 LOC) plus **28 types**:

`AddressField`, `AmountField`, `CheckboxField`, `CheckboxListField`, `CkEditorField`, `CodeField`,
`DateField`, `DatetimeField`, `DropDownField`, `EmailField`, `FileField`, `IconField`, `InlineGridField`,
`LanguageField`, `LocationMapField`, `MacAddressField`, `NumberField`, `PercentageField`, `PhoneField`,
`PictureField`, `QuantityField`, `RawPasswordField`, `ReminderField`, `StyleField`, `TaxNumField`,
`TextField`, `TextareaField`.

### The fluent API is generated

`Field` declares no `listable()`, `sortable()` … methods — they are `@method` annotations backed by
`getAutoSetters()` and `__call`. Each returns `$this`, so declarations chain:

```php
TextField::make( 'name' )->colClasses( 'w-100' )->searchable( false )
```

`CkEditorField` adds `matchHeight( '<selector>' )`, which renders a `data-match-height` attribute the admin
JS measures at init: the editor ends up as tall as the column next to it instead of a hard-coded pixel value
([`resources/js/admin/ckeditor.js`](../../resources/js/admin/ckeditor.js)).

`make( $name, $config )` is the constructor shorthand. `Field::init()` grabs
`Yii::$app->controller` into `$this->controller` when it is a `BaseController` — **fields know their
controller implicitly**, which is why they can render grid filters and modify queries.

### Field flags

| Flag | Controls |
|------|----------|
| `listable` | appears as a grid column |
| `sortable` | grid column is sortable |
| `searchable` | grid column gets a filter input |
| `creatable` / `editable` | appears in the create / edit form |
| `disabled` | rendered read-only |
| `multilingual` | **set automatically** by `filterFields()` from the model's `getMultilingualAttributes()` |

`filterFields( [ 'listable' => true ] )` is the selection mechanism used throughout — grid columns, the
filter model, the sort options and the query modifiers all come from a filtered pass over the same
declaration.

A field can also override `modifyGridQuery( $query )` to join what its column needs — so declaring a field
can change the grid's SQL.

## Form layouts

`getFormLayout()` returns an HTML string with `{fieldName}` placeholders. `getCreateLayout()` and
`getEditLayout()` default to it and can diverge. A field not mentioned in the layout is **not rendered** —
the layout is authoritative, not additive.

`{photos}` in the example refers to the `InlineGridField`, which is named after the relation, not an
attribute.

## Grid

[`modules/admin/grid/GridView.php`](../../modules/admin/grid/GridView.php) and
[`modules/admin/data/ActiveDataProvider.php`](../../modules/admin/data/ActiveDataProvider.php) wrap the Yii
equivalents. `getGridColumns()` assembles, in order: one column per listable field, then the submodule column,
then the actions column — the last two only when the administrator can see at least one entry in them.

The filter model is a [`Filter`](../../modules/admin/models/filters/Filter.php) built from the
listable+searchable fields, with a single `string, skipOnEmpty` rule over all of them.

## The dashboard

[`config/modules/admin.php`](../../config/modules/admin.php) declares two things:

- **`dashboardIndexIcons`** — one entry per screen: `modelClass`, `category` (`erp` / `crm` / `cms` / `opt`),
  optional `counterWhere` and `counterClass`, and `visible => false` for the submodule-only screens.
- **`dashboardNotificationGroups`** — 11 groups, each a `queryBuilder` closure plus an icon and a level.
  Some carry an `hrefBuilder` (location event reminders route to `locations`/`offices`/`ad-spaces` by the
  location's `type`) or an `itemRenderer` (failed gate-sync jobs render action, phone and truncated error).

The `locationEventReminders` group filters by the administrator's own rights inside its `queryBuilder`, which
is the only place per-user query scoping happens.

## Traps

1. **The whole backend is one Encore entry** (`admin`). A new admin screen needs no build change; a new admin
   JS file does.
2. **`Module::init()` forces `Yii::$app->language = APP_DEFAULT_LANGUAGE`.** The backend never sees the
   request language, and `bt()` keys only exist in Hungarian.
3. **A field missing from `getFormLayout()` silently disappears from the form**, including required ones —
   the record then fails validation with no visible cause.
4. **`propagates` comes from the request**, is memoized in a `static`, and is applied as a raw `WHERE`. It is
   how sub-grids scope themselves; it is also user-controllable input.
5. **Sortable grids load 10 000 rows.** Turning on `$gridSortable` for a large table is a memory decision.
6. **Rights are derived from the class name.** Renaming a controller renames its rights, and every
   administrator loses access until the new names are granted.
7. **`getRowClass()` receives `?ActiveRecord`.** The grid's `rowOptions` closure can pass `null`; an override
   must accept it.
