diff --git a/.gitignore b/.gitignore index a576c85..6eddb5a 100644 Binary files a/.gitignore and b/.gitignore differ diff --git a/commit-push.bat b/commit-push.bat index 14a416a..a27c47f 100644 --- a/commit-push.bat +++ b/commit-push.bat @@ -1,5 +1,5 @@ @echo-off -cd /d D:\maga\magricambi +cd /d D:\maga\magricambi2 git status echo. set /p MSG=Messaggio commit: diff --git a/src/app/api/articoli/ricerca-prezzi/route.ts b/src/app/api/articoli/ricerca-prezzi/route.ts new file mode 100644 index 0000000..96f50af --- /dev/null +++ b/src/app/api/articoli/ricerca-prezzi/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from "next/server"; + +import { requirePermission } from "@/src/lib/auth/requireSession"; +import { searchPriceOffers } from "@/src/lib/price-search"; +import type { PriceSearchRequest } from "@/src/types/priceSearch"; + +export async function POST(request: Request) { + const session = await requirePermission( + (p) => p.canSearchPrices, + "Non hai i permessi per la ricerca prezzi", + ); + if (session instanceof NextResponse) return session; + + try { + const body = (await request.json()) as Partial; + + const descrizione = String(body.descrizione ?? "").trim(); + if (!descrizione) { + return NextResponse.json( + { error: "La descrizione è obbligatoria per la ricerca" }, + { status: 400 }, + ); + } + + const payload: PriceSearchRequest = { + descrizione, + barcode: body.barcode ?? null, + codice: body.codice ?? null, + quantita: + body.quantita !== undefined && body.quantita !== null + ? Number(body.quantita) + : undefined, + }; + + const result = await searchPriceOffers(payload, { + userId: session.user.id, + userName: session.user.utente || session.user.name || session.user.email || undefined, + }); + + return NextResponse.json(result); + } catch (error) { + console.error("[API ricerca-prezzi]", error); + const message = + error instanceof Error ? error.message : "Errore ricerca prezzi"; + + const status = + message.includes("non configurata") ? 503 : 502; + + return NextResponse.json({ error: message }, { status }); + } +} \ No newline at end of file diff --git a/src/app/favicon.ico b/src/app/favicon.ico deleted file mode 100644 index bf14724..0000000 Binary files a/src/app/favicon.ico and /dev/null differ diff --git a/src/components/ArticleModal.tsx b/src/components/ArticleModal.tsx index c289718..db711a5 100644 --- a/src/components/ArticleModal.tsx +++ b/src/components/ArticleModal.tsx @@ -1,6 +1,9 @@ "use client"; import { useState, useEffect, useRef } from "react"; +import PriceSearchPanel from "@/src/components/PriceSearchPanel"; +import { usePermissions } from "@/src/contexts/PermissionsContext"; +import { usePriceSearch } from "@/src/hooks/usePriceSearch"; import type { Article, ArticleInput, ArticleFamily } from "@/src/types/article"; import type { Stabilimento, Magazzino } from "@/src/types/stabilimento"; @@ -19,6 +22,9 @@ export default function ArticleModal({ }: ArticleModalProps) { const isEdit = article !== null; const modalRef = useRef(null); + const { permissions } = usePermissions(); + const { result, error: searchError, search, cancel, reset, isLoading } = + usePriceSearch(); const [formData, setFormData] = useState({ codice: "", @@ -173,11 +179,36 @@ export default function ArticleModal({ setSelectedMagazzino(value); }; + const handlePriceSearch = async () => { + const descrizione = formData.descrizione.trim(); + if (!descrizione) { + setError("Inserisci una descrizione prima di cercare i prezzi"); + return; + } + + setError(null); + await search({ + descrizione, + barcode: formData.barcode || null, + codice: formData.codice || null, + }); + }; + + const handleApplyPrice = (price: number) => { + setFormData((prev) => ({ ...prev, prezzo: price })); + }; + + const showPriceSearchPanel = + permissions.canSearchPrices && + (isLoading || searchError !== null || result !== null); + return (
{/* Header */}
@@ -299,6 +330,65 @@ export default function ArticleModal({
+ {permissions.canSearchPrices && ( +
+ + {result && !isLoading && ( + + )} +
+ )} + + {permissions.canSearchPrices && ( + + )} + {/* Giacenza iniziale (solo per nuovo articolo) */} {!isEdit && (
diff --git a/src/components/PriceSearchPanel.tsx b/src/components/PriceSearchPanel.tsx new file mode 100644 index 0000000..7498644 --- /dev/null +++ b/src/components/PriceSearchPanel.tsx @@ -0,0 +1,162 @@ +"use client"; + +import type { PriceOffer, PriceSearchResponse } from "@/src/types/priceSearch"; + +const CONFIDENCE_LABELS: Record = { + high: "Alta", + medium: "Media", + low: "Bassa", +}; + +const CONFIDENCE_STYLES: Record = { + high: "bg-green-500/15 text-green-400 border-green-500/30", + medium: "bg-yellow-500/15 text-yellow-400 border-yellow-500/30", + low: "bg-zinc-500/15 text-zinc-400 border-zinc-500/30", +}; + +function formatPrice(price: number | null, currency: string) { + if (price === null) return "N/D"; + return new Intl.NumberFormat("it-IT", { + style: "currency", + currency, + }).format(price); +} + +interface PriceSearchPanelProps { + isLoading: boolean; + error: string | null; + result: PriceSearchResponse | null; + onApplyPrice: (price: number) => void; + onCancel: () => void; +} + +export default function PriceSearchPanel({ + isLoading, + error, + result, + onApplyPrice, + onCancel, +}: PriceSearchPanelProps) { + if (!isLoading && !error && !result) return null; + + return ( +
+
+

+ + + + Ricerca prezzi online +

+ {isLoading && ( + + )} +
+ + {isLoading && ( +
+ + + + + Ricerca in corso… può richiedere fino a 30 secondi +
+ )} + + {error && ( +
+ {error} +
+ )} + + {result && ( + <> +

+ Query: {result.query} +

+ + {result.offers.length === 0 ? ( +

Nessuna offerta trovata.

+ ) : ( +
    + {result.offers.map((offer, index) => ( +
  • +
    +
    +
    + + {offer.vendor} + + + {CONFIDENCE_LABELS[offer.confidence]} + +
    +

    {offer.title}

    + {offer.shippingNote && ( +

    {offer.shippingNote}

    + )} +
    +
    + + {formatPrice(offer.price, offer.currency)} + +
    + + Apri + + {offer.price !== null && ( + + )} +
    +
    +
    +
  • + ))} +
+ )} + +

{result.disclaimer}

+ + )} +
+ ); +} diff --git a/src/hooks/usePriceSearch.ts b/src/hooks/usePriceSearch.ts new file mode 100644 index 0000000..07d7c7a --- /dev/null +++ b/src/hooks/usePriceSearch.ts @@ -0,0 +1,82 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; + +import type { PriceSearchRequest, PriceSearchResponse } from "@/src/types/priceSearch"; + +type SearchStatus = "idle" | "loading" | "success" | "error"; + +export function usePriceSearch() { + const [status, setStatus] = useState("idle"); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const abortRef = useRef(null); + + const search = useCallback(async (input: PriceSearchRequest) => { + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + setStatus("loading"); + setError(null); + setResult(null); + + try { + const res = await fetch("/api/articoli/ricerca-prezzi", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + signal: controller.signal, + }); + + const data = await res.json(); + + if (!res.ok) { + throw new Error( + typeof data.error === "string" ? data.error : "Errore ricerca prezzi", + ); + } + + const response = data as PriceSearchResponse; + setResult(response); + setStatus("success"); + return response; + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + setStatus("idle"); + return null; + } + + const message = err instanceof Error ? err.message : "Errore sconosciuto"; + setError(message); + setStatus("error"); + return null; + } finally { + if (abortRef.current === controller) { + abortRef.current = null; + } + } + }, []); + + const cancel = useCallback(() => { + abortRef.current?.abort(); + abortRef.current = null; + setStatus("idle"); + }, []); + + const reset = useCallback(() => { + cancel(); + setResult(null); + setError(null); + }, [cancel]); + + return { + status, + result, + error, + search, + cancel, + reset, + isLoading: status === "loading", + }; +} diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts index 84c197a..3661ea9 100644 --- a/src/lib/permissions.ts +++ b/src/lib/permissions.ts @@ -37,6 +37,7 @@ export interface AppPermissions { canAssegnaArticoliMacchina: boolean; canEditMacchine: boolean; canRemoveArticoloMacchina: boolean; + canSearchPrices: boolean; } export function getPermissions(ruolo: UserRole): AppPermissions { @@ -49,6 +50,7 @@ export function getPermissions(ruolo: UserRole): AppPermissions { canAssegnaArticoliMacchina: true, canEditMacchine: true, canRemoveArticoloMacchina: true, + canSearchPrices: true, }; case "tecnico": return { @@ -58,6 +60,7 @@ export function getPermissions(ruolo: UserRole): AppPermissions { canAssegnaArticoliMacchina: true, canEditMacchine: true, canRemoveArticoloMacchina: true, + canSearchPrices: true, }; case "operatore": return { @@ -67,6 +70,7 @@ export function getPermissions(ruolo: UserRole): AppPermissions { canAssegnaArticoliMacchina: false, canEditMacchine: false, canRemoveArticoloMacchina: false, + canSearchPrices: false, }; } } diff --git a/src/lib/price-search/index.ts b/src/lib/price-search/index.ts new file mode 100644 index 0000000..5e849c3 --- /dev/null +++ b/src/lib/price-search/index.ts @@ -0,0 +1,15 @@ +import type { PriceSearchRequest, PriceSearchResponse } from "@/src/types/priceSearch"; +import { searchPriceOffersViaN8n } from "@/src/lib/price-search/n8n"; + +export async function searchPriceOffers( + input: PriceSearchRequest, + meta?: { userId?: string; userName?: string }, +): Promise { + const backend = (process.env.PRICE_SEARCH_BACKEND ?? "n8n").toLowerCase(); + + if (backend === "n8n") { + return searchPriceOffersViaN8n(input, meta); + } + + throw new Error(`Backend ricerca prezzi non supportato: ${backend}`); +} diff --git a/src/lib/price-search/n8n.ts b/src/lib/price-search/n8n.ts new file mode 100644 index 0000000..9405959 --- /dev/null +++ b/src/lib/price-search/n8n.ts @@ -0,0 +1,91 @@ +import type { PriceSearchRequest, PriceSearchResponse } from "@/src/types/priceSearch"; + +const DISCLAIMER = + "Prezzi indicativi da fonti web: verificare su ordine fornitore prima dell'acquisto."; + +function getWebhookConfig() { + const url = + process.env.N8N_PRICE_SEARCH_WEBHOOK_URL?.trim() ?? + process.env.N8N_PRICE_WEBHOOK_URL?.trim(); + const secret = process.env.N8N_PRICE_WEBHOOK_SECRET?.trim(); + const timeoutMs = Number(process.env.N8N_PRICE_WEBHOOK_TIMEOUT_MS ?? 35_000); + + if (!url || !secret) { + throw new Error("Ricerca prezzi n8n non configurata (URL o secret mancanti)"); + } + + return { url, secret, timeoutMs: Number.isFinite(timeoutMs) ? timeoutMs : 35_000 }; +} + +function isPriceSearchResponse(value: unknown): value is PriceSearchResponse { + if (!value || typeof value !== "object") return false; + const v = value as Record; + return ( + typeof v.query === "string" && + typeof v.searchedAt === "string" && + Array.isArray(v.offers) && + typeof v.disclaimer === "string" + ); +} + +export async function searchPriceOffersViaN8n( + input: PriceSearchRequest, + meta?: { userId?: string; userName?: string }, +): Promise { + const { url, secret, timeoutMs } = getWebhookConfig(); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Webhook-Secret": secret, + }, + body: JSON.stringify({ + ...input, + meta: { + source: "magricambi", + userId: meta?.userId ?? null, + userName: meta?.userName ?? null, + requestedAt: new Date().toISOString(), + }, + }), + signal: controller.signal, + cache: "no-store", + }); + + const text = await res.text(); + let data: unknown; + try { + data = text ? JSON.parse(text) : null; + } catch { + throw new Error("Risposta non valida dal workflow n8n"); + } + + if (!res.ok) { + const err = + data && typeof data === "object" && "error" in data + ? String((data as { error: unknown }).error) + : `Errore n8n (${res.status})`; + throw new Error(err); + } + + if (!isPriceSearchResponse(data)) { + throw new Error("Formato risposta n8n non conforme a PriceSearchResponse"); + } + + return data; + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw new Error("Timeout ricerca prezzi: riprova tra poco"); + } + throw error; + } finally { + clearTimeout(timer); + } +} + +export { DISCLAIMER }; diff --git a/src/types/priceSearch.ts b/src/types/priceSearch.ts new file mode 100644 index 0000000..0dc218c --- /dev/null +++ b/src/types/priceSearch.ts @@ -0,0 +1,27 @@ +export type PriceOfferConfidence = "high" | "medium" | "low"; +export type PriceOfferSource = "page" | "snippet" | "estimate"; + +export interface PriceOffer { + vendor: string; + title: string; + price: number | null; + currency: "EUR"; + url: string; + shippingNote?: string; + confidence: PriceOfferConfidence; + source: PriceOfferSource; +} + +export interface PriceSearchRequest { + descrizione: string; + barcode?: string | null; + codice?: string | null; + quantita?: number; +} + +export interface PriceSearchResponse { + query: string; + searchedAt: string; + offers: PriceOffer[]; + disclaimer: string; +}