# Adding an Admin Page

https://dev.wisecp.com/es/adding-an-admin-page

Give a module its own page in the admin panel with two files. An area class returns the HTML; one line in `router.php` registers it.

## Overview

A module that manages its own records or runs a bulk job needs a real page. That page does not belong in the core: you declare a class and register it, and the route, menu entry, privilege check, breadcrumb and theme shell come for free. New to modules? See [Your First Module](https://dev.wisecp.com/en/your-first-module).

The dispatcher is `coremio/controllers/admin/module-page.php`; slug routes resolve to it and the result goes to the addon theme wrapper.

## Structure

- **classes/ModuleAdminArea.php**: The base class and registry: `register()`, route wiring, menu hook, instance helpers.
- **admin/module-page.php**: The internal dispatcher: resolves `page_*` and `op_*`, invisible in the URL.
- **tools/addons-area.php**: The theme wrapper: title bar, buttons, plugins, styles, scripts, modals.
- **{module}/AdminArea.php + router.php**: The two files you write. `Router::loadModuleRouters()` reads the router file before any route is matched.

## Walkthrough

### Declare the Area Class

1. Create `AdminArea.php` in your module directory, namespaced `WISECP\Modules\{Type}\{Name}`.
2. Extend `\ModuleAdminArea` and return a manifest from the static `manifest()` method.
3. Override `available()` when the page only makes sense under a condition; it guards the menu entry too.

### Register It

1. Create `router.php` next to it: include the class file, then call `\ModuleAdminArea::register(AdminArea::class)`.
2. Nothing else belongs there. Registration adds three routes, stores the manifest and hooks the menu entry.

### Write a Page Method

1. `page_home()` answers the area root; every method takes the trailing URL segments as an array.
2. Build the HTML with heredoc; read your strings from `$this->lang`.
3. Point links at yourself with `$this->link()`, never a hand-written path.

### Write an Operation

1. Name the method `op_{name}`; only that prefix is callable.
2. Post to the area URL with an `operation` parameter; a thrown exception becomes the standard JSON error.
3. Start with `$operation->demo()`, read input through `Filter::init()`, finish with `$operation->output()`.

### Store Area Settings

1. Use `settings()`, `setting()` and `save_settings()`, not a file of your own.
2. `save_settings()` merges into the current values, so a partial save keeps the rest, then fires `action:module.area_settings_saved`.

## Reference

### Base Class API

```php
class ModuleAdminArea
{
    public string $module_type = '';   // resolved from the namespace
    public string $module_name = '';   // resolved from the namespace
    public array  $lang        = [];   // {module}/lang/{selected}.php, falling back to en.php

    public function __construct();

    // You override this one. Anything else with a default is optional.
    public static function manifest(): array;

    public static function register(string $areaClass): void;
    public static function get(string $type, string $name): ?array;

    public function available(): bool;                                  // default true
    public function slug(): string;
    public function link(array $params = []): string;
    public function module_dir(): string;                               // filesystem path, trailing separator
    public function module_url(): string;                               // public URL of the module directory

    public function settings(): array;
    public function setting(string $key, mixed $default = null): mixed;
    public function save_settings(array $values): void;

    protected function license_state_badge(string $slug): string;
}
```

> **Eleven module types, not sixteen**
> 
> The accepted types are Servers, Payment, Registrars, Product, Addons, SMS, Mail, Authentication, Pipe, Imports and Fraud. For anything else `register()` does nothing, silently.

### What the Manifest Accepts

- **title**: Page and browser title; the menu label when `menu.name` is absent.
- **slug**: The URL segment, default the lowercased module name. `Filter::route()` keeps `a-zA-Z0-9`, hyphen, underscore and dot.
- **privileges**: Privilege keys, checked once for the whole area. Empty means no check.
- **menu**: `['path' => ['PRODUCTS', 'GROUP_HOSTING_SERVER'], 'name' => 'Hetzner Cloud']`. The path walks down the tree; omit it and no entry is created.
- **type, name, class**: Written by `register()` from the namespace and the argument; do not set them.

### URL Scheme and Method Resolution

```php
// Three routes, most specific first. $target is module-page/{Type}/{Name}.
$router->add($slug . '-2', $slug . '/(?)/(?)', $target . '/(1)/(2)');
$router->add($slug . '-1', $slug . '/(?)',     $target . '/(1)');
$router->add($slug,        $slug,              $target);

// Which is why link() derives the route key from the parameter count:
//   $this->link()                     -> /{admin}/{slug}
//   $this->link(['configuration'])    -> /{admin}/{slug}/configuration
//   $this->link(['logs', 'archive'])  -> /{admin}/{slug}/logs/archive
```

| Request | Method called | Argument |
| --- | --- | --- |
| `GET /{admin}/{slug}` | `page_home()` | empty array |
| `GET /{admin}/{slug}/configuration` | `page_configuration()` | `['configuration']` |
| `GET /{admin}/{slug}/logs/archive` | `page_logs_archive()`, else `page_logs()` | `['logs', 'archive']` |
| `POST` with `operation=sync_prices` | `op_sync_prices(Operation $operation)` | the `Operation` |
| no matching method | 404 page | nothing appears |

Hyphens, dots and commas in a segment become underscores: `/{slug}/price-list` resolves to `page_price_list()`. Operation names too.

### What a Page Method May Return

A string becomes the page body; an array passes the keys below to the wrapper.

| Key | Type | Effect |
| --- | --- | --- |
| `content` | string | The page body. Empty shows a `No module area content is available.` alert. |
| `page_title` | string | Overrides the manifest title. |
| `page_title_buttons` | array | A list of button descriptors; plain HTML shows nothing. |
| `page_title_after` | string | Free HTML after the title and buttons. |
| `page_title_logo` | string | An image URL, or raw HTML when it contains `<`. |
| `content_layout` | string | `panel` by default; `plain` drops the panel. |
| `content_data_class` | string | Extra class on the content wrapper (plain layout). |
| `breadcrumbs` | array | Appended to the Dashboard + area title trail. |
| `plugins` | array | Merged onto the wrapper defaults. |
| `page_styles`, `page_scripts`, `modals` | string | Head, footer and modal area. |

### The Signed-in Administrator

```php
public static function LoginData($type = 'member', $isRemembered = false, $recheck = false);
```

```php
$adminId = (int) (\UserManager::LoginData('admin')['id'] ?? 0);   // 0 outside the panel (CLI, cron)

// The operator picker for an assignment field.
$staff = \Admin::list();          // [id => ['id' => 1, 'full_name' => 'Jane Doe'], ...]
```

## Example

A two-file area for an imaginary `Acme` module.

```php
<?php
namespace WISECP\Modules\Servers\Acme;

use AdminFormBuilder;
use Exception;
use Filter;
use Operation;
use User;
use UserManager;
use WDB;

class AdminArea extends \ModuleAdminArea
{
    public static function manifest(): array
    {
        return [
            'title'      => 'Acme',
            // 'slug'    => 'acme',                  // default: strtolower(module name)
            'privileges' => ['PRODUCTS_OPERATION'],
            'menu'       => ['path' => ['PRODUCTS', 'GROUP_HOSTING_SERVER'], 'name' => 'Acme'],
        ];
    }

    /** Hides the menu entry and 404s the page while no Acme server exists. */
    public function available(): bool
    {
        static $available = null;

        if ($available === null)
            $available = (bool) WDB::select('id')->from('servers')
                ->where('type', '=', 'Acme', '&&')
                ->where('status', '=', 'active')
                ->build();

        return $available;
    }

    public function page_home(array $params): array
    {
        $L    = $this->lang;
        $conf = $this->settings();

        $form = new AdminFormBuilder('acmeAreaForm', $this->link(), ['disableStickySubmit' => true]);
        $form->addHidden('operation', 'save_settings');
        $form->addAmount('profit_rate', $L['profit-rate'] ?? '', (string) ($conf['profit_rate'] ?? 25));
        $form->addSwitch('auto_sync', $L['auto-sync'] ?? '', $L['auto-sync-desc'] ?? '', '1', (int) ($conf['auto_sync'] ?? 0) === 1);

        return [
            'content'          => $form->render(),
            'page_title'       => $L['area-title'] ?? 'Acme',
            'page_title_after' => $this->license_state_badge('acme'),
        ];
    }

    public function op_save_settings(Operation $operation): bool
    {
        $operation->demo();

        $values = [
            'profit_rate' => (float) Filter::init('POST/profit_rate', 'amount'),
            'auto_sync'   => (int) Filter::init('POST/auto_sync', 'rnumbers') === 1,
        ];

        if ($values['profit_rate'] < 0)
            throw new Exception($this->lang['err-negative-rate'] ?? 'The profit rate cannot be negative.');

        $this->save_settings($values);

        $adminId = (int) (UserManager::LoginData('admin')['id'] ?? 0);
        User::addAction($adminId, 'update', 'acme-settings-updated', ['changes' => $values]);

        return $operation->output([
            'status'   => 'successful',
            'redirect' => 'reload',
        ]);
    }
}
```

```php
<?php
namespace WISECP\Modules\Servers\Acme;

include_once __DIR__ . DS . 'AdminArea.php';

\ModuleAdminArea::register(AdminArea::class);
```

The dispatcher decides whether your method is reached at all:

```php
$area_info = ModuleAdminArea::get($type, $name);
if (!$area_info) return $this->page_404();

if (($area_info['privileges'] ?? []) && !Admin::isPrivilege($area_info['privileges'])) return 'Access Denied';

$area = new $area_info['class']();
if (!$area->available()) return $this->page_404();

if ($operation = Filter::init('REQUEST/operation')) return $this->area_operation($area, $operation);

$page    = Filter::route($this->params[2] ?? '') ?: 'home';
$subpage = Filter::route($this->params[3] ?? '');

$filter_method = fn ($param) => str_replace(['-', '.', ','], '_', $param);
$method        = $filter_method('page_' . $page);            // page_logs
$method2       = $filter_method($method . '_' . $subpage);   // page_logs_archive

$page_params = array_slice($this->params, 2);

if ($subpage && method_exists($area, $method2)) $result = $area->$method2($page_params);
elseif (method_exists($area, $method))          $result = $area->$method($page_params);
else return $this->page_404();

if (!is_array($result)) $result = ['content' => (string) $result];
```

## Pitfalls

> **A page method that ends silently is a fatal, not a routing problem**
> 
> An undefined property or a forgotten import produces no output, and `try/catch` on `Exception` will not catch it because an `Error` is not an `Exception`. The commonest case is `Admin::$data['id']`: that static property does not exist, so read the operator with `UserManager::LoginData('admin')`.

> **A slug that collides with a core route breaks link generation**
> 
> Real controller files win at dispatch, so an area registered as `services` is unreachable and overwrites that key for every link. Pick a slug no core route uses.

> **A missing parent hides the menu entry without a word**
> 
> The menu hook walks down the tree and returns as soon as a step is absent. If the parent was trimmed by the administrator's privileges your entry is not added, while the page stays reachable by URL.

> **Operations are blocked while the licence is not active**
> 
> The area dispatcher checks the licence before it looks for your method and answers with a JSON error. Pages are affected too, at the view layer.

> **Area settings live under the module name**
> 
> The row key in the configurations table is the module name as the directory spells it, so renaming the directory orphans the saved settings. Migrate by overriding `settings()`.

## Related Articles

- [Module Anatomy](https://dev.wisecp.com/en/module-anatomy)
- [Registering Hooks from a Module](https://dev.wisecp.com/en/registering-hooks-from-a-module)
- [Exposing API Endpoints](https://dev.wisecp.com/en/exposing-api-endpoints)
- [The Admin Form Builder](https://dev.wisecp.com/en/the-admin-form-builder)
- [Operations](https://dev.wisecp.com/en/operations)
- [Building Links and Routes](https://dev.wisecp.com/en/building-links-and-routes)
