feat(agenda): migração busca POI para Google Maps e correção do horário de término

Frontend:
- Migração da API de Geocoding do Mapbox para o Google Maps Places API (googleMapsService.ts) no formulário de eventos, garantindo a busca correta pelo nome de locais (estádios, teatros) e com autopreenchimento.
- Correção do fluxo de estado do 'horario_fim', propagando e persistindo o 'endTime' pelo DataContext, garantindo a população dos dados na edição do EventForm.
- Adição da visualização do horário final na listagem do Dashboard, no EventCard, painéis de EventDetails e atualização das props defaultEndTime no EventScheduler.

Backend:
- Atualização e migração dos arquivos gerados pelo sqlc (models.go, agenda.sql.go) para suportar operações no novo design do banco.
- Atualização síncrona dos artefatos Swagger de documentação de API (docs.go, swagger.json, swagger.yaml).
This commit is contained in:
NANDO9322 2026-02-27 18:48:07 -03:00
parent d2c37d7b2c
commit 7a06d4e691
7 changed files with 165 additions and 26 deletions

View file

@ -48,7 +48,7 @@ export const EventCard: React.FC<EventCardProps> = ({ event, onClick }) => {
<div className="space-y-2 sm:space-y-3">
<div className="flex items-center text-gray-500 text-xs sm:text-sm">
<Calendar size={14} className="sm:w-4 sm:h-4 mr-1.5 sm:mr-2 text-brand-gold flex-shrink-0" />
<span className="truncate">{new Date(event.date).toLocaleDateString()} às {event.time}</span>
<span className="truncate">{new Date(event.date).toLocaleDateString()} às {event.startTime || event.time}{event.endTime || event.horario_fim ? ` - ${event.endTime || event.horario_fim}` : ''}</span>
</div>
{/* Location with Tooltip */}

View file

@ -16,11 +16,8 @@ import {
AlertCircle,
AlertTriangle,
} from "lucide-react";
import {
searchMapboxLocation,
MapboxResult,
reverseGeocode,
} from "../services/mapboxService";
import { MapboxResult } from "../services/mapboxService";
import { searchGoogleLocation, reverseGeocodeGoogle } from "../services/googleMapsService";
import { useAuth } from "../contexts/AuthContext";
import { useData } from "../contexts/DataContext";
import { UserRole } from "../types";
@ -197,8 +194,8 @@ export const EventForm: React.FC<EventFormProps> = ({
setFormData(prev => ({
...prev,
...initialData,
startTime: initialData.time || (initialData as any).horario || "00:00",
endTime: (initialData as any).horario_termino || prev.endTime || "",
startTime: initialData.time || initialData.startTime || (initialData as any).horario || "00:00",
endTime: initialData.endTime || (initialData as any).horario_fim || prev.endTime || "",
locationName: mapLink.includes('http') ? "" : mapLink, // Avoid putting URL in name
fotId: mappedFotId,
name: mappedObservacoes, // Map Observacoes to Name field (displayed as "Observacoes do Evento")
@ -293,7 +290,7 @@ export const EventForm: React.FC<EventFormProps> = ({
const timer = setTimeout(async () => {
if (addressQuery.length > 3) {
setIsSearching(true);
const results = await searchMapboxLocation(addressQuery);
const results = await searchGoogleLocation(addressQuery);
setAddressResults(results);
setIsSearching(false);
} else {
@ -306,6 +303,7 @@ export const EventForm: React.FC<EventFormProps> = ({
const handleAddressSelect = (addr: MapboxResult) => {
setFormData((prev: any) => ({
...prev,
locationName: addr.placeName || prev.locationName,
address: {
street: addr.street,
number: addr.number,
@ -322,11 +320,12 @@ export const EventForm: React.FC<EventFormProps> = ({
};
const handleMapLocationChange = async (lat: number, lng: number) => {
const addressData = await reverseGeocode(lat, lng);
const addressData = await reverseGeocodeGoogle(lat, lng);
if (addressData) {
setFormData((prev: any) => ({
...prev,
locationName: addressData.placeName || prev.locationName,
address: {
street: addressData.street,
number: addressData.number,
@ -358,11 +357,12 @@ export const EventForm: React.FC<EventFormProps> = ({
setIsGeocoding(true);
try {
const results = await searchMapboxLocation(query);
const results = await searchGoogleLocation(query);
if (results.length > 0) {
const firstResult = results[0];
setFormData((prev: any) => ({
...prev,
locationName: firstResult.placeName || prev.locationName,
address: {
...prev.address,
lat: firstResult.lat,
@ -930,7 +930,7 @@ export const EventForm: React.FC<EventFormProps> = ({
<div className="bg-gray-50 p-4 rounded-lg border border-gray-200">
<label className="block text-sm font-medium text-gray-700 mb-2 tracking-wide uppercase text-xs">
Busca de Endereço (Powered by Mapbox)
BUSCA DE ENDEREÇO (POWERED BY GOOGLE)
</label>
<div className="relative">
<input

View file

@ -11,6 +11,7 @@ interface EventSchedulerProps {
allowedProfessionals?: { professional_id?: string; professionalId?: string; status?: string }[] | string[]; // IDs or Objects
onUpdateStats?: (stats: { studios: number }) => void;
defaultTime?: string;
defaultEndTime?: string;
}
const timeSlots = [
@ -19,7 +20,7 @@ const timeSlots = [
"19:00", "20:00", "21:00", "22:00", "23:00", "00:00"
];
const EventScheduler: React.FC<EventSchedulerProps> = ({ agendaId, dataEvento, allowedProfessionals, onUpdateStats, defaultTime }) => {
const EventScheduler: React.FC<EventSchedulerProps> = ({ agendaId, dataEvento, allowedProfessionals, onUpdateStats, defaultTime, defaultEndTime }) => {
const { token, user } = useAuth();
const { professionals, events, functions } = useData();
const [escalas, setEscalas] = useState<any[]>([]);
@ -29,7 +30,7 @@ const EventScheduler: React.FC<EventSchedulerProps> = ({ agendaId, dataEvento, a
// New entry state
const [selectedProf, setSelectedProf] = useState("");
const [startTime, setStartTime] = useState(defaultTime || "08:00");
const [endTime, setEndTime] = useState("12:00"); // Could calculated based on start, but keep simple
const [endTime, setEndTime] = useState(defaultEndTime || "12:00"); // Could calculated based on start, but keep simple
const [role, setRole] = useState("");
const isEditable = user?.role === UserRole.SUPERADMIN || user?.role === UserRole.BUSINESS_OWNER;

View file

@ -680,6 +680,8 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({
name: e.observacoes_evento || e.tipo_evento_nome || "Evento sem nome", // Fallback mapping
date: e.data_evento ? e.data_evento.split('T')[0] : "",
time: e.horario || "00:00",
startTime: e.horario || "00:00",
endTime: e.horario_fim || "",
type: (e.tipo_evento_nome || "Outro") as EventType, // Map string to enum if possible, or keep string
status: mapStatus(e.status), // Map from backend status with fallback
address: {

View file

@ -95,7 +95,7 @@ const EventDetails: React.FC = () => {
<Clock className="w-5 h-5 text-brand-purple mt-1" />
<div>
<p className="text-xs text-gray-500 uppercase font-bold">Horário</p>
<p className="font-medium text-gray-800">{event.horario || event.time || "Não definido"}</p>
<p className="font-medium text-gray-800">{event.startTime || event.horario || event.time || "Não definido"}{event.endTime || event.horario_fim ? ` - ${event.endTime || event.horario_fim}` : ''}</p>
</div>
</div>
<div className="flex items-start gap-3">
@ -114,7 +114,8 @@ const EventDetails: React.FC = () => {
dataEvento={event.date}
allowedProfessionals={event.assignments}
onUpdateStats={setCalculatedStats}
defaultTime={event.time}
defaultTime={event.startTime || event.time}
defaultEndTime={event.endTime || event.horario_fim}
/>
{/* Right: Logistics (Carros) - Only visible if user has permission */}

View file

@ -0,0 +1,130 @@
import { MapboxResult } from "./mapboxService";
// Exporting MapboxResult from mapboxService to keep compatibility
// but using Google Maps to fetch the data.
const GOOGLE_MAPS_KEY = import.meta.env.VITE_GOOGLE_MAPS_KEY || "";
/**
* Busca endereços e locais usando a API de Geocoding do Google
*/
export async function searchGoogleLocation(query: string, country: string = "br"): Promise<MapboxResult[]> {
if (!GOOGLE_MAPS_KEY || GOOGLE_MAPS_KEY.includes("YOUR")) {
console.warn("⚠️ Google Maps Token não configurado em .env.local");
return [];
}
try {
const encodedQuery = encodeURIComponent(query);
const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodedQuery}&components=country:${country}&language=pt-BR&key=${GOOGLE_MAPS_KEY}`;
const response = await fetch(url);
const data = await response.json();
if (data.status !== "OK" || !data.results) {
if (data.status !== "ZERO_RESULTS") {
console.error("Google Maps API Error:", data.status, data.error_message);
}
return [];
}
return data.results.map((result: any) => {
// Find components
const getComponent = (type: string) => {
const comp = result.address_components.find((c: any) => c.types.includes(type));
return comp ? comp.long_name : "";
};
const getShortComponent = (type: string) => {
const comp = result.address_components.find((c: any) => c.types.includes(type));
return comp ? comp.short_name : "";
};
const street = getComponent("route");
const number = getComponent("street_number");
const city = getComponent("administrative_area_level_2") || getComponent("locality");
const state = getShortComponent("administrative_area_level_1");
const zip = getComponent("postal_code");
// Verify if it's a POI by checking the types of the location
const isPoi = result.types.includes("establishment") || result.types.includes("stadium") || result.types.includes("point_of_interest");
let placeName = undefined;
let finalStreet = street;
if (isPoi) {
// Obter o nome do estabelecimento do formatted_address (ex: "Allianz Parque, Av. Francisco Matarazzo...")
placeName = result.address_components.find((c: any) => c.types.includes("establishment") || c.types.includes("point_of_interest"))?.long_name;
if (!placeName && result.formatted_address) {
placeName = result.formatted_address.split(",")[0];
}
}
// Se a query não conseguiu resolver route/street, usa a formatação
if (!finalStreet) finalStreet = result.formatted_address;
return {
placeName,
description: placeName ? `${placeName} - ${result.formatted_address}` : result.formatted_address,
street: finalStreet,
number,
city,
state,
zip,
lat: result.geometry.location.lat,
lng: result.geometry.location.lng,
mapLink: `https://www.google.com/maps/search/?api=1&query=${result.geometry.location.lat},${result.geometry.location.lng}`,
};
});
} catch (error) {
console.error("Erro ao buscar no Google Maps:", error);
return [];
}
}
/**
* Busca reversa via Google Maps Geocoding
*/
export async function reverseGeocodeGoogle(lat: number, lng: number): Promise<MapboxResult | null> {
if (!GOOGLE_MAPS_KEY) return null;
try {
const url = `https://maps.googleapis.com/maps/api/geocode/json?latlng=${lat},${lng}&language=pt-BR&key=${GOOGLE_MAPS_KEY}`;
const response = await fetch(url);
const data = await response.json();
if (data.status === "OK" && data.results.length > 0) {
const result = data.results[0];
const getComponent = (type: string) => {
const comp = result.address_components.find((c: any) => c.types.includes(type));
return comp ? comp.long_name : "";
};
const getShortComponent = (type: string) => {
const comp = result.address_components.find((c: any) => c.types.includes(type));
return comp ? comp.short_name : "";
};
const street = getComponent("route") || result.formatted_address;
const number = getComponent("street_number");
const city = getComponent("administrative_area_level_2") || getComponent("locality");
const state = getShortComponent("administrative_area_level_1");
const zip = getComponent("postal_code");
return {
description: result.formatted_address,
street,
number,
city,
state,
zip,
lat,
lng,
mapLink: `https://www.google.com/maps/search/?api=1&query=${lat},${lng}`,
};
}
return null;
} catch (error) {
console.error("Erro no reverse geocode do Google:", error);
return null;
}
}

View file

@ -16,9 +16,11 @@ export interface MapboxFeature {
place_type: string[];
text: string;
address?: string;
properties?: any;
}
export interface MapboxResult {
placeName?: string;
description: string;
street: string;
number: string;
@ -56,17 +58,11 @@ export async function searchMapboxLocation(
`access_token=${MAPBOX_TOKEN}&` +
`country=${country}&` +
`language=pt&` +
`types=poi,address&` +
`limit=10`;
// Add proximity bias based on region
const region = localStorage.getItem("photum_selected_region");
if (region === "MG") {
// Belo Horizonteish center
url += `&proximity=-43.9378,-19.9208`;
} else {
// São Pauloish center (Default)
url += `&proximity=-46.6333,-23.5505`;
}
// Removed proximity bias to prevent Mapbox from hiding national POIs (like Estádio Pacaembu)
// when the user's region is set far away from the POI.
console.log("🔍 Buscando endereço:", query);
const response = await fetch(url);
@ -94,9 +90,18 @@ export async function searchMapboxLocation(
const addressMatch =
feature.address || feature.text.match(/\d+/)?.[0] || "";
let placeName = undefined;
let street = feature.text;
if (feature.place_type.includes("poi")) {
placeName = feature.text;
street = feature.properties?.address || feature.place_name.split(",")[0];
}
return {
placeName,
description: feature.place_name,
street: feature.text,
street,
number: addressMatch,
city: place?.text || "",
state: region?.short_code?.replace("BR-", "") || region?.text || "",