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.
382 lines
18 KiB
TypeScript
382 lines
18 KiB
TypeScript
import React, { useState, useEffect } from "react";
|
|
import { useAuth } from "../contexts/AuthContext";
|
|
import { getProfessionalFinancialStatement } from "../services/apiService";
|
|
import { formatCurrency } from "../utils/format";
|
|
|
|
interface FinancialTransactionDTO {
|
|
id: string;
|
|
data_evento: string;
|
|
nome_evento: string;
|
|
tipo_evento: string;
|
|
empresa: string;
|
|
valor_recebido: number;
|
|
valor_free?: number; // Optional as backend might not have sent it yet if cache/delays
|
|
valor_extra?: number;
|
|
descricao_extra?: string;
|
|
data_pagamento: string;
|
|
status: string;
|
|
}
|
|
|
|
interface FinancialStatementResponse {
|
|
total_recebido: number;
|
|
pagamentos_confirmados: number;
|
|
pagamentos_pendentes: number;
|
|
transactions: FinancialTransactionDTO[];
|
|
}
|
|
|
|
const ProfessionalStatement: React.FC = () => {
|
|
const { user, token } = useAuth();
|
|
const [data, setData] = useState<FinancialStatementResponse | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [selectedTransaction, setSelectedTransaction] = useState<FinancialTransactionDTO | null>(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();
|
|
} else {
|
|
const storedToken = localStorage.getItem("@Auth:token");
|
|
if (storedToken) {
|
|
fetchStatement(storedToken);
|
|
} else {
|
|
console.error("No token found");
|
|
setLoading(false);
|
|
}
|
|
}
|
|
}, [token]);
|
|
|
|
const fetchStatement = async (overrideToken?: string) => {
|
|
try {
|
|
const t = overrideToken || token;
|
|
if (!t) return;
|
|
const response = await getProfessionalFinancialStatement(t);
|
|
if (response.data) {
|
|
setData(response.data);
|
|
} else {
|
|
console.error(response.error);
|
|
}
|
|
} catch (error) {
|
|
console.error("Erro ao buscar extrato:", error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleRowClick = (t: FinancialTransactionDTO) => {
|
|
setSelectedTransaction(t);
|
|
setIsModalOpen(true);
|
|
};
|
|
|
|
const StatusBadge = ({ status }: { status: string }) => {
|
|
const isPaid = status === "Pago";
|
|
return (
|
|
<span
|
|
className={`px-3 py-1 rounded-full text-xs font-semibold ${
|
|
isPaid
|
|
? "bg-green-100 text-green-700"
|
|
: "bg-yellow-100 text-yellow-700"
|
|
}`}
|
|
>
|
|
{status}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
// 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 <div className="p-8 text-center text-gray-500">Carregando extrato...</div>;
|
|
}
|
|
|
|
if (!data) {
|
|
return <div className="p-8 text-center text-gray-500">Nenhum dado encontrado.</div>;
|
|
}
|
|
|
|
return (
|
|
<div className="bg-gray-50 min-h-screen p-4 sm:p-8 font-sans">
|
|
<div className="max-w-7xl mx-auto">
|
|
<header className="mb-8">
|
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">Meus Pagamentos</h1>
|
|
<p className="text-gray-500">
|
|
Visualize todos os pagamentos recebidos pelos eventos fotografados
|
|
</p>
|
|
</header>
|
|
|
|
{/* Summary Cards */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
|
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-100">
|
|
<p className="text-sm text-gray-500 mb-1">Total Recebido</p>
|
|
<p className="text-2xl font-bold text-gray-900">
|
|
{formatCurrency(data.total_recebido)}
|
|
</p>
|
|
</div>
|
|
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-100">
|
|
<p className="text-sm text-gray-500 mb-1">Pagamentos Confirmados</p>
|
|
<p className="text-2xl font-bold text-green-600">
|
|
{formatCurrency(data.pagamentos_confirmados)}
|
|
</p>
|
|
</div>
|
|
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-100">
|
|
<p className="text-sm text-gray-500 mb-1">Pagamentos Pendentes</p>
|
|
<p className="text-2xl font-bold text-yellow-600">
|
|
{formatCurrency(data.pagamentos_pendentes)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Transactions Table */}
|
|
<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">
|
|
<h2 className="text-lg font-bold text-gray-900">Histórico de Pagamentos</h2>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => setShowDateFilters(!showDateFilters)}
|
|
className="text-sm text-gray-600 hover:text-gray-900 flex items-center gap-2"
|
|
>
|
|
{showDateFilters ? "▼" : "▶"} Filtros Avançados de Data
|
|
</button>
|
|
</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">
|
|
<table className="w-full text-sm text-left">
|
|
<thead className="bg-gray-50 text-gray-500 font-medium">
|
|
<tr>
|
|
<th className="px-6 py-4">
|
|
<div className="flex flex-col gap-1">
|
|
<span>Data 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.data} onChange={e => setFilters({...filters, data: e.target.value})} />
|
|
</div>
|
|
</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>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-100">
|
|
{filteredTransactions.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={7} className="px-6 py-8 text-center text-gray-500">
|
|
Nenhum pagamento encontrado para os filtros.
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
filteredTransactions.map((t) => (
|
|
<tr
|
|
key={t.id}
|
|
className="hover:bg-gray-50 transition-colors cursor-pointer"
|
|
onClick={() => handleRowClick(t)}
|
|
>
|
|
<td className="px-6 py-4 text-gray-900">{t.data_evento}</td>
|
|
<td className="px-6 py-4 text-gray-900 font-medium">{t.nome_evento}</td>
|
|
<td className="px-6 py-4 text-gray-500">{t.tipo_evento}</td>
|
|
<td className="px-6 py-4 text-gray-500">{t.empresa}</td>
|
|
<td className={`px-6 py-4 font-medium ${t.status === 'Pago' ? 'text-green-600' : 'text-green-600'}`}>
|
|
{formatCurrency(t.valor_recebido)}
|
|
</td>
|
|
<td className="px-6 py-4 text-gray-500">{t.data_pagamento}</td>
|
|
<td className="px-6 py-4 text-center">
|
|
<StatusBadge status={t.status} />
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
<tfoot className="bg-gray-50">
|
|
<tr>
|
|
<td colSpan={7} className="px-6 py-3 text-xs text-gray-500">
|
|
<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>
|
|
</tr>
|
|
</tfoot>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Details Modal */}
|
|
{isModalOpen && selectedTransaction && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
|
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg overflow-hidden animate-in fade-in zoom-in duration-200">
|
|
<div className="px-6 py-4 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
|
|
<h3 className="text-lg font-bold text-gray-900">Detalhes do Pagamento</h3>
|
|
<button
|
|
onClick={() => setIsModalOpen(false)}
|
|
className="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded-full hover:bg-gray-100"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
|
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div className="p-6 space-y-6">
|
|
{/* Header Info */}
|
|
<div>
|
|
<p className="text-sm font-medium text-gray-500 uppercase tracking-wide mb-1">Evento</p>
|
|
<p className="text-xl font-bold text-gray-900">{selectedTransaction.nome_evento}</p>
|
|
<p className="text-sm text-gray-600 mt-1">{selectedTransaction.empresa} • {selectedTransaction.data_evento}</p>
|
|
</div>
|
|
|
|
{/* Financial Breakdown */}
|
|
<div className="bg-gray-50 rounded-xl p-4 space-y-3 border border-gray-100">
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-gray-600 text-sm">Valor Base (Free)</span>
|
|
<span className="font-semibold text-gray-900">{formatCurrency(selectedTransaction.valor_free || 0)}</span>
|
|
</div>
|
|
{(selectedTransaction.valor_extra || 0) > 0 && (
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-yellow-700 text-sm font-medium">Valor Extra</span>
|
|
<span className="font-semibold text-yellow-700">{formatCurrency(selectedTransaction.valor_extra || 0)}</span>
|
|
</div>
|
|
)}
|
|
{/* Divider */}
|
|
<div className="border-t border-gray-200 my-2"></div>
|
|
<div className="flex justify-between items-center pt-1">
|
|
<span className="text-gray-900 font-bold text-base">Total Recebido</span>
|
|
<span className="text-green-600 font-bold text-lg">{formatCurrency(selectedTransaction.valor_recebido)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Extra Description */}
|
|
{(selectedTransaction.descricao_extra) && (
|
|
<div>
|
|
<p className="text-sm font-medium text-gray-500 uppercase tracking-wide mb-2">Descrição do Extra</p>
|
|
<div className="bg-yellow-50 text-yellow-800 p-3 rounded-lg text-sm border border-yellow-100">
|
|
{selectedTransaction.descricao_extra}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Status Footer */}
|
|
<div className="flex justify-between items-center pt-2">
|
|
<span className="text-sm text-gray-500">Data do Pagamento</span>
|
|
<div className="flex gap-3 items-center">
|
|
<span className="font-medium text-gray-900">{selectedTransaction.data_pagamento}</span>
|
|
<StatusBadge status={selectedTransaction.status} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ProfessionalStatement;
|