# Módulo Associados Regionais — Cadastro de Instituições Associadas e Convênios

> **Namespace raiz:** `V2\Modules\Conexoes\Associados`
> **Blade namespace:** `v2-associados::`
> **Tabelas:** `v2_associado_instituicoes`, `v2_associado_tags`, `v2_associado_tag_pivot`, `v2_associado_instituicao_gallery`

---

## Sumário

1. [Visão Geral](#visão-geral)
2. [Estrutura de Diretórios](#estrutura-de-diretórios)
3. [Models](#models)
   - [AssociadoInstituicao](#associadoinstituicao)
   - [AssociadoTag](#associadotag)
   - [AssociadoInstituicaoGallery](#associadoinstituicaogallery)
4. [Controllers](#controllers)
   - [InstituicaoCmsController](#instituicaocmscontroller)
   - [AssociadosPortalController](#associadosportalcontroller)
5. [Views CMS](#views-cms)
6. [Views Portal](#views-portal)
7. [Rotas](#rotas)
8. [Migrations](#migrations)
9. [Sistema de Tags](#sistema-de-tags)
10. [Modal no Portal (Padrão Parceiros)](#modal-no-portal-padrão-parceiros)
11. [Vínculo com Unidade Regional](#vínculo-com-unidade-regional)
12. [Auditoria e Activity Log](#auditoria-e-activity-log)
13. [Changelog](#changelog)

---

## Visão Geral

O módulo **Associados Regionais** gerencia as instituições associadas exibidas no portal público e no CMS administrativo. Cada registro representa uma empresa/instituição conveniada à SUCESU, vinculada a uma unidade regional, classificada por tags (tecnologia, dados, inovação, etc.) e exibida em formato de cards no portal com modal de detalhes (padrão Parceiros).

### Fluxo de trabalho

```
CMS (CRUD) → v2_associado_instituicoes → Portal (/associados) → Modal com detalhes (AJAX)
```

### Funcionalidades-chave

- CRUD completo com formulário interativo (tags, redes sociais, benefícios, galeria)
- Tags múltiplas com picker interativo (primeira tag = "destacada")
- Unidade Regional obrigatória para todas as instituições
- Imagem de capa (cover) e logo via GalleryService
- Descrição rica (ContentRichEditor / Tiptap)
- Galeria de imagens com upload e reordenação
- Modal de detalhes no portal (fetch AJAX, padrão Parceiros)
- Página dedicada (show por slug) mantida para SEO/backward compat
- Busca pública indexada (PublicIndexerService)
- Preview no CMS com renderização do conteúdo rico
- Imagem de fundo configurável via PortalConfigsService
- Auditoria completa via ActivityLoggerService

---

## Estrutura de Diretórios

```
V2/Modules/Conexoes/Associados/
├── Controllers/
│   ├── InstituicaoCmsController.php          # CRUD + gallery + preview + fundo
│   └── AssociadosPortalController.php        # Portal: index + detalhe AJAX + show(slug)
├── Migrations/
│   ├── 2024_01_01_000004_create_v2_associado_instituicoes_table.php
│   ├── 2024_01_01_000005_create_v2_associado_instituicao_gallery_table.php
│   ├── 2024_01_01_000011_add_gallery_media_id_to_associado_instituicao_gallery.php
│   ├── 2024_01_01_000012_add_type_and_regionais_fields_to_associado_instituicoes.php (NO-OP)
│   ├── 2024_01_01_000013_archive_v2_chapters_table.php (NO-OP / não-relacionado)
│   ├── 2024_01_01_000014_add_api_token_to_associado_instituicoes.php (NO-OP)
│   ├── 2024_01_01_000015_add_metadata_to_associado_instituicoes.php
│   ├── 2024_01_01_000016_add_category_and_tags_to_associado_instituicoes.php
│   ├── 2024_01_01_000017_add_social_fields_to_associado_instituicoes.php
│   ├── 2024_01_01_000018_add_cover_image_to_associado_instituicoes.php
│   ├── 2024_01_01_000019_reset_tags_to_default_categories.php
│   └── 2024_01_01_000020_drop_category_from_associado_instituicoes.php
├── Models/
│   ├── AssociadoInstituicao.php               # Model principal
│   ├── AssociadoTag.php                       # Tags/classificações
│   └── AssociadoInstituicaoGallery.php        # Galeria de imagens
├── ModuleProvider.php
├── Routes/
│   ├── cms.php                                # Rotas administrativas
│   └── web.php                                # Rotas públicas
└── Views/
    ├── cms/
    │   └── instituicoes/
    │       ├── _form.blade.php                # Formulário compartilhado (tags picker, social, benefits)
    │       ├── create.blade.php               # Criação
    │       ├── edit.blade.php                 # Edição com split-screen + preview
    │       ├── index.blade.php                # Listagem CMS + filtro por tag
    │       └── preview.blade.php              # Preview no layout do portal
    └── portal/
        ├── detalhe.blade.php                  # Conteúdo do modal (fragmento AJAX)
        ├── instituicao.blade.php              # Página dedicada (SEO, slug)
        └── instituicoes.blade.php             # Grid de cards + modal interativo
```

---

## Models

### AssociadoInstituicao

**Arquivo:** `Models/AssociadoInstituicao.php`
**Tabela:** `v2_associado_instituicoes`

| Coluna | Tipo | Descrição |
|--------|------|-----------|
| `id` | string (UUID) | PK, gerado via `v2_uuid()` no boot |
| `name` | string (255) | Nome da instituição |
| `slug` | string (255) | Slug automático com dedup (`str_slug()` + `-N`) |
| `description` | text/null | Descrição curta |
| `convenio_description` | text/null | Conteúdo rico (JSON Tiptap) |
| `logo` | string (255)/null | Caminho da imagem do logo |
| `cover_image` | string (255)/null | Imagem de capa/hero |
| `url` | string (255)/null | Site da instituição |
| `linkedin_url` | string (255)/null | LinkedIn (legado) |
| `instagram_url` | string (255)/null | Instagram (legado) |
| `facebook_url` | string (255)/null | Facebook (legado) |
| `discount_badge` | string (255)/null | Ex: "15% OFF" |
| `benefits_list` | text/null | Lista de benefícios (newline-separated) |
| `sort_order` | integer | Ordenação manual (default: 0) |
| `is_active` | boolean | Ativo/inativo (default: true) |
| `metadata` | jsonb/null | Dados extras: `logo_id`, `cover_image_id`, `benefits[]`, `social_media[]` |
| `created_by` | string (36)/null | ID do usuário que criou |
| `created_at` | timestamp | |
| `updated_at` | timestamp | |
| `deleted_at` | timestamp/null | Soft delete |

**Relationships:**

| Método | Tipo | Modelo Relacionado | Pivot/Tabela |
|--------|------|--------------------|-------------|
| `gallery()` | hasMany | AssociadoInstituicaoGallery | FK: `instituicao_id` |
| `tags()` | belongsToMany | AssociadoTag | `v2_associado_tag_pivot` (with `is_primary`) |
| `primaryTag()` | belongsToMany | AssociadoTag | `v2_associado_tag_pivot` (where `is_primary = true`) |
| `unidadesRegionais()` | belongsToMany | UnidadeRegional | `v2_unidade_regional_empresas` |

**Scopes:**

- `scopeActive($query)` — `where('is_active', true)`
- `scopeOrdered($query)` — `orderBy('sort_order', 'asc')->orderBy('name', 'asc')`

**Boot Events:**

- `creating`: Gera UUID (`v2_uuid()`), slug automático via `str_slug(name)`
- `saving`: Garante unicidade do slug com append `-1`, `-2`, etc.
- `saved`: Sincroniza com PublicIndexerService (busca pública)
- `deleted`: Remove do índice público

**Accessors/Mutators:**

- `getBenefitsAttribute()` — lê de `metadata['benefits']`
- `setBenefitsAttribute($benefits)` — grava em `metadata['benefits']`
- `getSocialMediaAttribute()` — lê de `metadata['social_media']`, fallback para colunas legado (linkedin/instagram/facebook)
- `setSocialMediaAttribute($socialMedia)` — grava em `metadata['social_media']`

**Métodos de Tags:**

- `syncTagsFromString($tagsString)` — separa por vírgula, delega para `syncTagsFromArray`
- `syncTagsFromArray(array $tagNames)` — para cada nome, `firstOrCreate` por slug, sincroniza pivot com `is_primary=true` na primeira tag

**Search Indexing:**

- `syncToSearch()` — indexa com tags `['associado', 'convenio', 'instituicao']`, extrai texto plano do Tiptap JSON

---

### AssociadoTag

**Arquivo:** `Models/AssociadoTag.php`
**Tabela:** `v2_associado_tags`

| Coluna | Tipo | Descrição |
|--------|------|-----------|
| `id` | string (UUID) | PK |
| `name` | string (100) | Nome da tag (ex: "Tecnologia") |
| `slug` | string (100) | Slug único |
| `created_at` | timestamp | |
| `updated_at` | timestamp | |

**Relationships:**

- `associados()` — belongsToMany AssociadoInstituicao via `v2_associado_tag_pivot`

**Boot:** Gera UUID no creating.

**Categorias padrão (seed via migration 000019):**

| Tag | Slug |
|-----|------|
| Tecnologia | `tecnologia` |
| Dados | `dados` |
| Inovação | `inovacao` |

---

### AssociadoInstituicaoGallery

**Arquivo:** `Models/AssociadoInstituicaoGallery.php`
**Tabela:** `v2_associado_instituicao_gallery`

| Coluna | Tipo | Descrição |
|--------|------|-----------|
| `id` | string (UUID) | PK |
| `instituicao_id` | string (UUID) | FK → `v2_associado_instituicoes.id` (CASCADE) |
| `image` | string (255) | Caminho do arquivo |
| `gallery_media_id` | string (36)/null | FK → `gallery_media.id` (GalleryService) |
| `sort_order` | integer | Ordenação (default: 0) |
| `created_at` | timestamp | |
| `updated_at` | timestamp | |

**Relationships:**

- `instituicao()` — belongsTo AssociadoInstituicao
- `galleryMedia()` — belongsTo V2\Services\Gallery\Models\GalleryMedia

---

## Controllers

### InstituicaoCmsController

**Arquivo:** `Controllers/InstituicaoCmsController.php`
**Dependências:** ActivityLoggerService, SEOService, GalleryService

| Método | Finalidade |
|--------|-----------|
| `index(Request)` | Listagem com busca (name/description ILIKE), filtro por tag (via `whereHas`), paginação 15 |
| `archived(Request)` | Lista instituições arquivadas (soft deleted) |
| `restore($id)` | Restaura instituição arquivada |
| `create()` | Formulário de criação — carrega allTags e unidadesRegionais |
| `store(Request)` | Validação + criação — `syncTagsFromArray()`, `syncUnidadeRegional()` |
| `edit($id)` | Formulário de edição — eager load gallery, tags, unidadesRegionais |
| `update(Request, $id)` | Validação + atualização — sync tags, unidade regional, gallery media |
| `destroy($id)` | Soft delete (set is_active=false + delete) |
| `preview($id)` | Preview com renderização Tiptap→HTML |
| `uploadGallery(Request, $id)` | Upload múltiplo de imagens para galeria |
| `syncGallery(Request, $id)` | Sincroniza galeria por media IDs (endpoint JSON) |
| `destroyGallery($galleryId)` | Remove item da galeria |
| `saveBackgroundImage(Request)` | Salva imagem de fundo do portal via PortalConfigsService |

**Métodos auxiliares:**

- `syncUnidadeRegional()` — cria/atualiza pivot `UnidadeRegionalEmpresa` quando `unidade_regional_id` fornecido
- `handleLogoFromGallery()` — resolve `logo_id` → `GalleryMedia` file path, grava em metadata
- `handleCoverFromGallery()` — idem para cover_image
- `syncGalleryMediaIds()` — sincroniza galeria por media IDs, remove órfãs
- `clearPageCache()` — limpa Redis `page:*`

**Validação (store/update):**

```php
'name'                 => 'required|string|max:255',
'tags'                 => 'required|array|min:1',
'tags.*'               => 'string|max:100',
'url'                  => 'nullable|url|max:255',
'discount_badge'       => 'nullable|string|max:255',
'benefits_list'        => 'nullable|string',
'logo_id'              => 'nullable|string|max:36',
'cover_image_id'       => 'nullable|string|max:36',
'unidade_regional_id'  => 'required|string|max:36|exists:v2_unidade_regionais,id',
'description'          => 'nullable|string',
'convenio_description' => 'nullable|string',
'sort_order'           => 'nullable|integer|min:0',
'is_active'            => 'nullable|boolean',
'social_media_json'    => 'nullable|string',
```

---

### AssociadosPortalController

**Arquivo:** `Controllers/AssociadosPortalController.php`
**Dependências:** SEOService

| Método | Finalidade |
|--------|-----------|
| `index()` | Lista ativos, paginados 12, SEO, background image |
| `detalhe($id)` | Fragmento HTML para modal (sem layout) — carrega gallery, tags, unidadesRegionais |
| `show($slug)` | Página dedicada com SEO completo (mantida para backward compat e SEO) |

---

## Views CMS

### `_form.blade.php` — Formulário Compartilhado

Formulário usado tanto na criação quanto na edição. Inclui:

1. **Nome** (text, required) + **Tags** (tag picker interativo multi-select)
2. **Unidade Regional** (select, required, sempre visível)
3. **Site URL** + **Redes Sociais** (builder dinâmico: Facebook, Instagram, Twitter/X, YouTube, LinkedIn, WhatsApp, Telegram, TikTok, Flickr)
4. **Badge de Desconto** + **Lista de Benefícios** (builder key-value: nome + link opcional)
5. **Logo** + **Cover Image** (media pickers via GalleryService)

**Tag Picker:** Componente JS que permite:
- Digitar nome + Enter ou botão "+" para criar tag customizada
- Clicar em tags disponíveis (pool de `$allTags`) para adicionar
- Badge com "x" para remover
- Primeira tag marcada como "destacada" (`is_primary`)
- Hidden inputs `name="tags[]"` para envio

### `index.blade.php` — Listagem CMS

- Busca por nome/descrição
- Filtro por tag (dropdown dinâmico a partir de `$allTags`)
- Tabela: Logo | Nome | Tags (badges, máx 3 + "+N") | Unidade Regional | Criado em | Ativo | Ações
- Botão de.Background Image Modal
- Toggle ativo/inativo

### `create.blade.php` / `edit.blade.php`

- Incluem `_form.blade.php`
- Create: card simples com descrição textarea + ContentRichEditor + submit
- Edit: layout split-screen com form à esquerda e preview iframe à direita + galeria + activity log

### `preview.blade.php`

- Renderiza no layout real do portal (v2-portal::app-layout)
- Exibe logo, nome, tags, redes sociais, badge, conteúdo rico, benefícios
- Banner fixo inferior indicando "Preview"

---

## Views Portal

### `instituicoes.blade.php` — Grid + Modal

- Hero/breadcrumb com imagem de fundo configurável
- Grid 2 colunas de cards (logo, nome, badge desconto, descrição curta)
- Cards clicáveis → abrem modal Bootstrap 5 (`#associadoModal`)
- JS: `$.get()` para buscar fragmento HTML do `detalhe/{id}` no `show.bs.modal`
- Paginação
- Empty state amigável

### `detalhe.blade.php` — Fragmento do Modal (AJAX)

- Sem `@extends` — é um fragmento HTML puro
- Header: logo, nome, badge unidade regional, badge desconto
- Descrição + Conteúdo rico (Tiptap→HTML)
- Benefícios (grid)
- Galeria (thumbnails)
- Footer: botão site, redes sociais (icones), tags

### `instituicao.blade.php` — Página Dedicada (SEO)

- Mantida para SEO e links externos
- Hero com cover_image ou gradiente
- Breadcrumb completo
- Layout 2 colunas: conteúdo principal (8) + sidebar (4)
- Sidebar: informações, redes sociais, tags
- Galeria com modal de navegação (prev/next, setas, teclado)

---

## Rotas

### Rotas CMS (`Routes/cms.php`)

**Prefixo:** `cms/associados` | **Middleware:** `CmsAuthenticate`

| Método | URI | Nome | Método Controller |
|--------|-----|------|-------------------|
| GET | `/instituicoes` | `v2.associados.cms.instituicoes.index` | `index` |
| GET | `/instituicoes/arquivadas` | `v2.associados.cms.instituicoes.archived` | `archived` |
| POST | `/instituicoes/{id}/restaurar` | `v2.associados.cms.instituicoes.restore` | `restore` |
| GET | `/instituicoes/nova` | `v2.associados.cms.instituicoes.create` | `create` |
| POST | `/instituicoes/nova` | `v2.associados.cms.instituicoes.store` | `store` |
| GET | `/instituicoes/editar/{id}` | `v2.associados.cms.instituicoes.edit` | `edit` |
| PUT/POST | `/instituicoes/editar/{id}` | `v2.associados.cms.instituicoes.update` | `update` |
| GET | `/instituicoes/preview/{id}` | `v2.associados.cms.instituicoes.preview` | `preview` |
| DELETE | `/instituicoes/{id}` | `v2.associados.cms.instituicoes.destroy` | `destroy` |
| POST | `/instituicoes/{id}/gallery` | `v2.associados.cms.instituicoes.gallery.upload` | `uploadGallery` |
| POST | `/instituicoes/{id}/gallery/sync` | `v2.associados.cms.instituicoes.gallery.sync` | `syncGallery` |
| DELETE | `/gallery/{galleryId}` | `v2.associados.cms.instituicoes.gallery.destroy` | `destroyGallery` |
| POST | `/salvar-imagem-fundo` | `v2.associados.cms.instituicoes.save-bg` | `saveBackgroundImage` |

### Rotas Portal (`Routes/web.php`)

**Middleware:** `web`

| Método | URI | Nome | Método Controller |
|--------|-----|------|-------------------|
| GET | `/associados` | `v2.associados.portal.index` | `index` |
| GET | `/associados/detalhe/{id}` | `v2.associados.portal.detalhe` | `detalhe` |
| GET | `/associado/{slug}` | `v2.associados.portal.show` | `show` |

---

## Migrations

| # | Migration | Ação |
|---|-----------|------|
| 000004 | `create_v2_associado_instituicoes_table` | Cria tabela principal com UUID PK, campos base, indexes |
| 000005 | `create_v2_associado_instituicao_gallery_table` | Cria tabela de galeria com FK CASCADE |
| 000011 | `add_gallery_media_id_to_associado_instituicao_gallery` | Adiciona `gallery_media_id` (nullable) |
| 000012 | `add_type_and_regionais_fields` | NO-OP (campos movidos para UnidadeRegional) |
| 000013 | `archive_v2_chapters_table` | NO-OP (não-relacionado) |
| 000014 | `add_api_token_to_associado_instituicoes` | NO-OP (campos movidos para UnidadeRegional) |
| 000015 | `add_metadata_to_associado_instituicoes` | Adiciona `metadata` (jsonb) |
| 000016 | `add_category_and_tags_to_associado_instituicoes` | Cria `v2_associado_tags` + `v2_associado_tag_pivot` (coluna `category` foi removida depois) |
| 000017 | `add_social_fields_to_associado_instituicoes` | Adiciona linkedin/instagram/facebook_url |
| 000018 | `add_cover_image_to_associado_instituicoes` | Adiciona `cover_image` |
| 000019 | `reset_tags_to_default_categories` | Trunca tags/pivot, seed 3 padrões: Tecnologia, Dados, Inovação |
| 000020 | `drop_category_from_associado_instituicoes` | Remove coluna `category` (enum institucional/regional) + índice |

### Schema Final das Tabelas

**`v2_associado_instituicoes`** — 16 colunas de dados (excluindo timestamps):
`id`, `name`, `slug`, `description`, `convenio_description`, `logo`, `cover_image`, `url`, `linkedin_url`, `instagram_url`, `facebook_url`, `discount_badge`, `benefits_list`, `sort_order`, `is_active`, `metadata`, `created_by`

**`v2_associado_tags`** — `id` (UUID PK), `name` (100), `slug` (100 unique)

**`v2_associado_tag_pivot`** — `associado_id` (UUID FK), `tag_id` (UUID FK), `is_primary` (boolean), timestamps. PK composta.

**`v2_associado_instituicao_gallery`** — `id` (UUID PK), `instituicao_id` (UUID FK CASCADE), `image`, `gallery_media_id` (nullable), `sort_order`

---

## Sistema de Tags

O módulo usa um sistema de **tags many-to-many** para classificar instituições. Cada instituição pode ter múltiplas tags, com a primeira sendo marcada como "destacada" (`is_primary = true`).

### Tags Padrão (seeded)

| Tag | Slug |
|-----|------|
| Tecnologia | `tecnologia` |
| Dados | `dados` |
| Inovação | `inovacao` |

### Como Funciona

1. **Criação/Edição:** O formulário exibe um tag picker interativo com as tags disponíveis + campo de texto para criar novas
2. **Envio:** Tags são enviadas como array de strings (`tags[]`)
3. **Controller:** Valida como `required|array|min:1`, `tags.* => string|max:100`
4. **Model:** `syncTagsFromArray()` faz `firstOrCreate` por slug, sincroniza pivot com `is_primary=true` na primeira
5. **Exibição:** Até 3 tags como badges na listagem, todas na página de detalhe/preview

### Busca por Tag

A listagem CMS tem um filtro por tag (query param `category`). O controller usa `whereHas('tags')` para filtrar.

---

## Modal no Portal (Padrão Parceiros)

O portal usa o padrão **Parceiros** para exibir detalhes em modal:

1. **Cards** têm `data-id`, `data-bs-toggle="modal"`, `data-bs-target="#associadoModal"`
2. **Rota AJAX:** `GET /associados/detalhe/{id}` retorna fragmento HTML (sem layout)
3. **Controller `detalhe()`:** Carrega model com gallery+tags, renderiza Tiptap→HTML, retorna view fragment
4. **JavaScript:** No `show.bs.modal`, faz `$.get(url)` e injeta HTML no `#associadoModalBody`
5. **Fallback:** Mensagem de erro amigável se AJAX falhar
6. **Acessibilidade:** Focus management (foca no primeiro elemento focável após load)

A rota `/associado/{slug}` (página dedicada) é mantida para SEO e links externos.

---

## Vínculo com Unidade Regional

Todo associado **deve** estar vinculado a uma Unidade Regional:

- **Formulário:** Select sempre visível e obrigatório (`required`)
- **Validação:** `unidade_regional_id` → `required|string|max:36|exists:v2_unidade_regionais,id`
- **Controller `syncUnidadeRegional()`:** Cria ou atualiza pivot `UnidadeRegionalEmpresa` (tabela `v2_unidade_regional_empresas`)
- **Relationship:** `unidadesRegionais()` → belongsToMany via pivot (com `status`)

---

## Auditoria e Activity Log

Todas as operações CMS são logadas via `ActivityLoggerService`:

| Ação | Tipo |
|------|------|
| Criação | `associado_instituicao_create` |
| Atualização | `associado_instituicao_update` |
| Exclusão (soft) | `associado_instituicao_delete` |
| Restaurar | `associado_instituicao_restore` |
| Upload galeria | `associado_instituicao_gallery_upload` |
| Sync galeria | `associado_instituicao_gallery_sync` |
| Delete galeria | `associado_instituicao_gallery_delete` |

---

## Changelog

### Julho/2026 — Reformulação Completa do Módulo

1. **Renomeação:** "Associados" → "Associados Regionais" em títulos, breadcrumbs, SEO, flash messages. Nomes internos (classes, rotas, tabelas) preservados.

2. **Reforma de Categorias:**
   - Sistema de tags many-to-many substituiu a coluna enum `category` (institucional/regional)
   - Migration 000019: seed 3 categorias padrão (Tecnologia, Dados, Inovação)
   - Migration 000020: drop da coluna `category` do banco
   - Model: `syncCategoria()` temporário → removido em favor de `syncTagsFromArray()`

3. **Tag Picker Interativo:** Select único → picker multi-tag com JS (add/remove/disponíveis/hidden inputs)

4. **Unidade Regional Obrigatória:** Agora sempre visível e required, não mais condicional ao tipo de convênio

5. **Modal no Portal:** Padrão Parceiros implementado — cards clicáveis + modal Bootstrap 5 com fetch AJAX do fragmento `detalhe/{id}`. Rota `/associado/{slug}` mantida para SEO.

6. **Bug Fixes:**
   - `@csrf` → `{{ csrf_field() }}` (compatibilidade Laravel 5.5)
   - Route cache stale (solução: `route:clear` no container)
   - Validação `exists:v2_unidade_regional` → `exists:v2_unidade_regionais` (tabela correta)

7. **Testes:** `AssociadosModuleProviderTest` — 2 testes, 7 assertions, OK.
