forlogic-core 1.7.6 → 1.8.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # 🧱 Projeto atual
2
2
 
3
- **Schema padrão:** `central`
3
+ **Schema padrão:** `common`
4
4
 
5
5
  > **Nota sobre schemas:** Temos o schema padrão, mas módulos podem usar schemas diferentes quando necessário para organização ou isolamento de dados. Por exemplo, o módulo de treinamentos usa o schema `trainings`. Você pode especificar o schema no service usando `schemaName: 'nome_do_schema'`.
6
6
 
@@ -208,7 +208,7 @@ import { Button } from 'forlogic-core'
208
208
  // Formulários
209
209
  Button, Input, Textarea, Label, Select, SelectContent,
210
210
  SelectItem, SelectTrigger, SelectValue, Checkbox, RadioGroup,
211
- RadioGroupItem, Switch
211
+ RadioGroupItem, Switch, CreatableCombobox
212
212
 
213
213
  // Layout
214
214
  Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle
@@ -466,6 +466,48 @@ export function DepartmentSelect(props: DepartmentSelectProps) {
466
466
 
467
467
  O `BaseForm` suporta campos totalmente customizados através do tipo `'custom'`, permitindo usar **qualquer componente React** como campo de formulário.
468
468
 
469
+ #### ⚠️ IMPORTANTE: Como Usar Corretamente
470
+
471
+ **❌ ERRADO - Usar tipo customizado sem configurar:**
472
+ ```typescript
473
+ // ❌ ISSO NÃO VAI FUNCIONAR!
474
+ {
475
+ name: 'annual_training_ids',
476
+ label: 'Treinamentos Anuais',
477
+ type: 'annual-training-multi-select', // ❌ BaseForm não conhece esse tipo
478
+ }
479
+
480
+ // E passar customComponents na página:
481
+ <BaseForm
482
+ customComponents={{
483
+ 'annual-training-multi-select': AnnualTrainingMultiSelect // ❌ Ignorado!
484
+ }}
485
+ />
486
+ ```
487
+
488
+ **✅ CORRETO - Usar type: 'custom' com component:**
489
+ ```typescript
490
+ import { AnnualTrainingMultiSelect } from './components/AnnualTrainingMultiSelect';
491
+
492
+ {
493
+ name: 'annual_training_ids',
494
+ label: 'Treinamentos Anuais',
495
+ type: 'custom', // ✅ Tipo reconhecido pelo BaseForm
496
+ component: AnnualTrainingMultiSelect, // ✅ Componente passado diretamente
497
+ required: true,
498
+ componentProps: { // ✅ Props adicionais (opcional)
499
+ placeholder: 'Selecionar treinamentos...'
500
+ }
501
+ }
502
+ ```
503
+
504
+ #### 🔑 Pontos Críticos
505
+
506
+ 1. **Use sempre `type: 'custom'`** - É o único tipo que o BaseForm reconhece para componentes customizados
507
+ 2. **Passe o `component` diretamente** - Não existe prop `customComponents` no BaseForm
508
+ 3. **Importe o componente** - Deve ser importado no arquivo onde você define os campos
509
+ 4. **Interface obrigatória** - Seu componente DEVE aceitar: `value`, `onChange`, `disabled`, `error`
510
+
469
511
  #### Interface do Componente Customizado
470
512
 
471
513
  Todo campo customizado deve seguir esta interface:
@@ -574,30 +616,576 @@ const formFields: FormField[] = [
574
616
  - ✅ **Flexibilidade** - Props extras via `componentProps`
575
617
  - ✅ **Reatividade** - `updateField` e `onValueChange` funcionam corretamente
576
618
 
577
- #### Exemplos Adicionais
619
+ #### Exemplos Práticos Completos
578
620
 
579
- **MultiSelect com Busca:**
621
+ **Exemplo 1: MultiSelect de Objetivos Estratégicos**
580
622
 
581
623
  ```typescript
582
- {
583
- name: 'categories',
584
- type: 'custom',
585
- component: MultiSelectField,
586
- componentProps: {
587
- options: categories,
588
- searchable: true
624
+ // 1. Criar o componente customizado
625
+ // src/components/StrategicObjectivesMultiSelect.tsx
626
+ import { Label, Badge } from 'forlogic-core';
627
+ import { useStrategicObjectives } from '@/hooks/useStrategicObjectives';
628
+
629
+ interface Props {
630
+ value: string[];
631
+ onChange: (value: string[]) => void;
632
+ disabled?: boolean;
633
+ error?: string;
634
+ }
635
+
636
+ export function StrategicObjectivesMultiSelect({
637
+ value = [],
638
+ onChange,
639
+ disabled,
640
+ error
641
+ }: Props) {
642
+ const { data: objectives = [], isLoading } = useStrategicObjectives();
643
+
644
+ const toggleObjective = (id: string) => {
645
+ if (disabled) return;
646
+ const newValue = value.includes(id)
647
+ ? value.filter(v => v !== id)
648
+ : [...value, id];
649
+ onChange(newValue);
650
+ };
651
+
652
+ if (isLoading) return <div>Carregando...</div>;
653
+
654
+ return (
655
+ <div className="space-y-2">
656
+ <Label>Objetivos Estratégicos</Label>
657
+ <div className="flex flex-wrap gap-2">
658
+ {objectives.map(obj => (
659
+ <Badge
660
+ key={obj.id}
661
+ variant={value.includes(obj.id) ? 'default' : 'outline'}
662
+ className="cursor-pointer"
663
+ onClick={() => toggleObjective(obj.id)}
664
+ >
665
+ {obj.name}
666
+ </Badge>
667
+ ))}
668
+ </div>
669
+ {error && <p className="text-sm text-destructive">{error}</p>}
670
+ </div>
671
+ );
672
+ }
673
+
674
+ // 2. Usar no formulário - CORRETO ✅
675
+ import { StrategicObjectivesMultiSelect } from '@/components/StrategicObjectivesMultiSelect';
676
+
677
+ const formFields: FormField[] = [
678
+ {
679
+ name: 'strategic_objective_ids',
680
+ label: '',
681
+ type: 'custom', // ✅
682
+ component: StrategicObjectivesMultiSelect, // ✅
683
+ required: true,
589
684
  }
685
+ ];
686
+ ```
687
+
688
+ **Exemplo 2: Select de Usuários do Qualiex**
689
+
690
+ ```typescript
691
+ // 1. Componente customizado
692
+ // src/components/QualiexUserSelect.tsx
693
+ import { EntitySelect } from 'forlogic-core';
694
+ import { useQualiexUsers } from '@/hooks/useQualiexUsers';
695
+
696
+ interface Props {
697
+ value: string;
698
+ onChange: (value: string) => void;
699
+ disabled?: boolean;
700
+ error?: string;
701
+ placeId?: string; // Filtrar por local
590
702
  }
703
+
704
+ export function QualiexUserSelect({
705
+ value,
706
+ onChange,
707
+ disabled,
708
+ error,
709
+ placeId
710
+ }: Props) {
711
+ const { data: users = [], isLoading } = useQualiexUsers({ placeId });
712
+
713
+ return (
714
+ <div className="space-y-2">
715
+ <EntitySelect
716
+ value={value}
717
+ onChange={onChange}
718
+ items={users}
719
+ isLoading={isLoading}
720
+ disabled={disabled}
721
+ getItemValue={(u) => u.id}
722
+ getItemLabel={(u) => `${u.name} - ${u.email}`}
723
+ placeholder="Selecionar usuário..."
724
+ searchPlaceholder="Buscar usuário..."
725
+ />
726
+ {error && <p className="text-sm text-destructive">{error}</p>}
727
+ </div>
728
+ );
729
+ }
730
+
731
+ // 2. Usar no formulário - CORRETO ✅
732
+ import { QualiexUserSelect } from '@/components/QualiexUserSelect';
733
+
734
+ const formFields: FormField[] = [
735
+ {
736
+ name: 'id_responsible',
737
+ label: 'Responsável',
738
+ type: 'custom', // ✅
739
+ component: QualiexUserSelect, // ✅
740
+ required: true,
741
+ componentProps: {
742
+ placeId: currentPlaceId // Props extras
743
+ }
744
+ }
745
+ ];
591
746
  ```
592
747
 
593
- **Rich Text Editor:**
748
+ **Exemplo 3: MultiSelect de Treinamentos Anuais**
594
749
 
595
750
  ```typescript
596
- {
597
- name: 'content',
598
- type: 'custom',
599
- component: RichTextEditor,
600
- componentProps: {
751
+ // 1. Componente customizado
752
+ // src/training/components/AnnualTrainingMultiSelect.tsx
753
+ import { Checkbox, Label } from 'forlogic-core';
754
+ import { useAnnualTrainings } from '@/hooks/useAnnualTrainings';
755
+
756
+ interface Props {
757
+ value: string[];
758
+ onChange: (value: string[]) => void;
759
+ disabled?: boolean;
760
+ error?: string;
761
+ year?: number;
762
+ }
763
+
764
+ export function AnnualTrainingMultiSelect({
765
+ value = [],
766
+ onChange,
767
+ disabled,
768
+ error,
769
+ year
770
+ }: Props) {
771
+ const { data: trainings = [], isLoading } = useAnnualTrainings(year);
772
+
773
+ const toggleTraining = (id: string) => {
774
+ if (disabled) return;
775
+ const newValue = value.includes(id)
776
+ ? value.filter(v => v !== id)
777
+ : [...value, id];
778
+ onChange(newValue);
779
+ };
780
+
781
+ if (isLoading) return <div>Carregando treinamentos...</div>;
782
+
783
+ return (
784
+ <div className="space-y-2">
785
+ <Label>Treinamentos Anuais {year && `(${year})`}</Label>
786
+ <div className="space-y-2 max-h-60 overflow-y-auto border rounded-md p-3">
787
+ {trainings.map(training => (
788
+ <div key={training.id} className="flex items-center gap-2">
789
+ <Checkbox
790
+ id={`training-${training.id}`}
791
+ checked={value.includes(training.id)}
792
+ onCheckedChange={() => toggleTraining(training.id)}
793
+ disabled={disabled}
794
+ />
795
+ <Label
796
+ htmlFor={`training-${training.id}`}
797
+ className="font-normal cursor-pointer"
798
+ >
799
+ {training.title}
800
+ </Label>
801
+ </div>
802
+ ))}
803
+ </div>
804
+ {error && <p className="text-sm text-destructive">{error}</p>}
805
+ </div>
806
+ );
807
+ }
808
+
809
+ // 2. Usar no formulário - CORRETO ✅
810
+ import { AnnualTrainingMultiSelect } from '@/training/components/AnnualTrainingMultiSelect';
811
+
812
+ const formFields: FormField[] = [
813
+ {
814
+ name: 'annual_training_ids',
815
+ label: '',
816
+ type: 'custom', // ✅
817
+ component: AnnualTrainingMultiSelect, // ✅
818
+ required: true,
819
+ componentProps: {
820
+ year: 2025 // Props extras
821
+ },
822
+ validation: (value) => {
823
+ if (!value?.length) return 'Selecione pelo menos um treinamento';
824
+ }
825
+ }
826
+ ];
827
+ ```
828
+
829
+ #### ❌ Erros Comuns e Soluções
830
+
831
+ | Erro | Causa | Solução |
832
+ |------|-------|---------|
833
+ | Campo aparece como input text | Tipo customizado não reconhecido | Use `type: 'custom'` em vez de `type: 'meu-campo-custom'` |
834
+ | "customComponents is not used" | Passando prop que não existe | Remova `customComponents` e use `component` direto no field |
835
+ | "component is not defined" | Esqueceu de importar | Importe o componente no arquivo de fields |
836
+ | Campo não recebe props | componentProps ignorado | Verifique se seu componente aceita as props via `...props` |
837
+ | onChange não funciona | Componente não chama onChange | Certifique-se de chamar `onChange(novoValor)` |
838
+
839
+ #### 📋 Checklist de Implementação
840
+
841
+ Ao criar um campo customizado, verifique:
842
+
843
+ - [ ] Componente aceita `value`, `onChange`, `disabled`, `error`
844
+ - [ ] `type: 'custom'` está configurado
845
+ - [ ] `component: SeuComponente` está passado
846
+ - [ ] Componente está importado no arquivo de fields
847
+ - [ ] `onChange` é chamado quando valor muda
848
+ - [ ] `error` é exibido quando presente
849
+ - [ ] `disabled` desabilita interações
850
+
851
+ ---
852
+
853
+ ### 🎯 Campo Creatable Select
854
+
855
+ O `CreatableCombobox` permite que usuários **selecionem de opções existentes OU criem novos itens inline** sem sair do formulário.
856
+
857
+ #### Quando Usar?
858
+
859
+ - ✅ Seleção de tags, categorias, labels
860
+ - ✅ Campos onde usuários podem precisar criar valores rapidamente
861
+ - ✅ Dropdowns com centenas de opções (busca rápida)
862
+ - ✅ Single ou múltipla seleção
863
+
864
+ #### Uso Básico no BaseForm
865
+
866
+ ```typescript
867
+ import { createCrudPage } from 'forlogic-core';
868
+ import { TagService } from './tagService';
869
+
870
+ const formFields: FormSection[] = [
871
+ {
872
+ id: 'basic',
873
+ title: 'Informações',
874
+ fields: [
875
+ {
876
+ name: 'tags',
877
+ label: 'Tags',
878
+ type: 'creatable-select',
879
+ mode: 'multiple',
880
+ placeholder: 'Selecionar ou criar tags...',
881
+ options: tagsOptions, // [{ label: 'React', value: '1' }, ...]
882
+ onCreate: async (tagName) => {
883
+ // Criar tag no backend
884
+ const newTag = await TagService.create({ name: tagName });
885
+ // Atualizar lista de opções (refetch)
886
+ refetchTags();
887
+ return newTag.id; // Retorna ID para seleção automática
888
+ },
889
+ createLabel: (term) => `Criar "${term}"`,
890
+ required: true
891
+ },
892
+ {
893
+ name: 'category',
894
+ label: 'Categoria',
895
+ type: 'creatable-select',
896
+ mode: 'single',
897
+ placeholder: 'Selecionar categoria...',
898
+ options: categoriesOptions,
899
+ onCreate: async (categoryName) => {
900
+ const newCat = await CategoryService.create({ name: categoryName });
901
+ refetchCategories();
902
+ return newCat.id;
903
+ }
904
+ }
905
+ ]
906
+ }
907
+ ];
908
+ ```
909
+
910
+ #### Props do Campo
911
+
912
+ | Prop | Tipo | Padrão | Descrição |
913
+ | --------------------- | ------------------------------------------ | ----------------------- | ---------------------------------------------------- |
914
+ | `type` | `'creatable-select'` | - | Tipo do campo (obrigatório) |
915
+ | `mode` | `'single' \| 'multiple'` | `'single'` | Seleção única ou múltipla |
916
+ | `options` | `Array<{ label: string; value: string }>` | `[]` | Opções existentes para seleção |
917
+ | `onCreate` | `(searchTerm: string) => Promise<string>` | - | Callback para criar novo item (retorna ID) |
918
+ | `placeholder` | `string` | `'Selecionar...'` | Texto quando nenhum item selecionado |
919
+ | `searchPlaceholder` | `string` | `'Buscar...'` | Texto da busca interna |
920
+ | `createLabel` | `(term: string) => string` | `'Criar "{term}"'` | Função para gerar label do botão criar |
921
+ | `emptyMessage` | `string` | `'Nenhum item encontrado.'` | Mensagem quando não há opções |
922
+
923
+ #### Exemplo Completo com React Query
924
+
925
+ ```typescript
926
+ import { useQuery, useQueryClient } from '@tanstack/react-query';
927
+ import { createCrudPage } from 'forlogic-core';
928
+ import { TagService } from './tagService';
929
+
930
+ function TagsPage() {
931
+ const queryClient = useQueryClient();
932
+
933
+ // Buscar tags existentes
934
+ const { data: tags = [], isLoading } = useQuery({
935
+ queryKey: ['tags'],
936
+ queryFn: () => TagService.getAll()
937
+ });
938
+
939
+ // Converter para formato do CreatableCombobox
940
+ const tagsOptions = tags.map(tag => ({
941
+ label: tag.name,
942
+ value: tag.id
943
+ }));
944
+
945
+ const formFields: FormSection[] = [
946
+ {
947
+ id: 'article',
948
+ title: 'Artigo',
949
+ fields: [
950
+ {
951
+ name: 'title',
952
+ label: 'Título',
953
+ type: 'text',
954
+ required: true
955
+ },
956
+ {
957
+ name: 'tags',
958
+ label: 'Tags',
959
+ type: 'creatable-select',
960
+ mode: 'multiple',
961
+ options: tagsOptions,
962
+ onCreate: async (tagName) => {
963
+ // Validar nome
964
+ if (tagName.trim().length < 2) {
965
+ throw new Error('Nome deve ter ao menos 2 caracteres');
966
+ }
967
+
968
+ // Criar no backend
969
+ const newTag = await TagService.create({
970
+ name: tagName.trim()
971
+ });
972
+
973
+ // Invalidar cache para refetch automático
974
+ queryClient.invalidateQueries({ queryKey: ['tags'] });
975
+
976
+ // Retornar ID para seleção automática
977
+ return newTag.id;
978
+ },
979
+ placeholder: 'Selecionar tags...',
980
+ searchPlaceholder: 'Buscar ou criar tag...',
981
+ createLabel: (term) => `✨ Criar tag "${term}"`,
982
+ required: true
983
+ }
984
+ ]
985
+ }
986
+ ];
987
+
988
+ return createCrudPage({
989
+ // ... config do CRUD
990
+ formFields
991
+ });
992
+ }
993
+ ```
994
+
995
+ #### Uso Standalone (Fora do BaseForm)
996
+
997
+ ```typescript
998
+ import { CreatableCombobox } from 'forlogic-core/ui';
999
+ import { useState } from 'react';
1000
+
1001
+ function MyComponent() {
1002
+ const [selectedTags, setSelectedTags] = useState<string[]>([]);
1003
+
1004
+ const tagsOptions = [
1005
+ { label: 'React', value: '1' },
1006
+ { label: 'TypeScript', value: '2' },
1007
+ { label: 'Node.js', value: '3' }
1008
+ ];
1009
+
1010
+ return (
1011
+ <CreatableCombobox
1012
+ mode="multiple"
1013
+ value={selectedTags}
1014
+ onChange={setSelectedTags}
1015
+ options={tagsOptions}
1016
+ onCreate={async (tagName) => {
1017
+ const response = await fetch('/api/tags', {
1018
+ method: 'POST',
1019
+ body: JSON.stringify({ name: tagName })
1020
+ });
1021
+ const newTag = await response.json();
1022
+ return newTag.id;
1023
+ }}
1024
+ placeholder="Selecionar tags..."
1025
+ createLabel={(term) => `Criar "${term}"`}
1026
+ />
1027
+ );
1028
+ }
1029
+ ```
1030
+
1031
+ #### Interface TypeScript
1032
+
1033
+ ```typescript
1034
+ interface CreatableComboboxProps {
1035
+ // Dados
1036
+ options: Array<{ label: string; value: string }>;
1037
+ value?: string | string[];
1038
+
1039
+ // Eventos
1040
+ onChange: (value: string | string[]) => void;
1041
+ onCreate?: (searchTerm: string) => Promise<string | void>;
1042
+
1043
+ // Config
1044
+ placeholder?: string;
1045
+ searchPlaceholder?: string;
1046
+ createLabel?: (term: string) => string;
1047
+ emptyMessage?: string;
1048
+
1049
+ // Estado
1050
+ disabled?: boolean;
1051
+ error?: string;
1052
+ isLoading?: boolean;
1053
+
1054
+ // UI
1055
+ className?: string;
1056
+ mode?: 'single' | 'multiple';
1057
+ }
1058
+ ```
1059
+
1060
+ #### Fluxo de Criação
1061
+
1062
+ ```mermaid
1063
+ graph TD
1064
+ A[Usuário digita 'Design'] --> B{Termo existe?}
1065
+ B -->|Sim| C[Mostra opção existente]
1066
+ B -->|Não| D[Mostra botão 'Criar Design']
1067
+ D --> E[Usuário clica em criar]
1068
+ E --> F[onCreate chamado]
1069
+ F --> G{Retornou ID?}
1070
+ G -->|Sim| H[Adiciona à seleção automaticamente]
1071
+ G -->|Não| I[Apenas fecha dropdown]
1072
+ H --> J[queryClient.invalidateQueries atualiza lista]
1073
+ C --> K[Usuário seleciona]
1074
+ K --> L[onChange chamado]
1075
+ ```
1076
+
1077
+ #### Estados Visuais
1078
+
1079
+ **Campo Vazio (Single):**
1080
+ ```
1081
+ ┌────────────────────────────────┐
1082
+ │ Selecionar categoria... ▼ │
1083
+ └────────────────────────────────┘
1084
+ ```
1085
+
1086
+ **Campo com Múltiplos Selecionados:**
1087
+ ```
1088
+ ┌────────────────────────────────┐
1089
+ │ [React ✕] [TypeScript ✕] ▼ │
1090
+ └────────────────────────────────┘
1091
+ ```
1092
+
1093
+ **Dropdown com Busca (Termo Não Existe):**
1094
+ ```
1095
+ ┌────────────────────────────────┐
1096
+ │ 🔍 design │
1097
+ ├────────────────────────────────┤
1098
+ │ ┏━━━━━━━━━━━━━━━━━━━━━━━━━┓ │
1099
+ │ ┃ ✨ Criar "design" ┃ │ <- Botão azul
1100
+ │ ┗━━━━━━━━━━━━━━━━━━━━━━━━━┛ │
1101
+ └────────────────────────────────┘
1102
+ ```
1103
+
1104
+ **Dropdown com Opções Existentes:**
1105
+ ```
1106
+ ┌────────────────────────────────┐
1107
+ │ 🔍 react │
1108
+ ├────────────────────────────────┤
1109
+ │ ✓ React │ <- Selecionado
1110
+ │ React Native │
1111
+ │ React Query │
1112
+ └────────────────────────────────┘
1113
+ ```
1114
+
1115
+ #### Boas Práticas
1116
+
1117
+ 1. **Sempre retornar o ID no onCreate:**
1118
+ - Permite seleção automática do item criado
1119
+ - Evita usuário ter que procurar o item recém-criado
1120
+
1121
+ 2. **Invalidar cache após criar:**
1122
+ - Use `queryClient.invalidateQueries()` para refetch automático
1123
+ - Garante que lista de opções está sempre atualizada
1124
+
1125
+ 3. **Validação no onCreate:**
1126
+ - Valide o nome antes de criar
1127
+ - Lance erro amigável se inválido
1128
+ - Use `toast.error()` para feedback visual
1129
+
1130
+ 4. **Loading states:**
1131
+ - Passe `isLoading` durante fetch inicial de opções
1132
+ - `onCreate` já mostra loading interno automaticamente
1133
+
1134
+ 5. **Modo apropriado:**
1135
+ - `single`: Categoria, Status, Tipo
1136
+ - `multiple`: Tags, Skills, Departamentos
1137
+
1138
+ #### Troubleshooting
1139
+
1140
+ **Problema:** Opções não atualizam após criar
1141
+ ```typescript
1142
+ // ❌ ERRADO - Não invalida cache
1143
+ onCreate: async (name) => {
1144
+ await TagService.create({ name });
1145
+ return newTag.id;
1146
+ }
1147
+
1148
+ // ✅ CORRETO - Invalida cache
1149
+ onCreate: async (name) => {
1150
+ const newTag = await TagService.create({ name });
1151
+ queryClient.invalidateQueries({ queryKey: ['tags'] });
1152
+ return newTag.id;
1153
+ }
1154
+ ```
1155
+
1156
+ **Problema:** Dropdown fica transparente
1157
+ ```css
1158
+ /* Adicionar ao CSS global se necessário */
1159
+ .popover-content {
1160
+ background-color: white;
1161
+ z-index: 9999;
1162
+ }
1163
+ ```
1164
+
1165
+ **Problema:** onCreate não retorna ID
1166
+ ```typescript
1167
+ // ✅ Sempre retornar o ID para seleção automática
1168
+ onCreate: async (name) => {
1169
+ const response = await TagService.create({ name });
1170
+ return response.id; // <- IMPORTANTE
1171
+ }
1172
+ ```
1173
+
1174
+ ---
1175
+
1176
+ #### Integração com FormField Types
1177
+
1178
+ O tipo `'creatable-select'` está disponível para uso direto no `FormField`:
1179
+
1180
+ ```typescript
1181
+ type FormFieldType =
1182
+ | 'text'
1183
+ | 'textarea'
1184
+ | 'select'
1185
+ | 'creatable-select' // <- Novo tipo
1186
+ | 'checkbox'
1187
+ | 'radio'
1188
+ | 'date'
601
1189
  toolbar: ['bold', 'italic', 'link'],
602
1190
  minHeight: 200
603
1191
  }
@@ -1038,128 +1626,466 @@ userFieldsMapping: [
1038
1626
  ]
1039
1627
  ```
1040
1628
 
1041
- ✅ **BOM**: Usar sufixos distintos
1629
+ ✅ **BOM**: Usar sufixos distintos
1630
+
1631
+ ```typescript
1632
+ // ✅ Campos de saída únicos
1633
+ userFieldsMapping: [
1634
+ { idField: 'created_by_id', nameField: 'created_by_name' },
1635
+ { idField: 'updated_by_id', nameField: 'updated_by_name' },
1636
+ ]
1637
+ ```
1638
+
1639
+ ---
1640
+
1641
+ ### 🏢 Places - Hierarquia de Locais com Gerenciamento de Acessos
1642
+
1643
+ Sistema completo para visualização hierárquica de locais e gerenciamento configurável de acessos (gestores e membros).
1644
+
1645
+ #### 📦 Importações
1646
+
1647
+ ```typescript
1648
+ // Componentes
1649
+ import { PlacesList, PlaceCard, ManageAccessModal } from 'forlogic-core/places';
1650
+
1651
+ // Service
1652
+ import { placeService } from 'forlogic-core/places';
1653
+
1654
+ // Types
1655
+ import type { Place, PlacesListProps, PlaceCardProps, ManageAccessModalProps } from 'forlogic-core/places';
1656
+
1657
+ // Ou importação completa
1658
+ import { PlacesList, placeService } from 'forlogic-core';
1659
+ ```
1660
+
1661
+ #### 📋 Tipos de Dados
1662
+
1663
+ ```typescript
1664
+ interface Place {
1665
+ id: string;
1666
+ name: string;
1667
+ usersCount?: number;
1668
+ hasChild?: boolean;
1669
+ isActive?: boolean;
1670
+ parentId?: string;
1671
+ parentName?: string;
1672
+ subPlaces?: Place[];
1673
+ }
1674
+ ```
1675
+
1676
+ #### 🎯 Uso Básico - Apenas Visualização
1677
+
1678
+ ```typescript
1679
+ import { PlacesList } from 'forlogic-core';
1680
+ import { useAuth, placeService } from 'forlogic-core';
1681
+ import { useQuery } from '@tanstack/react-query';
1682
+
1683
+ function MyPlacesPage() {
1684
+ const { alias } = useAuth();
1685
+ const tokenData = TokenManager.extractTokenData();
1686
+ const companyId = tokenData?.companyId;
1687
+
1688
+ const { data: places = [], isLoading } = useQuery({
1689
+ queryKey: ['places', alias, companyId],
1690
+ queryFn: () => placeService.getPlaces(alias!, companyId!),
1691
+ enabled: !!alias && !!companyId,
1692
+ });
1693
+
1694
+ return <PlacesList places={places} isLoading={isLoading} />;
1695
+ }
1696
+ ```
1697
+
1698
+ > **💡 Neste modo**, o `PlacesList` apenas renderiza a hierarquia visual. O botão "Gerenciar Acessos" não aparece.
1699
+
1700
+ ---
1701
+
1702
+ #### 🔐 Uso Avançado - Com Gerenciamento de Acessos
1703
+
1704
+ ```typescript
1705
+ interface PlacesListProps {
1706
+ places: Place[];
1707
+ isLoading: boolean;
1708
+ manageAccessConfig?: {
1709
+ onMakeManager?: (userId: string, placeId: string) => void | Promise<void>;
1710
+ onMakeMembers?: (userIds: string[], placeId: string) => void | Promise<void>;
1711
+ getCurrentManagerId?: (placeId: string) => string | undefined;
1712
+ getCurrentMemberIds?: (placeId: string) => string[] | undefined;
1713
+ };
1714
+ }
1715
+ ```
1716
+
1717
+ **Exemplo Completo:**
1718
+
1719
+ ```typescript
1720
+ import { PlacesList, Card } from 'forlogic-core';
1721
+ import { useAuth, placeService } from 'forlogic-core';
1722
+ import { useQuery } from '@tanstack/react-query';
1723
+
1724
+ function MyPlacesPageWithManagement() {
1725
+ const { alias } = useAuth();
1726
+ const tokenData = TokenManager.extractTokenData();
1727
+ const companyId = tokenData?.companyId;
1728
+
1729
+ // Buscar places
1730
+ const { data: places = [], isLoading } = useQuery({
1731
+ queryKey: ['places', alias, companyId],
1732
+ queryFn: () => placeService.getPlaces(alias!, companyId!),
1733
+ enabled: !!alias && !!companyId,
1734
+ });
1735
+
1736
+ // Buscar gestores e membros atuais (implementação do módulo)
1737
+ const { data: accessData = [] } = useQuery({
1738
+ queryKey: ['placeAccess'],
1739
+ queryFn: () => myAccessService.getAllAccess(),
1740
+ });
1741
+
1742
+ // Callbacks de gerenciamento
1743
+ const handleMakeManager = async (userId: string, placeId: string) => {
1744
+ await myAccessService.setManager(placeId, userId);
1745
+ queryClient.invalidateQueries(['placeAccess']);
1746
+ };
1747
+
1748
+ const handleMakeMembers = async (userIds: string[], placeId: string) => {
1749
+ await myAccessService.addMembers(placeId, userIds);
1750
+ queryClient.invalidateQueries(['placeAccess']);
1751
+ };
1752
+
1753
+ const getCurrentManagerId = (placeId: string) => {
1754
+ return accessData.find(a => a.placeId === placeId && a.role === 'manager')?.userId;
1755
+ };
1756
+
1757
+ const getCurrentMemberIds = (placeId: string) => {
1758
+ return accessData.filter(a => a.placeId === placeId && a.role === 'member').map(a => a.userId);
1759
+ };
1760
+
1761
+ return (
1762
+ <Card className="p-6">
1763
+ <PlacesList
1764
+ places={places}
1765
+ isLoading={isLoading}
1766
+ manageAccessConfig={{
1767
+ onMakeManager: handleMakeManager,
1768
+ onMakeMembers: handleMakeMembers,
1769
+ getCurrentManagerId,
1770
+ getCurrentMemberIds
1771
+ }}
1772
+ />
1773
+ </Card>
1774
+ );
1775
+ }
1776
+ ```
1777
+
1778
+ ---
1779
+
1780
+ #### 🎨 Componentes Individuais
1781
+
1782
+ ##### PlacesList
1783
+
1784
+ Componente principal que renderiza a lista hierárquica de locais.
1785
+
1786
+ **Props:**
1787
+ - `places: Place[]` - Array de locais (hierarquia já construída)
1788
+ - `isLoading: boolean` - Estado de carregamento
1789
+ - `manageAccessConfig?: object` - Callbacks de gerenciamento (opcional)
1790
+
1791
+ ##### PlaceCard
1792
+
1793
+ Componente individual de local com suporte a colapso de sublocais.
1794
+
1795
+ **Props:**
1796
+ - `place: Place` - Dados do local
1797
+ - `level?: number` - Nível hierárquico (controla indentação)
1798
+ - `manageAccessConfig?: object` - Callbacks (propagado recursivamente)
1799
+
1800
+ ##### ManageAccessModal
1801
+
1802
+ Modal para gerenciamento de acessos ao local.
1803
+
1804
+ ```typescript
1805
+ interface ManageAccessModalProps {
1806
+ open: boolean;
1807
+ onOpenChange: (open: boolean) => void;
1808
+ placeId: string;
1809
+ placeName: string;
1810
+ onMakeManager?: (userId: string, placeId: string) => void | Promise<void>;
1811
+ onMakeMembers?: (userIds: string[], placeId: string) => void | Promise<void>;
1812
+ currentManagerId?: string;
1813
+ currentMemberIds?: string[];
1814
+ }
1815
+ ```
1816
+
1817
+ **Funcionalidades:**
1818
+ - Lista todos os usuários do alias (via `useQualiexUsers`)
1819
+ - Busca por nome ou email
1820
+ - Ordenação alfabética
1821
+ - Seleção múltipla de usuários
1822
+ - Regras automáticas:
1823
+ * 1 usuário selecionado: mostra "Tornar Gestor" + "Tornar Membro"
1824
+ * 2+ usuários selecionados: mostra apenas "Tornar Membros"
1825
+ * 0 usuários selecionados: botões desabilitados
1826
+
1827
+ ---
1828
+
1829
+ #### 🔧 Service - placeService
1830
+
1831
+ ```typescript
1832
+ async getPlaces(alias: string, companyId: string): Promise<Place[]>
1833
+ ```
1834
+
1835
+ Busca locais da API Qualiex e constrói hierarquia automaticamente.
1836
+
1837
+ **Características:**
1838
+ - Suporta renovação automática de token (via QualiexErrorInterceptor)
1839
+ - Constrói hierarquia baseada em `parentId`
1840
+ - Tratamento de erros com `errorService`
1042
1841
 
1842
+ **Exemplo:**
1043
1843
  ```typescript
1044
- // Campos de saída únicos
1045
- userFieldsMapping: [
1046
- { idField: 'created_by_id', nameField: 'created_by_name' },
1047
- { idField: 'updated_by_id', nameField: 'updated_by_name' },
1048
- ]
1844
+ const places = await placeService.getPlaces('my-alias', 'company-123');
1049
1845
  ```
1050
1846
 
1051
1847
  ---
1052
1848
 
1053
- ### 🏢 Sistema de Gestores de Locais (Qualiex)
1849
+ #### 🎯 Regras de Negócio
1054
1850
 
1055
- Componentes para gerenciamento de gestores e membros de locais/sublocais integrados com a API Qualiex.
1851
+ > **⚠️ Importante:** O módulo fornece apenas a INTERFACE. A lógica de persistência deve ser implementada pelo projeto consumidor.
1056
1852
 
1057
- #### Importação
1853
+ **Regras sugeridas:**
1854
+ 1. **Um gestor por local** - Ao atribuir novo gestor, remover o anterior
1855
+ 2. **Múltiplos membros por local** - Sem limite
1856
+ 3. **Usuário não pode ser gestor e membro simultaneamente** do mesmo local
1857
+ 4. **Validar permissões** antes de salvar
1058
1858
 
1059
- ```typescript
1060
- import {
1061
- PlaceManagerButton,
1062
- PlaceManagerBadge,
1063
- ManagerSelectionDialog,
1064
- usePlaceManagers,
1065
- PlaceManagerService,
1066
- type PlaceManager
1067
- } from 'forlogic-core';
1068
- ```
1859
+ ---
1069
1860
 
1070
- #### Componentes
1861
+ #### 🗄️ Estrutura de Banco Sugerida (Supabase)
1071
1862
 
1072
- **PlaceManagerButton** - Botão dropdown com ações de gerenciamento:
1863
+ ```sql
1864
+ CREATE TABLE IF NOT EXISTS public.place_access (
1865
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
1866
+ place_id text NOT NULL,
1867
+ user_id text NOT NULL,
1868
+ user_name text NOT NULL,
1869
+ user_email text NOT NULL,
1870
+ role text NOT NULL CHECK (role IN ('manager', 'member')),
1871
+ created_at timestamptz DEFAULT now(),
1872
+ updated_at timestamptz DEFAULT now(),
1873
+ UNIQUE (place_id, user_id)
1874
+ );
1073
1875
 
1074
- ```tsx
1075
- <PlaceManagerButton
1076
- placeId="abc-123"
1077
- placeName="Matriz São Paulo"
1078
- serviceConfig={{ tableName: 'place_managers' }}
1079
- />
1080
- ```
1876
+ CREATE INDEX idx_place_access_place ON public.place_access(place_id);
1877
+ CREATE INDEX idx_place_access_user ON public.place_access(user_id);
1081
1878
 
1082
- **PlaceManagerBadge** - Badge visual com contador de gestores:
1879
+ -- RLS Policies
1880
+ ALTER TABLE public.place_access ENABLE ROW LEVEL SECURITY;
1083
1881
 
1084
- ```tsx
1085
- <PlaceManagerBadge
1086
- placeId="abc-123"
1087
- userCount={15}
1088
- />
1882
+ CREATE POLICY "Users can view place access"
1883
+ ON public.place_access FOR SELECT
1884
+ USING (true);
1885
+
1886
+ CREATE POLICY "Authenticated users can manage place access"
1887
+ ON public.place_access FOR ALL
1888
+ USING (auth.role() = 'authenticated');
1089
1889
  ```
1090
1890
 
1091
- **ManagerSelectionDialog** - Diálogo completo de seleção:
1891
+ ---
1092
1892
 
1093
- ```tsx
1094
- <ManagerSelectionDialog
1095
- open={showDialog}
1096
- onOpenChange={setShowDialog}
1097
- onSelectManager={(user) => console.log('Gestor:', user)}
1098
- onSelectMember={(user) => console.log('Membro:', user)}
1099
- currentManagerId={manager?.user_id}
1100
- currentMemberIds={members.map(m => m.user_id)}
1101
- placeName="Matriz São Paulo"
1102
- />
1103
- ```
1893
+ #### 💡 Exemplo Completo de Implementação
1104
1894
 
1105
- #### Hook: usePlaceManagers
1895
+ **1. Service de Acesso (`src/places/services/PlaceAccessService.ts`):**
1106
1896
 
1107
1897
  ```typescript
1108
- const {
1109
- managers, // Todos (gestor + membros)
1110
- manager, // Apenas o gestor
1111
- members, // Apenas membros
1112
- setManager, // (user: QualiexUser) => void
1113
- addMember, // (user: QualiexUser) => void
1114
- remove, // (userId: string) => void
1115
- isLoading
1116
- } = usePlaceManagers(placeId);
1898
+ import { getSupabaseClient } from 'forlogic-core';
1899
+
1900
+ class PlaceAccessService {
1901
+ private supabase = getSupabaseClient();
1902
+
1903
+ async getAllAccess() {
1904
+ const { data, error } = await this.supabase
1905
+ .from('place_access')
1906
+ .select('*');
1907
+
1908
+ if (error) throw error;
1909
+ return data;
1910
+ }
1911
+
1912
+ async setManager(placeId: string, userId: string, userName: string, userEmail: string) {
1913
+ // Remover gestor anterior
1914
+ await this.supabase
1915
+ .from('place_access')
1916
+ .delete()
1917
+ .eq('place_id', placeId)
1918
+ .eq('role', 'manager');
1919
+
1920
+ // Adicionar novo gestor
1921
+ const { error } = await this.supabase
1922
+ .from('place_access')
1923
+ .insert({
1924
+ place_id: placeId,
1925
+ user_id: userId,
1926
+ user_name: userName,
1927
+ user_email: userEmail,
1928
+ role: 'manager'
1929
+ });
1930
+
1931
+ if (error) throw error;
1932
+ }
1933
+
1934
+ async addMembers(
1935
+ placeId: string,
1936
+ users: Array<{userId: string, userName: string, userEmail: string}>
1937
+ ) {
1938
+ const { error } = await this.supabase
1939
+ .from('place_access')
1940
+ .upsert(
1941
+ users.map(user => ({
1942
+ place_id: placeId,
1943
+ user_id: user.userId,
1944
+ user_name: user.userName,
1945
+ user_email: user.userEmail,
1946
+ role: 'member'
1947
+ }))
1948
+ );
1949
+
1950
+ if (error) throw error;
1951
+ }
1952
+ }
1953
+
1954
+ export const placeAccessService = new PlaceAccessService();
1117
1955
  ```
1118
1956
 
1119
- #### Service: PlaceManagerService
1957
+ **2. Hook Customizado (`src/places/hooks/usePlaceAccess.ts`):**
1120
1958
 
1121
1959
  ```typescript
1122
- import { PlaceManagerService } from 'forlogic-core';
1960
+ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
1961
+ import { placeAccessService } from '../services/PlaceAccessService';
1962
+ import { useToast } from 'forlogic-core';
1963
+ import type { QualiexUser } from 'forlogic-core';
1123
1964
 
1124
- // Instância customizada
1125
- const service = new PlaceManagerService({
1126
- tableName: 'my_place_managers',
1127
- schemaName: 'custom_schema'
1128
- });
1965
+ export function usePlaceAccess() {
1966
+ const queryClient = useQueryClient();
1967
+ const { toast } = useToast();
1968
+
1969
+ const { data: accessData = [] } = useQuery({
1970
+ queryKey: ['placeAccess'],
1971
+ queryFn: () => placeAccessService.getAllAccess(),
1972
+ });
1973
+
1974
+ const setManagerMutation = useMutation({
1975
+ mutationFn: ({ placeId, user }: { placeId: string; user: QualiexUser }) =>
1976
+ placeAccessService.setManager(
1977
+ placeId,
1978
+ user.userId!,
1979
+ user.userName!,
1980
+ user.userEmail!
1981
+ ),
1982
+ onSuccess: () => {
1983
+ queryClient.invalidateQueries(['placeAccess']);
1984
+ toast({ title: 'Gestor definido com sucesso' });
1985
+ },
1986
+ onError: () => {
1987
+ toast({
1988
+ title: 'Erro ao definir gestor',
1989
+ variant: 'destructive'
1990
+ });
1991
+ }
1992
+ });
1993
+
1994
+ const addMembersMutation = useMutation({
1995
+ mutationFn: ({ placeId, users }: { placeId: string; users: QualiexUser[] }) =>
1996
+ placeAccessService.addMembers(
1997
+ placeId,
1998
+ users.map(u => ({
1999
+ userId: u.userId!,
2000
+ userName: u.userName!,
2001
+ userEmail: u.userEmail!
2002
+ }))
2003
+ ),
2004
+ onSuccess: () => {
2005
+ queryClient.invalidateQueries(['placeAccess']);
2006
+ toast({ title: 'Membros adicionados com sucesso' });
2007
+ },
2008
+ onError: () => {
2009
+ toast({
2010
+ title: 'Erro ao adicionar membros',
2011
+ variant: 'destructive'
2012
+ });
2013
+ }
2014
+ });
1129
2015
 
1130
- // Métodos disponíveis
1131
- await service.getPlaceManagers(alias, placeId);
1132
- await service.setManager(alias, placeId, user);
1133
- await service.addMember(alias, placeId, user);
1134
- await service.removePlaceUser(alias, placeId, userId);
2016
+ return {
2017
+ accessData,
2018
+ setManager: setManagerMutation.mutateAsync,
2019
+ addMembers: addMembersMutation.mutateAsync,
2020
+ };
2021
+ }
1135
2022
  ```
1136
2023
 
1137
- > ⚠️ A configuração do banco de dados (tabelas, RLS policies) deve ser feita no projeto consumidor.
2024
+ **3. Página Final (`src/places/PlacesPage.tsx`):**
1138
2025
 
1139
- #### Regras de Negócio
2026
+ ```typescript
2027
+ import { useQuery } from '@tanstack/react-query';
2028
+ import { useAuth, TokenManager, placeService, PlacesList, Card } from 'forlogic-core';
2029
+ import { usePlaceAccess } from './hooks/usePlaceAccess';
2030
+ import { useQualiexUsers } from 'forlogic-core';
1140
2031
 
1141
- 1. **Um gestor por local** - Ao definir novo gestor, o anterior é removido automaticamente
1142
- 2. **Membros ilimitados** - Múltiplos membros podem ser adicionados
1143
- 3. **Ordenação alfabética** - Sempre ordenado por nome
1144
- 4. **Busca inteligente** - Filtra por nome ou email
2032
+ export function PlacesPage() {
2033
+ const { alias } = useAuth();
2034
+ const tokenData = TokenManager.extractTokenData();
2035
+ const companyId = tokenData?.companyId;
2036
+
2037
+ const { data: places = [], isLoading } = useQuery({
2038
+ queryKey: ['places', alias, companyId],
2039
+ queryFn: () => placeService.getPlaces(alias!, companyId!),
2040
+ enabled: !!alias && !!companyId,
2041
+ });
1145
2042
 
1146
- #### Exemplo Completo
2043
+ const { accessData, setManager, addMembers } = usePlaceAccess();
2044
+ const { data: users = [] } = useQualiexUsers();
1147
2045
 
1148
- ```tsx
1149
- function PlaceTreeItem({ place }) {
1150
- return (
1151
- <div className="flex items-center gap-3">
1152
- <span>{place.name}</span>
2046
+ const handleMakeManager = async (userId: string, placeId: string) => {
2047
+ const user = users.find(u => u.userId === userId);
2048
+ if (user) {
2049
+ await setManager({ placeId, user });
2050
+ }
2051
+ };
1153
2052
 
1154
- <PlaceManagerBadge
1155
- placeId={place.id}
1156
- userCount={place.usersIds.length}
1157
- />
2053
+ const handleMakeMembers = async (userIds: string[], placeId: string) => {
2054
+ const selectedUsers = users.filter(u => userIds.includes(u.userId!));
2055
+ await addMembers({ placeId, users: selectedUsers });
2056
+ };
1158
2057
 
1159
- <PlaceManagerButton
1160
- placeId={place.id}
1161
- placeName={place.name}
1162
- />
2058
+ const getCurrentManagerId = (placeId: string) => {
2059
+ return accessData.find(a => a.place_id === placeId && a.role === 'manager')?.user_id;
2060
+ };
2061
+
2062
+ const getCurrentMemberIds = (placeId: string) => {
2063
+ return accessData
2064
+ .filter(a => a.place_id === placeId && a.role === 'member')
2065
+ .map(a => a.user_id);
2066
+ };
2067
+
2068
+ return (
2069
+ <div className="container mx-auto py-6 space-y-6">
2070
+ <div className="flex flex-col gap-2">
2071
+ <h1 className="text-3xl font-bold tracking-tight">Locais</h1>
2072
+ <p className="text-muted-foreground">
2073
+ Visualização hierárquica de locais e gestão de acessos
2074
+ </p>
2075
+ </div>
2076
+
2077
+ <Card className="p-6">
2078
+ <PlacesList
2079
+ places={places}
2080
+ isLoading={isLoading}
2081
+ manageAccessConfig={{
2082
+ onMakeManager: handleMakeManager,
2083
+ onMakeMembers: handleMakeMembers,
2084
+ getCurrentManagerId,
2085
+ getCurrentMemberIds
2086
+ }}
2087
+ />
2088
+ </Card>
1163
2089
  </div>
1164
2090
  );
1165
2091
  }
@@ -1167,6 +2093,44 @@ function PlaceTreeItem({ place }) {
1167
2093
 
1168
2094
  ---
1169
2095
 
2096
+ #### 🛠️ Troubleshooting
2097
+
2098
+ | Problema | Causa | Solução |
2099
+ |----------|-------|---------|
2100
+ | Botão "Gerenciar Acessos" não aparece | `manageAccessConfig` não fornecido | Passar objeto `manageAccessConfig` para `PlacesList` |
2101
+ | Lista de usuários vazia no modal | `useQualiexUsers` não retorna dados | Verificar token e configuração de alias |
2102
+ | Erro ao salvar gestor/membro | Callbacks não implementados | Implementar `onMakeManager` e `onMakeMembers` |
2103
+ | Hierarquia não exibe sublocais | Dados sem `parentId` correto | Verificar estrutura retornada pela API Qualiex |
2104
+ | Modal não abre | Estado `open` não controlado | Verificar se `ActionMenu` está funcionando |
2105
+
2106
+ ---
2107
+
2108
+ #### ✅ Checklist de Implementação
2109
+
2110
+ - [ ] Importar componentes do `forlogic-core/places`
2111
+ - [ ] Criar tabela `place_access` no Supabase (ou equivalente)
2112
+ - [ ] Implementar `PlaceAccessService` com métodos CRUD
2113
+ - [ ] Criar hook `usePlaceAccess` com React Query
2114
+ - [ ] Configurar `manageAccessConfig` na página
2115
+ - [ ] Testar seleção de gestor (apenas 1 usuário)
2116
+ - [ ] Testar seleção de membros (múltiplos usuários)
2117
+ - [ ] Implementar regra de "remover gestor anterior"
2118
+ - [ ] Adicionar toast de sucesso/erro
2119
+ - [ ] Testar busca de usuários no modal
2120
+ - [ ] Validar ordenação alfabética
2121
+
2122
+ ---
2123
+
2124
+ #### 📚 Referências
2125
+
2126
+ - **Componentes:** `lib/places/components/`
2127
+ - **Service:** `lib/places/services/PlaceService.ts`
2128
+ - **Types:** `lib/places/types.ts`
2129
+ - **Exports:** `lib/places/index.ts`
2130
+ - **Exemplo completo:** `src/places/PlacesPage.tsx`
2131
+
2132
+ ---
2133
+
1170
2134
  ### ✅ CHECKLIST (antes de implementar)
1171
2135
 
1172
2136
  - [ ] Schema `schema` especificado em queries e service?
@@ -1734,35 +2698,356 @@ export default function ProcessesPage() {
1734
2698
  }
1735
2699
  ```
1736
2700
 
1737
- #### **Integração com Bulk Actions:**
2701
+ #### **Integração com Bulk Actions:**
2702
+
2703
+ ```typescript
2704
+ customActions: [
2705
+ {
2706
+ label: 'Exportar Selecionados',
2707
+ icon: Download,
2708
+ variant: 'outline',
2709
+ action: () => {
2710
+ if (manager.selectedIds.length === 0) {
2711
+ toast({ title: 'Selecione pelo menos um item', variant: 'destructive' });
2712
+ return;
2713
+ }
2714
+
2715
+ const selectedItems = manager.entities.filter(
2716
+ item => manager.selectedIds.includes(item.id)
2717
+ );
2718
+ exportToCSV(selectedItems);
2719
+ }
2720
+ }
2721
+ ]
2722
+ ```
2723
+
2724
+ **Boas Práticas:**
2725
+ - ✅ Use `useMemo` para evitar recriação em cada render
2726
+ - ✅ Use `variant="outline"` para ações secundárias
2727
+ - ✅ Use ícones do `lucide-react` para consistência visual
2728
+ - ✅ Mostre feedback (loading, toasts) para ações assíncronas
2729
+ - ❌ Evite muitas ações (máximo 3-4 para não poluir a UI)
2730
+
2731
+ ---
2732
+
2733
+ ### **🔍 TUTORIAL COMPLETO: FILTROS E EXPORTAÇÃO**
2734
+
2735
+ Este tutorial mostra como implementar filtros customizados e botão de exportação em páginas CRUD.
2736
+
2737
+ ---
2738
+
2739
+ #### **📋 Cenário: Página de Exemplos com Filtro de Status e Exportação**
2740
+
2741
+ Vamos criar uma página que:
2742
+ 1. ✅ Filtra exemplos por status (Ativo/Inativo/Todos)
2743
+ 2. ✅ Exporta todos os dados para CSV (não apenas da página atual)
2744
+ 3. ✅ Mantém filtros aplicados ao exportar
2745
+
2746
+ ---
2747
+
2748
+ #### **Passo 1: Configurar o Service**
2749
+
2750
+ ```typescript
2751
+ // src/examples/ExampleService.ts
2752
+ import { createSimpleService } from 'forlogic-core';
2753
+ import type { Example, CreateExamplePayload, UpdateExamplePayload } from './example';
2754
+
2755
+ export const { service: ExampleService, useCrudHook: useExamplesCrud } =
2756
+ createSimpleService<Example, CreateExamplePayload, UpdateExamplePayload>({
2757
+ tableName: 'examples',
2758
+ entityName: 'Exemplo',
2759
+ searchFields: ['title', 'description'],
2760
+ schemaName: 'central',
2761
+ enableQualiexEnrichment: true
2762
+ });
2763
+ ```
2764
+
2765
+ **✅ Por que exportar o `service`?**
2766
+ - Permite chamar `ExampleService.getAll()` diretamente para exportação
2767
+ - Busca todos os registros sem depender do manager paginado
2768
+
2769
+ ---
2770
+
2771
+ #### **Passo 2: Criar Componente de Filtro**
2772
+
2773
+ ```typescript
2774
+ // src/examples/ExamplesPage.tsx
2775
+
2776
+ // 1️⃣ Estado do filtro (padrão: true = apenas Ativos)
2777
+ const [statusFilter, setStatusFilter] = useState<boolean | 'all'>(true);
2778
+
2779
+ // 2️⃣ Manager com filtro aplicado no backend
2780
+ const manager = useExamplesCrud(
2781
+ statusFilter === 'all' ? {} : { is_actived: statusFilter }
2782
+ );
2783
+
2784
+ // 3️⃣ Componente de filtro customizado
2785
+ const StatusFilter = () => (
2786
+ <EntitySelect
2787
+ value={String(statusFilter)}
2788
+ onChange={(v) => setStatusFilter(v === 'all' ? 'all' : v === 'true')}
2789
+ items={[
2790
+ { id: 'true', name: 'Ativo' },
2791
+ { id: 'false', name: 'Inativo' },
2792
+ { id: 'all', name: '[Todos]' }
2793
+ ]}
2794
+ getItemValue={(item) => item.id}
2795
+ getItemLabel={(item) => item.name}
2796
+ placeholder="Status"
2797
+ className="w-full sm:w-[180px]"
2798
+ />
2799
+ );
2800
+ ```
2801
+
2802
+ **📝 Como funciona:**
2803
+ - `statusFilter` controla o estado do filtro
2804
+ - `useExamplesCrud` recebe `{ is_actived: true }` como filtro backend
2805
+ - Backend retorna apenas registros com `is_actived = true`
2806
+ - Paginação mostra total correto (ex: "1-10 de 43 itens")
2807
+
2808
+ ---
2809
+
2810
+ #### **Passo 3: Implementar Exportação para CSV**
2811
+
2812
+ ```typescript
2813
+ // src/examples/ExamplesPage.tsx
2814
+ import { Download } from 'lucide-react';
2815
+ import { useToast, formatDatetime } from 'forlogic-core';
2816
+ import { ExampleService } from './ExampleService';
2817
+
2818
+ export const ExamplesPage = () => {
2819
+ const { toast } = useToast();
2820
+ const [statusFilter, setStatusFilter] = useState<boolean | 'all'>(true);
2821
+ const manager = useExamplesCrud(
2822
+ statusFilter === 'all' ? {} : { is_actived: statusFilter }
2823
+ );
2824
+
2825
+ // 🚀 Função de exportação (busca TODOS os itens)
2826
+ const handleExport = async () => {
2827
+ try {
2828
+ // 1️⃣ Mostrar feedback
2829
+ toast({
2830
+ title: 'Exportando...',
2831
+ description: 'Buscando todos os registros'
2832
+ });
2833
+
2834
+ // 2️⃣ Buscar TODOS os registros (limite alto)
2835
+ const filters = statusFilter === 'all' ? {} : { is_actived: statusFilter };
2836
+ const allData = await ExampleService.getAll({
2837
+ search: manager.searchTerm, // Manter busca atual
2838
+ limit: 10000, // Limite alto para buscar tudo
2839
+ page: 1,
2840
+ ...filters // Aplicar filtros de status
2841
+ });
2842
+
2843
+ // 3️⃣ Validar dados
2844
+ if (!allData.data || allData.data.length === 0) {
2845
+ toast({
2846
+ variant: 'destructive',
2847
+ title: 'Nenhum dado disponível',
2848
+ description: 'Não há dados para exportar'
2849
+ });
2850
+ return;
2851
+ }
2852
+
2853
+ // 4️⃣ Definir cabeçalhos do CSV
2854
+ const headers = [
2855
+ 'Título',
2856
+ 'Responsável',
2857
+ 'Status',
2858
+ 'Link',
2859
+ 'Descrição',
2860
+ 'Cor',
2861
+ 'Ícone',
2862
+ 'Atualizado em'
2863
+ ];
2864
+
2865
+ // 5️⃣ Mapear dados para linhas CSV
2866
+ const rows = allData.data.map((item: Example) => [
2867
+ item.title || '',
2868
+ item.responsible_name || '',
2869
+ item.is_actived ? 'Ativo' : 'Inativo',
2870
+ item.url_field || '',
2871
+ item.description || '',
2872
+ item.color || '',
2873
+ item.icon_name || '',
2874
+ formatDatetime(item.updated_at)
2875
+ ]);
2876
+
2877
+ // 6️⃣ Criar conteúdo CSV
2878
+ const csvContent = [
2879
+ headers.join(','),
2880
+ ...rows.map(row =>
2881
+ row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(',')
2882
+ )
2883
+ ].join('\n');
2884
+
2885
+ // 7️⃣ Criar blob e fazer download
2886
+ const blob = new Blob(['\ufeff' + csvContent], {
2887
+ type: 'text/csv;charset=utf-8;'
2888
+ });
2889
+ const link = document.createElement('a');
2890
+ const url = URL.createObjectURL(blob);
2891
+ link.setAttribute('href', url);
2892
+ link.setAttribute('download', `exemplos_${new Date().toISOString().split('T')[0]}.csv`);
2893
+ link.style.visibility = 'hidden';
2894
+ document.body.appendChild(link);
2895
+ link.click();
2896
+ document.body.removeChild(link);
2897
+
2898
+ // 8️⃣ Feedback de sucesso
2899
+ toast({
2900
+ title: 'Exportação concluída',
2901
+ description: `${allData.data.length} registros exportados com sucesso`
2902
+ });
2903
+ } catch (error) {
2904
+ console.error('Erro ao exportar:', error);
2905
+ toast({
2906
+ variant: 'destructive',
2907
+ title: 'Erro na exportação',
2908
+ description: 'Não foi possível exportar os dados'
2909
+ });
2910
+ }
2911
+ };
2912
+
2913
+ // Restante do código...
2914
+ };
2915
+ ```
2916
+
2917
+ **⚠️ IMPORTANTE:**
2918
+ - `ExampleService.getAll()` busca TODOS os registros (não só da página)
2919
+ - Limite de 10.000 itens - ajuste se necessário
2920
+ - `\ufeff` adiciona BOM para Unicode (acentos no Excel)
2921
+ - Escape de aspas duplas: `""` dentro do CSV
2922
+
2923
+ ---
2924
+
2925
+ #### **Passo 4: Configurar `customActions` e `filters`**
2926
+
2927
+ ```typescript
2928
+ // src/examples/ExamplesPage.tsx
2929
+
2930
+ const CrudPage = createCrudPage({
2931
+ manager,
2932
+ config: {
2933
+ entityName: 'exemplo',
2934
+ entityNamePlural: 'exemplos',
2935
+ columns: exampleColumns,
2936
+ formSections,
2937
+
2938
+ // ✅ Filtros na barra superior
2939
+ filters: [
2940
+ { type: 'search' }, // Busca padrão
2941
+ { type: 'custom', component: StatusFilter } // Filtro customizado
2942
+ ],
2943
+
2944
+ // ✅ Botão de exportar na barra de ações
2945
+ customActions: [
2946
+ {
2947
+ label: 'Exportar',
2948
+ icon: Download as any,
2949
+ variant: 'outline' as const,
2950
+ action: handleExport
2951
+ }
2952
+ ],
2953
+
2954
+ enableBulkActions: true
2955
+ },
2956
+ onSave: handleSave,
2957
+ onToggleStatus: handleToggleStatus
2958
+ });
2959
+ ```
2960
+
2961
+ ---
2962
+
2963
+ #### **🎯 Tipos de Filtros Disponíveis**
2964
+
2965
+ ##### **1. Filtro de Busca (Search)**
2966
+ ```typescript
2967
+ filters: [
2968
+ { type: 'search' } // Busca automática nos searchFields do service
2969
+ ]
2970
+ ```
2971
+
2972
+ ##### **2. Filtro Select Nativo**
2973
+ ```typescript
2974
+ filters: [
2975
+ {
2976
+ type: 'select',
2977
+ placeholder: 'Status',
2978
+ value: statusFilter,
2979
+ onChange: setStatusFilter,
2980
+ options: [
2981
+ { value: 'all', label: '[Todos]' },
2982
+ { value: 'active', label: 'Ativo' },
2983
+ { value: 'inactive', label: 'Inativo' }
2984
+ ]
2985
+ }
2986
+ ]
2987
+ ```
2988
+
2989
+ ##### **3. Filtro Customizado (Recomendado)**
2990
+ ```typescript
2991
+ const StatusFilter = () => (
2992
+ <EntitySelect
2993
+ value={String(statusFilter)}
2994
+ onChange={(v) => setStatusFilter(v === 'all' ? 'all' : v === 'true')}
2995
+ items={[
2996
+ { id: 'true', name: 'Ativo' },
2997
+ { id: 'false', name: 'Inativo' },
2998
+ { id: 'all', name: '[Todos]' }
2999
+ ]}
3000
+ getItemValue={(item) => item.id}
3001
+ getItemLabel={(item) => item.name}
3002
+ placeholder="Status"
3003
+ />
3004
+ );
3005
+
3006
+ filters: [
3007
+ { type: 'custom', component: StatusFilter }
3008
+ ]
3009
+ ```
3010
+
3011
+ ---
1738
3012
 
1739
- ```typescript
1740
- customActions: [
1741
- {
1742
- label: 'Exportar Selecionados',
1743
- icon: Download,
1744
- variant: 'outline',
1745
- action: () => {
1746
- if (manager.selectedIds.length === 0) {
1747
- toast({ title: 'Selecione pelo menos um item', variant: 'destructive' });
1748
- return;
1749
- }
1750
-
1751
- const selectedItems = manager.entities.filter(
1752
- item => manager.selectedIds.includes(item.id)
1753
- );
1754
- exportToCSV(selectedItems);
1755
- }
1756
- }
1757
- ]
3013
+ #### **📊 Resultado Visual**
3014
+
3015
+ **Barra de Ações:**
3016
+ ```
3017
+ [ + Novo ] [ 🔽 Exportar ] [ 🔍 Buscar... ] [ Status: Ativo ▼ ]
1758
3018
  ```
1759
3019
 
1760
- **Boas Práticas:**
1761
- -Use `useMemo` para evitar recriação em cada render
1762
- -Use `variant="outline"` para ações secundárias
1763
- -Use ícones do `lucide-react` para consistência visual
1764
- - ✅ Mostre feedback (loading, toasts) para ações assíncronas
1765
- - Evite muitas ações (máximo 3-4 para não poluir a UI)
3020
+ **Ao clicar em "Exportar":**
3021
+ 1.Toast: "Exportando... Buscando todos os registros"
3022
+ 2.Download automático: `exemplos_2025-01-21.csv`
3023
+ 3.Toast: "43 registros exportados com sucesso"
3024
+
3025
+ **Conteúdo do CSV:**
3026
+ ```csv
3027
+ Título,Responsável,Status,Link,Descrição,Cor,Ícone,Atualizado em
3028
+ "Exemplo 1","João Silva","Ativo","https://...","Descrição exemplo","#3b82f6","Star","21/01/2025 14:30"
3029
+ "Exemplo 2","Maria Santos","Inativo","","Outro exemplo","#f59e0b","Heart","20/01/2025 09:15"
3030
+ ```
3031
+
3032
+ ---
3033
+
3034
+ #### **🔥 Boas Práticas**
3035
+
3036
+ **✅ FAZER:**
3037
+ - Exportar via `service.getAll()` para buscar todos os registros
3038
+ - Aplicar mesmos filtros na exportação
3039
+ - Escapar aspas duplas: `cell.replace(/"/g, '""')`
3040
+ - Adicionar BOM Unicode: `\ufeff` para acentos
3041
+ - Mostrar feedback (toast) durante exportação
3042
+ - Usar `async/await` para exportação
3043
+
3044
+ **❌ NÃO FAZER:**
3045
+ - Exportar apenas `manager.entities` (só dados da página atual)
3046
+ - Ignorar filtros aplicados ao exportar
3047
+ - Esquecer de tratar erros
3048
+ - Exportar sem feedback visual
3049
+
3050
+ ---
1766
3051
 
1767
3052
  ---
1768
3053
 
@@ -3400,339 +4685,6 @@ headers: { 'un-alias': 'true' }
3400
4685
 
3401
4686
  ---
3402
4687
 
3403
- ## 📍 PLACES - Locais e Sublocais
3404
-
3405
- O módulo **Places** permite gerenciar a hierarquia de locais e sublocais da organização, integrando com a API Qualiex.
3406
-
3407
- ### 🔌 Imports Disponíveis
3408
-
3409
- ```typescript
3410
- // Tipos
3411
- import type { Place, SubPlace } from 'forlogic-core';
3412
-
3413
- // Serviço
3414
- import { placeService, PlaceService } from 'forlogic-core';
3415
-
3416
- // Componente de Página Pronta
3417
- import { PlacesPage } from 'forlogic-core';
3418
- ```
3419
-
3420
- ### 📋 Estrutura dos Dados
3421
-
3422
- ```typescript
3423
- interface Place {
3424
- id: string;
3425
- placeId: string; // ID único do local no Qualiex
3426
- name: string; // Nome do local
3427
- companyId: string; // ID da empresa
3428
- usersIds: string[]; // Array de IDs de usuários vinculados
3429
- subPlaces?: SubPlace[]; // Sublocais (hierarquia)
3430
- parentId?: string | null; // ID do local pai (se for sublocalizado)
3431
- isActive: boolean; // Status do local
3432
- createdAt: string;
3433
- updatedAt: string;
3434
- }
3435
-
3436
- interface SubPlace {
3437
- id: string;
3438
- placeId: string;
3439
- name: string;
3440
- parentId: string;
3441
- usersIds: string[];
3442
- isActive: boolean;
3443
- subPlaces?: SubPlace[]; // Recursivo - permite múltiplos níveis
3444
- }
3445
- ```
3446
-
3447
- ### 🎯 Como Obter Places
3448
-
3449
- #### Método 1 (Recomendado): Hook com React Query
3450
-
3451
- ```typescript
3452
- import { useQuery } from '@tanstack/react-query';
3453
- import { useAuth, placeService } from 'forlogic-core';
3454
-
3455
- function MyComponent() {
3456
- const { alias } = useAuth();
3457
-
3458
- const { data: places = [], isLoading, error } = useQuery({
3459
- queryKey: ['places', alias],
3460
- queryFn: () => placeService.getPlaces(alias),
3461
- enabled: !!alias,
3462
- staleTime: 5 * 60 * 1000 // Cache de 5 minutos
3463
- });
3464
-
3465
- if (isLoading) return <LoadingState />;
3466
- if (error) return <div>Erro ao carregar locais</div>;
3467
-
3468
- return (
3469
- <div>
3470
- {places.map(place => (
3471
- <div key={place.id}>{place.name}</div>
3472
- ))}
3473
- </div>
3474
- );
3475
- }
3476
- ```
3477
-
3478
- #### Método 2: Hook Customizado Reutilizável
3479
-
3480
- ```typescript
3481
- // src/hooks/usePlaces.ts
3482
- import { useQuery } from '@tanstack/react-query';
3483
- import { useAuth, placeService } from 'forlogic-core';
3484
-
3485
- export function usePlaces() {
3486
- const { alias } = useAuth();
3487
-
3488
- return useQuery({
3489
- queryKey: ['places', alias],
3490
- queryFn: () => placeService.getPlaces(alias),
3491
- enabled: !!alias,
3492
- staleTime: 5 * 60 * 1000 // Cache de 5 minutos
3493
- });
3494
- }
3495
-
3496
- // Usar no componente
3497
- const { data: places = [], isLoading } = usePlaces();
3498
- ```
3499
-
3500
- #### Método 3: Chamada Direta (Service)
3501
-
3502
- ```typescript
3503
- // Para casos especiais (não recomendado para components)
3504
- const places = await placeService.getPlaces('my-alias');
3505
- ```
3506
-
3507
- ### 🏠 Usando PlacesPage Pronta
3508
-
3509
- ```typescript
3510
- // App.tsx ou routes
3511
- import { PlacesPage } from 'forlogic-core';
3512
-
3513
- <Route path="/places" element={<PlacesPage />} />
3514
- ```
3515
-
3516
- ### 🔗 Integrando Places em Módulos CRUD
3517
-
3518
- #### Cenário A: PlaceSelect em Formulários
3519
-
3520
- ```typescript
3521
- // src/components/PlaceSelect.tsx
3522
- import { EntitySelect } from 'forlogic-core';
3523
- import { usePlaces } from '@/hooks/usePlaces';
3524
- import { useMemo } from 'react';
3525
-
3526
- export function PlaceSelect({ value, onChange, disabled }: {
3527
- value?: string;
3528
- onChange: (value: string) => void;
3529
- disabled?: boolean;
3530
- }) {
3531
- const { data: places = [], isLoading } = usePlaces();
3532
-
3533
- // Achatar hierarquia para o select
3534
- const flatPlaces = useMemo(() => {
3535
- const flatten = (items: Place[], level = 0): any[] => {
3536
- return items.flatMap(place => [
3537
- { ...place, level },
3538
- ...flatten(place.subPlaces || [], level + 1)
3539
- ]);
3540
- };
3541
- return flatten(places);
3542
- }, [places]);
3543
-
3544
- return (
3545
- <EntitySelect
3546
- value={value}
3547
- onChange={onChange}
3548
- items={flatPlaces}
3549
- isLoading={isLoading}
3550
- getItemValue={(p) => p.placeId}
3551
- getItemLabel={(p) => `${' '.repeat(p.level)}${p.name}`}
3552
- disabled={disabled}
3553
- placeholder="Selecionar local"
3554
- />
3555
- );
3556
- }
3557
-
3558
- // Usar no CRUD config
3559
- {
3560
- key: 'place_id',
3561
- label: 'Local',
3562
- type: 'custom',
3563
- component: PlaceSelect,
3564
- required: true
3565
- }
3566
- ```
3567
-
3568
- #### Cenário B: Filtrar Dados por Place
3569
-
3570
- ```typescript
3571
- // Service com filtro de placeId
3572
- const { service, useCrudHook } = createSimpleService({
3573
- tableName: 'my_table',
3574
- schemaName: 'central',
3575
- additionalFilters: [
3576
- { field: 'place_id', operator: 'eq', value: selectedPlaceId }
3577
- ]
3578
- });
3579
- ```
3580
-
3581
- #### Cenário C: Exibir Nome do Local em Tabelas
3582
-
3583
- ```typescript
3584
- // Hook para buscar nome do place
3585
- function usePlaceName(placeId: string) {
3586
- const { data: places = [] } = usePlaces();
3587
-
3588
- return useMemo(() => {
3589
- const findPlace = (items: Place[]): Place | undefined => {
3590
- for (const place of items) {
3591
- if (place.placeId === placeId) return place;
3592
- if (place.subPlaces) {
3593
- const found = findPlace(place.subPlaces);
3594
- if (found) return found;
3595
- }
3596
- }
3597
- };
3598
- return findPlace(places)?.name || 'Local não encontrado';
3599
- }, [places, placeId]);
3600
- }
3601
-
3602
- // Usar na coluna da tabela
3603
- {
3604
- key: 'place_id',
3605
- header: 'Local',
3606
- render: (item) => {
3607
- const placeName = usePlaceName(item.place_id);
3608
- return <span>{placeName}</span>;
3609
- }
3610
- }
3611
- ```
3612
-
3613
- ### 🔑 Acessando placeId/placeName dos Tokens
3614
-
3615
- **⚠️ IMPORTANTE:** `placeId` e `placeName` **NÃO** vêm diretamente dos tokens JWT. Eles são obtidos da **API Qualiex**.
3616
-
3617
- ```typescript
3618
- // ❌ ERRADO - Não existe no token
3619
- const { placeId } = useAuth(); // undefined
3620
-
3621
- // ✅ CORRETO - Buscar da API Qualiex
3622
- const { data: places } = usePlaces();
3623
- const userPlace = places.find(p => p.usersIds.includes(userId));
3624
- const placeId = userPlace?.placeId;
3625
- const placeName = userPlace?.name;
3626
- ```
3627
-
3628
- **Fluxo de dados:**
3629
-
3630
- 1. Token JWT contém `alias` e `companyId`
3631
- 2. `placeService.getPlaces(alias)` busca os Places da API Qualiex
3632
- 3. Cada `Place` contém `usersIds` (array de IDs de usuários)
3633
- 4. Relacionar usuário logado com seu Place através de `usersIds`
3634
-
3635
- ### 🌳 Navegação Hierárquica (Tree View)
3636
-
3637
- ```typescript
3638
- function PlaceTree({ places, level = 0 }: {
3639
- places: Place[];
3640
- level?: number;
3641
- }) {
3642
- return (
3643
- <div>
3644
- {places.map(place => (
3645
- <div key={place.id}>
3646
- <div style={{ paddingLeft: `${level * 20}px` }}>
3647
- 📍 {place.name} ({place.usersIds.length} usuários)
3648
- {!place.isActive && <Badge variant="secondary">Inativo</Badge>}
3649
- </div>
3650
- {place.subPlaces && place.subPlaces.length > 0 && (
3651
- <PlaceTree places={place.subPlaces} level={level + 1} />
3652
- )}
3653
- </div>
3654
- ))}
3655
- </div>
3656
- );
3657
- }
3658
- ```
3659
-
3660
- ### 🛠️ Troubleshooting
3661
-
3662
- | Erro | Causa | Solução |
3663
- | ----------------------------------- | ------------------------------------- | ---------------------------------------------- |
3664
- | `CompanyId não encontrado no token` | Token não validado corretamente | Verificar edge function `validate-token` |
3665
- | `Alias da unidade é obrigatório` | `alias` não disponível no `useAuth()` | Aguardar carregamento do auth com `isLoading` |
3666
- | `Token Qualiex não encontrado` | Variável de ambiente faltando | Verificar `VITE_QUALIEX_API_URL` no `.env` |
3667
- | Places retorna array vazio `[]` | Empresa sem places cadastrados | Verificar cadastro no Qualiex admin |
3668
- | Hierarquia quebrada | `parentId` incorreto nos dados | Verificar integridade dos dados na API Qualiex |
3669
-
3670
- ### 📦 Exemplo Completo: Dashboard por Local
3671
-
3672
- ```typescript
3673
- import { usePlaces } from '@/hooks/usePlaces';
3674
- import { Card, CardHeader, CardTitle, CardContent } from 'forlogic-core';
3675
-
3676
- function PlacesDashboard() {
3677
- const { data: places = [], isLoading } = usePlaces();
3678
- const { data: metrics = [] } = useQuery({
3679
- queryKey: ['metrics'],
3680
- queryFn: fetchMetrics
3681
- });
3682
-
3683
- if (isLoading) return <LoadingState />;
3684
-
3685
- return (
3686
- <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
3687
- {places.map(place => {
3688
- const placeMetrics = metrics.filter(m => m.place_id === place.placeId);
3689
-
3690
- return (
3691
- <Card key={place.id}>
3692
- <CardHeader>
3693
- <CardTitle>{place.name}</CardTitle>
3694
- </CardHeader>
3695
- <CardContent>
3696
- <div className="space-y-2">
3697
- <p className="text-sm">
3698
- 👥 Usuários: <strong>{place.usersIds.length}</strong>
3699
- </p>
3700
- <p className="text-sm">
3701
- 📊 Registros: <strong>{placeMetrics.length}</strong>
3702
- </p>
3703
- <p className="text-sm">
3704
- 📍 Sublocais: <strong>{place.subPlaces?.length || 0}</strong>
3705
- </p>
3706
- </div>
3707
- </CardContent>
3708
- </Card>
3709
- );
3710
- })}
3711
- </div>
3712
- );
3713
- }
3714
- ```
3715
-
3716
- ### ✅ Checklist de Implementação
3717
-
3718
- - [ ] `VITE_QUALIEX_API_URL` configurada no `.env`
3719
- - [ ] Edge function `validate-token` retorna `company_id` corretamente
3720
- - [ ] `alias` disponível no `useAuth()`
3721
- - [ ] Hook `usePlaces()` criado e testado
3722
- - [ ] `PlaceSelect` component criado (se necessário)
3723
- - [ ] Tratamento de erro quando places vazio
3724
- - [ ] Cache configurado no React Query (`staleTime`)
3725
- - [ ] Hierarquia renderizada corretamente (se usar tree view)
3726
-
3727
- ### 📚 Referências
3728
-
3729
- - **Tipos:** `lib/qualiex/places/types.ts`
3730
- - **Service:** `lib/qualiex/places/PlaceService.ts`
3731
- - **Componente:** `lib/qualiex/places/PlacesPage.tsx`
3732
- - **Exports:** `lib/modular.ts` e `lib/exports/integrations.ts`
3733
- - **Token Manager:** `lib/auth/services/TokenManager.ts`
3734
-
3735
- ---
3736
4688
 
3737
4689
  ## 🗃️ MIGRATIONS + RLS
3738
4690
 
@@ -4256,6 +5208,195 @@ const columns: CrudColumn<MyEntity>[] = [
4256
5208
 
4257
5209
  ---
4258
5210
 
5211
+ ## 📧 ENVIO DE EMAILS
5212
+
5213
+ O `forlogic-core` fornece um serviço padronizado para envio de emails usando templates cadastrados no banco de dados.
5214
+
5215
+ ### **Como Funciona**
5216
+
5217
+ 1. **Template no banco**: Cadastre templates na tabela `common.email_templates` com variáveis usando `{{variableName}}`
5218
+ 2. **Envio via código**: Use `emailService.sendEmail()` passando o código do template e as variáveis
5219
+ 3. **Edge Function**: Automaticamente busca o template, substitui as variáveis e envia via SMTP
5220
+ 4. **Logs**: Registra todos os envios em `common.email_logs`
5221
+
5222
+ ### **Uso Básico**
5223
+
5224
+ ```typescript
5225
+ import { emailService } from 'forlogic-core';
5226
+
5227
+ // Enviar email de boas-vindas
5228
+ await emailService.sendEmail({
5229
+ templateCode: 'WELCOME',
5230
+ to: 'usuario@exemplo.com',
5231
+ variables: {
5232
+ userName: 'João Silva',
5233
+ activationLink: 'https://app.com/activate/abc123',
5234
+ companyName: 'Minha Empresa'
5235
+ }
5236
+ });
5237
+
5238
+ // Enviar para múltiplos destinatários
5239
+ await emailService.sendEmail({
5240
+ templateCode: 'NOTIFICATION',
5241
+ to: ['usuario1@exemplo.com', 'usuario2@exemplo.com'],
5242
+ variables: {
5243
+ title: 'Novo documento disponível',
5244
+ message: 'Um documento foi compartilhado com você',
5245
+ actionUrl: 'https://app.com/docs/123',
5246
+ actionLabel: 'Ver Documento'
5247
+ },
5248
+ cc: ['gestor@exemplo.com'],
5249
+ replyTo: 'suporte@empresa.com'
5250
+ });
5251
+ ```
5252
+
5253
+ ### **Parâmetros do emailService.sendEmail()**
5254
+
5255
+ ```typescript
5256
+ interface SendEmailParams {
5257
+ templateCode: string; // Código do template no banco (ex: 'WELCOME')
5258
+ to: string | string[]; // Email(s) do(s) destinatário(s)
5259
+ variables: Record<string, any>; // Variáveis para substituir no template
5260
+ cc?: string[]; // Emails em cópia (opcional)
5261
+ bcc?: string[]; // Emails em cópia oculta (opcional)
5262
+ replyTo?: string; // Email para resposta (opcional)
5263
+ }
5264
+ ```
5265
+
5266
+ ### **Criando Templates no Banco**
5267
+
5268
+ Os templates devem ser cadastrados na tabela `common.email_templates`:
5269
+
5270
+ ```sql
5271
+ INSERT INTO common.email_templates (
5272
+ alias,
5273
+ template_code,
5274
+ template_name,
5275
+ subject,
5276
+ html_body,
5277
+ is_active
5278
+ ) VALUES (
5279
+ 'minha_empresa',
5280
+ 'WELCOME',
5281
+ 'Email de Boas-vindas',
5282
+ 'Bem-vindo, {{userName}}!',
5283
+ '<h1>Olá, {{userName}}!</h1>
5284
+ <p>Bem-vindo à {{companyName}}!</p>
5285
+ <a href="{{activationLink}}">Ativar Conta</a>',
5286
+ true
5287
+ );
5288
+ ```
5289
+
5290
+ ### **Variáveis nos Templates**
5291
+
5292
+ Use a sintaxe `{{variableName}}` tanto no subject quanto no html_body:
5293
+
5294
+ ```html
5295
+ <!-- Exemplo de template HTML -->
5296
+ <div style="font-family: Arial, sans-serif;">
5297
+ <h1>Olá, {{userName}}!</h1>
5298
+ <p>{{message}}</p>
5299
+
5300
+ {{#if actionUrl}}
5301
+ <a href="{{actionUrl}}" style="background: #0066cc; color: white; padding: 10px 20px;">
5302
+ {{actionLabel}}
5303
+ </a>
5304
+ {{/if}}
5305
+
5306
+ <p>Atenciosamente,<br>{{companyName}}</p>
5307
+ </div>
5308
+ ```
5309
+
5310
+ ### **Tratamento de Erros**
5311
+
5312
+ ```typescript
5313
+ try {
5314
+ await emailService.sendEmail({
5315
+ templateCode: 'NOTIFICATION',
5316
+ to: 'usuario@exemplo.com',
5317
+ variables: { title: 'Teste', message: 'Mensagem' }
5318
+ });
5319
+
5320
+ toast.success('Email enviado com sucesso!');
5321
+ } catch (error) {
5322
+ console.error('Erro ao enviar email:', error);
5323
+ toast.error('Erro ao enviar email. Verifique as configurações SMTP.');
5324
+ }
5325
+ ```
5326
+
5327
+ ### **Requisitos**
5328
+
5329
+ 1. **Edge Function**: A edge function `send-email` deve estar configurada
5330
+ 2. **Secrets SMTP**: Configure as secrets no Supabase:
5331
+ - `SMTP_HOST` - Servidor SMTP (ex: smtp.gmail.com)
5332
+ - `SMTP_PORT` - Porta SMTP (ex: 587)
5333
+ - `SMTP_USER` - Usuário SMTP
5334
+ - `SMTP_PASSWORD` - Senha SMTP
5335
+ - `SMTP_FROM` - Email remetente (ex: noreply@empresa.com)
5336
+
5337
+ 3. **Tabelas**:
5338
+ - `common.email_templates` - Armazena os templates
5339
+ - `common.email_logs` - Registra os envios
5340
+
5341
+ ### **Exemplo Completo: Notificação de Mudança de Liderança**
5342
+
5343
+ ```typescript
5344
+ import { emailService } from 'forlogic-core';
5345
+ import { toast } from 'sonner';
5346
+
5347
+ async function notifyLeadershipChange(
5348
+ employeeEmail: string,
5349
+ employeeName: string,
5350
+ newLeaderName: string,
5351
+ oldLeaderName?: string
5352
+ ) {
5353
+ try {
5354
+ await emailService.sendEmail({
5355
+ templateCode: 'LEADERSHIP_CHANGE',
5356
+ to: employeeEmail,
5357
+ variables: {
5358
+ employeeName,
5359
+ newLeaderName,
5360
+ oldLeaderName: oldLeaderName || 'Nenhum líder anterior',
5361
+ companyName: 'ForLogic',
5362
+ changeDate: new Date().toLocaleDateString('pt-BR')
5363
+ }
5364
+ });
5365
+
5366
+ console.log(`Email de mudança de liderança enviado para ${employeeName}`);
5367
+ } catch (error) {
5368
+ console.error('Erro ao enviar email de liderança:', error);
5369
+ throw error;
5370
+ }
5371
+ }
5372
+
5373
+ // Uso
5374
+ await notifyLeadershipChange(
5375
+ 'joao@empresa.com',
5376
+ 'João Silva',
5377
+ 'Maria Santos',
5378
+ 'Pedro Costa'
5379
+ );
5380
+ ```
5381
+
5382
+ ### **Boas Práticas**
5383
+
5384
+ **✅ FAZER:**
5385
+ - Cadastrar todos os templates no banco com `is_active = true`
5386
+ - Usar nomes de variáveis descritivos (ex: `userName`, `actionUrl`)
5387
+ - Tratar erros de envio com try/catch
5388
+ - Validar emails antes de enviar
5389
+ - Logar envios importantes para auditoria
5390
+
5391
+ **❌ NÃO FAZER:**
5392
+ - Hardcodar templates no código
5393
+ - Enviar sem validar variáveis obrigatórias
5394
+ - Ignorar erros de envio
5395
+ - Usar variáveis não declaradas no template
5396
+ - Enviar emails sem confirmação do usuário (quando necessário)
5397
+
5398
+ ---
5399
+
4259
5400
  ## 📚 REFERÊNCIA RÁPIDA
4260
5401
 
4261
5402
  ### Imports Essenciais