feat(05-02): /admin/offers RSC page + ServiceCheckboxList + NavBar update
- Create src/app/admin/offers/page.tsx — RSC with macro/micro/service CRUD sections - Create src/components/admin/offers/ServiceCheckboxList.tsx — client checkbox UI for service-to-micro assignment - Update src/components/admin/NavBar.tsx — add Forecast and Offerte links (order: Statistiche | Forecast | Catalogo | Offerte | Impostazioni)
This commit is contained in:
@@ -0,0 +1,238 @@
|
|||||||
|
import { getCatalogWithMicros, getAllOfferServices } from "@/lib/offer-queries";
|
||||||
|
import {
|
||||||
|
createMacro,
|
||||||
|
createMicro,
|
||||||
|
createOfferService,
|
||||||
|
deleteMacro,
|
||||||
|
deleteMicro,
|
||||||
|
toggleOfferServiceActive,
|
||||||
|
} from "./actions";
|
||||||
|
import { ServiceCheckboxList } from "@/components/admin/offers/ServiceCheckboxList";
|
||||||
|
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export default async function OffersPage() {
|
||||||
|
const [catalog, allServices] = await Promise.all([
|
||||||
|
getCatalogWithMicros(),
|
||||||
|
getAllOfferServices(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl mx-auto py-8 px-4 space-y-10">
|
||||||
|
<h1 className="text-2xl font-bold text-[#1a1a1a]">Catalogo Offerte</h1>
|
||||||
|
|
||||||
|
{/* ── Sezione macro-offerte ── */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg font-semibold text-[#1a1a1a] mb-4">Macro-Offerte</h2>
|
||||||
|
|
||||||
|
{/* Create macro form */}
|
||||||
|
<form action={createMacro} className="bg-white rounded-lg border border-[#e5e7eb] p-4 mb-6 space-y-3 max-w-lg">
|
||||||
|
<p className="text-sm font-medium">Nuova Macro-Offerta</p>
|
||||||
|
<input
|
||||||
|
name="internal_name"
|
||||||
|
placeholder="Nome interno (es. Entry Offer)"
|
||||||
|
required
|
||||||
|
className="w-full border rounded px-3 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="public_name"
|
||||||
|
placeholder="Nome pubblico (es. Starter Branding)"
|
||||||
|
required
|
||||||
|
className="w-full border rounded px-3 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
name="transformation_promise"
|
||||||
|
placeholder="Promessa di trasformazione"
|
||||||
|
rows={2}
|
||||||
|
className="w-full border rounded px-3 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="bg-[#1A463C] text-white text-sm px-4 py-1.5 rounded hover:bg-[#163a31]"
|
||||||
|
>
|
||||||
|
Aggiungi Macro
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* List macros */}
|
||||||
|
<div className="space-y-8">
|
||||||
|
{catalog.map((macro) => (
|
||||||
|
<div key={macro.id} className="bg-white rounded-lg border border-[#e5e7eb] p-6">
|
||||||
|
<div className="flex items-start justify-between mb-1">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-[#1a1a1a]">{macro.internal_name}</p>
|
||||||
|
<p className="text-sm text-[#71717a]">Pubblico: {macro.public_name}</p>
|
||||||
|
{macro.transformation_promise && (
|
||||||
|
<p className="text-xs text-[#71717a] mt-1 italic">{macro.transformation_promise}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<form action={deleteMacro.bind(null, macro.id)}>
|
||||||
|
<button type="submit" className="text-xs text-red-600 hover:underline">
|
||||||
|
Elimina
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Micro-offers under this macro */}
|
||||||
|
<div className="mt-4 space-y-4 pl-4 border-l-2 border-[#e5e7eb]">
|
||||||
|
<p className="text-xs font-semibold text-[#71717a] uppercase tracking-wider">Micro-Offerte</p>
|
||||||
|
|
||||||
|
{macro.micros.map((micro) => (
|
||||||
|
<div key={micro.id} className="bg-[#f9f9f9] rounded-lg p-4 space-y-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{micro.internal_name}</p>
|
||||||
|
<p className="text-xs text-[#71717a]">
|
||||||
|
Pubblico: {micro.public_name} ·{" "}
|
||||||
|
{micro.duration_months} {micro.duration_months === 1 ? "mese" : "mesi"}
|
||||||
|
</p>
|
||||||
|
{micro.transformation_promise && (
|
||||||
|
<p className="text-xs text-[#71717a] italic">{micro.transformation_promise}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-[#1a1a1a] mt-1 font-medium">
|
||||||
|
Prezzo cumulativo: €{parseFloat(micro.cumulative_price).toFixed(2)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form action={deleteMicro.bind(null, micro.id)}>
|
||||||
|
<button type="submit" className="text-xs text-red-600 hover:underline">
|
||||||
|
Elimina
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/* Service assignment checkbox list */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-[#71717a] mb-2">Servizi inclusi:</p>
|
||||||
|
<ServiceCheckboxList
|
||||||
|
allServices={allServices.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
price: String(s.price),
|
||||||
|
}))}
|
||||||
|
assignedIds={micro.services.map((s) => s.id)}
|
||||||
|
microId={micro.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Create micro form */}
|
||||||
|
<form action={createMicro} className="bg-white rounded border border-[#e5e7eb] p-3 space-y-2">
|
||||||
|
<input type="hidden" name="macro_id" value={macro.id} />
|
||||||
|
<p className="text-xs font-medium">Nuova Micro-Offerta</p>
|
||||||
|
<input
|
||||||
|
name="internal_name"
|
||||||
|
placeholder="Nome interno"
|
||||||
|
required
|
||||||
|
className="w-full border rounded px-2 py-1 text-xs"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="public_name"
|
||||||
|
placeholder="Nome pubblico"
|
||||||
|
required
|
||||||
|
className="w-full border rounded px-2 py-1 text-xs"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
name="transformation_promise"
|
||||||
|
placeholder="Promessa di trasformazione"
|
||||||
|
rows={2}
|
||||||
|
className="w-full border rounded px-2 py-1 text-xs"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-xs">Durata (mesi):</label>
|
||||||
|
<input
|
||||||
|
name="duration_months"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
defaultValue="1"
|
||||||
|
required
|
||||||
|
className="w-16 border rounded px-2 py-1 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="bg-[#1A463C] text-white text-xs px-3 py-1 rounded hover:bg-[#163a31]"
|
||||||
|
>
|
||||||
|
Aggiungi Micro
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{catalog.length === 0 && (
|
||||||
|
<p className="text-sm text-[#71717a]">Nessuna macro-offerta ancora. Creane una sopra.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Sezione servizi offerta ── */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg font-semibold text-[#1a1a1a] mb-4">Servizi Offerta</h2>
|
||||||
|
<p className="text-sm text-[#71717a] mb-4">
|
||||||
|
I servizi qui sono diversi dal catalogo preventivi — hanno una descrizione della
|
||||||
|
trasformazione e vengono raggruppati nelle micro-offerte.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* List services */}
|
||||||
|
<div className="bg-white rounded-lg border border-[#e5e7eb] divide-y divide-[#e5e7eb] mb-6">
|
||||||
|
{allServices.map((svc) => (
|
||||||
|
<div key={svc.id} className="flex items-center justify-between px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{svc.name}</p>
|
||||||
|
{svc.transformation_description && (
|
||||||
|
<p className="text-xs text-[#71717a]">{svc.transformation_description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-sm font-mono">€{parseFloat(String(svc.price)).toFixed(2)}</span>
|
||||||
|
<form action={toggleOfferServiceActive.bind(null, svc.id, !svc.active)}>
|
||||||
|
<button type="submit" className="text-xs text-[#71717a] hover:text-[#1a1a1a]">
|
||||||
|
{svc.active ? "Disattiva" : "Attiva"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{allServices.length === 0 && (
|
||||||
|
<p className="px-4 py-3 text-sm text-[#71717a]">Nessun servizio ancora.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create service form */}
|
||||||
|
<form
|
||||||
|
action={createOfferService}
|
||||||
|
className="bg-white rounded-lg border border-[#e5e7eb] p-4 space-y-3 max-w-lg"
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium">Nuovo Servizio Offerta</p>
|
||||||
|
<input
|
||||||
|
name="name"
|
||||||
|
placeholder="Nome servizio"
|
||||||
|
required
|
||||||
|
className="w-full border rounded px-3 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="price"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
placeholder="Prezzo (€)"
|
||||||
|
required
|
||||||
|
className="w-full border rounded px-3 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
name="transformation_description"
|
||||||
|
placeholder="Descrizione trasformazione (marketing)"
|
||||||
|
rows={2}
|
||||||
|
className="w-full border rounded px-3 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="bg-[#1A463C] text-white text-sm px-4 py-1.5 rounded hover:bg-[#163a31]"
|
||||||
|
>
|
||||||
|
Aggiungi Servizio
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,9 +18,15 @@ export function NavBar() {
|
|||||||
<Link href="/admin/analytics" className="text-sm text-white/70 hover:text-white transition-colors">
|
<Link href="/admin/analytics" className="text-sm text-white/70 hover:text-white transition-colors">
|
||||||
Statistiche
|
Statistiche
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/admin/forecast" className="text-sm text-white/70 hover:text-white transition-colors">
|
||||||
|
Forecast
|
||||||
|
</Link>
|
||||||
<Link href="/admin/catalog" className="text-sm text-white/70 hover:text-white transition-colors">
|
<Link href="/admin/catalog" className="text-sm text-white/70 hover:text-white transition-colors">
|
||||||
Catalogo
|
Catalogo
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/admin/offers" className="text-sm text-white/70 hover:text-white transition-colors">
|
||||||
|
Offerte
|
||||||
|
</Link>
|
||||||
<Link href="/admin/impostazioni" className="text-sm text-white/70 hover:text-white transition-colors">
|
<Link href="/admin/impostazioni" className="text-sm text-white/70 hover:text-white transition-colors">
|
||||||
Impostazioni
|
Impostazioni
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { updateMicroOfferServices } from "@/app/admin/offers/actions";
|
||||||
|
|
||||||
|
export function ServiceCheckboxList({
|
||||||
|
allServices,
|
||||||
|
assignedIds,
|
||||||
|
microId,
|
||||||
|
}: {
|
||||||
|
allServices: Array<{ id: string; name: string; price: string }>;
|
||||||
|
assignedIds: string[];
|
||||||
|
microId: string;
|
||||||
|
}) {
|
||||||
|
const [selected, setSelected] = useState(new Set(assignedIds));
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
function toggle(serviceId: string) {
|
||||||
|
const next = new Set(selected);
|
||||||
|
if (next.has(serviceId)) next.delete(serviceId);
|
||||||
|
else next.add(serviceId);
|
||||||
|
setSelected(next);
|
||||||
|
startTransition(async () => {
|
||||||
|
await updateMicroOfferServices(microId, [...next]);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{allServices.map((svc) => (
|
||||||
|
<label key={svc.id} className="flex items-center gap-2 cursor-pointer text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.has(svc.id)}
|
||||||
|
onChange={() => toggle(svc.id)}
|
||||||
|
disabled={isPending}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<span>{svc.name}</span>
|
||||||
|
<span className="ml-auto text-xs text-[#71717a]">€{parseFloat(svc.price).toFixed(2)}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{allServices.length === 0 && (
|
||||||
|
<p className="text-xs text-[#71717a]">Nessun servizio nel catalogo ancora.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user