photum/frontend/components/System/PriceTableEditor.tsx
NANDO9322 f8bb2e66dd feat: suporte completo multi-região (SP/MG) e melhorias na validação de importação
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.
2026-02-05 16:18:40 -03:00

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>
);
};