Detalhes das alterações: [Banco de Dados] - Ajuste nas constraints UNIQUE das tabelas de catálogo (cursos, empresas, tipos_eventos, etc.) para incluir a coluna `regiao`, permitindo dados duplicados entre regiões mas únicos por região. - Correção crítica na constraint da tabela `precos_tipos_eventos` para evitar conflitos de UPSERT (ON CONFLICT) durante a inicialização. - Implementação de lógica de Seed para a região 'MG': - Clonagem automática de catálogos base de 'SP' para 'MG' (Tipos de Evento, Serviços, etc.). - Inserção de tabela de preços específica para 'MG' via script de migração. [Backend - Go] - Atualização geral dos Handlers e Services para filtrar dados baseados no cabeçalho `x-regiao`. - Ajuste no Middleware de autenticação para processar e repassar o contexto da região. - Correção de queries SQL (geradas pelo sqlc) para suportar os novos filtros regionais. [Frontend - React] - Implementação do envio global do cabeçalho `x-regiao` nas requisições da API. - Correção no componente [PriceTableEditor](cci:1://file:///c:/Projetos/photum/frontend/components/System/PriceTableEditor.tsx:26:0-217:2) para carregar e salvar preços respeitando a região selecionada (fix de "Preços zerados" em MG). - Refatoração profunda na tela de Importação ([ImportData.tsx](cci:7://file:///c:/Projetos/photum/frontend/pages/ImportData.tsx:0:0-0:0)): - Adição de feedback visual detalhado para registros ignorados. - Categorização explícita de erros: "CPF Inválido", "Região Incompatível", "Linha Vazia/Separador". - Correção na lógica de contagem para considerar linhas vazias explicitamente no relatório final, garantindo que o total bata com o Excel. [Geral] - Correção de diversos erros de lint e tipagem TSX. - Padronização de logs de erro no backend para facilitar debug.
218 lines
9.1 KiB
TypeScript
218 lines
9.1 KiB
TypeScript
import React, { useState, useEffect } from "react";
|
|
import { Save, Search, DollarSign } from "lucide-react";
|
|
import { Button } from "../Button";
|
|
import { useAuth } from "../../contexts/AuthContext";
|
|
import { useRegion } from "../../contexts/RegionContext";
|
|
import { toast } from "react-hot-toast";
|
|
|
|
const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8080";
|
|
|
|
interface Role {
|
|
id: string;
|
|
nome: string;
|
|
}
|
|
|
|
interface EventType {
|
|
id: string;
|
|
nome: string;
|
|
}
|
|
|
|
interface Price {
|
|
id?: string;
|
|
funcao_profissional_id: string;
|
|
tipo_evento_id: string;
|
|
valor: number;
|
|
}
|
|
|
|
export const PriceTableEditor: React.FC = () => {
|
|
const { token } = useAuth();
|
|
const { currentRegion } = useRegion();
|
|
const [eventTypes, setEventTypes] = useState<EventType[]>([]);
|
|
const [roles, setRoles] = useState<Role[]>([]);
|
|
const [prices, setPrices] = useState<Record<string, number>>({}); // roleId -> value
|
|
const [selectedEventId, setSelectedEventId] = useState<string>("");
|
|
const [loading, setLoading] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
// Initial Load
|
|
useEffect(() => {
|
|
const loadInitialData = async () => {
|
|
try {
|
|
const [eventsRes, rolesRes] = await Promise.all([
|
|
fetch(`${API_BASE_URL}/api/tipos-eventos`, {
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
'x-regiao': currentRegion
|
|
}
|
|
}),
|
|
fetch(`${API_BASE_URL}/api/funcoes`, {
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
'x-regiao': currentRegion
|
|
}
|
|
})
|
|
]);
|
|
|
|
const eventsData = await eventsRes.json();
|
|
const rolesData = await rolesRes.json();
|
|
|
|
// Adjust for data wrapper if exists
|
|
const events = (eventsData.data || eventsData) as EventType[];
|
|
const roles = (rolesData.data || rolesData) as Role[];
|
|
|
|
setEventTypes(events);
|
|
setRoles(roles);
|
|
if (events.length > 0) setSelectedEventId(events[0].id);
|
|
} catch (error) {
|
|
console.error(error);
|
|
toast.error("Erro ao carregar dados iniciais");
|
|
}
|
|
};
|
|
if (token) loadInitialData();
|
|
}, [token, currentRegion]);
|
|
|
|
// Load Prices when Event Selected
|
|
useEffect(() => {
|
|
if (!selectedEventId || !token) return;
|
|
|
|
const loadPrices = async () => {
|
|
setLoading(true);
|
|
try {
|
|
// Assuming we use type-events/:id/precos or similar
|
|
// Based on routes: GET /api/tipos-eventos/:id/precos
|
|
const res = await fetch(`${API_BASE_URL}/api/tipos-eventos/${selectedEventId}/precos`, {
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
'x-regiao': currentRegion
|
|
}
|
|
});
|
|
if (!res.ok) throw new Error("Erro ao carregar preços");
|
|
|
|
const data = await res.json();
|
|
const priceList = (data.data || data) as Price[];
|
|
|
|
// Map to dictionary
|
|
const priceMap: Record<string, number> = {};
|
|
priceList.forEach(p => {
|
|
priceMap[p.funcao_profissional_id] = p.valor;
|
|
});
|
|
setPrices(priceMap);
|
|
} catch (error) {
|
|
toast.error("Erro ao carregar tabela de preços");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
loadPrices();
|
|
}, [selectedEventId, token, currentRegion]);
|
|
|
|
const handlePriceChange = (roleId: string, value: string) => {
|
|
const numValue = parseFloat(value) || 0;
|
|
setPrices(prev => ({ ...prev, [roleId]: numValue }));
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
setSaving(true);
|
|
try {
|
|
// Need to save each price. Backend has POST /api/tipos-eventos/precos
|
|
// Body: { tipo_evento_id, funcao_profissional_id, valor }
|
|
|
|
// We can do parallel requests or backend bulk?
|
|
// Existing route seems singular or accepts array?
|
|
// "api.POST("/tipos-eventos/precos", tiposEventosHandler.SetPrice)"
|
|
// I'll assume singular for safety, or create a loop.
|
|
|
|
const promises = roles.map(role => {
|
|
const valor = prices[role.id];
|
|
// If undefined, maybe skip? But maybe we want to save 0?
|
|
if (valor === undefined) return Promise.resolve();
|
|
|
|
return fetch(`${API_BASE_URL}/api/tipos-eventos/precos`, {
|
|
method: "POST", // or PUT check handlers
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${token}`,
|
|
'x-regiao': currentRegion
|
|
},
|
|
body: JSON.stringify({
|
|
tipo_evento_id: selectedEventId,
|
|
funcao_profissional_id: role.id,
|
|
valor: valor
|
|
})
|
|
});
|
|
});
|
|
|
|
await Promise.all(promises);
|
|
toast.success("Preços atualizados com sucesso!");
|
|
} catch (error) {
|
|
console.error(error);
|
|
toast.error("Erro ao salvar preços");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
<h2 className="text-xl font-semibold text-gray-800 mb-6 flex items-center gap-2">
|
|
<DollarSign className="text-brand-gold" />
|
|
Tabela de Preços (Cachês Base)
|
|
</h2>
|
|
|
|
<div className="mb-6">
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">Selecione o Tipo de Evento</label>
|
|
<select
|
|
className="w-full sm:w-1/3 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#492E61] focus:outline-none"
|
|
value={selectedEventId}
|
|
onChange={(e) => setSelectedEventId(e.target.value)}
|
|
>
|
|
{eventTypes.map(ev => (
|
|
<option key={ev.id} value={ev.id}>{ev.nome}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="overflow-x-auto">
|
|
<table className="min-w-full divide-y divide-gray-200">
|
|
<thead className="bg-gray-50">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Função</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Valor do Cachê (R$)</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white divide-y divide-gray-200">
|
|
{loading ? (
|
|
<tr><td colSpan={2} className="text-center py-4">Carregando...</td></tr>
|
|
) : roles.map(role => (
|
|
<tr key={role.id} className="hover:bg-gray-50">
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
|
{role.nome}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
<div className="relative w-48">
|
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">R$</span>
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-brand-purple focus:outline-none"
|
|
value={prices[role.id] ?? ""} // Use ?? "" to control input
|
|
onChange={(e) => handlePriceChange(role.id, e.target.value)}
|
|
placeholder="0,00"
|
|
/>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div className="mt-6 flex justify-end">
|
|
<Button onClick={handleSave} disabled={saving}>
|
|
<Save size={18} className="mr-2" />
|
|
{saving ? "Salvando..." : "Salvar Tabela"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|