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:
parent
d2c37d7b2c
commit
7a06d4e691
7 changed files with 165 additions and 26 deletions
|
|
@ -48,7 +48,7 @@ export const EventCard: React.FC<EventCardProps> = ({ event, onClick }) => {
|
||||||
<div className="space-y-2 sm:space-y-3">
|
<div className="space-y-2 sm:space-y-3">
|
||||||
<div className="flex items-center text-gray-500 text-xs sm:text-sm">
|
<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" />
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Location with Tooltip */}
|
{/* Location with Tooltip */}
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,8 @@ import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import { MapboxResult } from "../services/mapboxService";
|
||||||
searchMapboxLocation,
|
import { searchGoogleLocation, reverseGeocodeGoogle } from "../services/googleMapsService";
|
||||||
MapboxResult,
|
|
||||||
reverseGeocode,
|
|
||||||
} from "../services/mapboxService";
|
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import { useData } from "../contexts/DataContext";
|
import { useData } from "../contexts/DataContext";
|
||||||
import { UserRole } from "../types";
|
import { UserRole } from "../types";
|
||||||
|
|
@ -197,8 +194,8 @@ export const EventForm: React.FC<EventFormProps> = ({
|
||||||
setFormData(prev => ({
|
setFormData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
...initialData,
|
...initialData,
|
||||||
startTime: initialData.time || (initialData as any).horario || "00:00",
|
startTime: initialData.time || initialData.startTime || (initialData as any).horario || "00:00",
|
||||||
endTime: (initialData as any).horario_termino || prev.endTime || "",
|
endTime: initialData.endTime || (initialData as any).horario_fim || prev.endTime || "",
|
||||||
locationName: mapLink.includes('http') ? "" : mapLink, // Avoid putting URL in name
|
locationName: mapLink.includes('http') ? "" : mapLink, // Avoid putting URL in name
|
||||||
fotId: mappedFotId,
|
fotId: mappedFotId,
|
||||||
name: mappedObservacoes, // Map Observacoes to Name field (displayed as "Observacoes do Evento")
|
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 () => {
|
const timer = setTimeout(async () => {
|
||||||
if (addressQuery.length > 3) {
|
if (addressQuery.length > 3) {
|
||||||
setIsSearching(true);
|
setIsSearching(true);
|
||||||
const results = await searchMapboxLocation(addressQuery);
|
const results = await searchGoogleLocation(addressQuery);
|
||||||
setAddressResults(results);
|
setAddressResults(results);
|
||||||
setIsSearching(false);
|
setIsSearching(false);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -306,6 +303,7 @@ export const EventForm: React.FC<EventFormProps> = ({
|
||||||
const handleAddressSelect = (addr: MapboxResult) => {
|
const handleAddressSelect = (addr: MapboxResult) => {
|
||||||
setFormData((prev: any) => ({
|
setFormData((prev: any) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
locationName: addr.placeName || prev.locationName,
|
||||||
address: {
|
address: {
|
||||||
street: addr.street,
|
street: addr.street,
|
||||||
number: addr.number,
|
number: addr.number,
|
||||||
|
|
@ -322,11 +320,12 @@ export const EventForm: React.FC<EventFormProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMapLocationChange = async (lat: number, lng: number) => {
|
const handleMapLocationChange = async (lat: number, lng: number) => {
|
||||||
const addressData = await reverseGeocode(lat, lng);
|
const addressData = await reverseGeocodeGoogle(lat, lng);
|
||||||
|
|
||||||
if (addressData) {
|
if (addressData) {
|
||||||
setFormData((prev: any) => ({
|
setFormData((prev: any) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
locationName: addressData.placeName || prev.locationName,
|
||||||
address: {
|
address: {
|
||||||
street: addressData.street,
|
street: addressData.street,
|
||||||
number: addressData.number,
|
number: addressData.number,
|
||||||
|
|
@ -358,11 +357,12 @@ export const EventForm: React.FC<EventFormProps> = ({
|
||||||
|
|
||||||
setIsGeocoding(true);
|
setIsGeocoding(true);
|
||||||
try {
|
try {
|
||||||
const results = await searchMapboxLocation(query);
|
const results = await searchGoogleLocation(query);
|
||||||
if (results.length > 0) {
|
if (results.length > 0) {
|
||||||
const firstResult = results[0];
|
const firstResult = results[0];
|
||||||
setFormData((prev: any) => ({
|
setFormData((prev: any) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
locationName: firstResult.placeName || prev.locationName,
|
||||||
address: {
|
address: {
|
||||||
...prev.address,
|
...prev.address,
|
||||||
lat: firstResult.lat,
|
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">
|
<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">
|
<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>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ interface EventSchedulerProps {
|
||||||
allowedProfessionals?: { professional_id?: string; professionalId?: string; status?: string }[] | string[]; // IDs or Objects
|
allowedProfessionals?: { professional_id?: string; professionalId?: string; status?: string }[] | string[]; // IDs or Objects
|
||||||
onUpdateStats?: (stats: { studios: number }) => void;
|
onUpdateStats?: (stats: { studios: number }) => void;
|
||||||
defaultTime?: string;
|
defaultTime?: string;
|
||||||
|
defaultEndTime?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeSlots = [
|
const timeSlots = [
|
||||||
|
|
@ -19,7 +20,7 @@ const timeSlots = [
|
||||||
"19:00", "20:00", "21:00", "22:00", "23:00", "00:00"
|
"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 { token, user } = useAuth();
|
||||||
const { professionals, events, functions } = useData();
|
const { professionals, events, functions } = useData();
|
||||||
const [escalas, setEscalas] = useState<any[]>([]);
|
const [escalas, setEscalas] = useState<any[]>([]);
|
||||||
|
|
@ -29,7 +30,7 @@ const EventScheduler: React.FC<EventSchedulerProps> = ({ agendaId, dataEvento, a
|
||||||
// New entry state
|
// New entry state
|
||||||
const [selectedProf, setSelectedProf] = useState("");
|
const [selectedProf, setSelectedProf] = useState("");
|
||||||
const [startTime, setStartTime] = useState(defaultTime || "08:00");
|
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 [role, setRole] = useState("");
|
||||||
|
|
||||||
const isEditable = user?.role === UserRole.SUPERADMIN || user?.role === UserRole.BUSINESS_OWNER;
|
const isEditable = user?.role === UserRole.SUPERADMIN || user?.role === UserRole.BUSINESS_OWNER;
|
||||||
|
|
|
||||||
|
|
@ -680,6 +680,8 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
name: e.observacoes_evento || e.tipo_evento_nome || "Evento sem nome", // Fallback mapping
|
name: e.observacoes_evento || e.tipo_evento_nome || "Evento sem nome", // Fallback mapping
|
||||||
date: e.data_evento ? e.data_evento.split('T')[0] : "",
|
date: e.data_evento ? e.data_evento.split('T')[0] : "",
|
||||||
time: e.horario || "00:00",
|
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
|
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
|
status: mapStatus(e.status), // Map from backend status with fallback
|
||||||
address: {
|
address: {
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,7 @@ const EventDetails: React.FC = () => {
|
||||||
<Clock className="w-5 h-5 text-brand-purple mt-1" />
|
<Clock className="w-5 h-5 text-brand-purple mt-1" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-500 uppercase font-bold">Horário</p>
|
<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>
|
</div>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
|
|
@ -114,7 +114,8 @@ const EventDetails: React.FC = () => {
|
||||||
dataEvento={event.date}
|
dataEvento={event.date}
|
||||||
allowedProfessionals={event.assignments}
|
allowedProfessionals={event.assignments}
|
||||||
onUpdateStats={setCalculatedStats}
|
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 */}
|
{/* Right: Logistics (Carros) - Only visible if user has permission */}
|
||||||
|
|
|
||||||
130
frontend/services/googleMapsService.ts
Normal file
130
frontend/services/googleMapsService.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,9 +16,11 @@ export interface MapboxFeature {
|
||||||
place_type: string[];
|
place_type: string[];
|
||||||
text: string;
|
text: string;
|
||||||
address?: string;
|
address?: string;
|
||||||
|
properties?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MapboxResult {
|
export interface MapboxResult {
|
||||||
|
placeName?: string;
|
||||||
description: string;
|
description: string;
|
||||||
street: string;
|
street: string;
|
||||||
number: string;
|
number: string;
|
||||||
|
|
@ -56,17 +58,11 @@ export async function searchMapboxLocation(
|
||||||
`access_token=${MAPBOX_TOKEN}&` +
|
`access_token=${MAPBOX_TOKEN}&` +
|
||||||
`country=${country}&` +
|
`country=${country}&` +
|
||||||
`language=pt&` +
|
`language=pt&` +
|
||||||
|
`types=poi,address&` +
|
||||||
`limit=10`;
|
`limit=10`;
|
||||||
|
|
||||||
// Add proximity bias based on region
|
// Removed proximity bias to prevent Mapbox from hiding national POIs (like Estádio Pacaembu)
|
||||||
const region = localStorage.getItem("photum_selected_region");
|
// when the user's region is set far away from the POI.
|
||||||
if (region === "MG") {
|
|
||||||
// Belo Horizonteish center
|
|
||||||
url += `&proximity=-43.9378,-19.9208`;
|
|
||||||
} else {
|
|
||||||
// São Pauloish center (Default)
|
|
||||||
url += `&proximity=-46.6333,-23.5505`;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("🔍 Buscando endereço:", query);
|
console.log("🔍 Buscando endereço:", query);
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
|
|
@ -94,9 +90,18 @@ export async function searchMapboxLocation(
|
||||||
const addressMatch =
|
const addressMatch =
|
||||||
feature.address || feature.text.match(/\d+/)?.[0] || "";
|
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 {
|
return {
|
||||||
|
placeName,
|
||||||
description: feature.place_name,
|
description: feature.place_name,
|
||||||
street: feature.text,
|
street,
|
||||||
number: addressMatch,
|
number: addressMatch,
|
||||||
city: place?.text || "",
|
city: place?.text || "",
|
||||||
state: region?.short_code?.replace("BR-", "") || region?.text || "",
|
state: region?.short_code?.replace("BR-", "") || region?.text || "",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue