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.
This commit is contained in:
NANDO9322 2026-02-23 22:16:53 -03:00
parent a8230769e6
commit b4b2f536f1
3 changed files with 160 additions and 23 deletions

View file

@ -1,6 +1,7 @@
import React, { createContext, useContext, useState, ReactNode, useEffect } from "react"; import React, { createContext, useContext, useState, ReactNode, useEffect } from "react";
import { useAuth } from "./AuthContext"; 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 { 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 { import {
EventData, EventData,
EventStatus, EventStatus,
@ -621,6 +622,7 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({
children, children,
}) => { }) => {
const { token, user } = useAuth(); // Consume Auth Context const { token, user } = useAuth(); // Consume Auth Context
const { currentRegion, isRegionReady } = useRegion();
const [events, setEvents] = useState<EventData[]>([]); const [events, setEvents] = useState<EventData[]>([]);
const [institutions, setInstitutions] = const [institutions, setInstitutions] =
useState<Institution[]>(INITIAL_INSTITUTIONS); useState<Institution[]>(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) // 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"); const visibleToken = token || localStorage.getItem("token");
if (visibleToken) { if (visibleToken && isRegionReady) {
setIsLoading(true); setIsLoading(true);
try { try {
// Import dynamic to avoid circular dependency if any, or just use imported service // 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(); fetchEvents();
}, [token, refreshTrigger]); // React to token change and manual refresh }, [token, refreshTrigger, currentRegion, isRegionReady]); // React to context changes
const refreshEvents = async () => { const refreshEvents = async () => {
setRefreshTrigger(prev => prev + 1); setRefreshTrigger(prev => prev + 1);
@ -795,7 +797,7 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({
useEffect(() => { useEffect(() => {
const fetchProfs = async () => { const fetchProfs = async () => {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
if (token) { if (token && isRegionReady) {
try { try {
const result = await getProfessionals(token); const result = await getProfessionals(token);
if (result.data) { if (result.data) {
@ -850,7 +852,7 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({
} }
}; };
fetchProfs(); fetchProfs();
}, [token]); }, [token, currentRegion, isRegionReady]);
const addEvent = async (event: any) => { const addEvent = async (event: any) => {
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");

View file

@ -7,12 +7,14 @@ interface RegionContextType {
currentRegion: string; currentRegion: string;
setRegion: (region: string) => void; setRegion: (region: string) => void;
availableRegions: string[]; availableRegions: string[];
isRegionReady: boolean;
} }
const RegionContext = createContext<RegionContextType>({ const RegionContext = createContext<RegionContextType>({
currentRegion: "SP", currentRegion: "SP",
setRegion: () => {}, setRegion: () => {},
availableRegions: [], availableRegions: [],
isRegionReady: false,
}); });
export const RegionProvider: React.FC<{ children: React.ReactNode }> = ({ 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. // Let's assume public = SP only or no switcher.
// BUT: If user is logged out, they shouldn't see switcher anyway. // BUT: If user is logged out, they shouldn't see switcher anyway.
const [availableRegions, setAvailableRegions] = useState<string[]>(["SP"]); const [availableRegions, setAvailableRegions] = useState<string[]>(["SP"]);
const [isRegionReady, setIsRegionReady] = useState(false);
useEffect(() => { useEffect(() => {
console.log("RegionContext Debug:", { user, allowedRegions: user?.allowedRegions }); 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) { if (user && user.allowedRegions && user.allowedRegions.length > 0) {
setAvailableRegions(user.allowedRegions); setAvailableRegions(user.allowedRegions);
@ -49,7 +60,9 @@ export const RegionProvider: React.FC<{ children: React.ReactNode }> = ({
// Fallback or Public // Fallback or Public
setAvailableRegions(["SP"]); setAvailableRegions(["SP"]);
} }
}, [user, user?.allowedRegions, currentRegion]);
setIsRegionReady(true);
}, [user, currentRegion]);
useEffect(() => { useEffect(() => {
localStorage.setItem(REGION_KEY, currentRegion); localStorage.setItem(REGION_KEY, currentRegion);
@ -67,7 +80,7 @@ export const RegionProvider: React.FC<{ children: React.ReactNode }> = ({
return ( return (
<RegionContext.Provider <RegionContext.Provider
value={{ currentRegion, setRegion, availableRegions }} value={{ currentRegion, setRegion, availableRegions, isRegionReady }}
> >
{children} {children}
</RegionContext.Provider> </RegionContext.Provider>

View file

@ -31,6 +31,21 @@ const ProfessionalStatement: React.FC = () => {
const [selectedTransaction, setSelectedTransaction] = useState<FinancialTransactionDTO | null>(null); const [selectedTransaction, setSelectedTransaction] = useState<FinancialTransactionDTO | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false); 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(() => { useEffect(() => {
if (token) { if (token) {
fetchStatement(); 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) { if (loading) {
return <div className="p-8 text-center text-gray-500">Carregando extrato...</div>; return <div className="p-8 text-center text-gray-500">Carregando extrato...</div>;
} }
@ -126,36 +178,103 @@ const ProfessionalStatement: React.FC = () => {
<div className="bg-white rounded-lg shadow-sm border border-gray-100 overflow-hidden"> <div className="bg-white rounded-lg shadow-sm border border-gray-100 overflow-hidden">
<div className="p-6 border-b border-gray-100 flex justify-between items-center"> <div className="p-6 border-b border-gray-100 flex justify-between items-center">
<h2 className="text-lg font-bold text-gray-900">Histórico de Pagamentos</h2> <h2 className="text-lg font-bold text-gray-900">Histórico de Pagamentos</h2>
<button className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-gray-50 hover:bg-gray-100 rounded-md transition-colors"> <div className="flex gap-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <button
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /> onClick={() => setShowDateFilters(!showDateFilters)}
</svg> className="text-sm text-gray-600 hover:text-gray-900 flex items-center gap-2"
Exportar >
</button> {showDateFilters ? "▼" : "▶"} Filtros Avançados de Data
</button>
</div>
</div> </div>
{/* Advanced Date Filters */}
{showDateFilters && (
<div className="bg-white border-b border-gray-100 p-4 grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Data Início</label>
<input
type="date"
className="w-full border rounded px-3 py-2 text-sm"
value={dateFilters.startDate}
onChange={e => setDateFilters({...dateFilters, startDate: e.target.value})}
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Data Final</label>
<input
type="date"
className="w-full border rounded px-3 py-2 text-sm"
value={dateFilters.endDate}
onChange={e => setDateFilters({...dateFilters, endDate: e.target.value})}
/>
</div>
{(dateFilters.startDate || dateFilters.endDate) && (
<div className="col-span-1 md:col-span-2 flex justify-end">
<button
onClick={() => setDateFilters({ startDate: "", endDate: "" })}
className="text-sm text-red-600 hover:text-red-800 flex items-center gap-1"
>
Limpar Filtros de Data
</button>
</div>
)}
</div>
)}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm text-left"> <table className="w-full text-sm text-left">
<thead className="bg-gray-50 text-gray-500 font-medium"> <thead className="bg-gray-50 text-gray-500 font-medium">
<tr> <tr>
<th className="px-6 py-4">Data Evento</th> <th className="px-6 py-4">
<th className="px-6 py-4">Nome Evento</th> <div className="flex flex-col gap-1">
<th className="px-6 py-4">Tipo Evento</th> <span>Data Evento</span>
<th className="px-6 py-4">Empresa</th> <input className="w-full text-xs box-border border border-gray-200 rounded px-2 py-1 font-normal text-gray-900" placeholder="Filtrar" value={filters.data} onChange={e => setFilters({...filters, data: e.target.value})} />
<th className="px-6 py-4">Valor Recebido</th> </div>
<th className="px-6 py-4">Data Pagamento</th> </th>
<th className="px-6 py-4 text-center">Status</th> <th className="px-6 py-4">
<div className="flex flex-col gap-1">
<span>Nome Evento</span>
<input className="w-full text-xs box-border border border-gray-200 rounded px-2 py-1 font-normal text-gray-900" placeholder="Filtrar" value={filters.nome} onChange={e => setFilters({...filters, nome: e.target.value})} />
</div>
</th>
<th className="px-6 py-4">
<div className="flex flex-col gap-1">
<span>Tipo Evento</span>
<input className="w-full text-xs box-border border border-gray-200 rounded px-2 py-1 font-normal text-gray-900" placeholder="Filtrar" value={filters.tipo} onChange={e => setFilters({...filters, tipo: e.target.value})} />
</div>
</th>
<th className="px-6 py-4">
<div className="flex flex-col gap-1">
<span>Empresa</span>
<input className="w-full text-xs box-border border border-gray-200 rounded px-2 py-1 font-normal text-gray-900" placeholder="Filtrar" value={filters.empresa} onChange={e => setFilters({...filters, empresa: e.target.value})} />
</div>
</th>
<th className="px-6 py-4 align-top">
<div className="whitespace-nowrap pt-1">Valor Recebido</div>
</th>
<th className="px-6 py-4 align-top">
<div className="whitespace-nowrap pt-1">Data Pagamento</div>
</th>
<th className="px-6 py-4 text-center">
<div className="flex flex-col gap-1 justify-center items-center">
<span>Status</span>
<input className="w-20 text-xs box-border border border-gray-200 rounded px-2 py-1 font-normal text-gray-900 text-center" placeholder="Pago..." value={filters.status} onChange={e => setFilters({...filters, status: e.target.value})} />
</div>
</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-100"> <tbody className="divide-y divide-gray-100">
{(data.transactions || []).length === 0 ? ( {filteredTransactions.length === 0 ? (
<tr> <tr>
<td colSpan={7} className="px-6 py-8 text-center text-gray-500"> <td colSpan={7} className="px-6 py-8 text-center text-gray-500">
Nenhum pagamento registrado. Nenhum pagamento encontrado para os filtros.
</td> </td>
</tr> </tr>
) : ( ) : (
(data.transactions || []).map((t) => ( filteredTransactions.map((t) => (
<tr <tr
key={t.id} key={t.id}
className="hover:bg-gray-50 transition-colors cursor-pointer" className="hover:bg-gray-50 transition-colors cursor-pointer"
@ -179,7 +298,10 @@ const ProfessionalStatement: React.FC = () => {
<tfoot className="bg-gray-50"> <tfoot className="bg-gray-50">
<tr> <tr>
<td colSpan={7} className="px-6 py-3 text-xs text-gray-500"> <td colSpan={7} className="px-6 py-3 text-xs text-gray-500">
Total de pagamentos: {(data.transactions || []).length} <div className="flex justify-between items-center w-full">
<span>Total filtrado: {filteredTransactions.length}</span>
<span className="font-bold text-gray-900">Soma Agrupada: {formatCurrency(filteredTotalSum)}</span>
</div>
</td> </td>
</tr> </tr>
</tfoot> </tfoot>