chore(18-01): remove quote builder route, exclusive components, and SendQuoteModal entry point (CLEAN-02)
- Delete src/app/admin/quotes/new/page.tsx and actions.ts re-export - Delete QuoteBuilderForm, OfferSelector, PriceOverrideInput, QuotePreview components - Rewrite SendQuoteModal: remove "Crea Nuovo" tab and window.location navigation to quotes/new - Remove stale JSDoc comment referencing /admin/quotes/new from admin-queries.ts
This commit is contained in:
@@ -1,3 +0,0 @@
|
||||
"use server";
|
||||
|
||||
export { createQuote } from "@/lib/quote-actions";
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { QuoteBuilderForm } from "@/components/admin/quotes/QuoteBuilderForm";
|
||||
import { getAllClientsWithPayments } from "@/lib/admin-queries";
|
||||
import { getAllOfferMacrosWithMicros } from "@/lib/admin-queries";
|
||||
|
||||
export const revalidate = 0;
|
||||
|
||||
export default async function QuoteBuilderPage() {
|
||||
const [clientsData, offersData] = await Promise.all([
|
||||
getAllClientsWithPayments(),
|
||||
getAllOfferMacrosWithMicros(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Crea Preventivo</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Componi un preventivo per il cliente selezionando l'offerta e il prezzo
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="p-6">
|
||||
<QuoteBuilderForm clients={clientsData} offerMacros={offersData} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
const assignQuoteSchema = z.object({
|
||||
@@ -33,7 +32,6 @@ const assignQuoteSchema = z.object({
|
||||
export function SendQuoteModal({ leadId }: { leadId: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [tab, setTab] = useState<"existing" | "new">("existing");
|
||||
|
||||
const form = useForm<any>({
|
||||
resolver: zodResolver(assignQuoteSchema),
|
||||
@@ -72,58 +70,36 @@ export function SendQuoteModal({ leadId }: { leadId: string }) {
|
||||
<DialogHeader>
|
||||
<DialogTitle>Invia Preventivo al Lead</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as "existing" | "new")}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="existing">Preventivo Esistente</TabsTrigger>
|
||||
<TabsTrigger value="new">Crea Nuovo</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="existing" className="mt-4">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="quote_token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Token Preventivo</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Incolla il token del preventivo"
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-gray-600">
|
||||
Copia il token dal preventivo e incollalo qui. Il lead sarà aggiornato a
|
||||
"Proposal Sent".
|
||||
</p>
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Invia
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="new" className="mt-4">
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Crea un nuovo preventivo per questo lead. Ti reindirizzeremo al builder.
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="quote_token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Token Preventivo</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Incolla il token del preventivo"
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-gray-600">
|
||||
Copia il token dal preventivo e incollalo qui. Il lead sarà aggiornato a
|
||||
"Proposal Sent".
|
||||
</p>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
window.location.href = `/admin/quotes/new?lead_id=${leadId}`;
|
||||
}}
|
||||
>
|
||||
Apri Quote Builder
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Invia
|
||||
</Button>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectGroup,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { OfferMacro, OfferMicro } from "@/db/schema";
|
||||
|
||||
interface OfferSelectorProps {
|
||||
macros: (OfferMacro & { micros: OfferMicro[] })[];
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function OfferSelector({ macros, value, onValueChange }: OfferSelectorProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="offer">Offerta*</Label>
|
||||
<Select value={value} onValueChange={onValueChange}>
|
||||
<SelectTrigger id="offer">
|
||||
<SelectValue placeholder="Seleziona un'offerta" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{macros.map((macro) => (
|
||||
<SelectGroup key={macro.id}>
|
||||
<SelectLabel className="text-xs font-semibold text-gray-600">
|
||||
{macro.public_name}
|
||||
</SelectLabel>
|
||||
{macro.micros.map((micro) => (
|
||||
<SelectItem key={micro.id} value={micro.id}>
|
||||
{micro.public_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Check, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface PriceOverrideInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
calculatedTotal?: number;
|
||||
}
|
||||
|
||||
export function PriceOverrideInput({ value, onChange, calculatedTotal }: PriceOverrideInputProps) {
|
||||
const [isTouched, setIsTouched] = useState(false);
|
||||
|
||||
const numValue = parseFloat(value) || 0;
|
||||
const matches = calculatedTotal !== undefined && Math.abs(numValue - calculatedTotal) < 0.01;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="total">Totale preventivo*</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="total"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={() => setIsTouched(true)}
|
||||
className="pr-10"
|
||||
required
|
||||
/>
|
||||
{isTouched && value && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
{matches ? (
|
||||
<Check size={18} className="text-green-500" />
|
||||
) : (
|
||||
<X size={18} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{calculatedTotal !== undefined && (
|
||||
<p className="text-xs text-gray-500">
|
||||
Prezzo calcolato: €{calculatedTotal.toLocaleString("it-IT", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isTouched && value && !matches && (
|
||||
<p className="text-xs text-yellow-600">
|
||||
Attenzione: il valore inserito non corrisponde al totale calcolato (server verificherà)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { QuotePreview } from "./QuotePreview";
|
||||
import { OfferSelector } from "./OfferSelector";
|
||||
import { PriceOverrideInput } from "./PriceOverrideInput";
|
||||
import { createQuote, getOfferWithPhases } from "@/lib/quote-actions";
|
||||
import { OfferMacro, OfferMicro } from "@/db/schema";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
|
||||
interface ClientOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface QuoteBuilderFormProps {
|
||||
clients: ClientOption[];
|
||||
offerMacros: (OfferMacro & { micros: OfferMicro[] })[];
|
||||
}
|
||||
|
||||
export function QuoteBuilderForm({ clients, offerMacros }: QuoteBuilderFormProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [selectedClient, setSelectedClient] = useState<string>("");
|
||||
const [selectedOffer, setSelectedOffer] = useState<string>("");
|
||||
const [selectedTotal, setSelectedTotal] = useState<string>("");
|
||||
const [currentOffer, setCurrentOffer] = useState<any>(null);
|
||||
const [successLink, setSuccessLink] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Fetch offer details when selection changes
|
||||
const handleOfferChange = useCallback((offerId: string) => {
|
||||
setSelectedOffer(offerId);
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const offer = await getOfferWithPhases(offerId);
|
||||
setCurrentOffer(offer);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSuccessLink(null);
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await createQuote({
|
||||
client_id: selectedClient,
|
||||
offer_micro_id: selectedOffer,
|
||||
accepted_total: selectedTotal,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
setSuccessLink(result.publicLink || "");
|
||||
// Reset form
|
||||
setSelectedClient("");
|
||||
setSelectedOffer("");
|
||||
setSelectedTotal("");
|
||||
setCurrentOffer(null);
|
||||
} else {
|
||||
setError(result.error || "Errore sconosciuto");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
if (successLink) {
|
||||
const fullUrl = `${window.location.origin}${successLink}`;
|
||||
await navigator.clipboard.writeText(fullUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
if (successLink) {
|
||||
return (
|
||||
<Card className="p-6 space-y-4">
|
||||
<div className="text-center space-y-3">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-green-100 rounded-full">
|
||||
<Check size={24} className="text-green-600" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Preventivo creato</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Il preventivo è stato salvato e il link è pronto per essere condiviso
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<p className="text-xs text-gray-500 mb-2">Link pubblico</p>
|
||||
<div className="flex gap-2">
|
||||
<code className="flex-1 bg-white border border-gray-200 rounded px-3 py-2 text-xs text-gray-900 font-mono">
|
||||
{window.location.origin}
|
||||
{successLink}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={copied ? "default" : "outline"}
|
||||
onClick={handleCopyLink}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check size={16} /> Copiato
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy size={16} /> Copia
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
setSuccessLink(null);
|
||||
setSelectedClient("");
|
||||
setSelectedOffer("");
|
||||
setSelectedTotal("");
|
||||
setCurrentOffer(null);
|
||||
}}
|
||||
>
|
||||
Crea un altro preventivo
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* LEFT COLUMN: FORM */}
|
||||
<div>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Crea Preventivo</h2>
|
||||
|
||||
{/* Client Select */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="client">Cliente*</Label>
|
||||
<Select value={selectedClient} onValueChange={setSelectedClient}>
|
||||
<SelectTrigger id="client">
|
||||
<SelectValue placeholder="Seleziona un cliente" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clients.map((client) => (
|
||||
<SelectItem key={client.id} value={client.id}>
|
||||
{client.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Offer Select */}
|
||||
{selectedClient && (
|
||||
<OfferSelector
|
||||
macros={offerMacros}
|
||||
value={selectedOffer}
|
||||
onValueChange={handleOfferChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Price Input */}
|
||||
{selectedOffer && (
|
||||
<PriceOverrideInput
|
||||
value={selectedTotal}
|
||||
onChange={setSelectedTotal}
|
||||
calculatedTotal={currentOffer?.duration_months ? currentOffer.duration_months * 1000 : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!selectedClient || !selectedOffer || !selectedTotal || isPending}
|
||||
className="w-full bg-[#1A463C] text-white hover:bg-[#1A463C]/90"
|
||||
>
|
||||
{isPending ? "Salvataggio…" : "Salva Preventivo"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* RIGHT COLUMN: PREVIEW */}
|
||||
<div>
|
||||
<QuotePreview offer={currentOffer} selectedTotal={parseFloat(selectedTotal) || 0} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { OfferMicro, OfferPhase } from "@/db/schema";
|
||||
|
||||
interface QuotePreviewProps {
|
||||
offer: (OfferMicro & { phases: OfferPhase[] }) | null;
|
||||
selectedTotal: number;
|
||||
}
|
||||
|
||||
export function QuotePreview({ offer, selectedTotal }: QuotePreviewProps) {
|
||||
if (!offer) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-gray-400">
|
||||
<p className="text-sm">Seleziona un'offerta per visualizzare l'anteprima</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-6 space-y-6">
|
||||
<div className="border-b pb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{offer.public_name}</h3>
|
||||
{offer.transformation_promise && (
|
||||
<p className="text-sm text-gray-600 mt-2">{offer.transformation_promise}</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 mt-3">
|
||||
Durata: {offer.duration_months} mese{offer.duration_months !== 1 ? "i" : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium text-gray-900">Fasi incluse</h4>
|
||||
{offer.phases && offer.phases.length > 0 ? (
|
||||
offer.phases.map((phase) => (
|
||||
<div key={phase.id} className="space-y-2">
|
||||
<p className="text-sm font-medium text-gray-800">{phase.title}</p>
|
||||
{phase.description && (
|
||||
<p className="text-xs text-gray-600 ml-2">{phase.description}</p>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-xs text-gray-400">Nessuna fase configurata</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-6 space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-600">Totale preventivo</span>
|
||||
<span className="text-2xl font-bold text-gray-900">
|
||||
€{selectedTotal.toLocaleString("it-IT", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
Questo preventivo sarà visibile al cliente tramite link riservato
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -859,7 +859,6 @@ export async function getQuotesByClient(clientId: string): Promise<Quote[]> {
|
||||
|
||||
/**
|
||||
* Get all offer macros with their micros (ordered by sort_order)
|
||||
* Used by: /admin/quotes/new form (OfferSelector dropdown)
|
||||
*/
|
||||
export async function getAllOfferMacrosWithMicros(): Promise<
|
||||
(OfferMacro & { micros: OfferMicro[] })[]
|
||||
|
||||
Reference in New Issue
Block a user