From a8230769e647f577a833b6a61bba9abb614020b4 Mon Sep 17 00:00:00 2001 From: NANDO9322 Date: Mon, 23 Feb 2026 20:33:36 -0300 Subject: [PATCH 1/2] feat(notificacoes): envios de whatsapp dinamicos por regiao (SP e MG) Implementa suporte a multiplas instancias da Evolution API via .env. O servico agora verifica a origiem do evento e roteia o disparo para garantir que cada franquia use seu proprio numero comercial. --- backend/cmd/api/main.go | 7 ++++++- backend/internal/agenda/service.go | 6 +++--- backend/internal/config/config.go | 8 ++++++++ backend/internal/notification/service.go | 26 +++++++++++++++--------- 4 files changed, 33 insertions(+), 14 deletions(-) diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index d37e113..9798a11 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -71,7 +71,12 @@ func main() { // Initialize services // Initialize services - notificationService := notification.NewService() + notificationService := notification.NewService( + cfg.EvolutionApiUrl, + cfg.EvolutionApiKey, + cfg.WhatsappInstanceSP, + cfg.WhatsappInstanceMG, + ) profissionaisService := profissionais.NewService(queries) financeService := finance.NewService(queries, profissionaisService) authService := auth.NewService(queries, profissionaisService, cfg) diff --git a/backend/internal/agenda/service.go b/backend/internal/agenda/service.go index d87952d..18028db 100644 --- a/backend/internal/agenda/service.go +++ b/backend/internal/agenda/service.go @@ -448,7 +448,7 @@ func (s *Service) AssignProfessional(ctx context.Context, agendaID uuid.UUID, pr baseUrl, ) - if err := s.notification.SendWhatsApp(prof.Whatsapp.String, msg); err != nil { + if err := s.notification.SendWhatsApp(prof.Whatsapp.String, msg, agenda.Regiao.String); err != nil { log.Printf("[Notification] Falha ao enviar WhatsApp para %s: %v", prof.Nome, err) } }() @@ -702,8 +702,8 @@ func (s *Service) NotifyLogistics(ctx context.Context, agendaID uuid.UUID, passe tipoEventoNome, logisticaMsg, ) - - if err := s.notification.SendWhatsApp(phone, msg); err != nil { + // Passa a agendaRegiao para definir o numero + if err := s.notification.SendWhatsApp(phone, msg, agenda.Regiao.String); err != nil { // Não logar erro para todos se for falha de validação de numero, mas logar warning log.Printf("[Notification] Erro ao enviar para %s: %v", p.Nome, err) } diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 68eacf4..4f454c0 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -24,6 +24,10 @@ type Config struct { S3Bucket string S3Region string FrontendURL string + EvolutionApiUrl string + EvolutionApiKey string + WhatsappInstanceSP string + WhatsappInstanceMG string } func LoadConfig() *Config { @@ -48,6 +52,10 @@ func LoadConfig() *Config { S3Bucket: getEnv("S3_BUCKET", ""), S3Region: getEnv("S3_REGION", "nyc1"), FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"), + EvolutionApiUrl: getEnv("EVOLUTION_API_URL", "https://others-evolution-api.nsowe9.easypanel.host"), + EvolutionApiKey: getEnv("EVOLUTION_API_KEY", "429683C4C977415CAAFCCE10F7D57E11"), + WhatsappInstanceSP: getEnv("WHATSAPP_INSTANCE_SP", "NANDO"), + WhatsappInstanceMG: getEnv("WHATSAPP_INSTANCE_MG", "NANDO"), } } diff --git a/backend/internal/notification/service.go b/backend/internal/notification/service.go index 4e1e5d4..7e25ed1 100644 --- a/backend/internal/notification/service.go +++ b/backend/internal/notification/service.go @@ -10,17 +10,18 @@ import ( ) type Service struct { - apiURL string - apiKey string - instance string + apiURL string + apiKey string + instanceSP string + instanceMG string } -func NewService() *Service { - // Hardcoded configuration as per user request +func NewService(apiURL, apiKey, instanceSP, instanceMG string) *Service { return &Service{ - apiURL: "https://others-evolution-api.nsowe9.easypanel.host", - apiKey: "429683C4C977415CAAFCCE10F7D57E11", - instance: "NANDO", + apiURL: apiURL, + apiKey: apiKey, + instanceSP: instanceSP, + instanceMG: instanceMG, } } @@ -29,13 +30,18 @@ type MessageRequest struct { Text string `json:"text"` } -func (s *Service) SendWhatsApp(number string, message string) error { +func (s *Service) SendWhatsApp(number string, message string, regiao string) error { cleanNumber := cleanPhoneNumber(number) if cleanNumber == "" { return fmt.Errorf("número de telefone inválido ou vazio") } - url := fmt.Sprintf("%s/message/sendText/%s", s.apiURL, s.instance) + instance := s.instanceSP // default + if regiao == "MG" { + instance = s.instanceMG + } + + url := fmt.Sprintf("%s/message/sendText/%s", s.apiURL, instance) payload := MessageRequest{ Number: cleanNumber, From b4b2f536f1f2add47db883dafe1334cf1927898d Mon Sep 17 00:00:00 2001 From: NANDO9322 Date: Mon, 23 Feb 2026 22:16:53 -0300 Subject: [PATCH 2/2] feat(notificacoes): envios de whatsapp dinamicos por regiao (SP e MG) Implementa suporte a multiplas instancias da Evolution API via .env. O servico agora verifica a origiem do evento e roteia o disparo para garantir que cada franquia use seu proprio numero comercial. --- frontend/contexts/DataContext.tsx | 10 +- frontend/contexts/RegionContext.tsx | 17 ++- frontend/pages/ProfessionalStatement.tsx | 156 ++++++++++++++++++++--- 3 files changed, 160 insertions(+), 23 deletions(-) diff --git a/frontend/contexts/DataContext.tsx b/frontend/contexts/DataContext.tsx index f8b800d..63784c7 100644 --- a/frontend/contexts/DataContext.tsx +++ b/frontend/contexts/DataContext.tsx @@ -1,6 +1,7 @@ import React, { createContext, useContext, useState, ReactNode, useEffect } from "react"; import { useAuth } from "./AuthContext"; import { getPendingUsers, approveUser as apiApproveUser, getProfessionals, assignProfessional as apiAssignProfessional, removeProfessional as apiRemoveProfessional, updateEventStatus as apiUpdateStatus, updateAssignmentStatus as apiUpdateAssignmentStatus, updateAgenda as apiUpdateAgenda } from "../services/apiService"; +import { useRegion } from "./RegionContext"; import { EventData, EventStatus, @@ -621,6 +622,7 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({ children, }) => { const { token, user } = useAuth(); // Consume Auth Context + const { currentRegion, isRegionReady } = useRegion(); const [events, setEvents] = useState([]); const [institutions, setInstitutions] = useState(INITIAL_INSTITUTIONS); @@ -639,7 +641,7 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({ // Use token from context or fallback to localStorage if context not ready (though context is preferred sources of truth) const visibleToken = token || localStorage.getItem("token"); - if (visibleToken) { + if (visibleToken && isRegionReady) { setIsLoading(true); try { // Import dynamic to avoid circular dependency if any, or just use imported service @@ -747,7 +749,7 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({ } }; fetchEvents(); - }, [token, refreshTrigger]); // React to token change and manual refresh + }, [token, refreshTrigger, currentRegion, isRegionReady]); // React to context changes const refreshEvents = async () => { setRefreshTrigger(prev => prev + 1); @@ -795,7 +797,7 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({ useEffect(() => { const fetchProfs = async () => { const token = localStorage.getItem('token'); - if (token) { + if (token && isRegionReady) { try { const result = await getProfessionals(token); if (result.data) { @@ -850,7 +852,7 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({ } }; fetchProfs(); - }, [token]); + }, [token, currentRegion, isRegionReady]); const addEvent = async (event: any) => { const token = localStorage.getItem("token"); diff --git a/frontend/contexts/RegionContext.tsx b/frontend/contexts/RegionContext.tsx index b7273f8..d615ac2 100644 --- a/frontend/contexts/RegionContext.tsx +++ b/frontend/contexts/RegionContext.tsx @@ -7,12 +7,14 @@ interface RegionContextType { currentRegion: string; setRegion: (region: string) => void; availableRegions: string[]; + isRegionReady: boolean; } const RegionContext = createContext({ currentRegion: "SP", setRegion: () => {}, availableRegions: [], + isRegionReady: false, }); export const RegionProvider: React.FC<{ children: React.ReactNode }> = ({ @@ -32,9 +34,18 @@ export const RegionProvider: React.FC<{ children: React.ReactNode }> = ({ // Let's assume public = SP only or no switcher. // BUT: If user is logged out, they shouldn't see switcher anyway. const [availableRegions, setAvailableRegions] = useState(["SP"]); + const [isRegionReady, setIsRegionReady] = useState(false); useEffect(() => { console.log("RegionContext Debug:", { user, allowedRegions: user?.allowedRegions }); + // If not logged in or user still fetching, wait (but if public page, we could mark ready. For now, mark ready if no token or after user loads) + const token = localStorage.getItem("token"); + if (token && !user) { + // Wait for user to load to evaluate regions + setIsRegionReady(false); + return; + } + if (user && user.allowedRegions && user.allowedRegions.length > 0) { setAvailableRegions(user.allowedRegions); @@ -49,7 +60,9 @@ export const RegionProvider: React.FC<{ children: React.ReactNode }> = ({ // Fallback or Public setAvailableRegions(["SP"]); } - }, [user, user?.allowedRegions, currentRegion]); + + setIsRegionReady(true); + }, [user, currentRegion]); useEffect(() => { localStorage.setItem(REGION_KEY, currentRegion); @@ -67,7 +80,7 @@ export const RegionProvider: React.FC<{ children: React.ReactNode }> = ({ return ( {children} diff --git a/frontend/pages/ProfessionalStatement.tsx b/frontend/pages/ProfessionalStatement.tsx index 0879e64..d7bfe26 100644 --- a/frontend/pages/ProfessionalStatement.tsx +++ b/frontend/pages/ProfessionalStatement.tsx @@ -31,6 +31,21 @@ const ProfessionalStatement: React.FC = () => { const [selectedTransaction, setSelectedTransaction] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); + // Filter States + const [filters, setFilters] = useState({ + data: "", + nome: "", + tipo: "", + empresa: "", + status: "" + }); + + const [dateFilters, setDateFilters] = useState({ + startDate: "", + endDate: "" + }); + const [showDateFilters, setShowDateFilters] = useState(false); + useEffect(() => { if (token) { fetchStatement(); @@ -82,6 +97,43 @@ const ProfessionalStatement: React.FC = () => { ); }; + // derived filtered state + const transactions = data?.transactions || []; + const filteredTransactions = transactions.filter(t => { + // String Column Filters + if (filters.data && !t.data_evento.toLowerCase().includes(filters.data.toLowerCase())) return false; + if (filters.nome && !t.nome_evento.toLowerCase().includes(filters.nome.toLowerCase())) return false; + if (filters.tipo && !t.tipo_evento.toLowerCase().includes(filters.tipo.toLowerCase())) return false; + if (filters.empresa && !t.empresa.toLowerCase().includes(filters.empresa.toLowerCase())) return false; + if (filters.status && !t.status.toLowerCase().includes(filters.status.toLowerCase())) return false; + + // Date Range Filter logic + if (dateFilters.startDate || dateFilters.endDate) { + // Parse DD/MM/YYYY into JS Date if possible + const [d, m, y] = t.data_evento.split('/'); + if (d && m && y) { + const eventDateObj = new Date(parseInt(y), parseInt(m) - 1, parseInt(d)); + + if (dateFilters.startDate) { + const [sy, sm, sd] = dateFilters.startDate.split('-'); + const startObj = new Date(parseInt(sy), parseInt(sm) - 1, parseInt(sd)); + if (eventDateObj < startObj) return false; + } + if (dateFilters.endDate) { + const [ey, em, ed] = dateFilters.endDate.split('-'); + const endObj = new Date(parseInt(ey), parseInt(em) - 1, parseInt(ed)); + // Set end of day for precise comparison + endObj.setHours(23, 59, 59, 999); + if (eventDateObj > endObj) return false; + } + } + } + + return true; + }); + + const filteredTotalSum = filteredTransactions.reduce((acc, curr) => acc + curr.valor_recebido, 0); + if (loading) { return
Carregando extrato...
; } @@ -126,36 +178,103 @@ const ProfessionalStatement: React.FC = () => {

Histórico de Pagamentos

- +
+ +
+ + {/* Advanced Date Filters */} + {showDateFilters && ( +
+
+ + setDateFilters({...dateFilters, startDate: e.target.value})} + /> +
+ +
+ + setDateFilters({...dateFilters, endDate: e.target.value})} + /> +
+ + {(dateFilters.startDate || dateFilters.endDate) && ( +
+ +
+ )} +
+ )}
- - - - - - - + + + + + + + - {(data.transactions || []).length === 0 ? ( + {filteredTransactions.length === 0 ? ( ) : ( - (data.transactions || []).map((t) => ( + filteredTransactions.map((t) => ( {
Data EventoNome EventoTipo EventoEmpresaValor RecebidoData PagamentoStatus +
+ Data Evento + setFilters({...filters, data: e.target.value})} /> +
+
+
+ Nome Evento + setFilters({...filters, nome: e.target.value})} /> +
+
+
+ Tipo Evento + setFilters({...filters, tipo: e.target.value})} /> +
+
+
+ Empresa + setFilters({...filters, empresa: e.target.value})} /> +
+
+
Valor Recebido
+
+
Data Pagamento
+
+
+ Status + setFilters({...filters, status: e.target.value})} /> +
+
- Nenhum pagamento registrado. + Nenhum pagamento encontrado para os filtros.
- Total de pagamentos: {(data.transactions || []).length} +
+ Total filtrado: {filteredTransactions.length} + Soma Agrupada: {formatCurrency(filteredTotalSum)} +