Compare commits

..

No commits in common. "4bfb3f01491004afc2624645dc22f227f26c7805" and "faa9e8ae4954e4dcef10793ec5dd26a6ce195e9e" have entirely different histories.

11 changed files with 10 additions and 557 deletions

41
.gitignore vendored
View file

@ -1,45 +1,20 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# env
.env
.env.*
# tesina (file troppo grandi per il remote)
/tesina_finale/
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
n8n_Tavily_API_KEY*.txt
# typescript
*.tsbuildinfo
next-env.d.ts
node_modules
.env
i m m a g i n i /
.specstory/

View file

@ -1,5 +1,5 @@
@echo-off
cd /d D:\maga\magricambi2
cd /d D:\maga\magricambi
git status
echo.
set /p MSG=Messaggio commit:

View file

@ -1,51 +0,0 @@
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<PriceSearchRequest>;
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 });
}
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

View file

@ -1,9 +1,6 @@
"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";
@ -22,9 +19,6 @@ export default function ArticleModal({
}: ArticleModalProps) {
const isEdit = article !== null;
const modalRef = useRef<HTMLDivElement>(null);
const { permissions } = usePermissions();
const { result, error: searchError, search, cancel, reset, isLoading } =
usePriceSearch();
const [formData, setFormData] = useState<ArticleInput>({
codice: "",
@ -179,36 +173,11 @@ 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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
<div
ref={modalRef}
className={`w-full bg-zinc-900 rounded-2xl shadow-2xl border border-zinc-800 overflow-hidden max-h-[90vh] flex flex-col ${
showPriceSearchPanel ? "max-w-2xl" : "max-w-lg"
}`}
className="w-full max-w-lg bg-zinc-900 rounded-2xl shadow-2xl border border-zinc-800 overflow-hidden max-h-[90vh] flex flex-col"
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-800 flex-shrink-0">
@ -330,65 +299,6 @@ export default function ArticleModal({
</div>
</div>
{permissions.canSearchPrices && (
<div className="flex items-center gap-3">
<button
type="button"
onClick={handlePriceSearch}
disabled={isLoading || !formData.descrizione.trim()}
className="px-4 py-2 rounded-lg bg-blue-600/20 border border-blue-500/40 text-blue-400 text-sm font-medium hover:bg-blue-600/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{isLoading ? (
<svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
)}
Cerca prezzi online
</button>
{result && !isLoading && (
<button
type="button"
onClick={reset}
className="text-xs text-zinc-500 hover:text-zinc-300 transition-colors"
>
Chiudi risultati
</button>
)}
</div>
)}
{permissions.canSearchPrices && (
<PriceSearchPanel
isLoading={isLoading}
error={searchError}
result={result}
onApplyPrice={handleApplyPrice}
onCancel={cancel}
/>
)}
{/* Giacenza iniziale (solo per nuovo articolo) */}
{!isEdit && (
<div className="p-4 rounded-lg bg-green-500/5 border border-green-500/30">

View file

@ -1,162 +0,0 @@
"use client";
import type { PriceOffer, PriceSearchResponse } from "@/src/types/priceSearch";
const CONFIDENCE_LABELS: Record<PriceOffer["confidence"], string> = {
high: "Alta",
medium: "Media",
low: "Bassa",
};
const CONFIDENCE_STYLES: Record<PriceOffer["confidence"], string> = {
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 (
<div className="p-4 rounded-lg bg-blue-500/5 border border-blue-500/30 space-y-3">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-blue-400 flex items-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
Ricerca prezzi online
</h3>
{isLoading && (
<button
type="button"
onClick={onCancel}
className="text-xs text-zinc-400 hover:text-white transition-colors"
>
Annulla
</button>
)}
</div>
{isLoading && (
<div className="flex items-center gap-3 text-sm text-zinc-400">
<svg className="w-4 h-4 animate-spin text-blue-400" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Ricerca in corso può richiedere fino a 30 secondi
</div>
)}
{error && (
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/50 text-red-400 text-sm">
{error}
</div>
)}
{result && (
<>
<p className="text-xs text-zinc-500">
Query: <span className="text-zinc-400">{result.query}</span>
</p>
{result.offers.length === 0 ? (
<p className="text-sm text-zinc-400">Nessuna offerta trovata.</p>
) : (
<ul className="space-y-2 max-h-48 overflow-y-auto">
{result.offers.map((offer, index) => (
<li
key={`${offer.vendor}-${offer.url}-${index}`}
className="p-3 rounded-lg bg-zinc-800/80 border border-zinc-700"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-white truncate">
{offer.vendor}
</span>
<span
className={`text-[10px] px-1.5 py-0.5 rounded border ${CONFIDENCE_STYLES[offer.confidence]}`}
>
{CONFIDENCE_LABELS[offer.confidence]}
</span>
</div>
<p className="text-xs text-zinc-400 mt-0.5 line-clamp-2">{offer.title}</p>
{offer.shippingNote && (
<p className="text-[11px] text-zinc-500 mt-1">{offer.shippingNote}</p>
)}
</div>
<div className="flex flex-col items-end gap-2 flex-shrink-0">
<span className="text-sm font-semibold text-green-400">
{formatPrice(offer.price, offer.currency)}
</span>
<div className="flex items-center gap-2">
<a
href={offer.url}
target="_blank"
rel="noopener noreferrer"
className="text-[11px] text-blue-400 hover:text-blue-300 transition-colors"
>
Apri
</a>
{offer.price !== null && (
<button
type="button"
onClick={() => onApplyPrice(offer.price as number)}
className="text-[11px] px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-500 transition-colors"
>
Applica
</button>
)}
</div>
</div>
</div>
</li>
))}
</ul>
)}
<p className="text-[11px] text-zinc-500 leading-relaxed">{result.disclaimer}</p>
</>
)}
</div>
);
}

View file

@ -1,82 +0,0 @@
"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<SearchStatus>("idle");
const [result, setResult] = useState<PriceSearchResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(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",
};
}

View file

@ -37,7 +37,6 @@ export interface AppPermissions {
canAssegnaArticoliMacchina: boolean;
canEditMacchine: boolean;
canRemoveArticoloMacchina: boolean;
canSearchPrices: boolean;
}
export function getPermissions(ruolo: UserRole): AppPermissions {
@ -50,7 +49,6 @@ export function getPermissions(ruolo: UserRole): AppPermissions {
canAssegnaArticoliMacchina: true,
canEditMacchine: true,
canRemoveArticoloMacchina: true,
canSearchPrices: true,
};
case "tecnico":
return {
@ -60,7 +58,6 @@ export function getPermissions(ruolo: UserRole): AppPermissions {
canAssegnaArticoliMacchina: true,
canEditMacchine: true,
canRemoveArticoloMacchina: true,
canSearchPrices: true,
};
case "operatore":
return {
@ -70,7 +67,6 @@ export function getPermissions(ruolo: UserRole): AppPermissions {
canAssegnaArticoliMacchina: false,
canEditMacchine: false,
canRemoveArticoloMacchina: false,
canSearchPrices: false,
};
}
}

View file

@ -1,15 +0,0 @@
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<PriceSearchResponse> {
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}`);
}

View file

@ -1,91 +0,0 @@
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<string, unknown>;
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<PriceSearchResponse> {
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 };

View file

@ -1,27 +0,0 @@
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;
}