feat(05-03): add OffersTab component + Offerte tab in project workspace
- Create OffersTab.tsx client component with assign form and offer list - Inline accepted_total editing via onBlur handler - Add Offerte tab trigger and content to project workspace page - Destructure projectOffers and availableMicros from getProjectFullDetail result
This commit is contained in:
@@ -8,6 +8,7 @@ import { DocumentsTab } from "@/components/admin/tabs/DocumentsTab";
|
|||||||
import { CommentsTab } from "@/components/admin/tabs/CommentsTab";
|
import { CommentsTab } from "@/components/admin/tabs/CommentsTab";
|
||||||
import { QuoteTab } from "@/components/admin/tabs/QuoteTab";
|
import { QuoteTab } from "@/components/admin/tabs/QuoteTab";
|
||||||
import { TimerTab } from "@/components/admin/tabs/TimerTab";
|
import { TimerTab } from "@/components/admin/tabs/TimerTab";
|
||||||
|
import { OffersTab } from "@/components/admin/tabs/OffersTab";
|
||||||
import { PhasesViewToggle } from "@/components/admin/kanban/PhasesViewToggle";
|
import { PhasesViewToggle } from "@/components/admin/kanban/PhasesViewToggle";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
@@ -38,6 +39,8 @@ export default async function ProjectDetailPage({
|
|||||||
activeTimerEntryId,
|
activeTimerEntryId,
|
||||||
activeTimerStartedAt,
|
activeTimerStartedAt,
|
||||||
totalTrackedSeconds,
|
totalTrackedSeconds,
|
||||||
|
projectOffers,
|
||||||
|
availableMicros,
|
||||||
} = detail;
|
} = detail;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -71,6 +74,7 @@ export default async function ProjectDetailPage({
|
|||||||
<TabsTrigger value="comments">Commenti</TabsTrigger>
|
<TabsTrigger value="comments">Commenti</TabsTrigger>
|
||||||
<TabsTrigger value="quote">Preventivo</TabsTrigger>
|
<TabsTrigger value="quote">Preventivo</TabsTrigger>
|
||||||
<TabsTrigger value="timer">Timer</TabsTrigger>
|
<TabsTrigger value="timer">Timer</TabsTrigger>
|
||||||
|
<TabsTrigger value="offers">Offerte</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="phases">
|
<TabsContent value="phases">
|
||||||
@@ -133,6 +137,14 @@ export default async function ProjectDetailPage({
|
|||||||
targetHourlyRate={targetHourlyRate}
|
targetHourlyRate={targetHourlyRate}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="offers">
|
||||||
|
<OffersTab
|
||||||
|
projectId={id}
|
||||||
|
projectOffers={projectOffers}
|
||||||
|
availableMicros={availableMicros}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTransition, useRef } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
assignOfferToProject,
|
||||||
|
removeProjectOffer,
|
||||||
|
updateProjectOfferTotal,
|
||||||
|
} from "@/app/admin/projects/project-actions";
|
||||||
|
import type { ProjectOfferWithMicro } from "@/lib/admin-queries";
|
||||||
|
|
||||||
|
type AvailableMicro = {
|
||||||
|
id: string;
|
||||||
|
internal_name: string;
|
||||||
|
public_name: string;
|
||||||
|
duration_months: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface OffersTabProps {
|
||||||
|
projectId: string;
|
||||||
|
projectOffers: ProjectOfferWithMicro[];
|
||||||
|
availableMicros: AvailableMicro[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OffersTab({ projectId, projectOffers, availableMicros }: OffersTabProps) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const router = useRouter();
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
|
||||||
|
function handleAssign(formData: FormData) {
|
||||||
|
startTransition(async () => {
|
||||||
|
await assignOfferToProject(formData);
|
||||||
|
formRef.current?.reset();
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemove(offerId: string) {
|
||||||
|
startTransition(async () => {
|
||||||
|
await removeProjectOffer(offerId, projectId);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTotalUpdate(offerId: string, value: string) {
|
||||||
|
startTransition(async () => {
|
||||||
|
await updateProjectOfferTotal(offerId, projectId, value);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-2xl">
|
||||||
|
{/* Active assignments */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-[#1a1a1a] mb-3">Offerte Attive</h3>
|
||||||
|
{projectOffers.length === 0 ? (
|
||||||
|
<p className="text-sm text-[#71717a]">Nessuna offerta assegnata a questo progetto.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{projectOffers.map((offer) => (
|
||||||
|
<div
|
||||||
|
key={offer.id}
|
||||||
|
className="bg-white rounded-lg border border-[#e5e7eb] p-4 flex items-start justify-between gap-4"
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-[#1a1a1a]">{offer.micro_internal_name}</p>
|
||||||
|
<p className="text-xs text-[#71717a]">
|
||||||
|
Pubblico: {offer.micro_public_name} · {offer.micro_duration_months}{" "}
|
||||||
|
{offer.micro_duration_months === 1 ? "mese" : "mesi"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[#71717a] mt-1">
|
||||||
|
Inizio: {new Date(offer.start_date).toLocaleDateString("it-IT")}
|
||||||
|
</p>
|
||||||
|
{/* Accepted total inline edit */}
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<label className="text-xs text-[#71717a]">Totale accettato €:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
defaultValue={offer.accepted_total ?? ""}
|
||||||
|
placeholder="0.00"
|
||||||
|
onBlur={(e) => {
|
||||||
|
const val = e.currentTarget.value.trim();
|
||||||
|
if (val !== (offer.accepted_total ?? "")) {
|
||||||
|
handleTotalUpdate(offer.id, val);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-24 border rounded px-2 py-0.5 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemove(offer.id)}
|
||||||
|
disabled={isPending}
|
||||||
|
className="text-xs text-red-600 hover:underline shrink-0"
|
||||||
|
>
|
||||||
|
Rimuovi
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Assignment form */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-[#1a1a1a] mb-3">Assegna Micro-Offerta</h3>
|
||||||
|
<form
|
||||||
|
ref={formRef}
|
||||||
|
action={handleAssign}
|
||||||
|
className="bg-white rounded-lg border border-[#e5e7eb] p-4 space-y-3"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="project_id" value={projectId} />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-[#71717a] block mb-1">Micro-offerta</label>
|
||||||
|
<select
|
||||||
|
name="micro_id"
|
||||||
|
required
|
||||||
|
className="w-full border rounded px-3 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Seleziona micro-offerta...</option>
|
||||||
|
{availableMicros.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.internal_name} ({m.duration_months}{" "}
|
||||||
|
{m.duration_months === 1 ? "mese" : "mesi"})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-[#71717a] block mb-1">Totale accettato € (opzionale)</label>
|
||||||
|
<input
|
||||||
|
name="accepted_total"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
placeholder="0.00"
|
||||||
|
className="w-full border rounded px-3 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending || availableMicros.length === 0}
|
||||||
|
className="bg-[#1A463C] text-white text-sm px-4 py-1.5 rounded hover:bg-[#163a31] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isPending ? "Salvataggio..." : "Assegna Offerta"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{availableMicros.length === 0 && (
|
||||||
|
<p className="text-xs text-[#71717a]">
|
||||||
|
Nessuna micro-offerta disponibile. Crea prima una micro-offerta in{" "}
|
||||||
|
<a href="/admin/offers" className="underline">Offerte</a>.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user