# build-dashboard-crud — Admin dashboard CRUD generator

> **Builds:** Controller + Service + Form Requests + Blade views (index / table / create / edit / show + statistics partial) + routes + sidebar entry + route translations + statistics (+ optional diagrams, soft-delete/restore UI, block/active toggle UI), for one already-generated model.
> **Source of truth (no duplication):**
> - Model / Translation model / Factory / Seeder / migration → [`build-database.md`](build-database.md) (runs **first**).
> - Column → type/flag/FK & JSON shape → [`../schema-json-contract.md`](../schema-json-contract.md).
> - This file owns ONLY the **dashboard CRUD layer**: controller, service, form requests, views, routes, sidebar, translations, statistics, read-only mode, soft-delete/restore UI, block/active-toggle UI.
> **Consumed by:** the per-feature plan (after the model layer exists) and the setup pipeline.

---

## 0) Prerequisite — model layer GATE (hard stop)

This builder runs **after** [`build-database.md`](build-database.md). Do not start until:

1. The migration + model (+ translation model) + factory + seeder exist and `php artisan migrate:fresh --seed` runs clean.
2. The model declares the base service contract constants: `UPLOAD_DIRECTORY`, `FILES`, `RELATIONS`, `EXPORT_COLUMNS` (and `$translatedAttributes` + `scope*` for translated columns) — see `build-database.md` §3.
3. **`COLUMNS` is the single source of truth.** Every form field, table column, filter, validation rule, and `EXPORT_COLUMNS` entry derives **only** from the columns provided. Never infer or add columns.

**Driven by the CRUD plan** `docs/project/cruds/<entity>.md` (template: [`../project-init/crud-plan-template.md`](../project-init/crud-plan-template.md)) — read it first; it already carries the columns, logic, and build flags.

> **Complete CRUD coverage.** The dashboard must have a `cruds/<entity>.md` (and a built CRUD) for **EVERY admin-managed table** in the approved schema — cross-check the CRUD list against the schema tables; **none skipped**. A managed entity with no CRUD = a gap to fix.

**Build flags** (from that plan; if any is missing, **ask — never infer**): statistics cards? diagrams? export formats? bulk email/notification buttons? sidebar single-vs-dropdown? show-page relations (stats/tables/both)? block toggle? soft-delete + restore? read-only (submission-only) entity? These flags drive which routes, views, and traits get emitted.

> **Sidebar entry is mandatory.** Every CRUD registers a sidebar entry (§9, `config/sidebar_routes.php`) with its title translated in `lang/ar/admin/routes.php` AND `lang/en/admin/routes.php` — a CRUD that isn't reachable from the sidebar is not done.

> **Translation completeness (hard rule).** EVERY user-facing string must exist in **both** `lang/ar/admin/*` and `lang/en/admin/*`: sidebar entry, page titles, table headers, form labels/placeholders, buttons, validation messages, **enum values/labels**, statistics-card titles, flash/confirmation messages. **Zero hardcoded Arabic/English** in views or controllers — nothing ships untranslated (verify before DoD).

Reference entities in this codebase (mirror exactly): **`sliders`** / **`pages`** (regular, translatable, `is_active` switch) and **`users`** / **`admins`** (authenticatable, `is_blocked` switch-block).

---

## 1) Controller

**Location:** `app/Http/Controllers/Admin/{Model}Controller.php`. Stays **thin** — all logic in the service.

**Extends:**
- Regular entity → `AdminBaseController` (e.g. `SliderController`, `PageController`).
- Authenticatable / block-toggle entity → `AuthenticatableBaseController` (e.g. `UserController`).

`AdminBaseController::__construct($service)` resolves everything from the service + model:
- `smallPluralName` / `smallSingularName` drive the view namespace (`admin.{plural}.*`) and `route('admin.{plural}.*')`.
- `createRequest` / `updateRequest` are resolved **by convention**: `App\Http\Requests\Admin\{ModelBaseName}\StoreRequest` and `...\UpdateRequest`. Class names are literally `StoreRequest` / `UpdateRequest` — the model lives in the namespace.

Inherited actions (do not re-implement): `index`, `create`, `store`, `edit`, `update`, `show`, `destroy`, `destroyAll`, `restore`. Export is handled inside `index()` when `$request->has('export')`. `index()` auto-computes `$is_retreivable = $this->service->getIsRetreivable()` and passes it to the view — **never pass it manually**. The AJAX branch renders `admin.{plural}.table`; the full-page branch renders `admin.{plural}.index` merged with `service->indexVars()`.

`AuthenticatableBaseController` adds `switchBlock($id)` → calls `service->switchBlock($id)` and responds with `['is_blocked' => $result]`.

**Concrete controller skeleton:**

```php
namespace App\Http\Controllers\Admin;

use App\Services\Admin\{Model}Service;
use Illuminate\Http\Request;
use Illuminate\Http\Response;

class {Model}Controller extends AdminBaseController   // or AuthenticatableBaseController
{
    public function __construct({Model}Service ${model}Service)
    {
        parent::__construct(${model}Service);
    }

    // Only when INDEX_STATISTICS = true
    public function statistics(Request $request): Response
    {
        $base = $this->service->statisticsBaseQuery($request);   // regular entities

        $total    = (clone $base)->count();
        $active   = (clone $base)->where('is_active', true)->count();
        $inactive = (clone $base)->where('is_active', false)->count();
        // ... cards adapted to this entity (see Statistics §)

        return response()->view('admin.{plural}.parts.statistics', compact('total', 'active', 'inactive'));
    }

    // Only when entity has an is_active switch (regular entities like sliders/socials/countries)
    public function switchActive($id): \Illuminate\Http\JsonResponse
    {
        try {
            $isActive = $this->service->switchIsActive($id);
            return $this->respondWithSuccess(__('admin/main.updated_successfully'), ['is_active' => $isActive]);
        } catch (\Exception $e) {
            return $this->respondWithFail($e->getMessage());
        }
    }
}
```

> **Base-exact note on the statistics base query:** Regular controllers (`SliderController`, `PageController`) call `$this->service->statisticsBaseQuery($request)`, where the service method is `return parent::index($request, $where);`. `UserController` calls `$this->service->index($request)` directly. Both reach `CrudBaseService::index()`. Prefer the `statisticsBaseQuery()` wrapper for regular entities (matches sliders/pages); use `index()` for authenticatable (matches users).

---

## 2) Service

**Location:** `app/Services/Admin/{Model}Service.php`.

**Extends:**
- Regular entity → `App\Services\Admin\Base\CrudBaseService` (e.g. `SliderService`, `PageService`).
- Block-toggle entity → `App\Services\Admin\Base\AuthenticatableBaseService` (e.g. `UserService`) — adds `switchBlock($id)` (flips `is_blocked`, returns the new bool; throws if the model has no `is_blocked`).

`CrudBaseService` already implements `index / create / store / edit / show / update / destroy / destroyAll / restore / switchActive / export / getIsRetreivable / paginate`, plus four overridable view-var hooks that return `[]` by default: **`indexVars()`, `createVars()`, `editVars()`, `showVars()`**.

```php
namespace App\Services\Admin;

use App\Models\{Model};
use App\Services\Admin\Base\CrudBaseService;   // or AuthenticatableBaseService

class {Model}Service extends CrudBaseService
{
    public function __construct()
    {
        parent::__construct({Model}::class);
    }

    // Wrapper used by the statistics() controller method (regular-entity convention)
    public function statisticsBaseQuery($request, $where = [])
    {
        return parent::index($request, $where);
    }

    // Only for entities with an is_active switch
    public function switchIsActive(int|string $id): bool
    {
        $object = {Model}::query()->findOrFail($id);
        $object->update(['is_active' => ! $object->is_active]);
        return (bool) $object->fresh()->is_active;
    }

    public function indexVars(): array  { return [/* select options for the filter panel */]; }
    public function createVars(): array { return [/* select options for the form */]; }
    public function editVars(): array   { return $this->createVars(); }
    public function showVars(): array   { return [/* non-model data the show view needs */]; }
}
```

**Rules:**
- `store()` / `update()` run inside `DB::transaction` and call `modelService->storeRelations / updateRelations` — relations come from the model's `RELATIONS` constant; `show()` / `edit()` eager-load `RELATIONS` and add `withTrashed()` when the model is retrievable.
- `createVars()` / `editVars()` return **only non-model select data**. Feed FK selects with the active-only `forSelect` shape: `Country::where('is_active', true)->forSelect(['code as id', 'code as name'])->toArray()` (gives the `{id,name}` shape `<x-form.select>` expects). For enums, map to `[['id'=>..., 'name'=>__('...')]]` (see `SliderService::createVars`).
- `showVars()` returns extra non-model variables only — **never query in Blade**. (`UserController::show()` overrides `show()` to attach related counts/collections; prefer `showVars()` for new CRUDs.)

---

## 3) Form Requests

**Location:** `app/Http/Requests/Admin/{Model}/StoreRequest.php` and `.../UpdateRequest.php`.
**Extend:** `App\Http\Requests\Admin\BaseAdminRequest` (provides `authorize(): true` + a `failedValidation()` that throws `ValidationException` to the error bag).

```php
namespace App\Http\Requests\Admin\{Model};

use App\Http\Requests\Admin\BaseAdminRequest;
use Illuminate\Validation\Rule;

class StoreRequest extends BaseAdminRequest
{
    public function prepareForValidation(): void
    {
        $this->merge([
            'is_active' => $this->boolean('is_active', true),   // coerce + sensible default
            // 'is_notify' => boolval($this->is_notify),
        ]);
    }

    public function rules(): array
    {
        return [
            // file inputs (required on store)
            'image'          => ['required', 'image', 'mimes:jpeg,png,jpg,gif,webp', 'max:2048'],
            'is_active'      => ['required', 'boolean'],
            'type'           => ['required', Rule::enum(\App\Enums\{Enum}::class)],
            // Translatable fields: validate the per-locale array shape
            'ar'             => ['required', 'array'],
            'ar.title'       => ['required', 'string', 'max:255'],
            'ar.description' => ['nullable', 'string'],
            'en'             => ['required', 'array'],
            'en.title'       => ['required', 'string', 'max:255'],
            'en.description' => ['nullable', 'string'],
            // ... every other rule derived strictly from COLUMNS
        ];
    }
}
```

`UpdateRequest` mirrors `StoreRequest` with these differences:
- File rule becomes `nullable` (no re-upload required).
- **Unique-with-ignore:** append the route-binding id, e.g. `'unique:users,email,' . $this->user` (the param name = `entity_singular_snake`; for `Route::resource('sliders')` it is `$this->slider`).
- Auth entities: `password` is `['required', Password::defaults()]` on store, `['nullable', Password::defaults()]` on update (`Illuminate\Validation\Rules\Password`).

**Rules:** `prepareForValidation()` coerces booleans (`boolval()` / `$this->boolean(...)`) and seeds **system** defaults only (not user-controlled values). For translatable models the request validates `{locale}[{field}]` arrays (note the `<x-form.text isMultiLanguage>` input name is `{lang}[{name}]`).

---

## 4) Index view

**Location:** `resources/views/admin/{plural}/index.blade.php`. Extends `admin.layouts.crud.index` (which auto-pushes the CSS/JS pipeline: sweetalert2, apexcharts, filter.js, admin-table.js, delete.js, restore.js, validation + submit-form + the error-handler stack).

```blade
@extends('admin.layouts.crud.index')

@push('css')
    <link rel="stylesheet" href="{{ asset('style/admin/css/admins.css') }}">
    <link rel="stylesheet" href="{{ asset('style/admin/css/{entity}.css') }}">
@endpush

@push('content')
    {{-- 1. Statistics (only when INDEX_STATISTICS) — loaderCards = EXACT card count --}}
    <x-table.statistics :loaderCards="5" />

    {{-- 2. Toolbar --}}
    <x-table.buttons
        createRoute="{{ route('admin.{plural}.create') }}"   {{-- OMIT entirely for read-only --}}
        :hasNotification="false"
        :hasEmail="false"
        :hasDeleteAll="true"
        :deleteAllRoute="route('admin.{plural}.destroyAll')"
        :hasReload="true"
        :hasFilter="true"
        :hasSearch="true"
        :hasExport="true"
        :exportCopy="true" :exportPdf="true" :exportExcel="true" :exportWord="false" :exportJson="false"
        :hasPagination="true"
        :perPage="20" />

    {{-- 3. Filter panel — one entry per filterable COLUMN --}}
    <x-table.filter
        :mainCol="'col-md-3'"
        :hasStartDate="true" :hasEndDate="true" :hasOrderBy="true"
        :hasRetrieve="$is_retreivable"
        :filters="[
            ['type' => 'text', 'name' => 'title'],
            ['type' => 'select', 'name' => 'is_active', 'options' => [
                ['id' => '',              'name' => __('admin/main.all')],
                ['id' => 'active_only',   'name' => __('admin/main.active')],
                ['id' => 'inactive_only', 'name' => __('admin/main.inactive')],
            ]],
        ]" />

    {{-- 4. Bulk-actions bar (always when hasDeleteAll) — the visible delete UI --}}
    <x-table.bulk-actions :hasDelete="true" :deleteRoute="route('admin.{plural}.destroyAll')" />

    {{-- 5. Table — owns skeleton + AJAX target; headers/checkbox/actions must match the partial --}}
    <x-table.table
        :hasCheckbox="true" :hasActions="true"
        :headers="[__('admin/{entity}.image_and_title'), __('admin/main.status')]" />

    {{-- 6. Modals (conditional) --}}
    @if(false) <x-model.notification :route="route('admin.notifications.sendNotifications')" :class="'App\Models\{Model}'" /> @endif
    @if(false) <x-model.email /> @endif
@endpush

@push('js')
    {{-- only when INDEX_STATISTICS --}}
    <script>var statsUrl = "{{ route('admin.{plural}.statistics') }}";</script>
    <script src="{{ asset('style/admin/custom-js/stats.js') }}"></script>
@endpush
```

**Rules:**
- `<x-table.statistics :loaderCards="N">` renders the stable `#{plural}StatsContainer` id that `stats.js` selects — set `N` to the **exact** card count.
- The delete-all button inside `<x-table.buttons>` is hidden; the visible bulk-delete UI is `<x-table.bulk-actions>` — always include it when `hasDeleteAll=true`.
- `:hasRetrieve="$is_retreivable"` — `$is_retreivable` is auto-injected by `AdminBaseController::index()`; only resolves `true` when the model uses `SoftDeletes` + `CanRetrieve`.
- `<x-table.table>` props `:headers` / `:hasCheckbox` / `:hasActions` drive the skeleton `colspan` and skeleton cells (fixed **9 rows**, not configurable). They MUST match the column count/order in `table.blade.php`.
- `<x-model.email>` renders a hardcoded `action="{{ route('admin.notifications.sendEmail') }}"` — that route exists in this codebase (`routes/admin.php`), so the component compiles even when `HAS_EMAIL=false`.

---

## 5) Table partial

**Location:** `resources/views/admin/{plural}/table.blade.php`. Loaded via AJAX by `AdminBaseController::index()`. Extends `admin.layouts.crud.table` and yields `@section('table')`.

```blade
@extends('admin.layouts.crud.table', [
    'rows'        => ${plural},
    'createRoute' => route('admin.{plural}.create'),   {{-- omit for read-only --}}
])

@section('table')
    @foreach (${plural} as $item)
        <tr class="data-rows {entity}-table-row {{ $item->deleted_at ? 'deleted-table-row' : '' }}"
            data-{entity}-id="{{ $item->id }}">

            @if (! $item->deleted_at)
                <td class="dt-checkboxes-cell">
                    <input type="checkbox" value="{{ $item->id }}" data-id="{{ $item->id }}"
                           class="dt-checkboxes form-check-input"
                           aria-label="{{ __('admin/main.select_row', ['name' => $item->title]) }}">
                </td>
            @else
                <td></td>   {{-- empty checkbox cell on soft-deleted rows --}}
            @endif

            {{-- Primary cell: thumb + title + (mobile-only) secondary info --}}
            <td>
                <div class="d-flex align-items-center gap-2">
                    <div class="{entity}-thumb-avatar flex-shrink-0"><img src="{{ $item->image }}" alt=""></div>
                    <div class="d-flex flex-column min-w-0">
                        <span class="fw-semibold text-truncate">{{ $item->title }}</span>
                        <span class="d-md-none text-muted small">{{-- stacked badge/info --}}</span>
                    </div>
                </div>
            </td>

            {{-- Secondary columns hidden on mobile --}}
            <td class="d-none d-md-table-cell {entity}-type-cell">{{-- badge --}}</td>

            {{-- Status cell --}}
            <td class="{entity}-status-cell">
                @if (! $item->deleted_at)
                    {{-- (A) is_active toggle (regular entities — sliders/socials) --}}
                    <div class="form-check form-switch {entity}-active-switch mb-0 d-flex justify-content-center">
                        <input class="form-check-input switch-active" type="checkbox" role="switch"
                               data-id="{{ $item->id }}"
                               data-route="{{ route('admin.{plural}.switchActive', ['id' => $item->id]) }}"
                               {{ $item->is_active ? 'checked' : '' }}
                               title="{{ $item->is_active ? __('admin/main.set_inactive') : __('admin/main.set_active') }}"
                               aria-label="{{ __('admin/main.is_active') }}">
                    </div>
                @else
                    <span class="text-muted small">—</span>
                @endif
            </td>

            {{-- Actions --}}
            <td class="{entity}-actions-cell">
                <div class="d-flex align-items-center gap-2 flex-nowrap {entity}-row-actions">
                    <a href="{{ route('admin.{plural}.show', ['{entity}' => $item]) }}"
                       class="custom-icon admins-action-btn admins-action-view"
                       data-bs-toggle="tooltip" data-bs-placement="top"
                       title="@lang('admin/main.show')" aria-label="@lang('admin/main.show')">
                        <i class="ti ti-eye" aria-hidden="true"></i>
                    </a>

                    @if (! $item->deleted_at)
                        <a href="{{ route('admin.{plural}.edit', ['{entity}' => $item]) }}"
                           class="custom-icon admins-action-btn admins-action-edit"
                           data-bs-toggle="tooltip" title="@lang('admin/main.edit')" aria-label="@lang('admin/main.edit')">
                            <i class="ti ti-pencil" aria-hidden="true"></i>
                        </a>
                    @endif

                    @if ($item->deleted_at)
                        <a href="javascript:void(0);" data-id="{{ $item->id }}"
                           data-route="{{ route('admin.{plural}.restore', ['id' => $item->id]) }}"
                           class="custom-icon admins-action-btn admins-action-restore restore-row"
                           data-bs-toggle="tooltip" title="@lang('admin/main.restore')" aria-label="@lang('admin/main.restore')">
                            <i class="ti ti-arrow-back-up" aria-hidden="true"></i>
                        </a>
                    @else
                        <a href="javascript:void(0);" data-id="{{ $item->id }}"
                           data-route="{{ route('admin.{plural}.destroy', ['{entity}' => $item]) }}"
                           class="custom-icon admins-action-btn admins-action-delete delete-record"
                           data-bs-toggle="tooltip" title="@lang('admin/main.delete')" aria-label="@lang('admin/main.delete')">
                            <i class="ti ti-trash" aria-hidden="true"></i>
                        </a>
                    @endif
                </div>
            </td>
        </tr>
    @endforeach
@endsection
```

**Rules:**
- `<tr>` always carries `data-rows` + `{entity}-table-row` + `data-{entity}-id` (admin-table.js removes `.data-rows` on reload).
- Soft-deleted rows: empty `<td></td>` checkbox + `deleted-table-row` class; render **restore** instead of edit/delete.
- Action classes in base reuse the **`admins-action-*`** theme (with `admins.css` pushed) — sliders/pages do `<link admins.css>` then `<link {entity}.css>`. You may either reuse `admins-action-*` (preferred, matches base) or rename to `{entity}-action-*` with a sibling CSS file.
- Delete trigger class is **`delete-record`** (used by sliders/pages/show). The users table uses `delete-row`; both are wired by `delete.js`. Match your reference entity.
- Secondary columns hidden on mobile (`d-none d-md-table-cell`) must have their info **stacked into the primary cell** with `d-md-none text-muted small`.
- **Two status patterns** — pick by entity type:
  - **(A) `is_active`** (regular: sliders/socials/countries) → bare `form-switch` with `.switch-active` + `data-route="...switchActive"`.
  - **(B) `is_blocked`** (authenticatable: users/admins) → `.{entity}-status-pill` + hidden `.switch-block` checkbox + `data-route="...switchBlock"` + `data-active-label`/`data-blocked-label`. Use `$item->statusData()['label']` for the pill text.
- NEVER place `data-bs-toggle="tooltip"` and `data-bs-toggle="dropdown"` on the same element. Notify/email triggers (when enabled) go inside a `.{entity}-more-dropdown` `dropdown-menu`, each carrying `data-id="{{ $item->id }}"`.

---

## 6) Create / Edit views

**Location:** `resources/views/admin/{plural}/create.blade.php` + `edit.blade.php`. Extend `admin.layouts.crud.create` / `admin.layouts.crud.edit` (already wrap the form in a card).

```blade
{{-- create --}}
<form class="mb-3 validated-form form" novalidate method="POST"
      action="{{ route('admin.{plural}.store') }}" enctype="multipart/form-data">
    @csrf
    {{-- edit: add @method('PUT') after @csrf; action = route('admin.{plural}.update', $id) --}}

    <div class="row g-3">
        {{-- File input(s) FIRST, always col-md-12 --}}
        <x-form.image :options="['name'=>'image','label'=>'image','class'=>'col-md-12','isRequired'=>true]" />

        {{-- Translatable text: isMultiLanguage emits ONE col-* per locale (ar+en at col-md-6 = 12) --}}
        <x-form.text :options="['name'=>'title','label'=>'title','class'=>'col-md-6','isRequired'=>true,'isMultiLanguage'=>true]" />
        <x-form.text-area :options="['name'=>'description','label'=>'description','class'=>'col-md-6','isMultiLanguage'=>true,'rows'=>3]" />

        <x-form.text :options="['name'=>'link','label'=>'link','class'=>'col-md-6']" />
        <x-form.select :options="['name'=>'type','label'=>'type','class'=>'col-md-3','isRequired'=>true,'options'=>$typeOptions]" />
        <x-form.select :options="['name'=>'is_active','label'=>'is_active','class'=>'col-md-3','value'=>1,'options'=>[
            ['id'=>1,'name'=>__('admin/main.active')], ['id'=>0,'name'=>__('admin/main.inactive')],
        ]]" />
    </div>

    <div class="pt-4 d-flex justify-content-center mt-3">
        <button type="submit" class="btn btn-primary me-sm-3 me-1 waves-effect waves-light submit-button">
            <i class="ti ti-device-floppy me-1"></i>{{ __('admin/main.create') }}
        </button>
    </div>
</form>
```

**Available `<x-form.*>` components** (in `resources/views/components/form/`): `text`, `text-area`, `number`, `email`, `password`, `select`, `checkbox`, `image`, `multi-image`, `date`, `datetime`, `map`.

**Rules:**
- **File inputs first**, always `col-md-12`.
- **Every row sums to exactly 12 columns.** `isMultiLanguage=true` generates one `col-*` div **per locale** (input name `{lang}[{name}]`), so `col-md-6` × 2 locales = a full 12-col row. Plan rows so nothing leaves a gap.
- Boolean/status → `<x-form.select>` with semantic `admin/main` labels (never raw Yes/No). Set a sensible default `value`.
- Submit button: **create = `btn-primary`, edit = `btn-success`**; both keep `waves-effect waves-light submit-button` (submit-form.js targets `.submit-button`); container `pt-4 d-flex justify-content-center mt-3`.
- Edit passes existing values; auth `password` field is optional on edit.

---

## 7) Show view

**Location:** `resources/views/admin/{plural}/show.blade.php`. Extends `admin.layouts.crud.show` with `['model' => $item]` (the layout already renders a deleted-state banner when `$model->deleted_at` is set — **do not duplicate it**).

- `@push('header')` → title + actions: **edit** (`btn btn-sm btn-success`) + **delete** (`btn btn-sm btn-danger delete-record`) + **back** (`btn btn-sm btn-outline-secondary`). For soft-deleted rows, edit/delete are replaced by a **restore** button (`btn btn-sm btn-success restore-row`). For **read-only** entities, omit the edit button entirely.
- `@push('content')` → a profile card (`col-xl-4 col-md-5`, `.admin-profile-card`) + a details card (`col-xl-8 col-md-7`, `.admin-details-card`). Base uses the shared partial `@include('admin.admins.parts.detail-row', ['icon'=>..., 'label'=>..., 'value'=>...])` for each scalar/translated attribute.
- Translated attributes are read via `$item->translate('ar')?->title` / `translate('en')?->...`.
- `@push('js')` loads `admin-table.js` so an inline `switch-block`/`switch-active` toggle works on the show page.
- Pass any relation data via `showVars()`; **never query in Blade**.

> **Show page = a COMPLETE 360° view (mandatory).** Render **everything related** to the record, not just its own
> columns — every `belongsTo`/`hasMany`/`morph` relation, presented meaningfully:
> - related **entities** (e.g. an Order → the customer, the counterparty/requestee, the location/address, the
>   assigned courier/delegate), **related lists** (child rows in mini-tables), and **quick counts** (e.g. Country →
>   its cities & districts + counts).
> - **status/lifecycle as a visual** — a **progress bar / stepper** for staged flows (order/delivery states), a
>   status pill for the current state, and a timeline/log when the entity has an events/log table.
> Eager-load all of it in `showVars()`/`show()` (from the model `RELATIONS`) — one page that answers every question
> about the record.

---

## 8) Routes

**Location:** `routes/admin.php`, inside the `auth:admin` → `CheckRolePermission` middleware group.

**Critical:** all custom routes go **BEFORE** `Route::resource(...)`, or `/{entity}/{anything}` binds to `show` first.

```php
// {plural} routes
Route::delete('{plural}/destroy-all', [{Model}Controller::class, 'destroyAll'])->name('{plural}.destroyAll');
Route::put('{plural}/{id}/switch-active', [{Model}Controller::class, 'switchActive'])->name('{plural}.switchActive'); // regular w/ is_active
Route::put('{plural}/{id}/switch-block',  [{Model}Controller::class, 'switchBlock'])->name('{plural}.switchBlock');  // authenticatable
Route::put('{plural}/{id}/restore', [{Model}Controller::class, 'restore'])->name('{plural}.restore');               // soft-delete
Route::get('{plural}/statistics', [{Model}Controller::class, 'statistics'])->name('{plural}.statistics');           // stats
Route::get('{plural}/diagrams',   [{Model}Controller::class, 'diagrams'])->name('{plural}.diagrams');               // diagrams
Route::resource('{plural}', {Model}Controller::class);          // FULL crud
// read-only entity instead:
// Route::resource('{plural}', {Model}Controller::class)->only(['index', 'show', 'destroy']);
```

Emit only the custom lines the flags enable. The `PermissionSeeder` auto-discovers every `admin.*` route name → **no per-CRUD permission entries**. (See `build-database.md` §6.)

---

## 9) Sidebar registration

**Location:** `config/sidebar_routes.php`, under the `'admin'` key. Builders in `app/Builders/Sidebar/` (`SidebarBuilder`, `SimpleRouteBuilder`, `DropdownRouteBuilder`, `GroupRouteBuilder`) render each entry; the title resolves from `admin.routes.{key}.index`.

```php
'{plural}' => [
    'group'     => 'appearance',          // OPTIONAL — only inside a labeled section
    'has_child' => true,                  // single link → false ; dropdown → true
    'icon'      => '<i class="ti ti-{icon} me-2"></i>',
    'childes'   => [],                    // sub-routes for dropdowns; [] is fine
],
```

**Rules:** key = `{entity_plural_snake}`. `has_child:false` = single link to index (like `home`); `has_child:true` = dropdown (empty `childes` allowed). `group` only when belonging to a section header — base groups: `admin_roles_management` (admins+roles), `appearance` (socials+sliders), `content` (pages). Icon uses Tabler (`ti ti-*`) with `me-2`.

---

## 10) Route translations

**Both files share the SAME structure — entities nested under the outer `'admin'` key.** (`lang/ar/admin/routes.php` AND `lang/en/admin/routes.php` are both `return ['admin' => [ '{plural}' => [...] ]]`.)

```php
// lang/{ar|en}/admin/routes.php  →  inside the 'admin' array:
'{plural}' => [
    'index'        => '...',   // list
    'create'       => '...',   // create page
    'store'        => '...',   // create
    'update'       => '...',   // update page
    'edit'         => '...',   // edit
    'show'         => '...',   // show
    'destroy'      => '...',   // delete
    'destroyAll'   => '...',   // bulk delete
    // custom keys — add only those whose routes exist:
    'restore'      => '...',
    'statistics'   => '...',
    'diagrams'     => '...',
    'switchActive' => '...',   // regular entities
    'switchBlock'  => '...',   // authenticatable entities
],
```

**Always-required keys:** `index, create, store, update, edit, show, destroy, destroyAll`. Add custom keys (`statistics`, `restore`, `diagrams`, `switchActive`/`switchBlock`, and any entity-specific action) matching the routes you emitted. AR = real Arabic; EN = English. (See `sliders`/`pages` entries for the exact phrasing.)

Also audit user-facing strings against `lang/{ar,en}/admin/main.php` (table headers, buttons, statuses) and `lang/{ar,en}/admin/inputs.php` (form labels/placeholders) — no orphaned or half-localized keys.

---

## 11) Statistics

When `INDEX_STATISTICS=true`: route `GET admin/{plural}/statistics` → controller `statistics()` → `response()->view('admin.{plural}.parts.statistics', compact(...))`. The base query comes from `service->statisticsBaseQuery($request)` (regular) or `service->index($request)` (auth). Index pushes `statsUrl` + `stats.js`.

**Adapt cards to the entity:** `is_active` entities → total / active / inactive (+ per-type counts for enums); `is_blocked` entities → total / active / blocked / today / this-week / this-month (with `$growth`); status-enum entities → one card per status. Set `<x-table.statistics :loaderCards="N">` to the exact count.

**Partial markup** — `crud-stats__card crud-stats__card--{total|active|blocked|inactive|today|week|month|...}`, label via `__('admin/main.*')`, value via `number_format(...)`, icon `<span class="crud-stats__icon"><i class="ti ti-..."></i></span>` (color from CSS token `--card-accent-rgb`, **no inline `style=`**). Month card adds a `crud-stats__delta--up/--down` chip from `$growth`.

`stats.js`: selects `#{plural}StatsContainer` by id, replaces `#{plural}StatsContent .row`, auto-runs on ready, retries via `.js-crud-stats-reload`. `admin-table.js::loadTable()` calls `loadStats()` first → **stats refresh with filters automatically.**

**Diagrams (optional, independent of cards):** `GET admin/{plural}/diagrams` returns JSON datasets; Blade renders with ApexCharts (already bundled by `crud.index`). See `SliderController::diagrams()` for the `byStatus`/`byType` JSON shape.

> **Metrics must be meaningful & correct (scientific — not decorative).** Pick KPIs that actually reflect the
> entity's health (totals, active/inactive, growth vs previous period, per-status/per-type breakdowns, and —
> where money exists — revenue, profit %, avg value, overdue/collection rate). Use **correct aggregations**
> (proper time buckets, no double counting) and the **right chart per data**: line/area = trend over time, bar =
> compare categories, donut/pie = share of a whole, stacked = composition over time. No random or misleading charts.

---

## 12) Soft-delete / restore + toggle wiring

**Soft-delete + restore** (`SOFT_DELETES` + `HAS_RESTORE`): model uses `SoftDeletes` + `CanRetrieve` (see `build-database.md`); route `PUT {plural}/{id}/restore`; filter prop `:hasRetrieve="$is_retreivable"` (auto-injected); table renders `deleted-table-row` + empty checkbox + restore action when `deleted_at`; `restore.js` (auto-loaded) handles the AJAX confirm. `CrudBaseService::restore()` prefers `retrieve()` (CanRetrieve) over `restore()` to cascade relations.

**Block toggle** (`HAS_BLOCK_TOGGLE`, authenticatable): model has `is_blocked` + `BaseAuthModelTrait` (`statusData()`); service extends `AuthenticatableBaseService`; controller extends `AuthenticatableBaseController` (inherits `switchBlock`); route `PUT {plural}/{id}/switch-block`; table uses the status-pill + hidden `.switch-block`; show page renders a `.switch-block` checkbox; `admin-table.js` does the AJAX + UI sync (revert+reload inside a row; flip+swap-badge on the show page).

**Active toggle** (regular `is_active`, e.g. sliders): service adds `switchIsActive($id)`; controller adds `switchActive($id)` (returns `['is_active' => $bool]`); route `PUT {plural}/{id}/switch-active`; table uses a bare `form-switch` + `.switch-active`.

---

## 13) Read-only (submission-only) entities

When the entity is only received (e.g. complaints, contact messages) — admin can view / change status / reply / delete, but not create or edit:

1. `Route::resource('{plural}', {Model}Controller::class)->only(['index', 'show', 'destroy'])`. Custom actions still go before it.
2. Index: **omit** the `createRoute` prop on `<x-table.buttons>` (leaving it throws `RouteNotFoundException` on first load).
3. Table: actions cell has **only** view + delete/restore (no edit button). Wrapper class `{entity}-row-actions` / reuse `admins-action-*`.
4. Show: remove the edit button from `@push('header')`; for complex custom show pages base builds a plain `admin.layouts.master` page instead of `crud.show`.
5. Skip `create.blade.php` / `edit.blade.php`. Any leftover reference to `admin.{plural}.create|edit` throws on first page load (Blade compiles all `route()` calls).
6. AJAX-toggled buttons must always exist in the DOM — hide via `style="{{ $count ? '' : 'display:none' }}"`, never `@if` (a missing element ignores JS `.show()`).

(See `routes/admin.php` `complaints` / `contactmessages` and their controllers for the live pattern.)

---

## 14) Dashboard main page (home)

The admin **home/dashboard page** (`admin.home`) is built once per project from its own plan
[`docs/project/cruds/dashboard-home.md`](../project/cruds/dashboard-home.md)
(template: [`../project-init/dashboard-home-template.md`](../project-init/dashboard-home-template.md)). It aggregates across the main entities:
- **KPI cards** (totals / active / this-month per key entity), **charts** (ApexCharts — trends, by-type/status), and **quick links** to the main CRUDs.
- Reuse `admin.layouts.master` + the existing stats/diagram partials & JS; pull figures from the entities' **services** (no queries in Blade).
- **All titles/labels translated** in `lang/ar` + `lang/en`. The `home` sidebar entry already exists in base.

---

## 15) Verify (definition of done)

- [ ] Controller extends `AdminBaseController` (or `AuthenticatableBaseController`); `__construct` calls `parent::__construct($service)` only; no inherited CRUD re-implemented.
- [ ] Service extends `CrudBaseService` (or `AuthenticatableBaseService`); `parent::__construct({Model}::class)`; `createVars/editVars/showVars/indexVars` return non-model data only.
- [ ] Form Requests at `app/Http/Requests/Admin/{Model}/{Store|Update}Request.php`, class names literally `StoreRequest`/`UpdateRequest`, extend `BaseAdminRequest`; rules derived **only** from `COLUMNS`; update unique-with-ignore uses `$this->{entity_singular}`.
- [ ] Views: `index`, `table`, `create`, `edit`, `show` (+ `parts/statistics`) extend the right `admin.layouts.crud.*` parents.
- [ ] `<x-table.table>` `:headers`/`:hasCheckbox`/`:hasActions` match `table.blade.php` column count/order; `<tr>` keeps `data-rows` + `data-{entity}-id`.
- [ ] Form rows each sum to 12 cols; `isMultiLanguage` expansion accounted for; file inputs first; create=`btn-primary`, edit=`btn-success`, both `.submit-button`.
- [ ] Routes: all custom lines BEFORE `Route::resource`; only enabled actions emitted; read-only uses `->only([...])`.
- [ ] Sidebar entry added under `config/sidebar_routes.php` `'admin'` with correct `has_child`/`group`/`icon`.
- [ ] **Both** `lang/ar` and `lang/en` `admin/routes.php` updated under the `'admin'` key with all required + custom keys.
- [ ] Statistics (if enabled): route + partial + `statsUrl` + `stats.js`; `:loaderCards` = exact count; `number_format` everywhere; no inline `style=` on icons.
- [ ] Toggle wiring matches entity type (`switch-active` vs status-pill `switch-block`); restore path present when soft-deletable.
- [ ] `php artisan route:list` shows the new `admin.{plural}.*` routes; `php artisan optimize:clear` then load index, create, edit, show without `RouteNotFoundException`.
- [ ] AR/EN audit of `admin/main` + `admin/inputs` keys — no orphaned/half-localized strings.
- [ ] **Nothing untranslated:** every label, table header, button, placeholder, validation message, **enum value/label**, statistics-card title, flash message, and the **sidebar entry** exist in **both** `lang/ar` and `lang/en` — zero hardcoded Arabic/English in views/controllers.

---

## References (single source — do not duplicate here)
- Model / Translation / Factory / Seeder / migration → [`build-database.md`](build-database.md)
- Schema JSON shape + type/flag/FK mapping → [`../schema-json-contract.md`](../schema-json-contract.md)
