# OpenAdmin Separation Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Remove the `open-admin-org/open-admin` framework as the admin-panel foundation while keeping all 90 Livewire components, their Blade views, the custom navigation, and the existing RBAC data unchanged.

**Architecture:** OpenAdmin is used only as a thin "shell" (HTML master layout + asset pipeline + `Content` page wrapper + session auth + RBAC/menu Eloquent models + route bootstrap). We replace each of those with app-owned equivalents, phase by phase, keeping the `admin_*` database schema and all Livewire UI intact. The package stays installed until the final phase so every phase is independently shippable and reversible.

**Tech Stack:** PHP 8.3 · Laravel 10 · Livewire 3 · Postgres (connections `collection` + `users`) · Vite. All Artisan/Pint/PHPUnit commands run inside the `polaris-service-new` container.

## Global Constraints

- Runtime: run all commands via `docker exec -i polaris-service-new <cmd>` (e.g. `docker exec -i polaris-service-new php artisan test --compact`).
- Format before finalizing each task: `docker exec -i polaris-service-new vendor/bin/pint --dirty` (never `pint --test`).
- PHP conventions: curly braces always, explicit return types, constructor property promotion, PHPDoc over inline comments.
- Eloquent only, no `DB::` raw queries in new app code; preserve each model's `$connection`.
- `env()` only inside `config/`; read settings via `config(...)`.
- **Do NOT migrate or rename any `admin_*` table or its columns.** Only the Eloquent *models* change ownership; the schema and data stay exactly as-is.
- **Namespace collision guard:** `App\Models\User\Role` already exists (legacy `perfiles` model with `descripcion`/`idperfil`) and is unrelated to OpenAdmin RBAC. All new RBAC models go under the **`App\Models\Admin`** namespace — never reuse `App\Models\User\Role`.
- Preserve existing route names, especially `admin.login` and `admin.logout`, and the `admin/*` prefixed route group in `app/Admin/routes.php`.
- **Asset strategy — FULLY remove OpenAdmin's bundle (user directive).** The admin THEME is already 100% app-owned (`public/css/{app.css,bootstrap.min.css,icons.min.css,datepicker.min.css,selectr.min.css,uppy.min.css}`, `public/js/{app.js,jquery.min.js,simplebar.min.js,bootstrap.bundle.min.js,selectr.min.js,uppy.min.js}`), injected today via `app/Admin/CustomAdmin.php` (`$baseJs` + `switchTheme()`). OpenAdmin only adds field/grid libs on top. Rule: for every asset under `public/vendor/open-admin/*` that the app actually uses, **copy the minified file verbatim into `public/js` or `public/css`** and reference the copy; **drop** every unused one. No `vendor/open-admin` reference may remain in any new/edited Blade. Do NOT switch a used lib to a different app-owned copy — copy the exact file the current head uses, to guarantee identical behavior.
  - COPY-verbatim (used, currently only from OpenAdmin): `sweetalert2` (css+js), `flatpickr` (js + `plugins/rangePlugin.js` + `plugins/minMaxTimePlugin.js` + `flatpicker-custom.css`), `nprogress` (css+js), `choicesjs` (css+js), `axios` (js), `leaflet` (css+js) + `leaflet-geosearch` (css+js).
  - KEEP app-owned (already used from `public/`): quill, tinymce, imask, apexcharts, selectr, uppy, datepicker, bootstrap, icons, app.css/app.js, jquery, simplebar.
  - DROP (0 references in app code): all `open-admin/js/open-admin*.js` (+ `polyfills.js`, `helpers.js`), `toastify-js`, `sortablejs`, `coloris`, `dual-listbox`, `fields/*` (file-upload, number-input, icon-picker), `inputmask`, and `open-admin/css/styles.css` (a duplicate Bootstrap 5.1.3).
- **Verification caveat:** the Task 1 smoke test only asserts HTTP 200 — it will NOT catch a missing JS library (e.g. `Swal is not defined`). Any task that changes the asset set MUST additionally verify in a real browser that the console is error-free and that SweetAlert2 dialogs, flatpickr pickers, choices selects, leaflet maps, nprogress bar, and tinymce/quill editors still work.
- Do not remove tests from `tests/` without approval.

---

## File Structure

New files created by this plan:
- `tests/Feature/Admin/AdminSmokeTest.php` — regression guard that logs in and hits every admin route.
- `resources/views/layouts/admin.blade.php` — app-owned HTML master shell (replaces `admin::index`).
- `resources/views/auth/login.blade.php`, `resources/views/auth/pause.blade.php` — app-owned auth views (moved from `vendor/admin/auth/*`).
- `app/Models/Admin/Administrator.php` — app-owned base for `User` (replaces `OpenAdmin\...\Administrator`).
- `app/Models/Admin/Role.php`, `Permission.php`, `Menu.php` — app-owned RBAC models (replace `OpenAdmin\...\Auth\Database\*`).
- `app/Providers/AdminPanelServiceProvider.php` — loads `app/Admin/routes.php` and registers the `admin` middleware group (replaces OpenAdmin's provider wiring).

Key files modified:
- `config/auth.php` — add the `admin` guard + `admin` provider.
- 36 controllers under `app/Admin/Controllers/**` — drop the `Content` wrapper.
- `resources/views/components/layouts/app.blade.php` — extend the new master shell.
- `app/Models/User/User.php` — extend `App\Models\Admin\Administrator`; drop OpenAdmin imports.
- `app/Livewire/Navigation/SideMenu.php`, `app/Http/Middleware/AdminAuthorization.php`, `app/Admin/Controllers/AuthController.php` — repoint to app-owned classes.
- `app/Http/Kernel.php` — register the `admin` middleware group.
- `composer.json`, `config/app.php` — remove the package + register the new provider.

---

## Task 1: Smoke-test regression guard

**Files:**
- Test: `tests/Feature/Admin/AdminSmokeTest.php`

**Interfaces:**
- Produces: a test class `AdminSmokeTest` with a data-provider `adminRoutes()` returning GET admin URLs; used as the manual gate after every later task.

- [ ] **Step 1: Generate the test file**

Run: `docker exec -i polaris-service-new php artisan make:test Admin/AdminSmokeTest --phpunit --no-interaction`
Expected: creates `tests/Feature/Admin/AdminSmokeTest.php`.

- [ ] **Step 2: Write the smoke test**

Replace the file contents with:

```php
<?php

namespace Tests\Feature\Admin;

use App\Models\User\User;
use Tests\TestCase;

class AdminSmokeTest extends TestCase
{
    /**
     * @return array<string, array{0: string}>
     */
    public static function adminRoutes(): array
    {
        return [
            'dashboard'          => ['/admin/dashboard'],
            'search'             => ['/admin/search'],
            'user-list'          => ['/admin/user-list'],
            'house-management'   => ['/admin/house-management'],
            'tasks'              => ['/admin/tasks'],
            'support'            => ['/admin/support'],
            'sms-companies'      => ['/admin/sms/companies'],
            'sms-histories'      => ['/admin/sms/histories'],
            'email-companies'    => ['/admin/email/companies'],
            'communication-jobs' => ['/admin/communication/jobs'],
            'actions'            => ['/admin/actions'],
            'settings'           => ['/admin/settings'],
        ];
    }

    /**
     * @dataProvider adminRoutes
     */
    public function test_admin_route_renders_for_authenticated_admin(string $uri): void
    {
        /** @var User $user */
        $user = User::query()->firstOrFail();

        $response = $this->actingAs($user, 'admin')->get($uri);

        $response->assertStatus(200);
    }

    public function test_guest_is_redirected_to_login(): void
    {
        $this->get('/admin/dashboard')->assertRedirect();
    }
}
```

- [ ] **Step 3: Run the smoke test against current (OpenAdmin) code**

Run: `docker exec -i polaris-service-new php artisan test --compact tests/Feature/Admin/AdminSmokeTest.php`
Expected: PASS (this captures the *current* green baseline before any refactor). If a route legitimately requires params or a permission the seed user lacks, remove that row from `adminRoutes()` so the baseline is green.

- [ ] **Step 4: Format and commit**

```bash
docker exec -i polaris-service-new vendor/bin/pint --dirty
git add tests/Feature/Admin/AdminSmokeTest.php
git commit -m "test: add admin smoke test as OpenAdmin-separation regression guard"
```

---

## Task 2: App-owned master layout + copy used libs out of OpenAdmin

**Goal:** Create an app-owned master layout whose `<head>` references ONLY app-owned assets, and copy the handful of still-used third-party libs out of `public/vendor/open-admin/` into `public/`. The dead OpenAdmin bundle is simply not referenced. See the "Asset strategy" Global Constraint for the authoritative COPY/KEEP/DROP lists.

**Files:**
- Create: `resources/views/layouts/admin.blade.php`
- Create (copied binaries): files under `public/js/` and `public/css/` (see Step 1)
- Modify: `resources/views/components/layouts/app.blade.php`
- Reference (source of truth for the head): the real rendered head dump from Step 0.

**Interfaces:**
- Produces: a Blade layout `layouts.admin` exposing `@yield('content')`, `@stack('styles')`, `@stack('scripts')`; `components.layouts.app` `@extends('layouts.admin')`.

> **Behavioral verification note:** at THIS commit, controllers still render via OpenAdmin `Content`/`CustomAdmin`, so `layouts.admin` is not yet the live head — full browser verification of the new asset set happens in Task 3 (when controllers switch to `view()`). Task 2 verifies only that files were copied, the layout compiles, and the smoke test stays green.

- [ ] **Step 0: Re-dump the real current head (source of truth)**

Write this helper and run it to get the definitive ordered asset list the live admin head loads:

```bash
cat > /tmp/head_dump.php <<'PHP'
$u = App\Models\User\User::orderBy('id')->get()->first(fn($x)=>$x->isRole('tecnologia'));
Illuminate\Support\Facades\Auth::guard('admin')->login($u);
$resp = app(Illuminate\Contracts\Http\Kernel::class)->handle(Illuminate\Http\Request::create('/admin/dashboard','GET'));
$head = substr($resp->getContent(), 0, strpos($resp->getContent(), '</head>') ?: 4000);
preg_match_all('/(href|src)="([^"]+\.(css|js)[^"]*)"/', $head, $m);
echo "STATUS: ".$resp->getStatusCode()."\n".implode("\n", array_unique($m[2]));
PHP
docker exec -i polaris-service-new php artisan tinker < /tmp/head_dump.php 2>&1 | grep -E "STATUS|\.css|\.js"
```
Keep this ordered list — the new `<head>` is this list with vendor→public swaps and dead-lib deletions applied.

- [ ] **Step 1: Copy the used-but-OpenAdmin-only libs into `public/`**

Copy each file verbatim (source under `public/vendor/open-admin/`, dest under `public/js` or `public/css`). Adjust exact source filenames to what exists in the tree — verify each with `ls` first:

```bash
cd /Users/olegbusmin/Desktop/dfi/PolarisInfra/polaris-service/source_code/modulo_drpeso/laravel/public
cp vendor/open-admin/sweetalert2/sweetalert2.min.js        js/sweetalert2.min.js
cp vendor/open-admin/sweetalert2/sweetalert2.min.css       css/sweetalert2.min.css
cp vendor/open-admin/nprogress/nprogress.js                js/nprogress.js
cp vendor/open-admin/nprogress/nprogress.css               css/nprogress.css
cp vendor/open-admin/axios/axios.min.js                    js/axios.min.js
cp vendor/open-admin/choicesjs/scripts/choices.min.js      js/choices.min.js
cp vendor/open-admin/choicesjs/styles/choices.min.css      css/choices.min.css
cp vendor/open-admin/flatpickr/flatpickr.min.js            js/flatpickr.min.js
cp vendor/open-admin/flatpickr/plugins/rangePlugin.js      js/flatpickr-rangePlugin.js
cp vendor/open-admin/flatpickr/plugins/minMaxTimePlugin.js js/flatpickr-minMaxTimePlugin.js
cp vendor/open-admin/flatpickr/flatpicker-custom.css       css/flatpickr-custom.css
cp vendor/open-admin/leaflet/leaflet.js                    js/leaflet.js
cp vendor/open-admin/leaflet/leaflet.css                   css/leaflet.css
cp vendor/open-admin/leaflet/leaflet-geosearch.js          js/leaflet-geosearch.js
cp vendor/open-admin/leaflet/leaflet-geosearch.css         css/leaflet-geosearch.css
```

If leaflet ships extra assets (marker images / a `leaflet.css` that references `images/`), copy that image folder too (`cp -R vendor/open-admin/leaflet/images js/../css/images` — check the CSS's `url(...)` refs) so maps still render.

- [ ] **Step 2: Write the master layout**

Create `resources/views/layouts/admin.blade.php`. Build the `<head>` asset block by taking the Step 0 list and applying: (a) keep every `/css/*` and `/js/*` line as-is (app-owned); (b) replace each COPY-list `vendor/open-admin/...` path with its new `public/` copy from Step 1 (e.g. `.../sweetalert2/sweetalert2.min.js` → `{{ asset('js/sweetalert2.min.js') }}`); (c) DELETE every DROP-list line (all `open-admin/js/open-admin*.js`, `polyfills.js`, `helpers.js`, `toastify-js/*`, `sortablejs/*`, `coloris/*`, `dual-listbox/*`, `fields/*`, `inputmask/*`, `open-admin/css/styles.css`). Preserve the original relative order of the surviving lines. Reproduce the conditional Sentry `headerJs` block from `app/Admin/bootstrap.php` (guarded by `config('polaris.sentry_js.enabled')`).

```blade
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
    <meta name="csrf-token" content="{{ csrf_token() }}">
    <meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport">
    <title>{{ config('admin.title', 'Polaris') }}</title>

    {{-- CSS: app-owned theme + copied libs (transform of Step 0 list) --}}
    <link rel="stylesheet" href="{{ asset('css/datepicker.min.css') }}">
    <link rel="stylesheet" href="{{ asset('css/bootstrap.min.css') }}">
    <link rel="stylesheet" href="{{ asset('css/icons.min.css') }}">
    <link rel="stylesheet" href="{{ asset('css/app.css') }}">
    <link rel="stylesheet" href="{{ asset('css/selectr.min.css') }}">
    <link rel="stylesheet" href="{{ asset('css/uppy.min.css') }}">
    <link rel="stylesheet" href="{{ asset('css/nprogress.css') }}">
    <link rel="stylesheet" href="{{ asset('css/sweetalert2.min.css') }}">
    <link rel="stylesheet" href="{{ asset('css/flatpickr-custom.css') }}">
    <link rel="stylesheet" href="{{ asset('css/choices.min.css') }}">
    <link rel="stylesheet" href="{{ asset('css/leaflet.css') }}">
    <link rel="stylesheet" href="{{ asset('css/leaflet-geosearch.css') }}">

    {{-- Sentry (conditional) — mirror app/Admin/bootstrap.php headerJs --}}
    @if(config('polaris.sentry_js.enabled'))
        @php($sentryCdn = 'https://browser.sentry-cdn.com/'.config('polaris.sentry_js.sdk_version'))
        <script src="{{ $sentryCdn }}/bundle.tracing.min.js"></script>
        <script src="{{ $sentryCdn }}/httpclient.min.js"></script>
        <script src="{{ asset('js/sentry-init.js') }}?v={{ filemtime(public_path('js/sentry-init.js')) }}&dsn={{ urlencode(config('polaris.sentry_js.dsn')) }}"></script>
    @endif

    {{-- JS: copied libs then app-owned theme (transform of Step 0 list) --}}
    <script src="{{ asset('js/nprogress.js') }}"></script>
    <script src="{{ asset('js/axios.min.js') }}"></script>
    <script src="{{ asset('js/sweetalert2.min.js') }}"></script>
    <script src="{{ asset('js/flatpickr.min.js') }}"></script>
    <script src="{{ asset('js/flatpickr-rangePlugin.js') }}"></script>
    <script src="{{ asset('js/flatpickr-minMaxTimePlugin.js') }}"></script>
    <script src="{{ asset('js/choices.min.js') }}"></script>
    <script src="{{ asset('js/leaflet.js') }}"></script>
    <script src="{{ asset('js/leaflet-geosearch.js') }}"></script>
    <script src="{{ asset('js/jquery.min.js') }}"></script>
    <script src="{{ asset('js/app.js') }}"></script>
    <script src="{{ asset('js/simplebar.min.js') }}"></script>
    <script src="{{ asset('js/bootstrap.bundle.min.js') }}"></script>
    <script src="{{ asset('js/selectr.min.js') }}"></script>
    <script src="{{ asset('js/uppy.min.js') }}"></script>

    @livewireStyles
    @stack('styles')
</head>
<body class="{{ config('admin.skin') }} sidebar-collapse">
    <div class="wrapper">
        <div id="app">
            @yield('content')
        </div>
    </div>

    @livewireScripts
    @stack('scripts')
</body>
</html>
```

Note: the exact list above is the expected result of the Step 0 transform on this instance. If Step 0 shows a used asset not covered here, add it (copy + reference); if it shows one of these missing, drop it. Step 0's output governs.

- [ ] **Step 3: Point the inner layout at the new shell**

Edit `resources/views/components/layouts/app.blade.php`: make the file begin with `@extends('layouts.admin')` + `@section('content')` and end with `@endsection`, keeping the inner markup (`@livewire('navigation.side-menu')`, `@livewire('navigation.top-menu')`, `.page-wrapper`, and the `<script>` block) byte-for-byte identical inside the section.

- [ ] **Step 4: Verify copied files resolve and the layout compiles**

Run:
```bash
cd .../public && for f in js/sweetalert2.min.js css/sweetalert2.min.css js/nprogress.js css/nprogress.css js/axios.min.js js/choices.min.js css/choices.min.css js/flatpickr.min.js js/flatpickr-rangePlugin.js js/flatpickr-minMaxTimePlugin.js css/flatpickr-custom.css js/leaflet.js css/leaflet.css js/leaflet-geosearch.js css/leaflet-geosearch.css; do test -f "$f" || echo "MISSING: $f"; done
docker exec -i polaris-service-new php artisan test --compact tests/Feature/Admin/AdminSmokeTest.php
```
Expected: no `MISSING:` lines; smoke test stays green (compile + 200; controllers still use `Content`, so this does not yet exercise the new head — that is Task 3).

- [ ] **Step 5: Format and commit**

```bash
docker exec -i polaris-service-new vendor/bin/pint --dirty
git add resources/views/layouts/admin.blade.php resources/views/components/layouts/app.blade.php public/js public/css
git commit -m "feat: app-owned admin master layout; copy used libs out of open-admin bundle"
```

---

## Task 3: Drop the `Content` page wrapper from controllers

**Files:**
- Modify (all 36): every controller under `app/Admin/Controllers/**` that returns `$content->body(...)`. Enumerate with:
  `grep -rln 'Layout\\Content' app/Admin/Controllers`
- Reference example: `app/Admin/Controllers/HouseManagement/HouseManagementController.php`

**Interfaces:**
- Consumes: `layouts.admin` + `components.layouts.app` from Task 2.
- Produces: controllers return `view('components.layouts.app', [...])` (a full page) instead of an `OpenAdmin\Admin\Layout\Content` instance.

- [ ] **Step 1: Apply the mechanical transform to every controller**

For each file, apply this exact pattern (example shown for `HouseManagementController::index`):

Before:
```php
public function index(Content $content)
{
    /** @var User $user */
    $user = \Auth::user();
    $user->checkAccess(PERMISSION_HOUSE_MANAGEMENT);

    Admin::switchTheme();

    Admin::css('css/uppy.min.css');
    Admin::js('js/uppy.min.js');

    return $content
        ->body(View::make('components.layouts.app', [
            'class' => IndexComponent::class,
            'title' => __('controllers.house_management.list'),
        ]));
}
```

After:
```php
public function index()
{
    /** @var User $user */
    $user = \Auth::user();
    $user->checkAccess(PERMISSION_HOUSE_MANAGEMENT);

    return view('components.layouts.app', [
        'class' => IndexComponent::class,
        'title' => __('controllers.house_management.list'),
    ]);
}
```

Rules for every method:
1. Remove the `Content $content` parameter.
2. Replace `return $content->body(View::make('components.layouts.app', <ARGS>));` with `return view('components.layouts.app', <ARGS>);` — keep `<ARGS>` identical.
3. Delete `Admin::switchTheme();` and every `Admin::css(...)`/`Admin::js(...)` call. For pages that loaded page-specific assets (uppy, flatpickr, imask, datepicker — in `HouseManagementController`, `TasksController`, `PaymentDetailsController`, `Auth/AuthController`), move those into the corresponding Livewire Blade view via `@push('scripts')` / `@push('styles')` referencing the same `css/...` / `js/...` asset paths.
4. Remove now-unused `use OpenAdmin\Admin\Layout\Content;`, `use OpenAdmin\Admin\Facades\Admin;`, and `use Illuminate\Support\Facades\View;` imports where they become unused.

- [ ] **Step 2: Verify no controller still references `Content` or `Admin::`**

Run: `grep -rn 'Layout\\Content\|Admin::switchTheme\|Admin::css\|Admin::js' app/Admin/Controllers | grep -v AuthController`
Expected: no output.

- [ ] **Step 3: Run the smoke test**

Run: `docker exec -i polaris-service-new php artisan test --compact tests/Feature/Admin/AdminSmokeTest.php`
Expected: PASS. Every admin page now renders through `view('components.layouts.app')` → `layouts.admin`, no OpenAdmin `Content` involved.

- [ ] **Step 4: Browser verification of the new head (REQUIRED — smoke test cannot catch this)**

The new `layouts.admin` head is now live for real pages. The HTTP-200 smoke test does NOT catch a missing JS library. Verify in a real browser (or headless via the Playwright MCP if available), logged in as an admin, that the console is free of `X is not defined` / 404s and that the app-owned + copied libs actually work. Concretely:
- Confirm no 404s for any `/css/*` or `/js/*` asset and no console errors on `/admin/dashboard`.
- SweetAlert2: `window.Swal` is defined (dashboard's call-schedule alert uses `Swal.fire`).
- flatpickr: open a page with a date field (e.g. `/admin/payment-details`) — the picker opens.
- choices: a page using a Choices select renders the enhanced select.
- leaflet: any page with a map (`L` is defined; grep `L.map` for the exact views) renders the map tiles.
- nprogress: the top progress bar appears on navigation.
- tinymce/quill: an editor field (email constructor) renders.
- Also spot-check that `open-admin` helper globals are NOT referenced (grep already showed 0 `open-admin`/`openadmin` refs in `resources`/`public/js`; confirm no `admin is not defined`-type errors).

Record in the report exactly which pages were opened and the console state. If any lib is broken, it is a missing/mis-copied asset from Task 2 — fix the copy/reference, do not paper over it.

- [ ] **Step 5: Format and commit**

```bash
docker exec -i polaris-service-new vendor/bin/pint --dirty
git add app/Admin/Controllers
git commit -m "refactor: return app view from admin controllers, drop OpenAdmin Content wrapper"
```

---

## Task 4: App-owned RBAC models

**Files:**
- Create: `app/Models/Admin/Role.php`, `app/Models/Admin/Permission.php`, `app/Models/Admin/Menu.php`, `app/Models/Admin/Administrator.php`
- Modify: `app/Models/User/User.php`, `app/Livewire/Navigation/SideMenu.php`, `app/Http/Middleware/AdminAuthorization.php`

**Interfaces:**
- Produces:
  - `App\Models\Admin\Role` with `permissions(): BelongsToMany`, `administrators(): BelongsToMany`, `menus(): BelongsToMany`.
  - `App\Models\Admin\Permission` with `roles(): BelongsToMany`.
  - `App\Models\Admin\Menu` with `allNodes(): array`, `roles(): BelongsToMany`.
  - `App\Models\Admin\Administrator` (abstract-ish base extending `Model implements AuthenticatableContract`) with `roles(): BelongsToMany`, `permissions(): BelongsToMany`, `isAdministrator(): bool`, `isRole(string): bool`, `allPermissions(): Collection`.
- Consumes: existing `admin_*` tables on the `collection` connection; the `usuarios` table on the `users` connection (via `User`).

- [ ] **Step 1: Create the Role model**

Create `app/Models/Admin/Role.php`:

```php
<?php

namespace App\Models\Admin;

use App\Models\User\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

class Role extends Model
{
    protected $connection = 'collection';

    protected $table = 'admin_roles';

    protected $fillable = ['name', 'slug'];

    public function administrators(): BelongsToMany
    {
        return $this->belongsToMany(User::class, 'admin_role_users', 'role_id', 'user_id');
    }

    public function permissions(): BelongsToMany
    {
        return $this->belongsToMany(Permission::class, 'admin_role_permissions', 'role_id', 'permission_id');
    }

    public function menus(): BelongsToMany
    {
        return $this->belongsToMany(Menu::class, 'admin_role_menu', 'role_id', 'menu_id');
    }
}
```

- [ ] **Step 2: Create the Permission model**

Create `app/Models/Admin/Permission.php`:

```php
<?php

namespace App\Models\Admin;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

class Permission extends Model
{
    protected $connection = 'collection';

    protected $table = 'admin_permissions';

    protected $fillable = ['name', 'slug', 'http_method', 'http_path'];

    public function roles(): BelongsToMany
    {
        return $this->belongsToMany(Role::class, 'admin_role_permissions', 'permission_id', 'role_id');
    }
}
```

- [ ] **Step 3: Create the Menu model**

Create `app/Models/Admin/Menu.php` (this reproduces the only method the app uses, `allNodes()`, without pulling OpenAdmin's `ModelTree` trait; `orderColumn` is `order`):

```php
<?php

namespace App\Models\Admin;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Facades\DB;

class Menu extends Model
{
    protected $connection = 'collection';

    protected $table = 'admin_menu';

    protected $fillable = ['parent_id', 'order', 'title', 'icon', 'uri', 'permission'];

    public function roles(): BelongsToMany
    {
        return $this->belongsToMany(Role::class, 'admin_role_menu', 'menu_id', 'role_id');
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    public function allNodes(): array
    {
        $orderColumn = DB::connection($this->connection)->getQueryGrammar()->wrap('order');
        $byOrder = 'ROOT ASC,'.$orderColumn;

        $query = static::query();

        if (config('admin.check_menu_roles') !== false) {
            $query->with('roles');
        }

        return $query->selectRaw('*, '.$orderColumn.' ROOT')
            ->orderByRaw($byOrder)
            ->get()
            ->toArray();
    }
}
```

- [ ] **Step 4: Create the Administrator base**

Create `app/Models/Admin/Administrator.php` (copies only the members `User` actually relies on: authenticatable behavior + `roles()`/`permissions()` relationships + role predicates; the app's `User::can()` overrides the trait `can()`):

```php
<?php

namespace App\Models\Admin;

use Illuminate\Auth\Authenticatable;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Collection;

abstract class Administrator extends Model implements AuthenticatableContract
{
    use Authenticatable;

    public function roles(): BelongsToMany
    {
        return $this->belongsToMany(Role::class, 'admin_role_users', 'user_id', 'role_id');
    }

    public function permissions(): BelongsToMany
    {
        return $this->belongsToMany(Permission::class, 'admin_user_permissions', 'user_id', 'permission_id');
    }

    public function allPermissions(): Collection
    {
        return $this->roles()->with('permissions')->get()
            ->pluck('permissions')->flatten()->merge($this->permissions);
    }

    public function isAdministrator(): bool
    {
        return $this->isRole('administrator');
    }

    public function isRole(string $role): bool
    {
        return $this->roles->pluck('slug')->contains($role);
    }

    public function inRoles(array $roles = []): bool
    {
        return $this->roles->pluck('slug')->intersect($roles)->isNotEmpty();
    }
}
```

- [ ] **Step 5: Repoint `User` to the app-owned base**

Edit `app/Models/User/User.php`:
- Line 18: replace `use OpenAdmin\Admin\Auth\Database\Administrator;` with `use App\Models\Admin\Administrator;`.
- Line 19: replace `use OpenAdmin\Admin\Auth\Database\Permission;` with `use App\Models\Admin\Permission;`.
- Leave the class declaration `class User extends Administrator` unchanged (now resolves to the app base).
- Do **not** touch the existing `Role::query()` usage in `getDescriptionAttribute()` — that is `App\Models\User\Role` (unqualified, same namespace) and must stay pointing at the legacy `perfiles` model. Confirm no `use App\Models\Admin\Role;` import is added to this file.
- The `Pjax` import (line 20) is handled in Task 6; leave it for now.

- [ ] **Step 6: Repoint SideMenu and AdminAuthorization**

- `app/Livewire/Navigation/SideMenu.php`: replace `use OpenAdmin\Admin\Auth\Database\Menu;` with `use App\Models\Admin\Menu;`. Body unchanged (`(new Menu())->allNodes()` still works).
- `app/Http/Middleware/AdminAuthorization.php`: replace `use OpenAdmin\Admin\Auth\Database\Menu;` with `use App\Models\Admin\Menu;` and `use OpenAdmin\Admin\Auth\Database\Permission;` with `use App\Models\Admin\Permission;`. (The `handle()` body is already a no-op `return $next($request);` — leave the logic as-is.)

- [ ] **Step 7: Verify RBAC still resolves via Tinker**

Run: `docker exec -i polaris-service-new php artisan tinker --execute="\$u = App\Models\User\User::first(); echo 'isAdmin='; var_export(\$u->isAdministrator()); echo ' roles='.\$u->roles()->count(); echo ' menu='.count((new App\Models\Admin\Menu())->allNodes());"`
Expected: prints a boolean, a role count, and a non-zero menu-node count with no errors.

- [ ] **Step 8: Run the smoke test**

Run: `docker exec -i polaris-service-new php artisan test --compact tests/Feature/Admin/AdminSmokeTest.php`
Expected: PASS (sidebar menu + `checkAccess()` now run entirely on app-owned models).

- [ ] **Step 9: Format and commit**

```bash
docker exec -i polaris-service-new vendor/bin/pint --dirty
git add app/Models/Admin app/Models/User/User.php app/Livewire/Navigation/SideMenu.php app/Http/Middleware/AdminAuthorization.php
git commit -m "refactor: replace OpenAdmin RBAC/menu models with app-owned App\\Models\\Admin classes"
```

---

## Task 5: App-owned auth (guard + standalone AuthController)

**Files:**
- Modify: `config/auth.php`, `app/Admin/Controllers/AuthController.php`
- Create: `resources/views/auth/login.blade.php`, `resources/views/auth/pause.blade.php` (moved from `resources/views/vendor/admin/auth/`)

**Interfaces:**
- Consumes: the `admin` guard defined in `config/auth.php`; `User` from Task 4.
- Produces: `AuthController` no longer extends any OpenAdmin class; exposes `getLogin()`, `postLogin(Request)`, `getLogout()`.

- [ ] **Step 1: Define the `admin` guard**

Edit `config/auth.php`. Add to `guards` (after `web`):

```php
'admin' => [
    'driver'   => 'session',
    'provider' => 'admin',
],
```

Add to `providers` (after `users`):

```php
'admin' => [
    'driver' => 'eloquent',
    'model'  => \App\Models\User\User::class,
],
```

- [ ] **Step 2: Move the auth views**

```bash
git mv resources/views/vendor/admin/auth/login.blade.php resources/views/auth/login.blade.php
git mv resources/views/vendor/admin/auth/pause.blade.php resources/views/auth/pause.blade.php
```

Then in `app/Admin/Controllers/AuthController.php` update the view names: `view('vendor.admin.auth.login')` → `view('auth.login')` and `view('vendor.admin.auth.pause', ...)` → `view('auth.pause', ...)`. Inside the two moved Blade files, replace any `@extends`/`@include` referencing `admin::` layouts or `vendor.admin.*` partials with self-contained markup or `layouts.admin`; keep the login form's field names (`usuario`, `password`, `remember`) and its `action` posting to `route('admin.login')`.

- [ ] **Step 3: Make AuthController standalone**

Edit `app/Admin/Controllers/AuthController.php`:
- Change `class AuthController extends BaseAuthController` to `class AuthController extends \App\Http\Controllers\Controller`.
- Remove `use OpenAdmin\Admin\Controllers\AuthController as BaseAuthController;`.
- Replace the `Admin::guardName()` call in `postLogin()` with the literal `'admin'`: `$rate_limit_key = 'login-tries-admin';`.
- Remove `use OpenAdmin\Admin\Facades\Admin;`.
- Add these inlined methods (they replace what was inherited from `BaseAuthController`):

```php
protected function guard(): \Illuminate\Contracts\Auth\StatefulGuard
{
    return \Illuminate\Support\Facades\Auth::guard('admin');
}

protected function sendLoginResponse(Request $request): \Illuminate\Http\RedirectResponse
{
    $request->session()->regenerate();

    return redirect()->to($this->redirectTo());
}

public function getLogout(Request $request): \Illuminate\Http\RedirectResponse
{
    $this->guard()->logout();

    $request->session()->invalidate();
    $request->session()->regenerateToken();

    return redirect()->route('admin.login');
}
```

- The existing `postLogin()` body already calls `$this->guard()->login(...)`, `$this->sendLoginResponse($request)`, `$this->username()`, `$this->redirectTo()` — all now satisfied locally. Leave that logic (including the MD5 password check and `EventLog` writes) unchanged.

- [ ] **Step 4: Verify AuthController has no OpenAdmin references**

Run: `grep -n 'OpenAdmin' app/Admin/Controllers/AuthController.php`
Expected: no output.

- [ ] **Step 5: Run the smoke test (includes guest-redirect + acting-as-admin)**

Run: `docker exec -i polaris-service-new php artisan test --compact tests/Feature/Admin/AdminSmokeTest.php`
Expected: PASS. Manually confirm in a browser that `/admin/auth/login` renders and a real login succeeds (auth is hard to fully assert without a known password; smoke test covers `actingAs`).

- [ ] **Step 6: Format and commit**

```bash
docker exec -i polaris-service-new vendor/bin/pint --dirty
git add config/auth.php app/Admin/Controllers/AuthController.php resources/views/auth
git commit -m "feat: app-owned admin auth guard and standalone AuthController"
```

---

## Task 6: App-owned route bootstrap + middleware group

**Files:**
- Modify: `app/Admin/routes.php`, `app/Http/Kernel.php`, `app/Models/User/User.php`
- Reference: `vendor/open-admin-org/open-admin/src/Admin.php:311-343` (what `Admin::routes()` registered)
- NOTE: `app/Providers/AdminPanelServiceProvider.php` and `config/app.php` are NOT touched here — moved to Task 7 (see sequencing note).

**Interfaces:**
- Consumes: `AuthController` from Task 5.
- Produces: our own `admin.login`/`admin.logout` routes (replacing `Admin::routes()`), the `admin/*` group running through our `admin.panel` middleware group; Pjax removed from `User::checkAccess`. The OpenAdmin provider still loads `routes.php` (single load) until Task 7.

> **IMPORTANT sequencing (controller refinement):** While the OpenAdmin package is still installed, its service provider's `boot()` calls `app('router')->middlewareGroup('admin', [...])`, which OVERWRITES any Kernel `admin` group we define, and it already `loadRoutesFrom(app/Admin/routes.php)`. Therefore Task 6 must (a) name our group **`admin.panel`** (distinct — no collision), and (b) NOT create a second route-loading provider (would double-load routes.php). Creating `AdminPanelServiceProvider`, editing `config/app.php`, and disabling the OpenAdmin provider are DEFERRED to Task 7 (bundled with `dont-discover`/package removal, after Task 3b). Task 6 leaves the OpenAdmin provider loading `routes.php`.

- [ ] **Step 1: Register the `admin.panel` middleware group in the kernel**

Edit `app/Http/Kernel.php`, add to `$middlewareGroups` (the constituent aliases already exist: `auth`, `session.log`; add throttle inline):

```php
'admin.panel' => [
    'web',
    'auth:admin',
    \Illuminate\Routing\Middleware\ThrottleRequests::class.':60,1',
    \App\Http\Middleware\LogSessionRequest::class,
],
```

Note: OpenAdmin's group was `admin.auth/throttle/pjax/log/bootstrap/permission`. `pjax`, `bootstrap`, and `permission` are dropped (Pjax replaced by Livewire navigation; bootstrap file no longer needed; permission enforcement already lives in controller `checkAccess()`). We use a distinct name `admin.panel` so the still-active OpenAdmin `admin` group cannot clobber ours.

- [ ] **Step 2: Replace `Admin::routes()` with explicit auth routes in the route file**

Edit `app/Admin/routes.php`:
- Remove the line `Admin::routes();`.
- Immediately above the existing `Route::group([...])`, add the login/logout routes (previously provided by `Admin::routes()`), preserving names:

```php
Route::group(['prefix' => config('admin.route.prefix'), 'middleware' => ['web']], function () {
    Route::get('auth/login', [\App\Admin\Controllers\AuthController::class, 'getLogin'])->name('admin.login');
    Route::post('auth/login', [\App\Admin\Controllers\AuthController::class, 'postLogin']);
    Route::get('auth/logout', [\App\Admin\Controllers\AuthController::class, 'getLogout'])->name('admin.logout');
});
```

- In the main `Route::group([...])`, change the `middleware` key from `array_merge(config('admin.route.middleware'), ['password.change'])` to an explicit list so it no longer depends on `config('admin.route.middleware')`:

```php
'middleware' => ['admin.panel', 'admin.user.rights', 'password.change'],
```

(`admin.panel` group now supplies `web` + `auth:admin` + throttle + session.log. The OpenAdmin provider still `loadRoutesFrom` this file — that is the single load; do NOT add a second provider here.)

- [ ] **Step 3: Replace the `Pjax::respond` call in `User::checkAccess`**

Edit `app/Models/User/User.php`:
- Remove `use OpenAdmin\Admin\Middleware\Pjax;` (line 20).
- In `checkAccess()`, replace:
  ```php
  $response = response(view('admin.400'));
  Pjax::respond($response);
  ```
  with:
  ```php
  abort(403, __('controllers.auth.access_denied'));
  ```
  (Keep `resources/views/admin/400.blade.php` as the 403 body if preferred: `abort(response(view('admin.400'), 403));`.)

- [ ] **Step 4: Verify routes register cleanly (no duplicates, our controller)**

Run: `docker exec -i polaris-service-new php artisan route:list --path=admin 2>&1 | grep -iE "auth/login|auth/logout|Class .* not found"`
Expected: exactly ONE `admin.login` (GET auth/login) and ONE `admin.logout`, both pointing at `App\Admin\Controllers\AuthController`; no duplicate rows; no "Class not found". Also confirm the OpenAdmin builtin `auth/users`/`auth/roles`/`auth/permissions`/`auth/menu` routes are GONE (they came from the removed `Admin::routes()`).

- [ ] **Step 5: Run the smoke test**

Run: `docker exec -i polaris-service-new php artisan test tests/Feature/Admin/AdminSmokeTest.php`
Expected: 33 passed. (The `admin/*` routes now run through the `admin.panel` group.)

- [ ] **Step 6: Format and commit**

```bash
docker exec -i polaris-service-new vendor/bin/pint app/Admin/routes.php app/Http/Kernel.php app/Models/User/User.php
git add app/Admin/routes.php app/Http/Kernel.php app/Models/User/User.php
git commit -m "feat: app-owned admin.panel middleware group + explicit auth routes; drop Pjax"
```

---

## Task 3b: Rebuild the 3 native-Content pages (MUST precede Task 7)

These three controller methods still use OpenAdmin's `Content`/`Row`/`Column`/`Box`/`Tab` builders (and the app-owned `App\Admin\Components\Widgets\Tab` which itself `extends OpenAdmin\Admin\Widgets\Tab` and renders via the `admin::` namespace). They break when the package is removed. All three are faithful ports — no redesign; reuse the existing Livewire components and partial blades.

**Files:**
- Modify: `app/Admin/Controllers/HomeController.php`, `app/Admin/Controllers/Company/CompanyController.php`, `app/Admin/Controllers/Client/ClientController.php`
- Create: `resources/views/admin/client/show.blade.php`
- Modify: `resources/views/admin/client/loan-table.blade.php` (fix `@include('admin::grid.empty-grid')`)
- Reference (markup source): `resources/views/vendor/admin/widgets/tab.blade.php` (the app's current tab markup), and the current `ClientController::show` body for the exact layout/data.

- [ ] **Step 1: HomeController::index → redirect**

`/admin` (route `home`) currently renders OpenAdmin's system dashboard (env/extensions/dependencies debug widgets) — not a business page; after login users go to `/admin/dashboard`. Replace the whole method body with:
```php
public function index()
{
    return redirect('/admin/dashboard');
}
```
Remove all `OpenAdmin\...` imports (`Admin`, `Dashboard`, `Column`, `Content`, `Row`).

- [ ] **Step 2: CompanyController::create/edit → single-component pattern**

Both methods mount a single `CompanyComponent` in a 12-col row (plus SideMenu/TopMenu that the new layout already provides). Convert to the Task-3 pattern:
```php
public function create()
{
    // keep any existing pre-checks/permission logic above
    return view('components.layouts.app', [
        'class' => CompanyComponent::class,
        'variables' => ['companyId' => null],
        'title' => __('...'), // keep the existing header/title string
    ]);
}
public function edit(int $id)
{
    return view('components.layouts.app', [
        'class' => CompanyComponent::class,
        'variables' => ['companyId' => $id],
        'title' => __('...'),
    ]);
}
```
Drop the `Content $content` params, the `Admin::content()`/`Livewire::mount(SideMenu/TopMenu)` calls, and the now-unused `Content`/`Admin`/`Row`/`Livewire` imports. (`index`/`launchList`/`launches` are already converted — leave them.)

- [ ] **Step 3: ClientController::show → Blade layout port**

Move the inline data-fetching (client lookup, `$authUser`/`$authUserCasa`, `$haveLoanWithHouses`, the access-restriction check, `$loansCount`, `$payments`) into the controller method, then `return view('components.layouts.app', ['view' => 'admin.client.show', 'variables' => [...all computed data...], 'title' => __('controllers.client.profile')])`. Drop `Admin::switchTheme()` and the `Admin::css/js` calls (assets are in the base head or move needed ones — `flatpickr-es.js`, `datepicker-full.min.js`, `imask.min.js`, `uppy` — via `@push` in `show.blade.php`). Remove `SideMenu`/`TopMenu` mounts (layout provides them). Remove all `OpenAdmin\...` imports.

Create `resources/views/admin/client/show.blade.php` reproducing the current layout with the theme's Bootstrap markup:
- Access-restricted branch (when the house-mismatch condition holds): a `col-12` `alert alert-warning` with `__('views.client.house_mismatch')`, then `@include('admin.client.basic_info', ['client' => $client])`, and nothing else.
- Full branch:
  - `@livewire(\App\Livewire\Client\HeaderComponent::class, ['client' => $client])`
  - a `row`: `col-3` → `@include('admin.personal', ['client' => $client])`; `col-9` → a Bootstrap nav-tabs block (classes `rizz-theme-custom-tabs action-tabs`) with tabs: Management → `@livewire(MainActionsComponent, ['client'=>$client])`, Files → `@livewire(FilesComponent, ['client'=>$client,'user'=>$user])`, Comments → `@livewire(CommentsComponent, ['client'=>$client,'user'=>$user])`.
  - a `col-12` nav-tabs block (`rizz-theme-custom-tabs`) with tabs: Loans (title with count badge) → `@livewire(LoansComponent, ['client'=>$client])`; Payments (count badge) → `@include('admin.client.payments', ['payments'=>$payments])`; Extensions (static table — copy the existing inline card/table HTML verbatim).
  - a `col-12` → `@livewire(\App\Livewire\Client\CommunicationsComponent::class, ['client'=>$client])`.
- Build the nav-tabs with plain Bootstrap 5 markup (`ul.nav.nav-tabs` + `.tab-content/.tab-pane`), modeled on `resources/views/vendor/admin/widgets/tab.blade.php`; use unique `wire:key`/ids per tab. Do NOT use `App\Admin\Components\Widgets\Tab` (it depends on OpenAdmin).

- [ ] **Step 4: Fix the `admin::` include in loan-table**

`resources/views/admin/client/loan-table.blade.php:58` — replace `@include('admin::grid.empty-grid')` with plain markup, e.g. `<tr><td colspan="N" class="text-center text-muted">{{ __('...no records...') }}</td></tr>` (match the column count of that table).

- [ ] **Step 5: Verify**

- `grep -rn 'Layout\\Content\|Admin::\|admin::\|Widgets\\(Tab|Box)\|Dashboard::' app/Admin/Controllers/HomeController.php app/Admin/Controllers/Company/CompanyController.php app/Admin/Controllers/Client/ClientController.php resources/views/admin/client/show.blade.php` → empty.
- Render the client page for a REAL client id via `Kernel::handle` as the tecnologia user (like the head_check helper) → 200, and `substr_count($head,'vendor/open-admin')===0`; also assert the response body contains the Livewire components (`wire:id`). Pick a real id: `docker exec -i polaris-service-new php artisan tinker --execute="echo App\Models\Collection\Loan::query()->value('documento');"` then hit `/admin/client/<doc>`.
- `/admin` redirects to `/admin/dashboard`; `/admin/company` (create) and `/admin/company/{id}` (edit) return 200.
- Smoke test 33/33 still green.
- **Browser (human):** the client detail page is visually rich — after this task, spot-check it in a browser (tabs switch, components load, no console errors). Headless can't fully verify the tab JS.

- [ ] **Step 6: Format and commit**
```bash
docker exec -i polaris-service-new vendor/bin/pint app/Admin/Controllers/HomeController.php app/Admin/Controllers/Company/CompanyController.php app/Admin/Controllers/Client/ClientController.php
git add app/Admin/Controllers/HomeController.php app/Admin/Controllers/Company/CompanyController.php app/Admin/Controllers/Client/ClientController.php resources/views/admin/client
git commit -m "refactor: rebuild home/company/client pages off OpenAdmin Content builders"
```

---

## Task 7: Remove the package, extensions, and dead config/assets

**Files:**
- Modify: `composer.json`, `app/Console/Kernel.php` (if it references log-viewer), `config/admin.php`
- Delete: `app/Admin/bootstrap.php`, `resources/views/vendor/admin/*`, `config/admin.php` (after inlining the 3 values still read)
- Reference: `grep -rn "config('admin\." app resources` to find every remaining `config('admin.*')` read.

**Interfaces:**
- Consumes: nothing new.
- Produces: a codebase with zero `open-admin` references in `app/`, `resources/`, `config/app.php`, and `composer.json`.

> **Prerequisite:** Task 3b (rebuild the 3 native-Content pages) MUST be done before this task — disabling the OpenAdmin provider removes the `admin::` view namespace + `Content` rendering those pages rely on.

- [ ] **Step 0: Create the app-owned route provider (must land with discovery-disable)**

The OpenAdmin provider currently `loadRoutesFrom(app/Admin/routes.php)`. The moment we disable it (Step 1) the admin routes vanish unless we load them ourselves. Create `app/Providers/AdminPanelServiceProvider.php`:

```php
<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;

class AdminPanelServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        $this->loadRoutesFrom(app_path('Admin/routes.php'));
    }
}
```
Register it in `config/app.php` `providers` (add `App\Providers\AdminPanelServiceProvider::class,`). Remove `OpenAdmin\Admin\AdminServiceProvider::class` if it is listed explicitly (it is normally auto-discovered — Step 1 handles that case).

- [ ] **Step 1: Neutralize package auto-discovery**

Edit `composer.json` `extra.laravel.dont-discover` (create the array if absent) to include:
```json
"open-admin-org/open-admin",
"open-admin-ext/log-viewer",
"open-admin-ext/media-manager"
```
Run: `docker exec -i polaris-service-new php artisan config:clear && docker exec -i polaris-service-new php artisan route:clear`
Run: `docker exec -i polaris-service-new php artisan route:list --path=admin 2>&1 | grep -c admin` — expected: non-zero (our provider now loads routes.php; `admin.login`/`admin.logout` + the `admin/*` group are present, loaded exactly once).
Run the smoke test: `docker exec -i polaris-service-new php artisan test tests/Feature/Admin/AdminSmokeTest.php`
Expected: 33 passed with the package present but its provider not registering — proves the app no longer needs the OpenAdmin provider. If routes 404 or a `admin::`/`Admin` facade error appears, a dependency was missed (Tasks 2–6 or 3b) — fix before removing the package.

- [ ] **Step 2: Find and inline remaining `config('admin.*')` reads**

Run: `grep -rn "config('admin\." app resources`
Expected findings and fixes: `config('admin.route.prefix')` (used in `app/Admin/routes.php`) — keep a minimal app config. Create `config/admin.php` replacement containing only the still-used keys, OR replace each read with a literal. Minimum keys still read after Tasks 2–6: `route.prefix` (default `'admin'`), `title`, `skin`, `check_menu_roles`. Trim `config/admin.php` down to just those four keys and delete the OpenAdmin-specific sections (`auth`, `database`, `operation_log`, `extensions`, etc.).

- [ ] **Step 3: Replace the two OpenAdmin extensions**

- `open-admin-ext/log-viewer`: confirm usage with `grep -rn 'log-viewer\|LogViewer' app resources routes`. If a route/link exists, replace with `opcodesio/log-viewer` (`docker exec -i polaris-service-new composer require opcodesio/log-viewer`) or the Boost `read-log-entries` tooling; otherwise just remove.
- `open-admin-ext/media-manager`: confirm usage with `grep -rn 'media-manager\|MediaManager' app resources routes`. HouseManagement/Tasks upload flows use `uppy` directly, so this is likely unused — if grep is empty, remove without replacement.

- [ ] **Step 4: Delete dead files, the CustomAdmin injector, and the whole OpenAdmin asset bundle**

`app/Admin/CustomAdmin.php` extends `\OpenAdmin\Admin\Admin` and only existed to inject/swap assets — it is dead once controllers stopped calling `Admin::switchTheme()` (Task 3). Its `Admin::class` singleton binding in `app/Providers/AppServiceProvider.php` (`$this->app->singleton(Admin::class, ...)`) must go too, or removing the package will fatal on the missing parent class.

```bash
git rm app/Admin/bootstrap.php app/Admin/CustomAdmin.php
git rm -r resources/views/vendor/admin
git rm -r public/vendor/open-admin
```
- Edit `app/Providers/AppServiceProvider.php`: remove the `Admin::class` singleton binding and its `use OpenAdmin\...\Admin` import (and any other `OpenAdmin\...` references in that provider).
- Confirm no straggling references: `grep -rn "vendor.admin\|admin::\|CustomAdmin\|vendor/open-admin" app resources public/js public/css` → expected no output. In particular the Task-0 gap `resources/views/admin/client/loan-table.blade.php:58` (`@include('admin::grid.empty-grid')`) must be replaced with plain "no records" markup (a simple `<tr><td colspan="N">…</td></tr>` or equivalent) since the package view is now gone.

- [ ] **Step 5: Remove the composer packages**

Run: `docker exec -i polaris-service-new composer remove open-admin-org/open-admin open-admin-ext/log-viewer open-admin-ext/media-manager`
Expected: composer resolves and removes them. Then the `dont-discover` entries from Step 1 can be deleted (packages are gone).

- [ ] **Step 6: Full grep sweep for any residual reference**

Run: `grep -rn 'OpenAdmin\|open-admin' app resources config composer.json public/js public/css`
Expected: no output. `public/vendor/open-admin` was deleted in Step 4; the used libs now live under `public/js` & `public/css` (copied in Task 2) with no `open-admin` in their paths. Any remaining hit is a real straggler to fix.

Also sweep for OpenAdmin **helper functions** (invisible to the string grep above, will fatal after `composer remove`):
Run: `grep -rnE 'admin_url\(|admin_asset\(|admin_trans\(|admin_base_path\(|admin_path\(' app resources`
Expected: no output. Replace any hit — `admin_url('x')` → `route(...)`/`url('admin/x')`, `admin_asset('x')` → `asset('x')`. (The Task 5 auth views were already converted to `route('admin.login')`; the OpenAdmin-partial `css.blade.php` using `admin_asset` is deleted with `resources/views/vendor/admin` in Step 4.)

- [ ] **Step 7: Run the full smoke test + boot checks**

Run:
```bash
docker exec -i polaris-service-new php artisan config:clear
docker exec -i polaris-service-new php artisan route:list --path=admin | head
docker exec -i polaris-service-new php artisan test --compact tests/Feature/Admin/AdminSmokeTest.php
```
Expected: routes list cleanly; smoke test PASS.

- [ ] **Step 8: Format and commit**

```bash
docker exec -i polaris-service-new vendor/bin/pint --dirty
git add -A
git commit -m "chore: remove open-admin package, extensions, dead config and vendor views"
```

---

## Rollout & Risk Notes

- **Ship per task.** Each task ends green on the smoke test and is a self-contained commit; the package stays installed until Task 7, so any task can be reverted with `git revert` without breaking the panel.
- **Biggest risk (Tasks 2–3): the asset set, not the theme.** The admin theme is app-owned; the risk is a used third-party lib (SweetAlert2, flatpickr, choices, leaflet, nprogress) that today comes only from the OpenAdmin bundle. Task 2 copies each into `public/`; Task 3 makes the new head live and REQUIRES a real-browser console check (see Task 3 Step 4). The HTTP-200 smoke test will NOT catch `Swal is not defined`. Do a visual+console pass on dashboard, payment-details (flatpickr), a maps page (leaflet), email constructor (tinymce/quill), and a Directories CRUD after Task 3, and again after Task 7 deletes `public/vendor/open-admin`.
- **Pjax (Task 6).** If any Blade/JS relies on `#pjax-container` or partial navigation, dropping Pjax may change navigation behavior. Grep `grep -rn 'pjax' resources public` before Task 6; migrate any needed links to `wire:navigate`.
- **SideMenu URI parsing.** `SideMenu::isItemActiveFast()` strips a `/laravel` prefix from `admin_menu.uri`. Route prefix is unchanged by this plan, so active-state highlighting is preserved; re-verify if `admin.route.prefix` is ever changed.
- **Two different `Role` models.** `App\Models\User\Role` (legacy `perfiles`) vs the new `App\Models\Admin\Role` (`admin_roles`). Never merge them.
- **No schema changes.** The ~10 existing `admin_*` migrations continue to run against the same tables; this plan never touches the database.

---

## Self-Review

- **Spec coverage:** shell/layout (Task 2), `Content` wrapper on 39 controllers (Task 3), auth guard + AuthController + login views (Task 5), RBAC/menu models + `User` base + `SideMenu`/`AdminAuthorization` (Task 4), route bootstrap + middleware group + Pjax removal (Task 6), extensions + package + dead config/assets removal (Task 7), regression guard (Task 1). All separation points from the analysis are covered.
- **Type consistency:** `Menu::allNodes(): array`, `Administrator::roles()/permissions(): BelongsToMany`, `isAdministrator(): bool`, `AuthController::guard(): StatefulGuard`/`getLogout(): RedirectResponse` are defined once and referenced consistently by `SideMenu`, `User::can()`, and the route file.
- **Placeholder scan:** asset tags in Task 2 Step 3 are captured verbatim in Step 1 (not invented); controller transform in Task 3 shows full before/after; all model code is complete copied source.