forlogic-core 1.6.11 → 1.6.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1221 -0
- package/dist/README.md +1221 -0
- package/dist/index.css +1 -1
- package/dist/index.css.map +1 -1
- package/dist/index.esm.js +2 -2
- package/dist/index.js +2 -2
- package/package.json +1 -1
- package/dist/assets/index-DdME9Z_4.css +0 -1
- package/dist/assets/index-zmVOoemO.js +0 -7939
- package/dist/index.html +0 -19
package/README.md
CHANGED
|
@@ -455,6 +455,224 @@ export function DepartmentSelect(props: DepartmentSelectProps) {
|
|
|
455
455
|
|
|
456
456
|
---
|
|
457
457
|
|
|
458
|
+
## 🏗️ ARQUITETURA CRUD
|
|
459
|
+
|
|
460
|
+
O sistema CRUD do forlogic-core segue uma arquitetura em camadas que separa responsabilidades e facilita manutenção:
|
|
461
|
+
|
|
462
|
+
```mermaid
|
|
463
|
+
graph TD
|
|
464
|
+
A[types.ts<br/>Interfaces TypeScript] --> B[Service.ts<br/>createSimpleService]
|
|
465
|
+
B --> C[useCrudHook<br/>React Query]
|
|
466
|
+
C --> D[Page.tsx<br/>Componente Principal]
|
|
467
|
+
D --> E[createCrudPage<br/>Gerador de CRUD]
|
|
468
|
+
E --> F[CrudTable<br/>Tabela]
|
|
469
|
+
E --> G[CrudForm<br/>Formulário]
|
|
470
|
+
E --> H[BulkActionBar<br/>Ações em Massa]
|
|
471
|
+
|
|
472
|
+
I[(Supabase DB)] -.->|RLS + Soft Delete| B
|
|
473
|
+
J[Qualiex API] -.->|responsible_name| B
|
|
474
|
+
|
|
475
|
+
style A fill:#e1f5ff
|
|
476
|
+
style B fill:#fff4e1
|
|
477
|
+
style C fill:#ffe1f5
|
|
478
|
+
style D fill:#e1ffe1
|
|
479
|
+
style E fill:#f5e1ff
|
|
480
|
+
style F fill:#ffe1e1
|
|
481
|
+
style G fill:#ffe1e1
|
|
482
|
+
style H fill:#ffe1e1
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
### **📁 Estrutura de Pastas Obrigatória**
|
|
486
|
+
|
|
487
|
+
```
|
|
488
|
+
src/
|
|
489
|
+
├── examples/ # 📁 Módulo completo
|
|
490
|
+
│ ├── example.ts # 🔷 Types (Entity + Payloads)
|
|
491
|
+
│ ├── ExampleService.ts # ⚙️ Service (createSimpleService)
|
|
492
|
+
│ ├── ExamplesPage.tsx # 🎨 Página CRUD
|
|
493
|
+
│ └── components/ # 📦 Componentes específicos (opcional)
|
|
494
|
+
│ └── ExampleSelect.tsx
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
### **✅ Checklist de Implementação**
|
|
498
|
+
|
|
499
|
+
```
|
|
500
|
+
✅ OBRIGATÓRIOS:
|
|
501
|
+
□ types.ts - Definir Example extends ContentEntity + VisualEntity + ...
|
|
502
|
+
□ types.ts - CreateExamplePayload com alias: string
|
|
503
|
+
□ types.ts - UpdateExamplePayload extends Partial<CreateExamplePayload>
|
|
504
|
+
□ Service.ts - Usar createSimpleService<Example, CreatePayload, UpdatePayload>
|
|
505
|
+
□ Service.ts - Configurar tableName, entityName, searchFields
|
|
506
|
+
□ Page.tsx - Chamar useExamplesCrud() DENTRO do componente
|
|
507
|
+
□ Page.tsx - Usar useMemo() para columns, formSections, config
|
|
508
|
+
□ Page.tsx - Importar { alias } de useAuth()
|
|
509
|
+
□ Page.tsx - Passar alias no CreatePayload
|
|
510
|
+
|
|
511
|
+
🔧 OPCIONAIS:
|
|
512
|
+
□ Filtros customizados (useState + useMemo)
|
|
513
|
+
□ onToggleStatus para ativar/desativar
|
|
514
|
+
□ Renderização customizada nas colunas
|
|
515
|
+
□ Campos de formulário customizados
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
### **🔄 Fluxo de Dados**
|
|
519
|
+
|
|
520
|
+
1. **types.ts**: Define interfaces TypeScript (Entity, CreatePayload, UpdatePayload)
|
|
521
|
+
2. **Service.ts**: Gera service com `createSimpleService` (métodos CRUD + hook)
|
|
522
|
+
3. **useCrudHook**: Hook React Query que gerencia estado, cache, loading
|
|
523
|
+
4. **Page.tsx**: Componente que usa o hook e define configuração visual
|
|
524
|
+
5. **createCrudPage**: Gera componente de página completo (tabela + formulário)
|
|
525
|
+
6. **UI Components**: Renderiza CrudTable, CrudForm, BulkActionBar, etc.
|
|
526
|
+
|
|
527
|
+
### **🔐 Integrações Automáticas**
|
|
528
|
+
|
|
529
|
+
- 🔒 **RLS (Row Level Security)**: Filtra por `alias`
|
|
530
|
+
- 🗑️ **Soft Delete**: Marca `is_removed = true` ao invés de deletar
|
|
531
|
+
- 👤 **Qualiex Enrichment**: Adiciona `responsible_name` aos registros
|
|
532
|
+
- 🔍 **Busca Global**: Header com busca automática integrada ao CRUD
|
|
533
|
+
|
|
534
|
+
---
|
|
535
|
+
|
|
536
|
+
## 🔍 Busca Global no Header
|
|
537
|
+
|
|
538
|
+
O `AppHeader` inclui uma barra de busca integrada que funciona automaticamente com o sistema CRUD.
|
|
539
|
+
|
|
540
|
+
### **Como Funciona:**
|
|
541
|
+
|
|
542
|
+
1. **Ativação Automática**: A busca aparece automaticamente em páginas CRUD quando `isSearchVisible = true` no AuthContext
|
|
543
|
+
2. **Debounce**: Usa delay de 500ms (configurável via `SEARCH_CONFIG.debounceDelay`) para evitar queries excessivas
|
|
544
|
+
3. **URL Sync**: Mantém o termo de busca sincronizado na URL (`?search=termo`)
|
|
545
|
+
4. **Reset de Paginação**: Ao buscar, reseta automaticamente para página 1
|
|
546
|
+
5. **Botão Refresh**: Atualiza os dados da página atual
|
|
547
|
+
|
|
548
|
+
### **Ativar Busca em uma Página:**
|
|
549
|
+
|
|
550
|
+
```typescript
|
|
551
|
+
// No AuthContext ou componente pai
|
|
552
|
+
const [isSearchVisible, setIsSearchVisible] = useState(true);
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
### **Integração com CRUD:**
|
|
556
|
+
|
|
557
|
+
O hook `useCrud` já lê automaticamente o parâmetro `search` da URL:
|
|
558
|
+
|
|
559
|
+
```typescript
|
|
560
|
+
// lib/crud/hooks/useCrud.ts
|
|
561
|
+
const [searchParams] = useSearchParams();
|
|
562
|
+
const searchTerm = searchParams.get('search') || '';
|
|
563
|
+
|
|
564
|
+
// A busca é aplicada automaticamente nos searchFields configurados no service
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
### **Configurar Campos de Busca:**
|
|
568
|
+
|
|
569
|
+
```typescript
|
|
570
|
+
// src/examples/ExampleService.ts
|
|
571
|
+
export const { service, useCrudHook } = createSimpleService<Example>({
|
|
572
|
+
tableName: 'examples',
|
|
573
|
+
entityName: 'exemplo',
|
|
574
|
+
schemaName: 'central',
|
|
575
|
+
searchFields: ['title', 'description', 'tags'], // 🔍 Campos pesquisáveis
|
|
576
|
+
});
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
### **Customizar Placeholder:**
|
|
580
|
+
|
|
581
|
+
O placeholder da busca se adapta automaticamente à rota. Para customizar:
|
|
582
|
+
|
|
583
|
+
```tsx
|
|
584
|
+
// AppHeader.tsx - Modificação direta (afeta todas as rotas)
|
|
585
|
+
<Input
|
|
586
|
+
placeholder={location.pathname === '/wiki' ? "Buscar artigos..." : "Buscar..."}
|
|
587
|
+
/>
|
|
588
|
+
|
|
589
|
+
// OU usar metadata (recomendado para páginas específicas)
|
|
590
|
+
import { usePageMetadataContext } from 'forlogic-core';
|
|
591
|
+
|
|
592
|
+
export default function MyPage() {
|
|
593
|
+
const { setMetadata } = usePageMetadataContext();
|
|
594
|
+
|
|
595
|
+
useEffect(() => {
|
|
596
|
+
setMetadata({
|
|
597
|
+
title: 'Meus Itens',
|
|
598
|
+
subtitle: 'Gerencie seus itens',
|
|
599
|
+
// Futura feature: searchPlaceholder: 'Buscar por nome...'
|
|
600
|
+
});
|
|
601
|
+
}, []);
|
|
602
|
+
}
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
### **Configuração Avançada:**
|
|
606
|
+
|
|
607
|
+
```typescript
|
|
608
|
+
// lib/config/index.ts
|
|
609
|
+
export const SEARCH_CONFIG = {
|
|
610
|
+
debounceDelay: 500, // ms - ajuste conforme necessidade
|
|
611
|
+
} as const;
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
**Quando aumentar o delay:**
|
|
615
|
+
- ✅ Muitos usuários simultâneos (reduz carga no servidor)
|
|
616
|
+
- ✅ Campos de busca muito amplos (muitos registros)
|
|
617
|
+
- ✅ Backend com rate limiting
|
|
618
|
+
|
|
619
|
+
**Quando reduzir o delay:**
|
|
620
|
+
- ✅ Poucos registros (resposta instantânea)
|
|
621
|
+
- ✅ Busca crítica para UX (feedback imediato)
|
|
622
|
+
|
|
623
|
+
### **Controle Programático:**
|
|
624
|
+
|
|
625
|
+
```typescript
|
|
626
|
+
// Limpar busca programaticamente
|
|
627
|
+
import { useSearchParams } from 'react-router-dom';
|
|
628
|
+
|
|
629
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
630
|
+
|
|
631
|
+
const clearSearch = () => {
|
|
632
|
+
const newParams = new URLSearchParams(searchParams);
|
|
633
|
+
newParams.delete('search');
|
|
634
|
+
newParams.delete('page');
|
|
635
|
+
setSearchParams(newParams);
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
// Definir busca programaticamente
|
|
639
|
+
const setSearch = (term: string) => {
|
|
640
|
+
const newParams = new URLSearchParams(searchParams);
|
|
641
|
+
newParams.set('search', term);
|
|
642
|
+
newParams.set('page', '1'); // Reset para primeira página
|
|
643
|
+
setSearchParams(newParams);
|
|
644
|
+
};
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
### **Refresh Manual:**
|
|
648
|
+
|
|
649
|
+
O botão de refresh chama a função `refreshData` do AuthContext:
|
|
650
|
+
|
|
651
|
+
```typescript
|
|
652
|
+
// Implementar no seu AuthContext
|
|
653
|
+
const refreshData = useCallback(() => {
|
|
654
|
+
queryClient.invalidateQueries(); // Invalida cache do React Query
|
|
655
|
+
toast.success('Dados atualizados');
|
|
656
|
+
}, [queryClient]);
|
|
657
|
+
```
|
|
658
|
+
|
|
659
|
+
### **Arquitetura da Busca:**
|
|
660
|
+
|
|
661
|
+
```mermaid
|
|
662
|
+
graph LR
|
|
663
|
+
A[Usuário digita] --> B[useState local]
|
|
664
|
+
B --> C[useDebounce 500ms]
|
|
665
|
+
C --> D[URL ?search=termo]
|
|
666
|
+
D --> E[useCrud lê URL]
|
|
667
|
+
E --> F[Supabase Query]
|
|
668
|
+
F --> G[Resultados filtrados]
|
|
669
|
+
|
|
670
|
+
style C fill:#d4f4dd
|
|
671
|
+
style F fill:#ffd4d4
|
|
672
|
+
```
|
|
673
|
+
|
|
674
|
+
---
|
|
675
|
+
|
|
458
676
|
## 🚀 QUICK START - Criar CRUD Completo
|
|
459
677
|
|
|
460
678
|
### **1️⃣ Type**
|
|
@@ -728,6 +946,676 @@ export function ProcessesPage() {
|
|
|
728
946
|
|
|
729
947
|
---
|
|
730
948
|
|
|
949
|
+
## 🎓 TUTORIAL COMPLETO: CRUD de Examples (Copy-Paste Ready)
|
|
950
|
+
|
|
951
|
+
Este tutorial mostra como criar um CRUD completo usando o módulo **Examples** como referência. É um exemplo funcional 100% que você pode copiar e adaptar.
|
|
952
|
+
|
|
953
|
+
### **Passo 1: Criar Types (`src/examples/example.ts`)**
|
|
954
|
+
|
|
955
|
+
```typescript
|
|
956
|
+
// ============= EXAMPLE MODULE TYPES =============
|
|
957
|
+
import {
|
|
958
|
+
ContentEntity, // title, description
|
|
959
|
+
VisualEntity, // color, icon_name
|
|
960
|
+
UserRelatedEntity, // id_user, responsible_name
|
|
961
|
+
ActivableEntity, // is_actived
|
|
962
|
+
FormEntity, // url_field, date_field
|
|
963
|
+
FilterState,
|
|
964
|
+
EntitySortField
|
|
965
|
+
} from 'forlogic-core';
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* Example - Entidade completa de exemplo
|
|
969
|
+
*
|
|
970
|
+
* ✅ Composição de Interfaces:
|
|
971
|
+
* - ContentEntity: fornece title, description
|
|
972
|
+
* - VisualEntity: fornece color, icon_name
|
|
973
|
+
* - UserRelatedEntity: fornece id_user, responsible_name (enriquecido via Qualiex)
|
|
974
|
+
* - ActivableEntity: fornece is_actived (campo de status on/off)
|
|
975
|
+
* - FormEntity: fornece url_field, date_field
|
|
976
|
+
*
|
|
977
|
+
* 🔒 Campos Herdados de BaseEntity (automáticos):
|
|
978
|
+
* - id: string
|
|
979
|
+
* - alias: string
|
|
980
|
+
* - company_id: string
|
|
981
|
+
* - is_removed: boolean
|
|
982
|
+
* - created_at: string
|
|
983
|
+
* - updated_at: string
|
|
984
|
+
*/
|
|
985
|
+
export interface Example extends
|
|
986
|
+
ContentEntity,
|
|
987
|
+
VisualEntity,
|
|
988
|
+
UserRelatedEntity,
|
|
989
|
+
ActivableEntity,
|
|
990
|
+
FormEntity {}
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* CreateExamplePayload - Dados para CRIAR novo registro
|
|
994
|
+
*
|
|
995
|
+
* ⚠️ IMPORTANTE:
|
|
996
|
+
* - Sempre incluir `alias: string` (obrigatório para RLS)
|
|
997
|
+
* - Campos opcionais devem ter `| null`
|
|
998
|
+
* - NÃO incluir id, created_at, updated_at (gerados automaticamente)
|
|
999
|
+
*/
|
|
1000
|
+
export interface CreateExamplePayload {
|
|
1001
|
+
title: string; // ✅ Obrigatório
|
|
1002
|
+
description?: string | null; // ❌ Opcional
|
|
1003
|
+
alias: string; // ✅ OBRIGATÓRIO para RLS
|
|
1004
|
+
url_field?: string | null;
|
|
1005
|
+
date_field?: string | null;
|
|
1006
|
+
color?: string;
|
|
1007
|
+
icon_name?: string;
|
|
1008
|
+
id_user?: string | null;
|
|
1009
|
+
is_actived?: boolean;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* UpdateExamplePayload - Dados para ATUALIZAR registro existente
|
|
1014
|
+
*
|
|
1015
|
+
* 📝 Pattern:
|
|
1016
|
+
* - Estende Partial<CreateExamplePayload> (todos os campos opcionais)
|
|
1017
|
+
* - MAS title é obrigatório (override)
|
|
1018
|
+
*/
|
|
1019
|
+
export interface UpdateExamplePayload extends Partial<CreateExamplePayload> {
|
|
1020
|
+
title: string; // ✅ Override: title obrigatório mesmo no update
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
export type ExampleInsert = CreateExamplePayload;
|
|
1024
|
+
export type ExampleUpdate = UpdateExamplePayload;
|
|
1025
|
+
```
|
|
1026
|
+
|
|
1027
|
+
**📖 Explicação Detalhada:**
|
|
1028
|
+
- **Composição de Interfaces:** Ao invés de redefinir campos, herda de interfaces prontas da lib
|
|
1029
|
+
- **`alias` no CreatePayload:** RLS do Supabase precisa desse campo para funcionar
|
|
1030
|
+
- **`Partial<>` no UpdatePayload:** Permite updates parciais (só manda os campos que mudaram)
|
|
1031
|
+
- **Campos `| null`:** Importante para sincronizar com Supabase (que aceita NULL)
|
|
1032
|
+
|
|
1033
|
+
---
|
|
1034
|
+
|
|
1035
|
+
### **Passo 2: Criar Service (`src/examples/ExampleService.ts`)**
|
|
1036
|
+
|
|
1037
|
+
```typescript
|
|
1038
|
+
// ============= SIMPLIFIED SERVICE (MIGRATED) =============
|
|
1039
|
+
|
|
1040
|
+
import { createSimpleService } from 'forlogic-core';
|
|
1041
|
+
import type { Example, CreateExamplePayload, UpdateExamplePayload } from './example';
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* ExampleService - Service CRUD completo gerado automaticamente
|
|
1045
|
+
*
|
|
1046
|
+
* ✅ O que é gerado:
|
|
1047
|
+
* - service.getAll(params)
|
|
1048
|
+
* - service.getById(id)
|
|
1049
|
+
* - service.create(data)
|
|
1050
|
+
* - service.update(id, data)
|
|
1051
|
+
* - service.delete(id)
|
|
1052
|
+
* - useCrudHook() - Hook React Query integrado
|
|
1053
|
+
*
|
|
1054
|
+
* 🔧 Configuração:
|
|
1055
|
+
* - tableName: Nome da tabela no Supabase (schema: central)
|
|
1056
|
+
* - entityName: Nome legível para toasts ("Exemplo criado com sucesso")
|
|
1057
|
+
* - searchFields: Campos que serão pesquisados pelo filtro de busca
|
|
1058
|
+
* - enableQualiexEnrichment: true → adiciona responsible_name automaticamente
|
|
1059
|
+
*
|
|
1060
|
+
* 📊 Estrutura de Tabela Esperada (Supabase):
|
|
1061
|
+
* CREATE TABLE central.examples (
|
|
1062
|
+
* id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
1063
|
+
* alias TEXT NOT NULL, -- ✅ Para RLS (obrigatório)
|
|
1064
|
+
* is_removed BOOLEAN DEFAULT false, -- ✅ Para soft delete (obrigatório)
|
|
1065
|
+
* created_at TIMESTAMPTZ DEFAULT now(),
|
|
1066
|
+
* updated_at TIMESTAMPTZ DEFAULT now(),
|
|
1067
|
+
* title TEXT NOT NULL,
|
|
1068
|
+
* description TEXT,
|
|
1069
|
+
* id_user TEXT,
|
|
1070
|
+
* is_actived BOOLEAN DEFAULT true,
|
|
1071
|
+
* color TEXT,
|
|
1072
|
+
* icon_name TEXT,
|
|
1073
|
+
* url_field TEXT,
|
|
1074
|
+
* date_field DATE
|
|
1075
|
+
* );
|
|
1076
|
+
*/
|
|
1077
|
+
export const { service: ExampleService, useCrudHook: useExamplesCrud } =
|
|
1078
|
+
createSimpleService<Example, CreateExamplePayload, UpdateExamplePayload>({
|
|
1079
|
+
tableName: 'examples', // 🗃️ Tabela no Supabase
|
|
1080
|
+
entityName: 'Exemplo', // 📣 Nome para mensagens
|
|
1081
|
+
searchFields: ['title'], // 🔍 Campos de busca textual
|
|
1082
|
+
schemaName: 'central', // 📂 Schema (default: 'central')
|
|
1083
|
+
enableQualiexEnrichment: true // 👤 Adiciona responsible_name
|
|
1084
|
+
});
|
|
1085
|
+
```
|
|
1086
|
+
|
|
1087
|
+
**📖 Explicação Detalhada:**
|
|
1088
|
+
- **Uma linha, tudo pronto:** `createSimpleService` gera todo o boilerplate
|
|
1089
|
+
- **Soft delete automático:** `deleteEntity()` marca `is_removed = true`, não deleta fisicamente
|
|
1090
|
+
- **RLS automático:** Filtra por `alias` automaticamente
|
|
1091
|
+
- **Enrichment Qualiex:** Busca o `responsible_name` na API Qualiex e adiciona aos registros
|
|
1092
|
+
|
|
1093
|
+
---
|
|
1094
|
+
|
|
1095
|
+
### **Passo 3: Criar Página (`src/examples/ExamplesPage.tsx`) - PARTE 1**
|
|
1096
|
+
|
|
1097
|
+
#### **Imports e Configuração de Colunas**
|
|
1098
|
+
|
|
1099
|
+
```typescript
|
|
1100
|
+
// ============= EXAMPLES PAGE (MIGRATED TO NEW API) =============
|
|
1101
|
+
|
|
1102
|
+
import { useExamplesCrud } from './ExampleService';
|
|
1103
|
+
import {
|
|
1104
|
+
createCrudPage,
|
|
1105
|
+
createSimpleSaveHandler,
|
|
1106
|
+
formatDatetime,
|
|
1107
|
+
useAuth,
|
|
1108
|
+
type CrudColumn,
|
|
1109
|
+
EntitySelect
|
|
1110
|
+
} from 'forlogic-core';
|
|
1111
|
+
import type { Example, CreateExamplePayload, UpdateExamplePayload } from './example';
|
|
1112
|
+
import { ExternalLink, Star } from 'lucide-react';
|
|
1113
|
+
import { toast } from 'sonner';
|
|
1114
|
+
import * as LucideIcons from 'lucide-react';
|
|
1115
|
+
import { useState, useMemo } from 'react';
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* ⚠️ IMPORTANTE: Importar `cn` da lib, NÃO do utils local
|
|
1119
|
+
* ❌ ERRADO: import { cn } from '@/lib/utils'
|
|
1120
|
+
* ✅ CORRETO: import { cn } from 'forlogic-core'
|
|
1121
|
+
*/
|
|
1122
|
+
```
|
|
1123
|
+
|
|
1124
|
+
---
|
|
1125
|
+
|
|
1126
|
+
### **Passo 4: Configuração do Formulário**
|
|
1127
|
+
|
|
1128
|
+
```typescript
|
|
1129
|
+
/**
|
|
1130
|
+
* 📝 CONFIGURAÇÃO DO FORMULÁRIO
|
|
1131
|
+
*
|
|
1132
|
+
* Organizado em seções (formSections) com campos (fields).
|
|
1133
|
+
*
|
|
1134
|
+
* Tipos de campos suportados:
|
|
1135
|
+
* - 'text' - Input de texto simples
|
|
1136
|
+
* - 'email' - Input de email com validação
|
|
1137
|
+
* - 'textarea' - Área de texto grande
|
|
1138
|
+
* - 'select' - Dropdown com options
|
|
1139
|
+
* - 'color-picker' - Seletor de cor
|
|
1140
|
+
* - 'icon-picker' - Seletor de ícone Lucide
|
|
1141
|
+
* - 'simple-qualiex-user-field' - Seletor de usuário Qualiex
|
|
1142
|
+
* - 'custom' - Campo completamente customizado
|
|
1143
|
+
* - 'group' - Agrupa campos horizontalmente
|
|
1144
|
+
*/
|
|
1145
|
+
const formSections = [{
|
|
1146
|
+
id: 'general',
|
|
1147
|
+
title: 'Informações Gerais',
|
|
1148
|
+
fields: [
|
|
1149
|
+
{
|
|
1150
|
+
// 🎯 GROUP: Agrupa campos horizontalmente
|
|
1151
|
+
type: 'group' as any,
|
|
1152
|
+
name: 'title-group',
|
|
1153
|
+
label: '',
|
|
1154
|
+
layout: 'horizontal' as const,
|
|
1155
|
+
className: 'grid grid-cols-1 sm:grid-cols-2 gap-6',
|
|
1156
|
+
fields: [
|
|
1157
|
+
{
|
|
1158
|
+
name: 'title',
|
|
1159
|
+
label: 'Título',
|
|
1160
|
+
type: 'text' as const,
|
|
1161
|
+
required: true,
|
|
1162
|
+
placeholder: 'Digite o título do exemplo',
|
|
1163
|
+
},
|
|
1164
|
+
{
|
|
1165
|
+
name: 'id_user',
|
|
1166
|
+
label: 'Responsável',
|
|
1167
|
+
type: 'simple-qualiex-user-field' as const, // 👤 Campo especial da lib
|
|
1168
|
+
required: true,
|
|
1169
|
+
placeholder: 'Selecionar responsável',
|
|
1170
|
+
defaultValue: '',
|
|
1171
|
+
}
|
|
1172
|
+
],
|
|
1173
|
+
},
|
|
1174
|
+
{
|
|
1175
|
+
name: 'url_field',
|
|
1176
|
+
label: 'Link',
|
|
1177
|
+
type: 'text' as const,
|
|
1178
|
+
required: false,
|
|
1179
|
+
placeholder: 'https://exemplo.com'
|
|
1180
|
+
},
|
|
1181
|
+
{
|
|
1182
|
+
name: 'description',
|
|
1183
|
+
label: 'Descrição',
|
|
1184
|
+
type: 'textarea' as const,
|
|
1185
|
+
required: false,
|
|
1186
|
+
placeholder: 'Descrição detalhada do exemplo'
|
|
1187
|
+
},
|
|
1188
|
+
{
|
|
1189
|
+
// 🎨 GROUP: Visual (cor + ícone)
|
|
1190
|
+
type: 'group' as any,
|
|
1191
|
+
name: 'visual-group',
|
|
1192
|
+
label: 'Visual',
|
|
1193
|
+
layout: 'horizontal' as const,
|
|
1194
|
+
className: 'grid grid-cols-1 sm:grid-cols-2 gap-6',
|
|
1195
|
+
fields: [
|
|
1196
|
+
{
|
|
1197
|
+
name: 'color',
|
|
1198
|
+
label: 'Cor',
|
|
1199
|
+
type: 'color-picker' as const, // 🎨 Color picker da lib
|
|
1200
|
+
required: false,
|
|
1201
|
+
defaultValue: '#3b82f6'
|
|
1202
|
+
},
|
|
1203
|
+
{
|
|
1204
|
+
name: 'icon_name',
|
|
1205
|
+
label: 'Ícone',
|
|
1206
|
+
type: 'icon-picker' as const, // 🔷 Icon picker da lib
|
|
1207
|
+
required: false,
|
|
1208
|
+
defaultValue: 'Star'
|
|
1209
|
+
}
|
|
1210
|
+
]
|
|
1211
|
+
}
|
|
1212
|
+
],
|
|
1213
|
+
}];
|
|
1214
|
+
```
|
|
1215
|
+
|
|
1216
|
+
---
|
|
1217
|
+
|
|
1218
|
+
### **Passo 5: Componente Principal com Filtros**
|
|
1219
|
+
|
|
1220
|
+
Ver código completo no arquivo `src/examples/ExamplesPage.tsx` do projeto.
|
|
1221
|
+
|
|
1222
|
+
**Estrutura básica:**
|
|
1223
|
+
1. Hooks no topo
|
|
1224
|
+
2. Estados de filtros com `useState`
|
|
1225
|
+
3. Estados derivados com `useMemo`
|
|
1226
|
+
4. Manager customizado com `useMemo`
|
|
1227
|
+
5. Handlers (`handleToggleStatus`, `handleSave`)
|
|
1228
|
+
6. Criar página com `createCrudPage`
|
|
1229
|
+
|
|
1230
|
+
---
|
|
1231
|
+
|
|
1232
|
+
## 🎯 PADRÕES DE FILTROS CUSTOMIZADOS
|
|
1233
|
+
|
|
1234
|
+
### **Pattern 1: Filtro de Status (Ativo/Inativo/Todos)**
|
|
1235
|
+
|
|
1236
|
+
```typescript
|
|
1237
|
+
// ✅ PADRÃO COMPLETO: Filtro + Estado Derivado + Manager Customizado
|
|
1238
|
+
|
|
1239
|
+
// 1) Estado do filtro
|
|
1240
|
+
const [statusFilter, setStatusFilter] = useState<'active' | 'inactive' | 'all'>('active');
|
|
1241
|
+
|
|
1242
|
+
// 2) Estado derivado (filtra entities)
|
|
1243
|
+
const filteredEntities = useMemo(() => {
|
|
1244
|
+
if (statusFilter === 'all') return manager.entities;
|
|
1245
|
+
return manager.entities.filter(e =>
|
|
1246
|
+
statusFilter === 'active' ? e.is_actived : !e.is_actived
|
|
1247
|
+
);
|
|
1248
|
+
}, [manager.entities, statusFilter]);
|
|
1249
|
+
|
|
1250
|
+
// 3) Manager customizado
|
|
1251
|
+
const filteredManager = useMemo(() => ({
|
|
1252
|
+
...manager,
|
|
1253
|
+
entities: filteredEntities
|
|
1254
|
+
}), [manager, filteredEntities]);
|
|
1255
|
+
|
|
1256
|
+
// 4) Componente do filtro
|
|
1257
|
+
const StatusFilter = () => (
|
|
1258
|
+
<EntitySelect
|
|
1259
|
+
value={statusFilter}
|
|
1260
|
+
onChange={(value) => setStatusFilter(value as 'active' | 'inactive' | 'all')}
|
|
1261
|
+
items={[
|
|
1262
|
+
{ id: 'active', name: 'Ativo' },
|
|
1263
|
+
{ id: 'inactive', name: 'Inativo' },
|
|
1264
|
+
{ id: 'all', name: '[Todos]' }
|
|
1265
|
+
]}
|
|
1266
|
+
getItemValue={(item) => item.id}
|
|
1267
|
+
getItemLabel={(item) => item.name}
|
|
1268
|
+
placeholder="Status"
|
|
1269
|
+
/>
|
|
1270
|
+
);
|
|
1271
|
+
|
|
1272
|
+
// 5) Usar em config.filters
|
|
1273
|
+
config: {
|
|
1274
|
+
filters: [
|
|
1275
|
+
{ type: 'search' },
|
|
1276
|
+
{ type: 'custom', component: StatusFilter }
|
|
1277
|
+
]
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
// 6) Passar filteredManager para createCrudPage
|
|
1281
|
+
const CrudPage = createCrudPage({
|
|
1282
|
+
manager: filteredManager, // ← Não manager original!
|
|
1283
|
+
config,
|
|
1284
|
+
onSave
|
|
1285
|
+
});
|
|
1286
|
+
```
|
|
1287
|
+
|
|
1288
|
+
**📖 Explicação:**
|
|
1289
|
+
- **`useState`**: Armazena valor selecionado no filtro
|
|
1290
|
+
- **`useMemo` (filteredEntities)**: Evita re-filtrar a cada render
|
|
1291
|
+
- **`useMemo` (filteredManager)**: Evita re-criar objeto manager
|
|
1292
|
+
- **`EntitySelect`**: Componente de dropdown da lib
|
|
1293
|
+
- **`filteredManager`**: Manager modificado que createCrudPage vai usar
|
|
1294
|
+
|
|
1295
|
+
---
|
|
1296
|
+
|
|
1297
|
+
### **Pattern 2: Filtro de Departamento (Select Nativo)**
|
|
1298
|
+
|
|
1299
|
+
```typescript
|
|
1300
|
+
// ✅ PADRÃO: Filtro por categoria/departamento
|
|
1301
|
+
|
|
1302
|
+
const [deptFilter, setDeptFilter] = useState<string>('all');
|
|
1303
|
+
|
|
1304
|
+
const filteredEntities = useMemo(() => {
|
|
1305
|
+
if (deptFilter === 'all') return manager.entities;
|
|
1306
|
+
return manager.entities.filter(e => e.department === deptFilter);
|
|
1307
|
+
}, [manager.entities, deptFilter]);
|
|
1308
|
+
|
|
1309
|
+
const filteredManager = useMemo(() => ({
|
|
1310
|
+
...manager,
|
|
1311
|
+
entities: filteredEntities
|
|
1312
|
+
}), [manager, filteredEntities]);
|
|
1313
|
+
|
|
1314
|
+
const DepartmentFilter = () => (
|
|
1315
|
+
<select
|
|
1316
|
+
value={deptFilter}
|
|
1317
|
+
onChange={(e) => setDeptFilter(e.target.value)}
|
|
1318
|
+
className="px-3 py-2 border rounded-md"
|
|
1319
|
+
>
|
|
1320
|
+
<option value="all">Todos Departamentos</option>
|
|
1321
|
+
<option value="HR">RH</option>
|
|
1322
|
+
<option value="IT">TI</option>
|
|
1323
|
+
<option value="Finance">Financeiro</option>
|
|
1324
|
+
</select>
|
|
1325
|
+
);
|
|
1326
|
+
```
|
|
1327
|
+
|
|
1328
|
+
---
|
|
1329
|
+
|
|
1330
|
+
### **Pattern 3: Filtro de Data Range (Date Picker)**
|
|
1331
|
+
|
|
1332
|
+
```typescript
|
|
1333
|
+
// ✅ PADRÃO: Filtro por intervalo de datas
|
|
1334
|
+
|
|
1335
|
+
import { format, isAfter, isBefore, parseISO } from 'date-fns';
|
|
1336
|
+
|
|
1337
|
+
const [dateRange, setDateRange] = useState<{ from?: Date; to?: Date }>({});
|
|
1338
|
+
|
|
1339
|
+
const filteredEntities = useMemo(() => {
|
|
1340
|
+
if (!dateRange.from && !dateRange.to) return manager.entities;
|
|
1341
|
+
|
|
1342
|
+
return manager.entities.filter(e => {
|
|
1343
|
+
const itemDate = parseISO(e.created_at);
|
|
1344
|
+
if (dateRange.from && isBefore(itemDate, dateRange.from)) return false;
|
|
1345
|
+
if (dateRange.to && isAfter(itemDate, dateRange.to)) return false;
|
|
1346
|
+
return true;
|
|
1347
|
+
});
|
|
1348
|
+
}, [manager.entities, dateRange]);
|
|
1349
|
+
|
|
1350
|
+
const filteredManager = useMemo(() => ({
|
|
1351
|
+
...manager,
|
|
1352
|
+
entities: filteredEntities
|
|
1353
|
+
}), [manager, filteredEntities]);
|
|
1354
|
+
```
|
|
1355
|
+
|
|
1356
|
+
---
|
|
1357
|
+
|
|
1358
|
+
## 🪝 HOOKS REACT NO CRUD
|
|
1359
|
+
|
|
1360
|
+
| Hook | Quando Usar | Exemplo no CRUD | ⚠️ Evitar |
|
|
1361
|
+
|------|-------------|-----------------|-----------|
|
|
1362
|
+
| **useMemo** | Cálculos pesados que dependem de props/state | • Configuração de colunas<br>• Filtros derivados<br>• Manager customizado | Valores simples (strings, números) |
|
|
1363
|
+
| **useState** | Valores que mudam via interação do usuário | • Filtros customizados<br>• Modal open/close<br>• Seleção temporária | Estados derivados de outros estados |
|
|
1364
|
+
| **useCallback** | Funções que são passadas como props e dependem de state | • Handlers que dependem de filtros<br>• Callbacks de child components | Handlers simples sem dependências |
|
|
1365
|
+
| **useEffect** | Side effects (fetch, subscriptions) | • Fetch inicial de dados (já feito pelo manager)<br>• Sincronização externa | Cálculos ou transformações de dados |
|
|
1366
|
+
|
|
1367
|
+
### **📖 Exemplos Práticos**
|
|
1368
|
+
|
|
1369
|
+
```typescript
|
|
1370
|
+
// ✅ CORRETO: useMemo para config (evita re-render do formulário)
|
|
1371
|
+
const config = useMemo(() => ({
|
|
1372
|
+
columns: [...],
|
|
1373
|
+
formSections: [...]
|
|
1374
|
+
}), []);
|
|
1375
|
+
|
|
1376
|
+
// ❌ ERRADO: sem useMemo (re-cria objeto a cada render)
|
|
1377
|
+
const config = {
|
|
1378
|
+
columns: [...],
|
|
1379
|
+
formSections: [...]
|
|
1380
|
+
};
|
|
1381
|
+
|
|
1382
|
+
// ✅ CORRETO: useState para filtro
|
|
1383
|
+
const [statusFilter, setStatusFilter] = useState('active');
|
|
1384
|
+
|
|
1385
|
+
// ❌ ERRADO: useMemo para valor que muda via interação
|
|
1386
|
+
const statusFilter = useMemo(() => 'active', []); // Não faz sentido!
|
|
1387
|
+
|
|
1388
|
+
// ✅ CORRETO: useMemo para estado derivado
|
|
1389
|
+
const filteredEntities = useMemo(() =>
|
|
1390
|
+
manager.entities.filter(e => e.is_actived),
|
|
1391
|
+
[manager.entities]
|
|
1392
|
+
);
|
|
1393
|
+
|
|
1394
|
+
// ❌ ERRADO: recalcula a cada render (lento)
|
|
1395
|
+
const filteredEntities = manager.entities.filter(e => e.is_actived);
|
|
1396
|
+
```
|
|
1397
|
+
|
|
1398
|
+
---
|
|
1399
|
+
|
|
1400
|
+
## ❌ ERROS COMUNS E SOLUÇÕES
|
|
1401
|
+
|
|
1402
|
+
### **Erro 1: Importar `cn` do lugar errado**
|
|
1403
|
+
|
|
1404
|
+
```typescript
|
|
1405
|
+
// ❌ SINTOMA: Classes CSS não aplicam
|
|
1406
|
+
// ❌ CAUSA: import { cn } from '@/lib/utils'
|
|
1407
|
+
import { cn } from '@/lib/utils';
|
|
1408
|
+
|
|
1409
|
+
// ✅ SOLUÇÃO: Importar da lib
|
|
1410
|
+
import { cn } from 'forlogic-core';
|
|
1411
|
+
```
|
|
1412
|
+
|
|
1413
|
+
---
|
|
1414
|
+
|
|
1415
|
+
### **Erro 2: Esquecer `useMemo` no config**
|
|
1416
|
+
|
|
1417
|
+
```typescript
|
|
1418
|
+
// ❌ SINTOMA: Formulário fecha/reabre sozinho, re-renders infinitos
|
|
1419
|
+
// ❌ CAUSA: Config recriado a cada render
|
|
1420
|
+
const config = {
|
|
1421
|
+
columns: exampleColumns,
|
|
1422
|
+
formSections
|
|
1423
|
+
};
|
|
1424
|
+
|
|
1425
|
+
// ✅ SOLUÇÃO: Envolver em useMemo
|
|
1426
|
+
const config = useMemo(() => ({
|
|
1427
|
+
columns: exampleColumns,
|
|
1428
|
+
formSections
|
|
1429
|
+
}), []);
|
|
1430
|
+
```
|
|
1431
|
+
|
|
1432
|
+
---
|
|
1433
|
+
|
|
1434
|
+
### **Erro 3: Passar `entities` ao invés de `manager`**
|
|
1435
|
+
|
|
1436
|
+
```typescript
|
|
1437
|
+
// ❌ SINTOMA: TypeError: manager.createEntity is not a function
|
|
1438
|
+
// ❌ CAUSA: Passou array direto
|
|
1439
|
+
const CrudPage = createCrudPage({
|
|
1440
|
+
manager: manager.entities, // ← Errado!
|
|
1441
|
+
config
|
|
1442
|
+
});
|
|
1443
|
+
|
|
1444
|
+
// ✅ SOLUÇÃO: Passar manager completo
|
|
1445
|
+
const CrudPage = createCrudPage({
|
|
1446
|
+
manager, // ← Correto!
|
|
1447
|
+
config
|
|
1448
|
+
});
|
|
1449
|
+
```
|
|
1450
|
+
|
|
1451
|
+
---
|
|
1452
|
+
|
|
1453
|
+
### **Erro 4: Filtro sem `useMemo`**
|
|
1454
|
+
|
|
1455
|
+
```typescript
|
|
1456
|
+
// ❌ SINTOMA: Performance ruim, travamentos
|
|
1457
|
+
// ❌ CAUSA: Recalcula filtro a cada render
|
|
1458
|
+
const filteredEntities = manager.entities.filter(e => e.is_actived);
|
|
1459
|
+
|
|
1460
|
+
// ✅ SOLUÇÃO: Envolver em useMemo
|
|
1461
|
+
const filteredEntities = useMemo(() =>
|
|
1462
|
+
manager.entities.filter(e => e.is_actived),
|
|
1463
|
+
[manager.entities]
|
|
1464
|
+
);
|
|
1465
|
+
```
|
|
1466
|
+
|
|
1467
|
+
---
|
|
1468
|
+
|
|
1469
|
+
### **Erro 5: Esquecer `alias` no CREATE**
|
|
1470
|
+
|
|
1471
|
+
```typescript
|
|
1472
|
+
// ❌ SINTOMA: Erro de RLS no Supabase, registro não é criado
|
|
1473
|
+
// ❌ CAUSA: Payload sem alias
|
|
1474
|
+
const handleSave = createSimpleSaveHandler(
|
|
1475
|
+
manager,
|
|
1476
|
+
(data) => ({
|
|
1477
|
+
title: data.title,
|
|
1478
|
+
email: data.email
|
|
1479
|
+
// ← Falta alias!
|
|
1480
|
+
})
|
|
1481
|
+
);
|
|
1482
|
+
|
|
1483
|
+
// ✅ SOLUÇÃO: Incluir alias do token
|
|
1484
|
+
const { alias: currentAlias } = useAuth();
|
|
1485
|
+
|
|
1486
|
+
const handleSave = createSimpleSaveHandler(
|
|
1487
|
+
manager,
|
|
1488
|
+
(data) => ({
|
|
1489
|
+
title: data.title,
|
|
1490
|
+
email: data.email,
|
|
1491
|
+
alias: currentAlias // ← Correto!
|
|
1492
|
+
})
|
|
1493
|
+
);
|
|
1494
|
+
```
|
|
1495
|
+
|
|
1496
|
+
---
|
|
1497
|
+
|
|
1498
|
+
### **Erro 6: Chamar hooks fora do componente**
|
|
1499
|
+
|
|
1500
|
+
```typescript
|
|
1501
|
+
// ❌ SINTOMA: Error: Hooks can only be called inside of the body of a function component
|
|
1502
|
+
// ❌ CAUSA: Hook chamado fora do componente
|
|
1503
|
+
const manager = useExamplesCrud(); // ← Fora do componente!
|
|
1504
|
+
|
|
1505
|
+
export const ExamplesPage = () => {
|
|
1506
|
+
return <CrudPage />;
|
|
1507
|
+
};
|
|
1508
|
+
|
|
1509
|
+
// ✅ SOLUÇÃO: Chamar hooks DENTRO do componente
|
|
1510
|
+
export const ExamplesPage = () => {
|
|
1511
|
+
const manager = useExamplesCrud(); // ← Dentro do componente!
|
|
1512
|
+
return <CrudPage />;
|
|
1513
|
+
};
|
|
1514
|
+
```
|
|
1515
|
+
|
|
1516
|
+
---
|
|
1517
|
+
|
|
1518
|
+
## 🎨 PERSONALIZAÇÃO AVANÇADA
|
|
1519
|
+
|
|
1520
|
+
### **1. Renderização Customizada de Colunas**
|
|
1521
|
+
|
|
1522
|
+
```typescript
|
|
1523
|
+
// Exemplo 1: Badge de status com cores
|
|
1524
|
+
{
|
|
1525
|
+
key: 'status',
|
|
1526
|
+
header: 'Status',
|
|
1527
|
+
render: (item) => (
|
|
1528
|
+
<span className={`px-2 py-1 rounded-full text-xs ${
|
|
1529
|
+
item.status === 'completed' ? 'bg-green-100 text-green-800' :
|
|
1530
|
+
item.status === 'pending' ? 'bg-yellow-100 text-yellow-800' :
|
|
1531
|
+
'bg-gray-100 text-gray-800'
|
|
1532
|
+
}`}>
|
|
1533
|
+
{item.status}
|
|
1534
|
+
</span>
|
|
1535
|
+
)
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
// Exemplo 2: Link externo
|
|
1539
|
+
{
|
|
1540
|
+
key: 'website',
|
|
1541
|
+
header: 'Site',
|
|
1542
|
+
render: (item) => (
|
|
1543
|
+
<a
|
|
1544
|
+
href={item.website}
|
|
1545
|
+
target="_blank"
|
|
1546
|
+
rel="noopener noreferrer"
|
|
1547
|
+
className="text-blue-600 hover:underline"
|
|
1548
|
+
>
|
|
1549
|
+
Visitar
|
|
1550
|
+
</a>
|
|
1551
|
+
)
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
// Exemplo 3: Ícone colorido
|
|
1555
|
+
{
|
|
1556
|
+
key: 'priority',
|
|
1557
|
+
header: 'Prioridade',
|
|
1558
|
+
render: (item) => {
|
|
1559
|
+
const icons = { high: '🔴', medium: '🟡', low: '🟢' };
|
|
1560
|
+
return <span>{icons[item.priority]}</span>;
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
```
|
|
1564
|
+
|
|
1565
|
+
---
|
|
1566
|
+
|
|
1567
|
+
### **2. Campos de Formulário Customizados**
|
|
1568
|
+
|
|
1569
|
+
```typescript
|
|
1570
|
+
// Exemplo: Campo customizado com EntitySelect
|
|
1571
|
+
{
|
|
1572
|
+
name: 'category_id',
|
|
1573
|
+
label: 'Categoria',
|
|
1574
|
+
type: 'custom' as const,
|
|
1575
|
+
component: (props) => {
|
|
1576
|
+
const { data: categories } = useCategoriesCrud();
|
|
1577
|
+
return (
|
|
1578
|
+
<EntitySelect
|
|
1579
|
+
{...props}
|
|
1580
|
+
items={categories}
|
|
1581
|
+
getItemValue={(item) => item.id}
|
|
1582
|
+
getItemLabel={(item) => item.name}
|
|
1583
|
+
placeholder="Selecionar categoria"
|
|
1584
|
+
/>
|
|
1585
|
+
);
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
```
|
|
1589
|
+
|
|
1590
|
+
---
|
|
1591
|
+
|
|
1592
|
+
### **3. Ações Customizadas na Linha**
|
|
1593
|
+
|
|
1594
|
+
```typescript
|
|
1595
|
+
// Adicionar botão "Duplicar" nas ações da linha
|
|
1596
|
+
const customActions = (item: Example) => [
|
|
1597
|
+
{
|
|
1598
|
+
label: 'Duplicar',
|
|
1599
|
+
icon: Copy,
|
|
1600
|
+
onClick: () => {
|
|
1601
|
+
const newItem = { ...item, id: undefined, title: `${item.title} (cópia)` };
|
|
1602
|
+
manager.createEntity(newItem);
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
];
|
|
1606
|
+
|
|
1607
|
+
// Passar para createCrudPage
|
|
1608
|
+
const CrudPage = createCrudPage({
|
|
1609
|
+
manager,
|
|
1610
|
+
config: {
|
|
1611
|
+
...config,
|
|
1612
|
+
customRowActions: customActions
|
|
1613
|
+
}
|
|
1614
|
+
});
|
|
1615
|
+
```
|
|
1616
|
+
|
|
1617
|
+
---
|
|
1618
|
+
|
|
731
1619
|
### 🔗 Integração Qualiex (opcional)
|
|
732
1620
|
|
|
733
1621
|
**Auto-enrichment** (já configurado no BaseService):
|
|
@@ -791,6 +1679,339 @@ headers: { 'un-alias': 'true' }
|
|
|
791
1679
|
|
|
792
1680
|
---
|
|
793
1681
|
|
|
1682
|
+
## 📍 PLACES - Locais e Sublocais
|
|
1683
|
+
|
|
1684
|
+
O módulo **Places** permite gerenciar a hierarquia de locais e sublocais da organização, integrando com a API Qualiex.
|
|
1685
|
+
|
|
1686
|
+
### 🔌 Imports Disponíveis
|
|
1687
|
+
|
|
1688
|
+
```typescript
|
|
1689
|
+
// Tipos
|
|
1690
|
+
import type { Place, SubPlace } from 'forlogic-core';
|
|
1691
|
+
|
|
1692
|
+
// Serviço
|
|
1693
|
+
import { placeService, PlaceService } from 'forlogic-core';
|
|
1694
|
+
|
|
1695
|
+
// Componente de Página Pronta
|
|
1696
|
+
import { PlacesPage } from 'forlogic-core';
|
|
1697
|
+
```
|
|
1698
|
+
|
|
1699
|
+
### 📋 Estrutura dos Dados
|
|
1700
|
+
|
|
1701
|
+
```typescript
|
|
1702
|
+
interface Place {
|
|
1703
|
+
id: string;
|
|
1704
|
+
placeId: string; // ID único do local no Qualiex
|
|
1705
|
+
name: string; // Nome do local
|
|
1706
|
+
companyId: string; // ID da empresa
|
|
1707
|
+
usersIds: string[]; // Array de IDs de usuários vinculados
|
|
1708
|
+
subPlaces?: SubPlace[]; // Sublocais (hierarquia)
|
|
1709
|
+
parentId?: string | null; // ID do local pai (se for sublocalizado)
|
|
1710
|
+
isActive: boolean; // Status do local
|
|
1711
|
+
createdAt: string;
|
|
1712
|
+
updatedAt: string;
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
interface SubPlace {
|
|
1716
|
+
id: string;
|
|
1717
|
+
placeId: string;
|
|
1718
|
+
name: string;
|
|
1719
|
+
parentId: string;
|
|
1720
|
+
usersIds: string[];
|
|
1721
|
+
isActive: boolean;
|
|
1722
|
+
subPlaces?: SubPlace[]; // Recursivo - permite múltiplos níveis
|
|
1723
|
+
}
|
|
1724
|
+
```
|
|
1725
|
+
|
|
1726
|
+
### 🎯 Como Obter Places
|
|
1727
|
+
|
|
1728
|
+
#### Método 1 (Recomendado): Hook com React Query
|
|
1729
|
+
|
|
1730
|
+
```typescript
|
|
1731
|
+
import { useQuery } from '@tanstack/react-query';
|
|
1732
|
+
import { useAuth, placeService } from 'forlogic-core';
|
|
1733
|
+
|
|
1734
|
+
function MyComponent() {
|
|
1735
|
+
const { alias } = useAuth();
|
|
1736
|
+
|
|
1737
|
+
const { data: places = [], isLoading, error } = useQuery({
|
|
1738
|
+
queryKey: ['places', alias],
|
|
1739
|
+
queryFn: () => placeService.getPlaces(alias),
|
|
1740
|
+
enabled: !!alias,
|
|
1741
|
+
staleTime: 5 * 60 * 1000 // Cache de 5 minutos
|
|
1742
|
+
});
|
|
1743
|
+
|
|
1744
|
+
if (isLoading) return <LoadingState />;
|
|
1745
|
+
if (error) return <div>Erro ao carregar locais</div>;
|
|
1746
|
+
|
|
1747
|
+
return (
|
|
1748
|
+
<div>
|
|
1749
|
+
{places.map(place => (
|
|
1750
|
+
<div key={place.id}>{place.name}</div>
|
|
1751
|
+
))}
|
|
1752
|
+
</div>
|
|
1753
|
+
);
|
|
1754
|
+
}
|
|
1755
|
+
```
|
|
1756
|
+
|
|
1757
|
+
#### Método 2: Hook Customizado Reutilizável
|
|
1758
|
+
|
|
1759
|
+
```typescript
|
|
1760
|
+
// src/hooks/usePlaces.ts
|
|
1761
|
+
import { useQuery } from '@tanstack/react-query';
|
|
1762
|
+
import { useAuth, placeService } from 'forlogic-core';
|
|
1763
|
+
|
|
1764
|
+
export function usePlaces() {
|
|
1765
|
+
const { alias } = useAuth();
|
|
1766
|
+
|
|
1767
|
+
return useQuery({
|
|
1768
|
+
queryKey: ['places', alias],
|
|
1769
|
+
queryFn: () => placeService.getPlaces(alias),
|
|
1770
|
+
enabled: !!alias,
|
|
1771
|
+
staleTime: 5 * 60 * 1000 // Cache de 5 minutos
|
|
1772
|
+
});
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
// Usar no componente
|
|
1776
|
+
const { data: places = [], isLoading } = usePlaces();
|
|
1777
|
+
```
|
|
1778
|
+
|
|
1779
|
+
#### Método 3: Chamada Direta (Service)
|
|
1780
|
+
|
|
1781
|
+
```typescript
|
|
1782
|
+
// Para casos especiais (não recomendado para components)
|
|
1783
|
+
const places = await placeService.getPlaces('my-alias');
|
|
1784
|
+
```
|
|
1785
|
+
|
|
1786
|
+
### 🏠 Usando PlacesPage Pronta
|
|
1787
|
+
|
|
1788
|
+
```typescript
|
|
1789
|
+
// App.tsx ou routes
|
|
1790
|
+
import { PlacesPage } from 'forlogic-core';
|
|
1791
|
+
|
|
1792
|
+
<Route path="/places" element={<PlacesPage />} />
|
|
1793
|
+
```
|
|
1794
|
+
|
|
1795
|
+
### 🔗 Integrando Places em Módulos CRUD
|
|
1796
|
+
|
|
1797
|
+
#### Cenário A: PlaceSelect em Formulários
|
|
1798
|
+
|
|
1799
|
+
```typescript
|
|
1800
|
+
// src/components/PlaceSelect.tsx
|
|
1801
|
+
import { EntitySelect } from 'forlogic-core';
|
|
1802
|
+
import { usePlaces } from '@/hooks/usePlaces';
|
|
1803
|
+
import { useMemo } from 'react';
|
|
1804
|
+
|
|
1805
|
+
export function PlaceSelect({ value, onChange, disabled }: {
|
|
1806
|
+
value?: string;
|
|
1807
|
+
onChange: (value: string) => void;
|
|
1808
|
+
disabled?: boolean;
|
|
1809
|
+
}) {
|
|
1810
|
+
const { data: places = [], isLoading } = usePlaces();
|
|
1811
|
+
|
|
1812
|
+
// Achatar hierarquia para o select
|
|
1813
|
+
const flatPlaces = useMemo(() => {
|
|
1814
|
+
const flatten = (items: Place[], level = 0): any[] => {
|
|
1815
|
+
return items.flatMap(place => [
|
|
1816
|
+
{ ...place, level },
|
|
1817
|
+
...flatten(place.subPlaces || [], level + 1)
|
|
1818
|
+
]);
|
|
1819
|
+
};
|
|
1820
|
+
return flatten(places);
|
|
1821
|
+
}, [places]);
|
|
1822
|
+
|
|
1823
|
+
return (
|
|
1824
|
+
<EntitySelect
|
|
1825
|
+
value={value}
|
|
1826
|
+
onChange={onChange}
|
|
1827
|
+
items={flatPlaces}
|
|
1828
|
+
isLoading={isLoading}
|
|
1829
|
+
getItemValue={(p) => p.placeId}
|
|
1830
|
+
getItemLabel={(p) => `${' '.repeat(p.level)}${p.name}`}
|
|
1831
|
+
disabled={disabled}
|
|
1832
|
+
placeholder="Selecionar local"
|
|
1833
|
+
/>
|
|
1834
|
+
);
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
// Usar no CRUD config
|
|
1838
|
+
{
|
|
1839
|
+
key: 'place_id',
|
|
1840
|
+
label: 'Local',
|
|
1841
|
+
type: 'custom',
|
|
1842
|
+
component: PlaceSelect,
|
|
1843
|
+
required: true
|
|
1844
|
+
}
|
|
1845
|
+
```
|
|
1846
|
+
|
|
1847
|
+
#### Cenário B: Filtrar Dados por Place
|
|
1848
|
+
|
|
1849
|
+
```typescript
|
|
1850
|
+
// Service com filtro de placeId
|
|
1851
|
+
const { service, useCrudHook } = createSimpleService({
|
|
1852
|
+
tableName: 'my_table',
|
|
1853
|
+
schemaName: 'central',
|
|
1854
|
+
additionalFilters: [
|
|
1855
|
+
{ field: 'place_id', operator: 'eq', value: selectedPlaceId }
|
|
1856
|
+
]
|
|
1857
|
+
});
|
|
1858
|
+
```
|
|
1859
|
+
|
|
1860
|
+
#### Cenário C: Exibir Nome do Local em Tabelas
|
|
1861
|
+
|
|
1862
|
+
```typescript
|
|
1863
|
+
// Hook para buscar nome do place
|
|
1864
|
+
function usePlaceName(placeId: string) {
|
|
1865
|
+
const { data: places = [] } = usePlaces();
|
|
1866
|
+
|
|
1867
|
+
return useMemo(() => {
|
|
1868
|
+
const findPlace = (items: Place[]): Place | undefined => {
|
|
1869
|
+
for (const place of items) {
|
|
1870
|
+
if (place.placeId === placeId) return place;
|
|
1871
|
+
if (place.subPlaces) {
|
|
1872
|
+
const found = findPlace(place.subPlaces);
|
|
1873
|
+
if (found) return found;
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
};
|
|
1877
|
+
return findPlace(places)?.name || 'Local não encontrado';
|
|
1878
|
+
}, [places, placeId]);
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
// Usar na coluna da tabela
|
|
1882
|
+
{
|
|
1883
|
+
key: 'place_id',
|
|
1884
|
+
header: 'Local',
|
|
1885
|
+
render: (item) => {
|
|
1886
|
+
const placeName = usePlaceName(item.place_id);
|
|
1887
|
+
return <span>{placeName}</span>;
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
```
|
|
1891
|
+
|
|
1892
|
+
### 🔑 Acessando placeId/placeName dos Tokens
|
|
1893
|
+
|
|
1894
|
+
**⚠️ IMPORTANTE:** `placeId` e `placeName` **NÃO** vêm diretamente dos tokens JWT. Eles são obtidos da **API Qualiex**.
|
|
1895
|
+
|
|
1896
|
+
```typescript
|
|
1897
|
+
// ❌ ERRADO - Não existe no token
|
|
1898
|
+
const { placeId } = useAuth(); // undefined
|
|
1899
|
+
|
|
1900
|
+
// ✅ CORRETO - Buscar da API Qualiex
|
|
1901
|
+
const { data: places } = usePlaces();
|
|
1902
|
+
const userPlace = places.find(p => p.usersIds.includes(userId));
|
|
1903
|
+
const placeId = userPlace?.placeId;
|
|
1904
|
+
const placeName = userPlace?.name;
|
|
1905
|
+
```
|
|
1906
|
+
|
|
1907
|
+
**Fluxo de dados:**
|
|
1908
|
+
1. Token JWT contém `alias` e `companyId`
|
|
1909
|
+
2. `placeService.getPlaces(alias)` busca os Places da API Qualiex
|
|
1910
|
+
3. Cada `Place` contém `usersIds` (array de IDs de usuários)
|
|
1911
|
+
4. Relacionar usuário logado com seu Place através de `usersIds`
|
|
1912
|
+
|
|
1913
|
+
### 🌳 Navegação Hierárquica (Tree View)
|
|
1914
|
+
|
|
1915
|
+
```typescript
|
|
1916
|
+
function PlaceTree({ places, level = 0 }: {
|
|
1917
|
+
places: Place[];
|
|
1918
|
+
level?: number;
|
|
1919
|
+
}) {
|
|
1920
|
+
return (
|
|
1921
|
+
<div>
|
|
1922
|
+
{places.map(place => (
|
|
1923
|
+
<div key={place.id}>
|
|
1924
|
+
<div style={{ paddingLeft: `${level * 20}px` }}>
|
|
1925
|
+
📍 {place.name} ({place.usersIds.length} usuários)
|
|
1926
|
+
{!place.isActive && <Badge variant="secondary">Inativo</Badge>}
|
|
1927
|
+
</div>
|
|
1928
|
+
{place.subPlaces && place.subPlaces.length > 0 && (
|
|
1929
|
+
<PlaceTree places={place.subPlaces} level={level + 1} />
|
|
1930
|
+
)}
|
|
1931
|
+
</div>
|
|
1932
|
+
))}
|
|
1933
|
+
</div>
|
|
1934
|
+
);
|
|
1935
|
+
}
|
|
1936
|
+
```
|
|
1937
|
+
|
|
1938
|
+
### 🛠️ Troubleshooting
|
|
1939
|
+
|
|
1940
|
+
| Erro | Causa | Solução |
|
|
1941
|
+
|------|-------|---------|
|
|
1942
|
+
| `CompanyId não encontrado no token` | Token não validado corretamente | Verificar edge function `validate-token` |
|
|
1943
|
+
| `Alias da unidade é obrigatório` | `alias` não disponível no `useAuth()` | Aguardar carregamento do auth com `isLoading` |
|
|
1944
|
+
| `Token Qualiex não encontrado` | Variável de ambiente faltando | Verificar `VITE_QUALIEX_API_URL` no `.env` |
|
|
1945
|
+
| Places retorna array vazio `[]` | Empresa sem places cadastrados | Verificar cadastro no Qualiex admin |
|
|
1946
|
+
| Hierarquia quebrada | `parentId` incorreto nos dados | Verificar integridade dos dados na API Qualiex |
|
|
1947
|
+
|
|
1948
|
+
### 📦 Exemplo Completo: Dashboard por Local
|
|
1949
|
+
|
|
1950
|
+
```typescript
|
|
1951
|
+
import { usePlaces } from '@/hooks/usePlaces';
|
|
1952
|
+
import { Card, CardHeader, CardTitle, CardContent } from 'forlogic-core';
|
|
1953
|
+
|
|
1954
|
+
function PlacesDashboard() {
|
|
1955
|
+
const { data: places = [], isLoading } = usePlaces();
|
|
1956
|
+
const { data: metrics = [] } = useQuery({
|
|
1957
|
+
queryKey: ['metrics'],
|
|
1958
|
+
queryFn: fetchMetrics
|
|
1959
|
+
});
|
|
1960
|
+
|
|
1961
|
+
if (isLoading) return <LoadingState />;
|
|
1962
|
+
|
|
1963
|
+
return (
|
|
1964
|
+
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
1965
|
+
{places.map(place => {
|
|
1966
|
+
const placeMetrics = metrics.filter(m => m.place_id === place.placeId);
|
|
1967
|
+
|
|
1968
|
+
return (
|
|
1969
|
+
<Card key={place.id}>
|
|
1970
|
+
<CardHeader>
|
|
1971
|
+
<CardTitle>{place.name}</CardTitle>
|
|
1972
|
+
</CardHeader>
|
|
1973
|
+
<CardContent>
|
|
1974
|
+
<div className="space-y-2">
|
|
1975
|
+
<p className="text-sm">
|
|
1976
|
+
👥 Usuários: <strong>{place.usersIds.length}</strong>
|
|
1977
|
+
</p>
|
|
1978
|
+
<p className="text-sm">
|
|
1979
|
+
📊 Registros: <strong>{placeMetrics.length}</strong>
|
|
1980
|
+
</p>
|
|
1981
|
+
<p className="text-sm">
|
|
1982
|
+
📍 Sublocais: <strong>{place.subPlaces?.length || 0}</strong>
|
|
1983
|
+
</p>
|
|
1984
|
+
</div>
|
|
1985
|
+
</CardContent>
|
|
1986
|
+
</Card>
|
|
1987
|
+
);
|
|
1988
|
+
})}
|
|
1989
|
+
</div>
|
|
1990
|
+
);
|
|
1991
|
+
}
|
|
1992
|
+
```
|
|
1993
|
+
|
|
1994
|
+
### ✅ Checklist de Implementação
|
|
1995
|
+
|
|
1996
|
+
- [ ] `VITE_QUALIEX_API_URL` configurada no `.env`
|
|
1997
|
+
- [ ] Edge function `validate-token` retorna `company_id` corretamente
|
|
1998
|
+
- [ ] `alias` disponível no `useAuth()`
|
|
1999
|
+
- [ ] Hook `usePlaces()` criado e testado
|
|
2000
|
+
- [ ] `PlaceSelect` component criado (se necessário)
|
|
2001
|
+
- [ ] Tratamento de erro quando places vazio
|
|
2002
|
+
- [ ] Cache configurado no React Query (`staleTime`)
|
|
2003
|
+
- [ ] Hierarquia renderizada corretamente (se usar tree view)
|
|
2004
|
+
|
|
2005
|
+
### 📚 Referências
|
|
2006
|
+
|
|
2007
|
+
- **Tipos:** `lib/qualiex/places/types.ts`
|
|
2008
|
+
- **Service:** `lib/qualiex/places/PlaceService.ts`
|
|
2009
|
+
- **Componente:** `lib/qualiex/places/PlacesPage.tsx`
|
|
2010
|
+
- **Exports:** `lib/modular.ts` e `lib/exports/integrations.ts`
|
|
2011
|
+
- **Token Manager:** `lib/auth/services/TokenManager.ts`
|
|
2012
|
+
|
|
2013
|
+
---
|
|
2014
|
+
|
|
794
2015
|
## 🗃️ MIGRATIONS + RLS
|
|
795
2016
|
|
|
796
2017
|
### Template SQL Completo
|