Add internal purchases products page
This commit is contained in:
parent
69ec17cd91
commit
949398b867
1 changed files with 261 additions and 0 deletions
261
saveinmed-frontend/src/app/compras-internas/page.tsx
Normal file
261
saveinmed-frontend/src/app/compras-internas/page.tsx
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import Header from "@/components/Header";
|
||||
import { empresaApiService } from "@/services/empresaApiService";
|
||||
import { produtosVendaService, ProdutoCompleto } from "@/services/produtosVendaService";
|
||||
import { MapPinIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
interface Coordenadas {
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
interface GrupoProdutos {
|
||||
chave: string;
|
||||
nome: string;
|
||||
descricao?: string;
|
||||
categoria?: string;
|
||||
laboratorio?: string;
|
||||
itens: ProdutoCompleto[];
|
||||
}
|
||||
|
||||
const calcularDistanciaKm = (origem: Coordenadas, destino: Coordenadas): number => {
|
||||
const raioTerraKm = 6371;
|
||||
const dLat = ((destino.lat - origem.lat) * Math.PI) / 180;
|
||||
const dLng = ((destino.lng - origem.lng) * Math.PI) / 180;
|
||||
const lat1 = (origem.lat * Math.PI) / 180;
|
||||
const lat2 = (destino.lat * Math.PI) / 180;
|
||||
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.sin(dLng / 2) * Math.sin(dLng / 2) * Math.cos(lat1) * Math.cos(lat2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return raioTerraKm * c;
|
||||
};
|
||||
|
||||
const normalizarCoordenadas = (valor?: string | number | null): number | null => {
|
||||
if (valor === undefined || valor === null) {
|
||||
return null;
|
||||
}
|
||||
const numero = Number(valor);
|
||||
if (!Number.isFinite(numero)) {
|
||||
return null;
|
||||
}
|
||||
if (numero === 0) {
|
||||
return null;
|
||||
}
|
||||
return numero;
|
||||
};
|
||||
|
||||
const ComprasInternasPage = () => {
|
||||
const [produtos, setProdutos] = useState<ProdutoCompleto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [erro, setErro] = useState<string | null>(null);
|
||||
const [clienteCoords, setClienteCoords] = useState<Coordenadas | null>(null);
|
||||
const [erroGeolocalizacao, setErroGeolocalizacao] = useState<string | null>(null);
|
||||
const [coordenadasEmpresas, setCoordenadasEmpresas] = useState<Record<string, Coordenadas>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const carregarProdutos = async () => {
|
||||
setLoading(true);
|
||||
setErro(null);
|
||||
|
||||
try {
|
||||
const produtosCarregados = await produtosVendaService.buscarTodosProdutosCompletos();
|
||||
setProdutos(produtosCarregados);
|
||||
|
||||
const empresaIds = Array.from(
|
||||
new Set(produtosCarregados.map((produto) => produto.empresa_id).filter(Boolean))
|
||||
) as string[];
|
||||
|
||||
if (empresaIds.length) {
|
||||
const empresas = await Promise.all(
|
||||
empresaIds.map(async (empresaId) => ({
|
||||
empresaId,
|
||||
dados: await empresaApiService.buscarPorId(empresaId),
|
||||
}))
|
||||
);
|
||||
|
||||
const mapaCoordenadas: Record<string, Coordenadas> = {};
|
||||
empresas.forEach(({ empresaId, dados }) => {
|
||||
const lat = normalizarCoordenadas(dados?.latitude ?? null);
|
||||
const lng = normalizarCoordenadas(dados?.longitude ?? null);
|
||||
if (lat !== null && lng !== null) {
|
||||
mapaCoordenadas[empresaId] = { lat, lng };
|
||||
}
|
||||
});
|
||||
|
||||
setCoordenadasEmpresas(mapaCoordenadas);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erro ao carregar produtos para compra:", error);
|
||||
setErro("Não foi possível carregar os produtos no momento.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
carregarProdutos();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!navigator.geolocation) {
|
||||
setErroGeolocalizacao("Geolocalização não suportada neste navegador.");
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
setClienteCoords({
|
||||
lat: position.coords.latitude,
|
||||
lng: position.coords.longitude,
|
||||
});
|
||||
},
|
||||
(error) => {
|
||||
console.warn("Erro ao obter geolocalização:", error);
|
||||
setErroGeolocalizacao("Não foi possível obter sua localização.");
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
timeout: 10000,
|
||||
maximumAge: 60000,
|
||||
}
|
||||
);
|
||||
}, []);
|
||||
|
||||
const grupos = useMemo<GrupoProdutos[]>(() => {
|
||||
const mapa = new Map<string, GrupoProdutos>();
|
||||
|
||||
produtos.forEach((produto) => {
|
||||
const chave = produto.catalogo_id || produto.codigo_ean || produto.nome;
|
||||
const grupoExistente = mapa.get(chave);
|
||||
|
||||
if (grupoExistente) {
|
||||
grupoExistente.itens.push(produto);
|
||||
} else {
|
||||
mapa.set(chave, {
|
||||
chave,
|
||||
nome: produto.nome || "Produto sem nome",
|
||||
descricao: produto.descricao,
|
||||
categoria: produto.cat_nome || produto.categoria,
|
||||
laboratorio: produto.lab_nome || produto.laboratorio,
|
||||
itens: [produto],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(mapa.values()).map((grupo) => ({
|
||||
...grupo,
|
||||
itens: [...grupo.itens].sort(
|
||||
(a, b) => (a.preco_final || a.preco_venda) - (b.preco_final || b.preco_venda)
|
||||
),
|
||||
}));
|
||||
}, [produtos]);
|
||||
|
||||
const obterDistancia = (produto: ProdutoCompleto): number | null => {
|
||||
if (!clienteCoords || !produto.empresa_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const coordenadasEmpresa = coordenadasEmpresas[produto.empresa_id];
|
||||
if (!coordenadasEmpresa) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return calcularDistanciaKm(clienteCoords, coordenadasEmpresa);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Header />
|
||||
<main className="max-w-6xl mx-auto px-4 py-8">
|
||||
<div className="flex flex-col gap-3 mb-8">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Produtos para compra interna</h1>
|
||||
<p className="text-sm text-gray-600">
|
||||
Lista interna de produtos para compra, agrupados por similaridade e com a distância estimada
|
||||
até você.
|
||||
</p>
|
||||
{erroGeolocalizacao && (
|
||||
<div className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-md px-3 py-2">
|
||||
{erroGeolocalizacao}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && <div className="text-gray-500 text-sm">Carregando produtos...</div>}
|
||||
|
||||
{!loading && erro && (
|
||||
<div className="text-sm text-red-700 bg-red-50 border border-red-200 rounded-md px-4 py-3">
|
||||
{erro}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !erro && grupos.length === 0 && (
|
||||
<div className="text-sm text-gray-600">Nenhum produto disponível para compra no momento.</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
{grupos.map((grupo) => (
|
||||
<section key={grupo.chave} className="bg-white border border-gray-200 rounded-lg p-5 shadow-sm">
|
||||
<div className="flex flex-col gap-2 mb-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-lg font-semibold text-gray-900">{grupo.nome}</h2>
|
||||
<span className="text-xs text-gray-500">({grupo.itens.length} oferta(s))</span>
|
||||
</div>
|
||||
{grupo.descricao && <p className="text-sm text-gray-600">{grupo.descricao}</p>}
|
||||
<div className="flex flex-wrap gap-3 text-xs text-gray-500">
|
||||
{grupo.categoria && <span>Categoria: {grupo.categoria}</span>}
|
||||
{grupo.laboratorio && <span>Laboratório: {grupo.laboratorio}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{grupo.itens.map((produto) => {
|
||||
const distanciaKm = obterDistancia(produto);
|
||||
const preco = produto.preco_final || produto.preco_venda;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={produto.id}
|
||||
className="border border-gray-100 rounded-lg p-4 flex flex-col gap-3 bg-gray-50"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-700 font-medium">Estoque</div>
|
||||
<div className="text-sm font-semibold text-gray-900">
|
||||
{produto.quantidade_estoque ?? 0} un.
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-700 font-medium">Preço</div>
|
||||
<div className="text-sm font-semibold text-green-700">
|
||||
R$ {preco.toFixed(2).replace(".", ",")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1 text-sm text-gray-700 font-medium">
|
||||
<MapPinIcon className="w-4 h-4 text-blue-600" />
|
||||
Distância
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-blue-700">
|
||||
{distanciaKm !== null ? `${distanciaKm.toFixed(1)} km` : "Indisponível"}
|
||||
</div>
|
||||
</div>
|
||||
{produto.data_validade && (
|
||||
<div className="text-xs text-gray-500">
|
||||
Validade: {new Date(produto.data_validade).toLocaleDateString("pt-BR")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComprasInternasPage;
|
||||
Loading…
Reference in a new issue