feat(offers): ciclo di vita dei servizi ricorrenti (v2.4 Phase 13)
Un retainer, una volta assegnato, non si poteva fermare: project_offers aveva solo start_date e il forecast sommava il canone a ogni mese dell'orizzonte da li in poi, per sempre. Un cliente che disdiceva continuava a gonfiare il forecast a 12 mesi e a vedersi l'abbonamento attivo nel portale. - migration 0016 (gia applicata a prod): project_offers.status (attivo|sospeso|cessato, CHECK) + end_date. Additiva pura, default 'attivo' cosi le righe esistenti conservano il comportamento di prima - forecast: i retainer si fermano a end_date, sospesi e cessati escono. getOffersSoldBreakdown NON filtra per stato: e uno storico di vendita, escludere le cessate riscriverebbe il passato - offersAcceptedTotal esclude le cessate (default del piano pagamenti) - admin: comandi Sospendi/Riattiva/Cessa + data fine nella tab Offerte, solo per i ricorrenti. setProjectOfferLifecycle valida con Zod e filtra anche per project_id, cosi un id arbitrario non tocca altri progetti - portale: "Attivo dal", "fino al", badge In pausa, "Canone mensile" invece di "Prezzo finale"; le cessate non arrivano al client - fix: un retainer sospeso continuava a intestare i pagamenti "Totale Pagamento Mensile" e a sovrascrivere l'importo Igiene nello stesso giro: - STATUS.md riscritto: era fermo al 22 giugno e diceva che node/docker non sono disponibili sul server e che le migrazioni si applicano da locale con uno script postgres.js — il contrario della procedura reale - rimossi ChatSection/CommentList/CommentForm, senza importatori (308 righe) - overrides postcss>=8.5.18 e sharp>=0.35.0: 3 CVE high transitive di Next senza fix upstream. npm audit ora pulito, build verde Verifica: forecast controllato sui dati veri in 5 scenari (baseline invariata, end_date, sospeso, cessato, ripristino); portale verificato nei 4 stati; tab admin verificata con Playwright sul build di produzione (i comandi non compaiono sulle una tantum). Dati di test ripuliti, tabelle protette invariate 4/5/11/10. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
assignOfferToProject,
|
||||
removeProjectOffer,
|
||||
updateProjectOfferTotal,
|
||||
setProjectOfferLifecycle,
|
||||
} from "@/app/admin/projects/project-actions";
|
||||
import type { ProjectOfferWithMicro } from "@/lib/admin-queries";
|
||||
|
||||
@@ -49,6 +50,42 @@ function meaningfulPublicName(m: { public_name: string; tier_letter: string | nu
|
||||
return p;
|
||||
}
|
||||
|
||||
// Ciclo di vita (v2.4 Phase 13). Scritto a token semantici + coppie dark:
|
||||
// secondo il design system corrente, anche se la pagina attorno è ancora a
|
||||
// palette raw: al redesign di /admin/projects/[id] questa parte non si tocca.
|
||||
const LIFECYCLE_STYLES: Record<string, string> = {
|
||||
attivo:
|
||||
"bg-emerald-50 text-emerald-600 border-emerald-100 dark:bg-emerald-950/40 dark:text-emerald-300 dark:border-emerald-900",
|
||||
sospeso:
|
||||
"bg-amber-50 text-amber-600 border-amber-100 dark:bg-amber-950/40 dark:text-amber-300 dark:border-amber-900",
|
||||
cessato:
|
||||
"bg-muted text-muted-foreground border-border",
|
||||
};
|
||||
|
||||
const LIFECYCLE_LABELS: Record<string, string> = {
|
||||
attivo: "Attivo",
|
||||
sospeso: "Sospeso",
|
||||
cessato: "Cessato",
|
||||
};
|
||||
|
||||
function LifecycleBadge({ status }: { status: string }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-block rounded-full border px-2 py-0.5 text-[11px] font-medium ${
|
||||
LIFECYCLE_STYLES[status] ?? LIFECYCLE_STYLES.cessato
|
||||
}`}
|
||||
>
|
||||
{LIFECYCLE_LABELS[status] ?? status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Date → "YYYY-MM-DD" per il value di un <input type="date">. */
|
||||
function toDateInput(d: Date | null): string {
|
||||
if (!d) return "";
|
||||
return new Date(d).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function TypeBadge({ offerType }: { offerType: string }) {
|
||||
const retainer = offerType === "retainer";
|
||||
return (
|
||||
@@ -70,6 +107,10 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
|
||||
const [tierId, setTierId] = useState<string>("");
|
||||
const [acceptedTotal, setAcceptedTotal] = useState<string>("");
|
||||
const [importPhases, setImportPhases] = useState(true);
|
||||
// id dell'offerta per cui "Cessa" attende conferma (due passaggi, come
|
||||
// l'eliminazione in ClientActions).
|
||||
const [ceaseArmed, setCeaseArmed] = useState<string | null>(null);
|
||||
const [lifecycleError, setLifecycleError] = useState<string | null>(null);
|
||||
|
||||
// Group tiers by offer (macro), preserving query order.
|
||||
const offers = useMemo(() => {
|
||||
@@ -133,11 +174,30 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
|
||||
});
|
||||
}
|
||||
|
||||
function handleLifecycle(
|
||||
offerId: string,
|
||||
input: { status: string; end_date?: string | null }
|
||||
) {
|
||||
setLifecycleError(null);
|
||||
startTransition(async () => {
|
||||
const res = await setProjectOfferLifecycle(offerId, projectId, input);
|
||||
if (!res.ok) {
|
||||
setLifecycleError(res.error);
|
||||
return;
|
||||
}
|
||||
setCeaseArmed(null);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 max-w-2xl">
|
||||
{/* Active assignments */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[#1a1a1a] mb-3">Offerte Attive</h3>
|
||||
{lifecycleError && (
|
||||
<p className="mb-3 text-xs text-destructive">{lifecycleError}</p>
|
||||
)}
|
||||
{projectOffers.length === 0 ? (
|
||||
<p className="text-sm text-[#71717a]">Nessuna offerta assegnata a questo progetto.</p>
|
||||
) : (
|
||||
@@ -147,6 +207,7 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
|
||||
public_name: offer.micro_public_name,
|
||||
tier_letter: offer.tier_letter,
|
||||
});
|
||||
const isRetainer = offer.macro_offer_type === "retainer";
|
||||
return (
|
||||
<div
|
||||
key={offer.id}
|
||||
@@ -161,10 +222,13 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
|
||||
</span>
|
||||
)}
|
||||
<TypeBadge offerType={offer.macro_offer_type} />
|
||||
{isRetainer && <LifecycleBadge status={offer.status} />}
|
||||
</div>
|
||||
{pub && <p className="text-xs text-[#71717a] mt-1">Pubblico: {offer.micro_public_name}</p>}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<label className="text-xs text-[#71717a]">Totale accettato €</label>
|
||||
<label className="text-xs text-[#71717a]">
|
||||
{isRetainer ? "Canone mensile €" : "Totale accettato €"}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
@@ -178,6 +242,97 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
|
||||
className="w-28 border border-[#e5e7eb] rounded-md px-2 py-1 text-xs tabular-nums focus:outline-none focus:ring-2 focus:ring-[#1A463C]/15"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Ciclo di vita — solo per i ricorrenti: per una una tantum
|
||||
"sospendere" o "cessare" non significa nulla, la durata è
|
||||
già data da duration_months. */}
|
||||
{isRetainer && (
|
||||
<div className="mt-3 rounded-lg border border-border bg-card p-3">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-muted-foreground">Attivo fino al</label>
|
||||
<input
|
||||
type="date"
|
||||
defaultValue={toDateInput(offer.end_date)}
|
||||
disabled={isPending}
|
||||
onChange={(e) => {
|
||||
const val = e.currentTarget.value;
|
||||
handleLifecycle(offer.id, {
|
||||
status: offer.status,
|
||||
end_date: val === "" ? null : val,
|
||||
});
|
||||
}}
|
||||
className="rounded-md border border-border bg-background px-2 py-1 text-xs text-foreground focus:border-ring focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
{!offer.end_date && (
|
||||
<span className="text-[11px] text-muted-foreground">continuativo</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{offer.status === "attivo" ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => handleLifecycle(offer.id, { status: "sospeso" })}
|
||||
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline disabled:opacity-50"
|
||||
>
|
||||
Sospendi
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() =>
|
||||
handleLifecycle(offer.id, { status: "attivo", end_date: null })
|
||||
}
|
||||
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline disabled:opacity-50"
|
||||
>
|
||||
Riattiva
|
||||
</button>
|
||||
)}
|
||||
|
||||
{offer.status !== "cessato" &&
|
||||
(ceaseArmed === offer.id ? (
|
||||
<>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
Chiudere l'abbonamento?
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => handleLifecycle(offer.id, { status: "cessato" })}
|
||||
className="text-xs font-semibold text-destructive underline-offset-2 hover:underline disabled:opacity-50"
|
||||
>
|
||||
Sì, cessa
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCeaseArmed(null)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Annulla
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => setCeaseArmed(offer.id)}
|
||||
className="text-xs text-muted-foreground underline-offset-2 hover:text-destructive hover:underline disabled:opacity-50"
|
||||
>
|
||||
Cessa
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-[11px] leading-relaxed text-muted-foreground">
|
||||
Sospeso e cessato escono dal forecast dei prossimi 12 mesi. Lo storico
|
||||
delle offerte vendute non cambia.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -22,8 +22,14 @@ interface ClientDashboardProps {
|
||||
}
|
||||
|
||||
export function ClientDashboard({ view, token, comments, embedded = false }: ClientDashboardProps) {
|
||||
// Determine payment display mode based on active offers
|
||||
const retainerOffer = view.activeOffers?.find((o) => o.offer_type === "retainer");
|
||||
// Determine payment display mode based on active offers.
|
||||
// Solo i retainer ATTIVI cambiano la modalità: uno sospeso continuerebbe
|
||||
// altrimenti a intestare i pagamenti "Totale Pagamento Mensile" e a
|
||||
// sovrascrivere l'importo con un canone che al momento non si paga.
|
||||
// (Le offerte cessate sono già escluse a monte, in client-view.ts.)
|
||||
const retainerOffer = view.activeOffers?.find(
|
||||
(o) => o.offer_type === "retainer" && o.status === "attivo"
|
||||
);
|
||||
const hasRetainer = !!retainerOffer;
|
||||
|
||||
const paymentLabel = hasRetainer ? "Totale Pagamento Mensile" : "Totale Investimento";
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition, useRef, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { ClientView } from "@/lib/client-view";
|
||||
import type { Comment } from "@/db/schema";
|
||||
|
||||
type Entity = { id: string; type: "general" | "task" | "deliverable"; label: string };
|
||||
|
||||
function buildEntityList(clientId: string, phases: ClientView["phases"]): Entity[] {
|
||||
const entities: Entity[] = [
|
||||
{ id: clientId, type: "general", label: "Messaggio generale" },
|
||||
];
|
||||
for (const phase of phases) {
|
||||
for (const task of phase.tasks) {
|
||||
entities.push({
|
||||
id: task.id,
|
||||
type: "task",
|
||||
label: `${phase.title} — ${task.title}`,
|
||||
});
|
||||
for (const d of task.deliverables) {
|
||||
entities.push({
|
||||
id: d.id,
|
||||
type: "deliverable",
|
||||
label: `${task.title} — ${d.title}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return entities;
|
||||
}
|
||||
|
||||
function buildLabelMap(entities: Entity[]): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
for (const e of entities) map.set(e.id, e.label);
|
||||
return map;
|
||||
}
|
||||
|
||||
function formatTime(ts: Date | string): string {
|
||||
const d = typeof ts === "string" ? new Date(ts) : ts;
|
||||
return d.toLocaleString("it-IT", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export function ChatSection({
|
||||
clientId,
|
||||
phases,
|
||||
token,
|
||||
comments,
|
||||
}: {
|
||||
clientId: string;
|
||||
phases: ClientView["phases"];
|
||||
token: string;
|
||||
comments: Comment[];
|
||||
}) {
|
||||
const entities = buildEntityList(clientId, phases);
|
||||
const labelMap = buildLabelMap(entities);
|
||||
|
||||
const [body, setBody] = useState("");
|
||||
const [selectedEntityId, setSelectedEntityId] = useState(clientId);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [, startTransition] = useTransition();
|
||||
const router = useRouter();
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Sort all comments chronologically
|
||||
const sorted = [...comments].sort(
|
||||
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [comments.length]);
|
||||
|
||||
function handleSend(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
const selectedEntity = entities.find((en) => en.id === selectedEntityId);
|
||||
if (!selectedEntity) return;
|
||||
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/client/comment", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
entity_type: selectedEntity.type,
|
||||
entity_id: selectedEntity.id,
|
||||
body: trimmed,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
setError(data.error ?? "Errore nell'invio");
|
||||
return;
|
||||
}
|
||||
setBody("");
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError("Errore di rete");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col rounded-xl border border-[#e5e7eb] overflow-hidden bg-white">
|
||||
{/* Chat feed */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3 max-h-[480px] min-h-[200px]">
|
||||
{sorted.length === 0 && (
|
||||
<p className="text-sm text-[#71717a] italic text-center py-10">
|
||||
Nessun messaggio ancora. Scrivi qui sotto per iniziare.
|
||||
</p>
|
||||
)}
|
||||
{sorted.map((c) => {
|
||||
const isClient = c.author === "client";
|
||||
const entityLabel = labelMap.get(c.entity_id);
|
||||
const showTag = c.entity_id !== clientId && entityLabel;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={c.id}
|
||||
className={`flex flex-col gap-1 ${isClient ? "items-end" : "items-start"}`}
|
||||
>
|
||||
{/* Author + tag */}
|
||||
<div className={`flex items-center gap-2 ${isClient ? "flex-row-reverse" : ""}`}>
|
||||
<span
|
||||
className={`text-[10px] font-bold uppercase tracking-wide px-2 py-0.5 rounded-full ${
|
||||
isClient
|
||||
? "bg-[#DEF168] text-[#1A463C]"
|
||||
: "bg-[#1A463C] text-white"
|
||||
}`}
|
||||
>
|
||||
{isClient ? "Tu" : "iamcavalli"}
|
||||
</span>
|
||||
{showTag && (
|
||||
<span className="text-[10px] text-[#71717a] bg-[#f4f4f5] px-2 py-0.5 rounded-full truncate max-w-[180px]">
|
||||
{entityLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bubble */}
|
||||
<div
|
||||
className={`max-w-[75%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed ${
|
||||
isClient
|
||||
? "bg-[#1A463C] text-white rounded-tr-sm"
|
||||
: "bg-[#f4f4f5] text-[#1a1a1a] rounded-tl-sm"
|
||||
}`}
|
||||
>
|
||||
{c.body}
|
||||
</div>
|
||||
|
||||
{/* Timestamp */}
|
||||
<span className="text-[10px] text-[#71717a]">{formatTime(c.created_at)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-[#e5e7eb]" />
|
||||
|
||||
{/* Input area */}
|
||||
<form onSubmit={handleSend} className="p-3 space-y-2 bg-[#fafafa]">
|
||||
{/* Entity selector */}
|
||||
<select
|
||||
value={selectedEntityId}
|
||||
onChange={(e) => setSelectedEntityId(e.target.value)}
|
||||
className="w-full text-xs border border-[#e5e7eb] rounded-lg px-3 py-1.5 bg-white text-[#1a1a1a] focus:outline-none focus:ring-2 focus:ring-[#1A463C]/30"
|
||||
>
|
||||
{entities.map((en) => (
|
||||
<option key={en.id} value={en.id}>
|
||||
{en.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend(e as unknown as React.FormEvent);
|
||||
}
|
||||
}}
|
||||
placeholder="Scrivi un messaggio… (Invio per inviare)"
|
||||
rows={2}
|
||||
className="flex-1 text-sm border border-[#e5e7eb] rounded-lg px-3 py-2 bg-white resize-none focus:outline-none focus:ring-2 focus:ring-[#1A463C]/30"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!body.trim()}
|
||||
className="self-end px-4 py-2 rounded-lg bg-[#1A463C] text-white text-sm font-semibold disabled:opacity-40 hover:bg-[#1A463C]/90 transition-colors"
|
||||
>
|
||||
Invia
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-600">{error}</p>}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
type Props = {
|
||||
token: string;
|
||||
entityType: "task" | "deliverable";
|
||||
entityId: string;
|
||||
};
|
||||
|
||||
export function CommentForm({ token, entityType, entityId }: Props) {
|
||||
const router = useRouter();
|
||||
const [body, setBody] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!body.trim()) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/client/comment", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token, entity_type: entityType, entity_id: entityId, body }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
setError(data.error ?? "Errore durante l'invio");
|
||||
return;
|
||||
}
|
||||
setBody("");
|
||||
router.refresh(); // Re-fetch Server Component to show new comment
|
||||
} catch {
|
||||
setError("Errore di rete");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="mt-3 flex gap-2 items-end">
|
||||
<Textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder="Lascia un commento..."
|
||||
rows={2}
|
||||
className="text-sm resize-none flex-1"
|
||||
/>
|
||||
<div className="flex flex-col justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={loading || !body.trim()}
|
||||
className="text-xs"
|
||||
>
|
||||
{loading ? "Invio..." : "Invia"}
|
||||
</Button>
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { Comment } from "@/db/schema";
|
||||
|
||||
type Props = { comments: Comment[] };
|
||||
|
||||
export function CommentList({ comments }: Props) {
|
||||
if (comments.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-2">
|
||||
{comments.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className={`flex gap-2 ${c.author === "admin" ? "flex-row-reverse" : ""}`}
|
||||
>
|
||||
<div
|
||||
className={`rounded-lg px-3 py-2 text-xs max-w-xs ${
|
||||
c.author === "admin"
|
||||
? "bg-gray-900 text-white"
|
||||
: "bg-gray-100 text-gray-800"
|
||||
}`}
|
||||
>
|
||||
<p className="font-medium mb-0.5 opacity-60">
|
||||
{c.author === "admin" ? "iamcavalli" : "Tu"}
|
||||
</p>
|
||||
<p>{c.body}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,9 +15,21 @@ interface ActiveOffer {
|
||||
offer_type: string; // una_tantum | retainer
|
||||
cumulative_price: string; // sum of service prices
|
||||
accepted_total: string | null;
|
||||
status: string; // attivo | sospeso — le cessate non arrivano qui
|
||||
start_date: string; // ISO
|
||||
end_date: string | null; // ISO — null = continuativo
|
||||
services: IncludedService[];
|
||||
}
|
||||
|
||||
/** "15 giugno 2026" — le date nel portale sono in italiano esteso. */
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString("it-IT", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
interface OffersSectionProps {
|
||||
offers: ActiveOffer[];
|
||||
}
|
||||
@@ -27,6 +39,8 @@ function OfferCard({ offer }: { offer: ActiveOffer }) {
|
||||
const price = parseFloat(offer.cumulative_price);
|
||||
const hasPrice = price > 0;
|
||||
const hasServices = offer.services.length > 0;
|
||||
const isRetainer = offer.offer_type === "retainer";
|
||||
const isSuspended = offer.status === "sospeso";
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-xl border border-border-light shadow-card overflow-hidden">
|
||||
@@ -34,7 +48,23 @@ function OfferCard({ offer }: { offer: ActiveOffer }) {
|
||||
<div className="p-5">
|
||||
<div className="border-l-2 border-primary pl-3">
|
||||
{/* Heading: macro public name */}
|
||||
<p className="text-sm font-bold text-foreground">{offer.offer_name}</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-bold text-foreground">{offer.offer_name}</p>
|
||||
{isSuspended && (
|
||||
<span className="inline-block rounded-full border border-amber-100 bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-amber-600 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-300">
|
||||
In pausa
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Periodo di attività — solo per i servizi ricorrenti: per una una
|
||||
tantum "attivo dal" non aggiunge nulla. */}
|
||||
{isRetainer && (
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">
|
||||
Attivo dal {formatDate(offer.start_date)}
|
||||
{offer.end_date && ` · fino al ${formatDate(offer.end_date)}`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{/* "Valore incluso" — hidden when 0 */}
|
||||
@@ -45,10 +75,12 @@ function OfferCard({ offer }: { offer: ActiveOffer }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* "Prezzo finale" */}
|
||||
{/* Prezzo: per un ricorrente è il canone, non un totale una tantum */}
|
||||
{offer.accepted_total && (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="font-semibold text-foreground">Prezzo finale</span>
|
||||
<span className="font-semibold text-foreground">
|
||||
{isRetainer ? "Canone mensile" : "Prezzo finale"}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-primary">
|
||||
€{parseFloat(offer.accepted_total).toFixed(2)}
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user