Este commit introduz o módulo financeiro completo e refatora o sistema de profissionais para suportar múltiplas funções, corrigindo a contabilização e validação de equipes. Principais alterações: - **Módulo Financeiro:** - Criação da tabela `financial_transactions` e queries associadas. - Implementação do backend (Handler/Service) para gerenciar transações. - Nova página [Finance.tsx](cci:7://file:///c:/Projetos/photum/frontend/pages/Finance.tsx:0:0-0:0) com listagem, edição, filtros avançados e agrupamento por FOT. - Correção na busca de FOTs e formatação de datas. - **Gestão de Equipe e Profissionais:** - Refatoração para suportar múltiplas funções por profissional (Backend & Frontend). - Atualização do [Dashboard](cci:1://file:///c:/Projetos/photum/frontend/pages/Dashboard.tsx:31:0-1663:2) e [EventTable](cci:1://file:///c:/Projetos/photum/frontend/components/EventTable.tsx:28:0-659:2) para contabilizar corretamente profissionais (Fotografo, Cinegrafista, Recepcionista) verificando a lista de funções. - Implementação de validação de cota no aceite de convites (bloqueia se a equipe da função específica já estiver completa). - Ajuste visual nos indicadores de "Equipe Completa" e contadores de faltantes na listagem de eventos. - **Geral:** - Atualização da documentação Swagger. - Ajustes de tipagem e migrações de banco de dados.
880 lines
39 KiB
TypeScript
880 lines
39 KiB
TypeScript
import React, { useState, useEffect } from "react";
|
|
import {
|
|
Download,
|
|
Plus,
|
|
ArrowUpDown,
|
|
ArrowUp,
|
|
ArrowDown,
|
|
X,
|
|
AlertCircle,
|
|
Search,
|
|
} from "lucide-react";
|
|
|
|
interface FinancialTransaction {
|
|
id: string;
|
|
fot: number;
|
|
fot_id?: string; // Add fot_id
|
|
data: string; // date string YYYY-MM-DD
|
|
dataRaw?: string; // Optional raw date for editing
|
|
curso: string;
|
|
instituicao: string;
|
|
anoFormatura: number; // or string label
|
|
empresa: string;
|
|
tipoEvento: string;
|
|
tipoServico: string;
|
|
nome: string; // professional_name
|
|
endereco?: string; // Not in DB schema but in UI? Schema doesn't have address. We'll omit or map if needed.
|
|
whatsapp: string;
|
|
cpf: string;
|
|
tabelaFree: string;
|
|
valorFree: number;
|
|
valorExtra: number;
|
|
descricaoExtra: string;
|
|
totalPagar: number;
|
|
dataPgto: string; // date string
|
|
pgtoOk: boolean;
|
|
}
|
|
|
|
const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8080";
|
|
|
|
const Finance: React.FC = () => {
|
|
const [transactions, setTransactions] = useState<FinancialTransaction[]>([]);
|
|
const [showAddModal, setShowAddModal] = useState(false);
|
|
const [showEditModal, setShowEditModal] = useState(false);
|
|
const [selectedTransaction, setSelectedTransaction] =
|
|
useState<FinancialTransaction | null>(null);
|
|
const [sortConfig, setSortConfig] = useState<{
|
|
key: keyof FinancialTransaction;
|
|
direction: "asc" | "desc";
|
|
} | null>(null);
|
|
|
|
// API Data States
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState("");
|
|
const [tiposEventos, setTiposEventos] = useState<any[]>([]);
|
|
const [tiposServicos, setTiposServicos] = useState<any[]>([]);
|
|
|
|
// Form State
|
|
const [formData, setFormData] = useState<Partial<FinancialTransaction>>({
|
|
fot: 0,
|
|
data: new Date().toISOString().split("T")[0],
|
|
curso: "",
|
|
instituicao: "",
|
|
anoFormatura: new Date().getFullYear(),
|
|
empresa: "",
|
|
tipoEvento: "",
|
|
tipoServico: "",
|
|
nome: "",
|
|
whatsapp: "",
|
|
cpf: "",
|
|
tabelaFree: "",
|
|
valorFree: 0,
|
|
valorExtra: 0,
|
|
descricaoExtra: "",
|
|
totalPagar: 0,
|
|
dataPgto: "",
|
|
pgtoOk: false,
|
|
});
|
|
|
|
// Auto-fill state
|
|
const [fotLoading, setFotLoading] = useState(false);
|
|
const [fotFound, setFotFound] = useState(false);
|
|
const [fotEvents, setFotEvents] = useState<any[]>([]); // New state
|
|
const [showEventSelector, setShowEventSelector] = useState(false);
|
|
// FOT Search State
|
|
const [fotQuery, setFotQuery] = useState("");
|
|
const [fotResults, setFotResults] = useState<any[]>([]);
|
|
const [showFotSuggestions, setShowFotSuggestions] = useState(false);
|
|
|
|
// Professional Search State
|
|
const [proQuery, setProQuery] = useState("");
|
|
const [proResults, setProResults] = useState<any[]>([]);
|
|
const [showProSuggestions, setShowProSuggestions] = useState(false);
|
|
const [proFunctions, setProFunctions] = useState<any[]>([]); // Functions of selected professional
|
|
const [selectedProId, setSelectedProId] = useState<string | null>(null);
|
|
|
|
// Validations
|
|
const validateCpf = (cpf: string) => {
|
|
// Simple length check for now, can be enhanced
|
|
return cpf.replace(/\D/g, "").length === 11;
|
|
};
|
|
|
|
const loadTransactions = async () => {
|
|
const token = localStorage.getItem("token");
|
|
if (!token) {
|
|
setError("Usuário não autenticado");
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
try {
|
|
const res = await fetch(`${API_BASE_URL}/api/finance`, {
|
|
headers: {
|
|
"Authorization": `Bearer ${token}`
|
|
}
|
|
});
|
|
if (res.status === 401) throw new Error("Não autorizado");
|
|
if (!res.ok) throw new Error("Falha ao carregar transações");
|
|
const data = await res.json();
|
|
|
|
// Map Backend DTO to Frontend Interface
|
|
const mapped = data.map((item: any) => ({
|
|
id: item.id,
|
|
fot: item.fot_numero || 0,
|
|
// Format to DD/MM/YYYY for display, keep YYYY-MM-DD for editing if needed?
|
|
// Actually, logic uses `data` for display. `data_cobranca` comes as ISO.
|
|
data: item.data_cobranca ? new Date(item.data_cobranca).toLocaleDateString("pt-BR", {timeZone: "UTC"}) : "",
|
|
dataRaw: item.data_cobranca ? item.data_cobranca.split("T")[0] : "", // Store raw for edit
|
|
curso: "",
|
|
instituicao: "",
|
|
anoFormatura: 0,
|
|
empresa: "",
|
|
tipoEvento: item.tipo_evento,
|
|
tipoServico: item.tipo_servico,
|
|
nome: item.professional_name,
|
|
whatsapp: item.whatsapp,
|
|
cpf: item.cpf,
|
|
tabelaFree: item.tabela_free,
|
|
valorFree: parseFloat(item.valor_free),
|
|
valorExtra: parseFloat(item.valor_extra),
|
|
descricaoExtra: item.descricao_extra,
|
|
totalPagar: parseFloat(item.total_pagar),
|
|
dataPgto: item.data_pagamento ? item.data_pagamento.split("T")[0] : "",
|
|
pgtoOk: item.pgto_ok,
|
|
}));
|
|
setTransactions(mapped);
|
|
} catch (err) {
|
|
console.error(err);
|
|
setError("Erro ao carregar dados.");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const loadAuxiliaryData = async () => {
|
|
const token = localStorage.getItem("token");
|
|
if (!token) return;
|
|
|
|
try {
|
|
const headers = { "Authorization": `Bearer ${token}` };
|
|
const [evRes, servRes] = await Promise.all([
|
|
fetch(`${API_BASE_URL}/api/tipos-eventos`, { headers }),
|
|
fetch(`${API_BASE_URL}/api/tipos-servicos`, { headers }),
|
|
]);
|
|
if (evRes.ok) setTiposEventos(await evRes.json());
|
|
if (servRes.ok) setTiposServicos(await servRes.json());
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadTransactions();
|
|
loadAuxiliaryData();
|
|
}, []);
|
|
|
|
// Filters
|
|
const [filters, setFilters] = useState({
|
|
fot: "",
|
|
data: "",
|
|
evento: "",
|
|
servico: "",
|
|
nome: "",
|
|
status: "",
|
|
});
|
|
|
|
// Calculate filtered and sorted transactions
|
|
const sortedTransactions = React.useMemo(() => {
|
|
let result = [...transactions];
|
|
|
|
// 1. Filter
|
|
if (filters.fot) result = result.filter(t => String(t.fot).includes(filters.fot));
|
|
if (filters.data) result = result.filter(t => t.data.includes(filters.data));
|
|
if (filters.evento) result = result.filter(t => t.tipoEvento.toLowerCase().includes(filters.evento.toLowerCase()));
|
|
if (filters.servico) result = result.filter(t => t.tipoServico.toLowerCase().includes(filters.servico.toLowerCase()));
|
|
if (filters.nome) result = result.filter(t => t.nome.toLowerCase().includes(filters.nome.toLowerCase()));
|
|
if (filters.status) {
|
|
const s = filters.status.toLowerCase();
|
|
if (s === 'ok' || s === 'sim') result = result.filter(t => t.pgtoOk);
|
|
if (s === 'no' || s === 'nao' || s === 'não') result = result.filter(t => !t.pgtoOk);
|
|
}
|
|
|
|
// 2. Sort by FOT (desc) then Date (desc) to group FOTs
|
|
// Default sort is grouped by FOT
|
|
if (!sortConfig) {
|
|
return result.sort((a, b) => {
|
|
if (a.fot !== b.fot) return b.fot - a.fot; // Group by FOT
|
|
return new Date(b.dataRaw || b.data).getTime() - new Date(a.dataRaw || a.data).getTime();
|
|
});
|
|
}
|
|
|
|
// Custom sort if implemented
|
|
return result.sort((a, b) => {
|
|
// @ts-ignore
|
|
const aValue = a[sortConfig.key];
|
|
// @ts-ignore
|
|
const bValue = b[sortConfig.key];
|
|
if (aValue < bValue) return sortConfig.direction === "asc" ? -1 : 1;
|
|
if (aValue > bValue) return sortConfig.direction === "asc" ? 1 : -1;
|
|
return 0;
|
|
});
|
|
}, [transactions, filters, sortConfig]);
|
|
|
|
|
|
const handleSort = (key: keyof FinancialTransaction) => {
|
|
let direction: "asc" | "desc" = "asc";
|
|
if (sortConfig && sortConfig.key === key && sortConfig.direction === "asc") {
|
|
direction = "desc";
|
|
}
|
|
setSortConfig({ key, direction });
|
|
};
|
|
|
|
const handleFotSearch = async (query: string) => {
|
|
setFotQuery(query);
|
|
|
|
// If user types numbers, list options
|
|
if (query.length < 2) {
|
|
setFotResults([]);
|
|
setShowFotSuggestions(false);
|
|
return;
|
|
}
|
|
|
|
const token = localStorage.getItem("token");
|
|
try {
|
|
const res = await fetch(`${API_BASE_URL}/api/finance/fot-search?q=${query}`, {
|
|
headers: { "Authorization": `Bearer ${token}` }
|
|
});
|
|
if(res.ok) {
|
|
const data = await res.json();
|
|
setFotResults(data);
|
|
setShowFotSuggestions(true);
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
};
|
|
|
|
const selectFot = (fot: any) => {
|
|
setFotQuery(String(fot.fot));
|
|
setShowFotSuggestions(false);
|
|
handleAutoFill(fot.fot);
|
|
};
|
|
|
|
const handleAutoFill = async (fotNum: number) => {
|
|
if (!fotNum) return;
|
|
|
|
const token = localStorage.getItem("token");
|
|
if (!token) return;
|
|
|
|
setFotLoading(true);
|
|
setFotEvents([]);
|
|
setShowEventSelector(false);
|
|
try {
|
|
const res = await fetch(`${API_BASE_URL}/api/finance/autofill?fot=${fotNum}`, {
|
|
headers: { "Authorization": `Bearer ${token}` }
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setFormData(prev => ({
|
|
...prev,
|
|
curso: data.curso_nome,
|
|
instituicao: data.empresa_nome,
|
|
empresa: data.empresa_nome,
|
|
anoFormatura: data.ano_formatura_label,
|
|
fot: fotNum,
|
|
}));
|
|
setFotFound(true);
|
|
// @ts-ignore
|
|
const fotId = data.id;
|
|
setFormData(prev => ({ ...prev, fot_id: fotId }));
|
|
setFotQuery(String(fotNum)); // Ensure query matches found fot
|
|
|
|
// Now fetch events
|
|
const evRes = await fetch(`${API_BASE_URL}/api/finance/fot-events?fot_id=${fotId}`, {
|
|
headers: { "Authorization": `Bearer ${token}` }
|
|
});
|
|
if (evRes.ok) {
|
|
const events = await evRes.json();
|
|
if (events && events.length > 0) {
|
|
setFotEvents(events);
|
|
setShowEventSelector(true);
|
|
}
|
|
}
|
|
|
|
} else {
|
|
setFotFound(false);
|
|
}
|
|
} catch (error) {
|
|
console.error(error);
|
|
setFotFound(false);
|
|
} finally {
|
|
setFotLoading(false);
|
|
}
|
|
};
|
|
|
|
// Auto-Pricing Effect
|
|
useEffect(() => {
|
|
const fetchPrice = async () => {
|
|
if (!formData.tipoEvento || !formData.tipoServico) return;
|
|
|
|
// If editing existing transaction, maybe don't overwrite unless user changes something?
|
|
// But for "Nova Transação", strictly overwrite.
|
|
// Let's assume overwrite if price is 0 or user changed inputs.
|
|
// Simplified: always fetch if inputs present.
|
|
|
|
const token = localStorage.getItem("token");
|
|
if (!token) return;
|
|
|
|
try {
|
|
const res = await fetch(`${API_BASE_URL}/api/finance/price?event=${encodeURIComponent(formData.tipoEvento)}&service=${encodeURIComponent(formData.tipoServico)}`, {
|
|
headers: { "Authorization": `Bearer ${token}` }
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
if (data.valor !== undefined) {
|
|
setFormData(prev => ({ ...prev, valorFree: data.valor }));
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
};
|
|
fetchPrice();
|
|
}, [formData.tipoEvento, formData.tipoServico]);
|
|
|
|
const handleProSearch = async (query: string) => {
|
|
setProQuery(query);
|
|
setFormData(prev => ({ ...prev, nome: query })); // Update name as typed
|
|
|
|
// Allow empty query to list filtered professionals if Function is selected
|
|
if (query.length < 3 && !formData.tipoServico) {
|
|
setProResults([]);
|
|
setShowProSuggestions(false);
|
|
return;
|
|
}
|
|
|
|
const token = localStorage.getItem("token");
|
|
try {
|
|
const fnParam = formData.tipoServico ? `&function=${encodeURIComponent(formData.tipoServico)}` : "";
|
|
const res = await fetch(`${API_BASE_URL}/api/finance/professionals?q=${query}${fnParam}`, {
|
|
headers: { "Authorization": `Bearer ${token}` }
|
|
});
|
|
if(res.ok) {
|
|
const data = await res.json();
|
|
setProResults(data);
|
|
setShowProSuggestions(true);
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
};
|
|
|
|
const selectProfessional = (pro: any) => {
|
|
// Parse functions
|
|
let funcs = [];
|
|
try {
|
|
funcs = pro.functions ? (typeof pro.functions === 'string' ? JSON.parse(pro.functions) : pro.functions) : [];
|
|
} catch(e) {
|
|
funcs = [];
|
|
}
|
|
setProFunctions(funcs);
|
|
setSelectedProId(pro.id);
|
|
|
|
setFormData(prev => ({
|
|
...prev,
|
|
nome: pro.nome,
|
|
whatsapp: pro.whatsapp,
|
|
cpf: pro.cpf_cnpj_titular,
|
|
// Default to first function if available, else empty
|
|
tabelaFree: funcs.length > 0 ? funcs[0].nome : "",
|
|
}));
|
|
setProQuery(pro.nome);
|
|
setShowProSuggestions(false);
|
|
};
|
|
|
|
const selectEvent = (ev: any) => {
|
|
setFormData(prev => ({
|
|
...prev,
|
|
tipoEvento: ev.tipo_evento_nome,
|
|
// If event has date, we could pre-fill? User request suggests keeping it flexible or maybe they didn't ask explicitly.
|
|
}));
|
|
setShowEventSelector(false);
|
|
};
|
|
|
|
const handleEdit = async (t: FinancialTransaction) => {
|
|
setSelectedTransaction(t);
|
|
setFormData({
|
|
id: t.id,
|
|
fot_id: t.fot_id,
|
|
data: t.dataRaw || t.data, // Use raw YYYY-MM-DD for input
|
|
tipoEvento: t.tipoEvento,
|
|
tipoServico: t.tipoServico,
|
|
nome: t.nome,
|
|
whatsapp: t.whatsapp,
|
|
cpf: t.cpf,
|
|
tabelaFree: t.tabelaFree,
|
|
valorFree: t.valorFree,
|
|
valorExtra: t.valorExtra,
|
|
descricaoExtra: t.descricaoExtra,
|
|
dataPgto: t.dataPgto,
|
|
pgtoOk: t.pgtoOk,
|
|
totalPagar: t.totalPagar,
|
|
});
|
|
setFotFound(false); // Reset fotFound state for edit modal
|
|
|
|
// Fetch FOT details if ID exists
|
|
if (t.fot_id) {
|
|
const token = localStorage.getItem("token");
|
|
if (!token) return;
|
|
try {
|
|
const res = await fetch(`${API_BASE_URL}/api/cadastro-fot/${t.fot_id}`, {
|
|
headers: { "Authorization": `Bearer ${token}` }
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setFormData(prev => ({
|
|
...prev,
|
|
curso: data.curso_nome || "",
|
|
instituicao: data.empresa_nome || data.instituicao || "",
|
|
empresa: data.empresa_nome || "",
|
|
anoFormatura: data.ano_formatura_label || "",
|
|
fot: data.fot,
|
|
}));
|
|
setFotFound(true);
|
|
}
|
|
} catch (err) {
|
|
console.error("Error fetching FOT details for edit:", err);
|
|
}
|
|
}
|
|
|
|
// Fetch professional functions if professional name is present
|
|
if (t.nome) {
|
|
const token = localStorage.getItem("token");
|
|
if (!token) return;
|
|
try {
|
|
const res = await fetch(`${API_BASE_URL}/api/finance/professionals?q=${encodeURIComponent(t.nome)}`, {
|
|
headers: { "Authorization": `Bearer ${token}` }
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
const professional = data.find((p: any) => p.nome === t.nome);
|
|
if (professional) {
|
|
let funcs = [];
|
|
try {
|
|
funcs = professional.functions ? (typeof professional.functions === 'string' ? JSON.parse(professional.functions) : professional.functions) : [];
|
|
} catch(e) {
|
|
funcs = [];
|
|
}
|
|
setProFunctions(funcs);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error("Error fetching professional functions for edit:", err);
|
|
}
|
|
}
|
|
|
|
setShowEditModal(true);
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
const token = localStorage.getItem("token");
|
|
if (!token) {
|
|
alert("Sessão expirada. Faça login novamente.");
|
|
return;
|
|
}
|
|
|
|
// Prepare payload
|
|
const payload = {
|
|
fot_id: formData.fot_id, // Ensure this is sent
|
|
data_cobranca: formData.data,
|
|
tipo_evento: formData.tipoEvento,
|
|
tipo_servico: formData.tipoServico,
|
|
professional_name: formData.nome,
|
|
whatsapp: formData.whatsapp,
|
|
cpf: formData.cpf,
|
|
tabela_free: formData.tabelaFree,
|
|
valor_free: formData.valorFree ? Number(formData.valorFree) : 0,
|
|
valor_extra: formData.valorExtra ? Number(formData.valorExtra) : 0,
|
|
descricao_extra: formData.descricaoExtra,
|
|
total_pagar: formData.totalPagar ? Number(formData.totalPagar) : 0, // Should be calculated
|
|
data_pagamento: formData.dataPgto || null,
|
|
pgto_ok: formData.pgtoOk
|
|
};
|
|
|
|
// Calculate total before sending if not set?
|
|
// Actually totalPagar IS sent. But let's recalculate to be safe or ensure it's updated.
|
|
payload.total_pagar = (payload.valor_free || 0) + (payload.valor_extra || 0);
|
|
|
|
try {
|
|
let res;
|
|
if (formData.id) {
|
|
res = await fetch(`${API_BASE_URL}/api/finance/${formData.id}`, {
|
|
method: "PUT",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"Authorization": `Bearer ${token}`
|
|
},
|
|
body: JSON.stringify(payload)
|
|
});
|
|
} else {
|
|
res = await fetch(`${API_BASE_URL}/api/finance`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"Authorization": `Bearer ${token}`
|
|
},
|
|
body: JSON.stringify(payload)
|
|
});
|
|
}
|
|
if (!res.ok) throw new Error("Erro ao salvar");
|
|
|
|
setShowAddModal(false);
|
|
setShowEditModal(false);
|
|
setSelectedTransaction(null); // Clear selected transaction
|
|
setProFunctions([]); // Clear professional functions
|
|
loadTransactions(); // Reload list
|
|
} catch (error) {
|
|
alert("Erro ao salvar transação");
|
|
}
|
|
};
|
|
|
|
// Calculations
|
|
useEffect(() => {
|
|
const total = (Number(formData.valorFree) || 0) + (Number(formData.valorExtra) || 0);
|
|
setFormData((prev) => ({ ...prev, totalPagar: total }));
|
|
}, [formData.valorFree, formData.valorExtra]);
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 pt-20 sm:pt-24 md:pt-28 lg:pt-32 pb-8 sm:pb-12">
|
|
<div className="max-w-[98%] mx-auto px-2">
|
|
<div className="mb-6 flex justify-between items-center">
|
|
<div>
|
|
<h1 className="text-3xl font-serif font-bold text-gray-900">Extrato</h1>
|
|
<p className="text-gray-500 text-sm">Controle financeiro e transações</p>
|
|
</div>
|
|
<button
|
|
onClick={() => {
|
|
setFormData({ // Clear form to initial state
|
|
fot: 0,
|
|
data: new Date().toISOString().split("T")[0],
|
|
curso: "",
|
|
instituicao: "",
|
|
anoFormatura: new Date().getFullYear(),
|
|
empresa: "",
|
|
tipoEvento: "",
|
|
tipoServico: "",
|
|
nome: "",
|
|
whatsapp: "",
|
|
cpf: "",
|
|
tabelaFree: "",
|
|
valorFree: 0,
|
|
valorExtra: 0,
|
|
descricaoExtra: "",
|
|
totalPagar: 0,
|
|
dataPgto: "",
|
|
pgtoOk: false,
|
|
});
|
|
setFotFound(false);
|
|
setProFunctions([]); // Clear professional functions
|
|
setSelectedTransaction(null); // Ensure no transaction is selected for add
|
|
setShowAddModal(true);
|
|
}}
|
|
className="bg-brand-gold text-white px-4 py-2 rounded shadow hover:bg-yellow-600 transition flex items-center gap-2">
|
|
<Plus size={18}/> Nova Transação
|
|
</button>
|
|
</div>
|
|
|
|
{/* List */}
|
|
<div className="bg-white rounded shadow overflow-x-auto">
|
|
<table className="w-full text-xs text-left whitespace-nowrap">
|
|
<thead className="bg-gray-100 border-b">
|
|
<tr>
|
|
{["FOT", "Data", "Evento", "Serviço", "Nome", "WhatsApp", "CPF", "Tab. Free", "V. Free", "V. Extra", "Desc. Extra", "Total", "Dt. Pgto", "OK"].map(h => (
|
|
<th key={h} className="px-3 py-2 font-semibold text-gray-700 align-top">
|
|
<div className="flex flex-col gap-1">
|
|
<span>{h}</span>
|
|
{/* Filters */}
|
|
{h === "FOT" && <input className="w-full text-[10px] border rounded px-1" placeholder="Filtrar" value={filters.fot} onChange={e => setFilters({...filters, fot: e.target.value})} />}
|
|
{h === "Data" && <input className="w-full text-[10px] border rounded px-1" placeholder="Filtrar" value={filters.data} onChange={e => setFilters({...filters, data: e.target.value})} />}
|
|
{h === "Evento" && <input className="w-full text-[10px] border rounded px-1" placeholder="Filtrar" value={filters.evento} onChange={e => setFilters({...filters, evento: e.target.value})} />}
|
|
{h === "Serviço" && <input className="w-full text-[10px] border rounded px-1" placeholder="Filtrar" value={filters.servico} onChange={e => setFilters({...filters, servico: e.target.value})} />}
|
|
{h === "Nome" && <input className="w-full text-[10px] border rounded px-1" placeholder="Filtrar" value={filters.nome} onChange={e => setFilters({...filters, nome: e.target.value})} />}
|
|
{h === "OK" && <input className="w-full text-[10px] border rounded px-1" placeholder="Sim/Não" value={filters.status} onChange={e => setFilters({...filters, status: e.target.value})} />}
|
|
</div>
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y relative">
|
|
{sortedTransactions.map((t, index) => {
|
|
const isNewFot = index > 0 && t.fot !== sortedTransactions[index - 1].fot;
|
|
return (
|
|
<tr key={t.id}
|
|
className={`hover:bg-gray-50 cursor-pointer ${isNewFot ? "border-t-[3px] border-gray-400" : ""}`}
|
|
onClick={() => handleEdit(t)}
|
|
>
|
|
<td className="px-3 py-2 font-bold">{t.fot || "?"}</td>
|
|
<td className="px-3 py-2">{t.data}</td>
|
|
<td className="px-3 py-2">{t.tipoEvento}</td>
|
|
<td className="px-3 py-2">{t.tipoServico}</td>
|
|
<td className="px-3 py-2">{t.nome}</td>
|
|
<td className="px-3 py-2">{t.whatsapp}</td>
|
|
<td className="px-3 py-2">{t.cpf}</td>
|
|
<td className="px-3 py-2">{t.tabelaFree}</td>
|
|
<td className="px-3 py-2 text-right">{t.valorFree?.toFixed(2)}</td>
|
|
<td className="px-3 py-2 text-right">{t.valorExtra?.toFixed(2)}</td>
|
|
<td className="px-3 py-2 max-w-[150px] truncate" title={t.descricaoExtra}>{t.descricaoExtra}</td>
|
|
<td className="px-3 py-2 text-right font-bold text-green-700">{t.totalPagar?.toFixed(2)}</td>
|
|
<td className="px-3 py-2">
|
|
{(() => {
|
|
try {
|
|
if (!t.dataPgto) return "-";
|
|
const d = new Date(t.dataPgto);
|
|
if (isNaN(d.getTime())) return "-";
|
|
return d.toLocaleDateString("pt-BR", {timeZone: "UTC"});
|
|
} catch (e) {
|
|
return "-";
|
|
}
|
|
})()}
|
|
</td>
|
|
<td className="px-3 py-2 text-center">
|
|
{t.pgtoOk
|
|
? <span className="bg-green-100 text-green-800 px-2 py-0.5 rounded-full text-[10px]">Sim</span>
|
|
: <span className="bg-red-100 text-red-800 px-2 py-0.5 rounded-full text-[10px]">Não</span>}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
{sortedTransactions.length === 0 && !loading && (
|
|
<div className="p-8 text-center text-gray-500">Nenhuma transação encontrada.</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Modal */}
|
|
{(showAddModal || showEditModal) && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] overflow-y-auto">
|
|
<div className="p-4 border-b flex justify-between items-center bg-gray-50 sticky top-0">
|
|
<h2 className="text-xl font-bold text-gray-800">
|
|
{showAddModal ? "Nova Transação" : "Editar Transação"}
|
|
</h2>
|
|
<button onClick={() => { setShowAddModal(false); setShowEditModal(false); setSelectedTransaction(null); setProFunctions([]); }} className="p-1 hover:bg-gray-200 rounded">
|
|
<X size={20}/>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="p-6 grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
{/* Auto-fill Section */}
|
|
<div className="col-span-1 md:col-span-3 bg-blue-50 p-3 rounded border border-blue-100 mb-2">
|
|
<div className="flex gap-2 items-end">
|
|
<div className="w-1/3 relative">
|
|
<label className="block text-xs font-bold text-blue-700">Buscar por FOT</label>
|
|
<div className="flex relative">
|
|
<input
|
|
type="text"
|
|
className="border rounded-l w-full px-2 py-1"
|
|
placeholder="Nº FOT"
|
|
value={fotQuery}
|
|
onChange={e => handleFotSearch(e.target.value)}
|
|
onBlur={() => setTimeout(() => setShowFotSuggestions(false), 200)}
|
|
/>
|
|
<button disabled className="bg-blue-600 text-white px-2 rounded-r hover:bg-blue-700">
|
|
<Search size={16}/>
|
|
</button>
|
|
{showFotSuggestions && fotResults && fotResults.length > 0 && (
|
|
<div className="absolute top-10 z-10 w-full bg-white border rounded shadow-lg max-h-60 overflow-y-auto">
|
|
{fotResults.map(f => (
|
|
<div
|
|
key={f.id}
|
|
className="p-2 hover:bg-gray-100 cursor-pointer text-xs border-b last:border-0"
|
|
onClick={() => selectFot(f)}
|
|
>
|
|
<div className="font-bold">FOT: {f.fot}</div>
|
|
<div className="text-gray-500">{f.curso_nome} | {f.empresa_nome}</div>
|
|
<div className="text-gray-400 text-[10px]">{f.ano_formatura_label}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{fotLoading && <span className="text-xs text-gray-500 mb-2">Buscando...</span>}
|
|
</div>
|
|
{fotFound && (
|
|
<div className="mt-2 text-xs text-green-700 flex flex-col gap-1">
|
|
<div className="flex gap-4">
|
|
<span><strong>Curso:</strong> {formData.curso}</span>
|
|
<span><strong>Inst:</strong> {formData.instituicao}</span>
|
|
<span><strong>Ano:</strong> {formData.anoFormatura}</span>
|
|
</div>
|
|
{fotEvents.length > 0 && (
|
|
<div className="bg-yellow-50 p-2 rounded border border-yellow-200 mt-1">
|
|
<p className="font-bold text-yellow-800 mb-1">Eventos encontrados:</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{fotEvents.map(ev => (
|
|
<button
|
|
key={ev.id}
|
|
onClick={() => selectEvent(ev)}
|
|
className="bg-white border px-2 py-1 rounded hover:bg-yellow-100 text-yellow-900 border-yellow-300"
|
|
>
|
|
{ev.tipo_evento_nome} ({new Date(ev.data_evento).toLocaleDateString()})
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Data */}
|
|
<div>
|
|
<label className="block text-xs font-medium text-gray-700">Data Cobrança</label>
|
|
<input type="date" className="w-full border rounded px-2 py-1.5 mt-1"
|
|
value={formData.data} onChange={e => setFormData({...formData, data: e.target.value})}
|
|
/>
|
|
</div>
|
|
|
|
{/* Tipo Evento */}
|
|
<div>
|
|
<label className="block text-xs font-medium text-gray-700">Tipo Evento</label>
|
|
<select className="w-full border rounded px-2 py-1.5 mt-1"
|
|
value={formData.tipoEvento} onChange={e => setFormData({...formData, tipoEvento: e.target.value})}
|
|
>
|
|
<option value="">Selecione...</option>
|
|
{tiposEventos.map(t => <option key={t.id} value={t.nome}>{t.nome}</option>)}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Tipo Serviço */}
|
|
<div>
|
|
<label className="block text-xs font-medium text-gray-700">Tipo Serviço</label>
|
|
<select className="w-full border rounded px-2 py-1.5 mt-1"
|
|
value={formData.tipoServico} onChange={e => setFormData({...formData, tipoServico: e.target.value})}
|
|
>
|
|
<option value="">Selecione...</option>
|
|
{tiposServicos.map(t => <option key={t.id} value={t.nome}>{t.nome}</option>)}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Professional Info */}
|
|
<div className="md:col-span-2 relative">
|
|
<label className="block text-xs font-medium text-gray-700">Nome Profissional</label>
|
|
<input
|
|
type="text"
|
|
className="w-full border rounded px-2 py-1.5 mt-1"
|
|
value={formData.nome}
|
|
onChange={e => handleProSearch(e.target.value)}
|
|
onBlur={() => setTimeout(() => setShowProSuggestions(false), 200)} // Delay to allow click
|
|
placeholder="Digite para buscar..."
|
|
/>
|
|
{showProSuggestions && proResults && proResults.length > 0 && (
|
|
<div className="absolute z-10 w-full bg-white border rounded shadow-lg max-h-40 overflow-y-auto mt-1">
|
|
{proResults.map(p => (
|
|
<div
|
|
key={p.id}
|
|
className="p-2 hover:bg-gray-100 cursor-pointer text-xs"
|
|
onClick={() => selectProfessional(p)}
|
|
>
|
|
<strong>{p.nome}</strong> <span className="text-gray-500">({p.funcao_nome})</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-gray-700">WhatsApp</label>
|
|
<input type="text" className="w-full border rounded px-2 py-1.5 mt-1"
|
|
value={formData.whatsapp} onChange={e => setFormData({...formData, whatsapp: e.target.value})}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-gray-700">CPF</label>
|
|
<input type="text" className="w-full border rounded px-2 py-1.5 mt-1"
|
|
value={formData.cpf} onChange={e => setFormData({...formData, cpf: e.target.value})}
|
|
/>
|
|
</div>
|
|
|
|
{/* Values */}
|
|
<div>
|
|
<label className="block text-xs font-medium text-gray-700">Tabela Free</label>
|
|
{proFunctions.length > 0 ? (
|
|
<select
|
|
className="w-full border rounded px-2 py-1.5 mt-1"
|
|
value={formData.tabelaFree}
|
|
onChange={e => {
|
|
setFormData({...formData, tabelaFree: e.target.value});
|
|
// Also set Tipo Servico to match? Or keep them separate?
|
|
// Usually Tabela Free matches the function they are performing.
|
|
}}
|
|
>
|
|
{proFunctions.map((fn: any) => (
|
|
<option key={fn.id} value={fn.nome}>{fn.nome}</option>
|
|
))}
|
|
</select>
|
|
) : (
|
|
<input type="text" className="w-full border rounded px-2 py-1.5 mt-1"
|
|
value={formData.tabelaFree} onChange={e => setFormData({...formData, tabelaFree: e.target.value})}
|
|
/>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-gray-700">Valor Free (R$)</label>
|
|
<input type="number" step="0.01" className="w-full border rounded px-2 py-1.5 mt-1"
|
|
value={formData.valorFree} onChange={e => setFormData({...formData, valorFree: parseFloat(e.target.value)})}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-gray-700">Valor Extra (R$)</label>
|
|
<input type="number" step="0.01" className="w-full border rounded px-2 py-1.5 mt-1"
|
|
value={formData.valorExtra} onChange={e => setFormData({...formData, valorExtra: parseFloat(e.target.value)})}
|
|
/>
|
|
</div>
|
|
|
|
<div className="md:col-span-3">
|
|
<label className="block text-xs font-medium text-gray-700">Descrição Extra</label>
|
|
<input type="text" className="w-full border rounded px-2 py-1.5 mt-1"
|
|
value={formData.descricaoExtra} onChange={e => setFormData({...formData, descricaoExtra: e.target.value})}
|
|
/>
|
|
</div>
|
|
|
|
{/* Payment */}
|
|
<div>
|
|
<label className="block text-xs font-medium text-gray-700">Data Pagamento</label>
|
|
<input type="date" className="w-full border rounded px-2 py-1.5 mt-1"
|
|
value={formData.dataPgto} onChange={e => setFormData({...formData, dataPgto: e.target.value})}
|
|
/>
|
|
</div>
|
|
<div className="flex items-end pb-3">
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input type="checkbox" className="w-4 h-4 rounded text-brand-gold focus:ring-brand-gold"
|
|
checked={formData.pgtoOk} onChange={e => setFormData({...formData, pgtoOk: e.target.checked})}
|
|
/>
|
|
<span className="text-sm font-medium text-gray-700">Pagamento Realizado?</span>
|
|
</label>
|
|
</div>
|
|
<div className="bg-gray-100 p-2 rounded text-right flex flex-col justify-center">
|
|
<span className="text-xs text-gray-500">Total a Pagar</span>
|
|
<span className="text-xl font-bold text-gray-800">
|
|
R$ {formData.totalPagar?.toFixed(2)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-4 border-t bg-gray-50 flex justify-end gap-3 sticky bottom-0">
|
|
<button onClick={() => { setShowAddModal(false); setShowEditModal(false); }} className="px-4 py-2 text-gray-700 hover:bg-gray-200 rounded">Cancelar</button>
|
|
<button onClick={handleSave} className="px-6 py-2 bg-brand-gold text-white rounded hover:bg-yellow-600 shadow font-medium">
|
|
Salvar Transação
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Finance;
|