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 +1605 -464
- package/dist/README.md +1605 -464
- package/dist/assets/index-2WBeKMgq.css +1 -0
- package/dist/assets/index-Dg0JuUel.js +696 -0
- package/dist/index.css +1 -1
- package/dist/index.css.map +1 -1
- package/dist/index.esm.js +2 -2
- package/dist/index.html +19 -0
- package/dist/index.js +2 -2
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 🧱 Projeto atual
|
|
2
2
|
|
|
3
|
-
**Schema padrão:** `
|
|
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
|
|
619
|
+
#### Exemplos Práticos Completos
|
|
578
620
|
|
|
579
|
-
**MultiSelect
|
|
621
|
+
**Exemplo 1: MultiSelect de Objetivos Estratégicos**
|
|
580
622
|
|
|
581
623
|
```typescript
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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
|
-
**
|
|
748
|
+
**Exemplo 3: MultiSelect de Treinamentos Anuais**
|
|
594
749
|
|
|
595
750
|
```typescript
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1849
|
+
#### 🎯 Regras de Negócio
|
|
1054
1850
|
|
|
1055
|
-
|
|
1851
|
+
> **⚠️ Importante:** O módulo fornece apenas a INTERFACE. A lógica de persistência deve ser implementada pelo projeto consumidor.
|
|
1056
1852
|
|
|
1057
|
-
|
|
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
|
-
|
|
1060
|
-
import {
|
|
1061
|
-
PlaceManagerButton,
|
|
1062
|
-
PlaceManagerBadge,
|
|
1063
|
-
ManagerSelectionDialog,
|
|
1064
|
-
usePlaceManagers,
|
|
1065
|
-
PlaceManagerService,
|
|
1066
|
-
type PlaceManager
|
|
1067
|
-
} from 'forlogic-core';
|
|
1068
|
-
```
|
|
1859
|
+
---
|
|
1069
1860
|
|
|
1070
|
-
####
|
|
1861
|
+
#### 🗄️ Estrutura de Banco Sugerida (Supabase)
|
|
1071
1862
|
|
|
1072
|
-
|
|
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
|
-
|
|
1075
|
-
|
|
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
|
-
|
|
1879
|
+
-- RLS Policies
|
|
1880
|
+
ALTER TABLE public.place_access ENABLE ROW LEVEL SECURITY;
|
|
1083
1881
|
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
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
|
-
|
|
1891
|
+
---
|
|
1092
1892
|
|
|
1093
|
-
|
|
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
|
-
|
|
1895
|
+
**1. Service de Acesso (`src/places/services/PlaceAccessService.ts`):**
|
|
1106
1896
|
|
|
1107
1897
|
```typescript
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
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
|
-
|
|
1957
|
+
**2. Hook Customizado (`src/places/hooks/usePlaceAccess.ts`):**
|
|
1120
1958
|
|
|
1121
1959
|
```typescript
|
|
1122
|
-
import {
|
|
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
|
-
|
|
1125
|
-
const
|
|
1126
|
-
|
|
1127
|
-
|
|
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
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
2016
|
+
return {
|
|
2017
|
+
accessData,
|
|
2018
|
+
setManager: setManagerMutation.mutateAsync,
|
|
2019
|
+
addMembers: addMembersMutation.mutateAsync,
|
|
2020
|
+
};
|
|
2021
|
+
}
|
|
1135
2022
|
```
|
|
1136
2023
|
|
|
1137
|
-
|
|
2024
|
+
**3. Página Final (`src/places/PlacesPage.tsx`):**
|
|
1138
2025
|
|
|
1139
|
-
|
|
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
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
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
|
-
|
|
2043
|
+
const { accessData, setManager, addMembers } = usePlaceAccess();
|
|
2044
|
+
const { data: users = [] } = useQualiexUsers();
|
|
1147
2045
|
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
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
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
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
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
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
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
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
|
-
**
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
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
|