- Add photographer finance page at /meus-pagamentos with payment history table - Remove university management page and related routes - Update Finance and UserApproval pages with consistent spacing and typography - Fix Dashboard background color to match other pages (bg-gray-50) - Standardize navbar logo sizing across all pages - Change institution field in course form from dropdown to text input - Add year and semester fields for university graduation dates - Improve header spacing on all pages to pt-20 sm:pt-24 md:pt-28 lg:pt-32 - Apply font-serif styling consistently across page headers
401 lines
14 KiB
TypeScript
401 lines
14 KiB
TypeScript
import React, { useState, useEffect, useMemo } from "react";
|
|
import {
|
|
Download,
|
|
ArrowUpDown,
|
|
ArrowUp,
|
|
ArrowDown,
|
|
AlertCircle,
|
|
} from "lucide-react";
|
|
|
|
interface PhotographerPayment {
|
|
id: string;
|
|
data: string;
|
|
nomeEvento: string;
|
|
tipoEvento: string;
|
|
empresa: string;
|
|
valorRecebido: number;
|
|
dataPagamento: string;
|
|
statusPagamento: "Pago" | "Pendente" | "Atrasado";
|
|
}
|
|
|
|
type SortField = keyof PhotographerPayment | null;
|
|
type SortDirection = "asc" | "desc" | null;
|
|
|
|
const PhotographerFinance: React.FC = () => {
|
|
const [payments, setPayments] = useState<PhotographerPayment[]>([
|
|
{
|
|
id: "1",
|
|
data: "2025-11-15",
|
|
nomeEvento: "Formatura Medicina UFPR 2025",
|
|
tipoEvento: "Formatura",
|
|
empresa: "PhotoPro Studio",
|
|
valorRecebido: 1500.0,
|
|
dataPagamento: "2025-11-20",
|
|
statusPagamento: "Pago",
|
|
},
|
|
{
|
|
id: "2",
|
|
data: "2025-11-18",
|
|
nomeEvento: "Formatura Direito PUC-PR 2025",
|
|
tipoEvento: "Formatura",
|
|
empresa: "PhotoPro Studio",
|
|
valorRecebido: 1200.0,
|
|
dataPagamento: "2025-11-25",
|
|
statusPagamento: "Pago",
|
|
},
|
|
{
|
|
id: "3",
|
|
data: "2025-12-01",
|
|
nomeEvento: "Formatura Engenharia UTFPR 2025",
|
|
tipoEvento: "Formatura",
|
|
empresa: "Lens & Art",
|
|
valorRecebido: 1800.0,
|
|
dataPagamento: "2025-12-15",
|
|
statusPagamento: "Pendente",
|
|
},
|
|
]);
|
|
|
|
const [sortField, setSortField] = useState<SortField>(null);
|
|
const [sortDirection, setSortDirection] = useState<SortDirection>(null);
|
|
const [apiError, setApiError] = useState<string>("");
|
|
|
|
// Load API data
|
|
useEffect(() => {
|
|
loadApiData();
|
|
}, []);
|
|
|
|
const loadApiData = async () => {
|
|
try {
|
|
// TODO: Implementar chamada real da API
|
|
// const response = await fetch("http://localhost:3000/api/photographer/payments");
|
|
// const data = await response.json();
|
|
// setPayments(data);
|
|
} catch (error) {
|
|
console.error("Erro ao carregar pagamentos:", error);
|
|
setApiError(
|
|
"Não foi possível carregar os dados da API. Usando dados de exemplo."
|
|
);
|
|
}
|
|
};
|
|
|
|
// Sorting logic
|
|
const handleSort = (field: keyof PhotographerPayment) => {
|
|
if (sortField === field) {
|
|
if (sortDirection === "asc") {
|
|
setSortDirection("desc");
|
|
} else if (sortDirection === "desc") {
|
|
setSortDirection(null);
|
|
setSortField(null);
|
|
}
|
|
} else {
|
|
setSortField(field);
|
|
setSortDirection("asc");
|
|
}
|
|
};
|
|
|
|
const getSortIcon = (field: keyof PhotographerPayment) => {
|
|
if (sortField !== field) {
|
|
return (
|
|
<ArrowUpDown
|
|
size={16}
|
|
className="opacity-0 group-hover:opacity-50 transition-opacity"
|
|
/>
|
|
);
|
|
}
|
|
if (sortDirection === "asc") {
|
|
return <ArrowUp size={16} className="text-brand-gold" />;
|
|
}
|
|
if (sortDirection === "desc") {
|
|
return <ArrowDown size={16} className="text-brand-gold" />;
|
|
}
|
|
return (
|
|
<ArrowUpDown
|
|
size={16}
|
|
className="opacity-0 group-hover:opacity-50 transition-opacity"
|
|
/>
|
|
);
|
|
};
|
|
|
|
const sortedPayments = useMemo(() => {
|
|
if (!sortField || !sortDirection) return payments;
|
|
|
|
return [...payments].sort((a, b) => {
|
|
const aValue = a[sortField];
|
|
const bValue = b[sortField];
|
|
|
|
if (aValue === null || aValue === undefined) return 1;
|
|
if (bValue === null || bValue === undefined) return -1;
|
|
|
|
let comparison = 0;
|
|
if (typeof aValue === "string" && typeof bValue === "string") {
|
|
comparison = aValue.localeCompare(bValue);
|
|
} else if (typeof aValue === "number" && typeof bValue === "number") {
|
|
comparison = aValue - bValue;
|
|
} else if (typeof aValue === "boolean" && typeof bValue === "boolean") {
|
|
comparison = aValue === bValue ? 0 : aValue ? 1 : -1;
|
|
}
|
|
|
|
return sortDirection === "asc" ? comparison : -comparison;
|
|
});
|
|
}, [payments, sortField, sortDirection]);
|
|
|
|
// Export to CSV
|
|
const handleExport = () => {
|
|
const headers = [
|
|
"Data Evento",
|
|
"Nome Evento",
|
|
"Tipo Evento",
|
|
"Empresa",
|
|
"Valor Recebido",
|
|
"Data Pagamento",
|
|
"Status",
|
|
];
|
|
|
|
const csvContent = [
|
|
headers.join(","),
|
|
...sortedPayments.map((p) =>
|
|
[
|
|
p.data,
|
|
`"${p.nomeEvento}"`,
|
|
p.tipoEvento,
|
|
p.empresa,
|
|
p.valorRecebido.toFixed(2),
|
|
p.dataPagamento,
|
|
p.statusPagamento,
|
|
].join(",")
|
|
),
|
|
].join("\n");
|
|
|
|
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
|
|
const link = document.createElement("a");
|
|
const url = URL.createObjectURL(blob);
|
|
link.setAttribute("href", url);
|
|
link.setAttribute("download", `meus_pagamentos_${Date.now()}.csv`);
|
|
link.style.visibility = "hidden";
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
};
|
|
|
|
// Calculate totals
|
|
const totalRecebido = sortedPayments.reduce(
|
|
(sum, p) => sum + p.valorRecebido,
|
|
0
|
|
);
|
|
const totalPago = sortedPayments
|
|
.filter((p) => p.statusPagamento === "Pago")
|
|
.reduce((sum, p) => sum + p.valorRecebido, 0);
|
|
const totalPendente = sortedPayments
|
|
.filter((p) => p.statusPagamento === "Pendente")
|
|
.reduce((sum, p) => sum + p.valorRecebido, 0);
|
|
|
|
const getStatusBadge = (status: string) => {
|
|
const statusColors = {
|
|
Pago: "bg-green-100 text-green-800",
|
|
Pendente: "bg-yellow-100 text-yellow-800",
|
|
Atrasado: "bg-red-100 text-red-800",
|
|
};
|
|
|
|
return (
|
|
<span
|
|
className={`px-2 py-1 rounded-full text-xs font-medium ${
|
|
statusColors[status as keyof typeof statusColors] ||
|
|
"bg-gray-100 text-gray-800"
|
|
}`}
|
|
>
|
|
{status}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 pt-20">
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
{/* Header */}
|
|
<div className="mb-8">
|
|
<h1 className="text-3xl font-bold text-gray-900">Meus Pagamentos</h1>
|
|
<p className="mt-2 text-gray-600">
|
|
Visualize todos os pagamentos recebidos pelos eventos fotografados
|
|
</p>
|
|
</div>
|
|
|
|
{/* Summary Cards */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<p className="text-sm text-gray-600 mb-1">Total Recebido</p>
|
|
<p className="text-2xl font-bold text-gray-900">
|
|
R$ {totalRecebido.toFixed(2)}
|
|
</p>
|
|
</div>
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<p className="text-sm text-gray-600 mb-1">Pagamentos Confirmados</p>
|
|
<p className="text-2xl font-bold text-green-600">
|
|
R$ {totalPago.toFixed(2)}
|
|
</p>
|
|
</div>
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<p className="text-sm text-gray-600 mb-1">Pagamentos Pendentes</p>
|
|
<p className="text-2xl font-bold text-yellow-600">
|
|
R$ {totalPendente.toFixed(2)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main Card */}
|
|
<div className="bg-white rounded-lg shadow">
|
|
{/* Actions Bar */}
|
|
<div className="px-6 py-4 border-b border-gray-200">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-lg font-semibold text-gray-900">
|
|
Histórico de Pagamentos
|
|
</h2>
|
|
<button
|
|
onClick={handleExport}
|
|
className="flex items-center gap-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition-colors"
|
|
>
|
|
<Download size={20} />
|
|
Exportar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{apiError && (
|
|
<div className="mx-6 mt-6 p-4 bg-yellow-50 border border-yellow-200 rounded-lg flex items-start gap-3">
|
|
<AlertCircle
|
|
className="text-yellow-600 flex-shrink-0 mt-0.5"
|
|
size={20}
|
|
/>
|
|
<div className="flex-1">
|
|
<p className="text-sm font-medium text-yellow-800">Aviso</p>
|
|
<p className="text-sm text-yellow-700 mt-1">{apiError}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Table */}
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead className="bg-gray-50 border-b border-gray-200">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left">
|
|
<button
|
|
onClick={() => handleSort("data")}
|
|
className="group flex items-center gap-2 text-xs font-medium text-gray-700 hover:text-gray-900"
|
|
>
|
|
Data Evento
|
|
{getSortIcon("data")}
|
|
</button>
|
|
</th>
|
|
<th className="px-4 py-3 text-left">
|
|
<button
|
|
onClick={() => handleSort("nomeEvento")}
|
|
className="group flex items-center gap-2 text-xs font-medium text-gray-700 hover:text-gray-900"
|
|
>
|
|
Nome Evento
|
|
{getSortIcon("nomeEvento")}
|
|
</button>
|
|
</th>
|
|
<th className="px-4 py-3 text-left">
|
|
<button
|
|
onClick={() => handleSort("tipoEvento")}
|
|
className="group flex items-center gap-2 text-xs font-medium text-gray-700 hover:text-gray-900"
|
|
>
|
|
Tipo Evento
|
|
{getSortIcon("tipoEvento")}
|
|
</button>
|
|
</th>
|
|
<th className="px-4 py-3 text-left">
|
|
<button
|
|
onClick={() => handleSort("empresa")}
|
|
className="group flex items-center gap-2 text-xs font-medium text-gray-700 hover:text-gray-900"
|
|
>
|
|
Empresa
|
|
{getSortIcon("empresa")}
|
|
</button>
|
|
</th>
|
|
<th className="px-4 py-3 text-left">
|
|
<button
|
|
onClick={() => handleSort("valorRecebido")}
|
|
className="group flex items-center gap-2 text-xs font-medium text-gray-700 hover:text-gray-900"
|
|
>
|
|
Valor Recebido
|
|
{getSortIcon("valorRecebido")}
|
|
</button>
|
|
</th>
|
|
<th className="px-4 py-3 text-left">
|
|
<button
|
|
onClick={() => handleSort("dataPagamento")}
|
|
className="group flex items-center gap-2 text-xs font-medium text-gray-700 hover:text-gray-900"
|
|
>
|
|
Data Pagamento
|
|
{getSortIcon("dataPagamento")}
|
|
</button>
|
|
</th>
|
|
<th className="px-4 py-3 text-left">
|
|
<button
|
|
onClick={() => handleSort("statusPagamento")}
|
|
className="group flex items-center gap-2 text-xs font-medium text-gray-700 hover:text-gray-900"
|
|
>
|
|
Status
|
|
{getSortIcon("statusPagamento")}
|
|
</button>
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white divide-y divide-gray-200">
|
|
{sortedPayments.length === 0 ? (
|
|
<tr>
|
|
<td
|
|
colSpan={7}
|
|
className="px-4 py-8 text-center text-gray-500"
|
|
>
|
|
Nenhum pagamento encontrado
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
sortedPayments.map((payment) => (
|
|
<tr
|
|
key={payment.id}
|
|
className="hover:bg-gray-50 transition-colors"
|
|
>
|
|
<td className="px-4 py-3 whitespace-nowrap text-sm">
|
|
{new Date(payment.data).toLocaleDateString("pt-BR")}
|
|
</td>
|
|
<td className="px-4 py-3 text-sm">
|
|
{payment.nomeEvento}
|
|
</td>
|
|
<td className="px-4 py-3 whitespace-nowrap text-sm">
|
|
{payment.tipoEvento}
|
|
</td>
|
|
<td className="px-4 py-3 text-sm">{payment.empresa}</td>
|
|
<td className="px-4 py-3 whitespace-nowrap text-sm font-medium text-green-600">
|
|
R$ {payment.valorRecebido.toFixed(2)}
|
|
</td>
|
|
<td className="px-4 py-3 whitespace-nowrap text-sm">
|
|
{new Date(payment.dataPagamento).toLocaleDateString(
|
|
"pt-BR"
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3 whitespace-nowrap text-sm">
|
|
{getStatusBadge(payment.statusPagamento)}
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50">
|
|
<p className="text-sm text-gray-600">
|
|
Total de pagamentos: {sortedPayments.length}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default PhotographerFinance;
|