feat: payments plans, phase auto-cascade, transcripts, manual timer
- Pagamenti: totale ereditato dalla somma accepted_total delle offerte attive (con override) + selettore schema rate 1/2/3 step; nuova colonna payments.percent per rescalare gli importi preservando label/stato - Fasi & Task: recomputePhaseStatus auto-cascade (task -> fase) su updateTaskStatus/addTask, fix UI stale, etichette Da iniziare/In corso/Completata - Pagina pubblica: colori fasi (grigio/blu/#1A463C) + fasi collassabili - Transcript del cliente visibili in tab Documenti admin e dashboard pubblica - Timer: inserimento manuale (+30/+60, minuti liberi, data) + elimina entry - CLAUDE.md: procedura Deploy & DB Access; push su main automatico Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { addManualTimeEntry, deleteTimeEntry } from "@/app/admin/timer-actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
type TimeEntry = {
|
||||
id: string;
|
||||
started_at: Date;
|
||||
ended_at: Date | null;
|
||||
duration_seconds: number | null;
|
||||
};
|
||||
|
||||
function formatDuration(seconds: number | null): string {
|
||||
if (!seconds) return "0 min";
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (h > 0) return `${h}h ${m}min`;
|
||||
return `${m} min`;
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return new Date(date).toLocaleDateString("it-IT", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
recentEntries: TimeEntry[];
|
||||
};
|
||||
|
||||
export function ManualTimeEntry({ projectId, recentEntries }: Props) {
|
||||
const router = useRouter();
|
||||
const [, startTransition] = useTransition();
|
||||
const [minutes, setMinutes] = useState("");
|
||||
const [date, setDate] = useState(() => new Date().toISOString().split("T")[0]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
async function handleQuickAdd(mins: number) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const startedAt = new Date(`${date}T09:00:00`).toISOString();
|
||||
await addManualTimeEntry(projectId, mins, startedAt);
|
||||
startTransition(() => router.refresh());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCustomAdd(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const mins = parseFloat(minutes);
|
||||
if (!mins || mins <= 0) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const startedAt = new Date(`${date}T09:00:00`).toISOString();
|
||||
await addManualTimeEntry(projectId, mins, startedAt);
|
||||
setMinutes("");
|
||||
startTransition(() => router.refresh());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(entryId: string) {
|
||||
setDeletingId(entryId);
|
||||
try {
|
||||
await deleteTimeEntry(entryId, projectId);
|
||||
startTransition(() => router.refresh());
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-[#e5e7eb] p-4 space-y-4">
|
||||
<h3 className="font-medium text-[#1a1a1a]">Aggiungi tempo manuale</h3>
|
||||
|
||||
{/* Data */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="entry-date">Data</Label>
|
||||
<Input
|
||||
id="entry-date"
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="max-w-[180px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick buttons */}
|
||||
<div>
|
||||
<p className="text-xs text-[#71717a] mb-2">Aggiunte rapide</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={loading}
|
||||
onClick={() => handleQuickAdd(30)}
|
||||
>
|
||||
+30 min
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={loading}
|
||||
onClick={() => handleQuickAdd(60)}
|
||||
>
|
||||
+60 min
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom minutes */}
|
||||
<form onSubmit={handleCustomAdd} className="flex items-end gap-2">
|
||||
<div className="space-y-1 flex-1">
|
||||
<Label htmlFor="manual-minutes">Minuti personalizzati</Label>
|
||||
<Input
|
||||
id="manual-minutes"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
placeholder="es. 45"
|
||||
value={minutes}
|
||||
onChange={(e) => setMinutes(e.target.value)}
|
||||
className="max-w-[120px]"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" size="sm" disabled={loading || !minutes}>
|
||||
Aggiungi
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Recent entries */}
|
||||
{recentEntries.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-[#71717a] mb-2 font-medium">Ultime registrazioni</p>
|
||||
<ul className="space-y-1.5">
|
||||
{recentEntries.map((entry) => (
|
||||
<li
|
||||
key={entry.id}
|
||||
className="flex items-center justify-between gap-2 bg-[#f9f9f9] rounded px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-[#1a1a1a]">
|
||||
{formatDuration(entry.duration_seconds)}
|
||||
</p>
|
||||
<p className="text-xs text-[#71717a]">{formatDate(entry.started_at)}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={deletingId === entry.id}
|
||||
onClick={() => handleDelete(entry.id)}
|
||||
className="text-[#999999] hover:text-red-500 transition-colors shrink-0 text-xs"
|
||||
aria-label="Elimina registrazione"
|
||||
>
|
||||
{deletingId === entry.id ? "..." : "✕"}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
type Transcript = {
|
||||
id: string;
|
||||
title: string | null;
|
||||
call_date: string;
|
||||
content: string;
|
||||
created_at: Date;
|
||||
};
|
||||
|
||||
export function TranscriptItem({ transcript }: { transcript: Transcript }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const date = new Date(transcript.call_date).toLocaleDateString("it-IT", {
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="border border-[#e5e7eb] rounded-lg bg-white overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 text-left hover:bg-[#f9f9f9] transition-colors"
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-[#1a1a1a] truncate">
|
||||
{transcript.title ?? "Transcript chiamata"}
|
||||
</p>
|
||||
<p className="text-xs text-[#71717a] mt-0.5">{date}</p>
|
||||
</div>
|
||||
<svg
|
||||
className={`w-4 h-4 text-[#999999] shrink-0 ml-3 transition-transform ${expanded ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="px-4 pb-4 pt-1 border-t border-[#e5e7eb]">
|
||||
<pre className="text-xs text-[#1a1a1a] whitespace-pre-wrap font-sans leading-relaxed max-h-96 overflow-y-auto">
|
||||
{transcript.content}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,29 @@
|
||||
import { addDocument } from "@/app/admin/clients/[id]/actions";
|
||||
import { DocumentRow } from "@/components/admin/DocumentRow";
|
||||
import { TranscriptItem } from "@/components/admin/TranscriptItem";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { Document } from "@/db/schema";
|
||||
|
||||
type Props = { documents: Document[]; clientId: string };
|
||||
type Transcript = {
|
||||
id: string;
|
||||
title: string | null;
|
||||
call_date: string;
|
||||
content: string;
|
||||
created_at: Date;
|
||||
};
|
||||
|
||||
export async function DocumentsTab({ documents, clientId }: Props) {
|
||||
type Props = {
|
||||
documents: Document[];
|
||||
clientId: string;
|
||||
transcripts?: Transcript[];
|
||||
};
|
||||
|
||||
export async function DocumentsTab({ documents, clientId, transcripts = [] }: Props) {
|
||||
return (
|
||||
<div className="space-y-6 max-w-lg">
|
||||
<div className="space-y-8 max-w-lg">
|
||||
{/* Add document form */}
|
||||
<form
|
||||
action={async (fd: FormData) => {
|
||||
"use server";
|
||||
@@ -42,14 +56,29 @@ export async function DocumentsTab({ documents, clientId }: Props) {
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{documents.length === 0 && (
|
||||
<p className="text-sm text-[#71717a]">Nessun documento ancora.</p>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{documents.map((doc) => (
|
||||
<DocumentRow key={doc.id} doc={doc} clientId={clientId} />
|
||||
))}
|
||||
{/* Documents list */}
|
||||
<div>
|
||||
{documents.length === 0 && (
|
||||
<p className="text-sm text-[#71717a]">Nessun documento ancora.</p>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{documents.map((doc) => (
|
||||
<DocumentRow key={doc.id} doc={doc} clientId={clientId} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transcripts section (read-only, expandable via client component) */}
|
||||
{transcripts.length > 0 && (
|
||||
<div>
|
||||
<h3 className="font-medium text-[#1a1a1a] mb-3">Transcript chiamate</h3>
|
||||
<div className="space-y-2">
|
||||
{transcripts.map((t) => (
|
||||
<TranscriptItem key={t.id} transcript={t} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
updatePaymentStatus,
|
||||
updateAcceptedTotal,
|
||||
} from "@/app/admin/clients/[id]/actions";
|
||||
import { initProjectPayments } from "@/app/admin/projects/project-actions";
|
||||
import { setPaymentPlan } from "@/app/admin/projects/project-actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -14,6 +18,7 @@ type Props = {
|
||||
acceptedTotal: string;
|
||||
clientId: string;
|
||||
projectId?: string; // set only from project detail page — enables init & split
|
||||
offersAcceptedTotal?: number; // sum of accepted_total from active offers
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
@@ -22,28 +27,111 @@ const statusLabels: Record<string, string> = {
|
||||
saldato: "Saldato",
|
||||
};
|
||||
|
||||
export async function PaymentsTab({ payments, acceptedTotal, clientId, projectId }: Props) {
|
||||
type PlanMode = "single" | "two" | "three";
|
||||
|
||||
const planOptions: { mode: PlanMode; label: string; description: string }[] = [
|
||||
{ mode: "single", label: "Pagamento unico", description: "100% in un'unica soluzione" },
|
||||
{ mode: "two", label: "2 step", description: "Acconto 50% + Saldo 50%" },
|
||||
{ mode: "three", label: "3 step", description: "50% + 30% + 20%" },
|
||||
];
|
||||
|
||||
export function PaymentsTab({
|
||||
payments,
|
||||
acceptedTotal,
|
||||
clientId,
|
||||
projectId,
|
||||
offersAcceptedTotal = 0,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const [overrideValue, setOverrideValue] = useState(acceptedTotal);
|
||||
const [planLoading, setPlanLoading] = useState<PlanMode | null>(null);
|
||||
const [statusLoading, setStatusLoading] = useState<string | null>(null);
|
||||
|
||||
const currentTotal = parseFloat(acceptedTotal) || 0;
|
||||
const offersTotal = offersAcceptedTotal;
|
||||
const hasOffers = offersTotal > 0;
|
||||
|
||||
async function handleUseOffersTotal() {
|
||||
if (!projectId) return;
|
||||
setOverrideValue(offersTotal.toFixed(2));
|
||||
const fd = new FormData();
|
||||
fd.set("accepted_total", offersTotal.toFixed(2));
|
||||
await updateAcceptedTotal(projectId, fd);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function handleSaveTotal(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const fd = new FormData();
|
||||
fd.set("accepted_total", overrideValue);
|
||||
await updateAcceptedTotal(clientId, fd);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function handleSetPlan(mode: PlanMode) {
|
||||
if (!projectId) return;
|
||||
setPlanLoading(mode);
|
||||
try {
|
||||
const total = parseFloat(overrideValue) || currentTotal;
|
||||
await setPaymentPlan(projectId, mode, total);
|
||||
router.refresh();
|
||||
} finally {
|
||||
setPlanLoading(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStatusUpdate(paymentId: string, status: string) {
|
||||
setStatusLoading(paymentId);
|
||||
try {
|
||||
await updatePaymentStatus(paymentId, clientId, status);
|
||||
router.refresh();
|
||||
} finally {
|
||||
setStatusLoading(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-md">
|
||||
{/* Accepted total */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<h3 className="font-medium text-gray-900 mb-3">Totale preventivo</h3>
|
||||
<form
|
||||
action={async (fd: FormData) => {
|
||||
"use server";
|
||||
await updateAcceptedTotal(clientId, fd);
|
||||
}}
|
||||
className="flex items-end gap-3"
|
||||
>
|
||||
{/* Totale preventivo */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4 space-y-3">
|
||||
<h3 className="font-medium text-gray-900">Totale preventivo</h3>
|
||||
|
||||
{/* Totale ereditato dalle offerte */}
|
||||
{hasOffers && (
|
||||
<div className="flex items-center justify-between bg-[#f9f9f9] border border-[#e5e7eb] rounded-md px-3 py-2 gap-3">
|
||||
<span className="text-sm text-[#71717a]">
|
||||
Ereditato dalle offerte attive:{" "}
|
||||
<span className="font-semibold text-[#1a1a1a]">
|
||||
€{" "}
|
||||
{offersTotal.toLocaleString("it-IT", { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
</span>
|
||||
{projectId && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleUseOffersTotal}
|
||||
className="shrink-0 text-xs"
|
||||
>
|
||||
Usa questo totale
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Override manuale */}
|
||||
<form onSubmit={handleSaveTotal} className="flex items-end gap-3">
|
||||
<div className="space-y-1 flex-1">
|
||||
<Label htmlFor="accepted_total">Importo (€)</Label>
|
||||
<Label htmlFor="accepted_total">Importo override (€)</Label>
|
||||
<Input
|
||||
id="accepted_total"
|
||||
name="accepted_total"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
defaultValue={acceptedTotal}
|
||||
value={overrideValue}
|
||||
onChange={(e) => setOverrideValue(e.target.value)}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
@@ -51,40 +139,71 @@ export async function PaymentsTab({ payments, acceptedTotal, clientId, projectId
|
||||
Salva
|
||||
</Button>
|
||||
</form>
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
Le rate Acconto e Saldo vengono aggiornate automaticamente al 50% ciascuna.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Selettore schema rate */}
|
||||
{projectId && (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4 space-y-3">
|
||||
<h3 className="font-medium text-gray-900">Schema di pagamento</h3>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{planOptions.map(({ mode, label, description }) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => handleSetPlan(mode)}
|
||||
disabled={planLoading !== null}
|
||||
className={`flex-1 min-w-[120px] border rounded-lg px-3 py-2.5 text-left transition-colors ${
|
||||
planLoading === mode
|
||||
? "border-[#1A463C] bg-[#1A463C]/5 opacity-70"
|
||||
: "border-[#e5e7eb] hover:border-[#1A463C] hover:bg-[#1A463C]/5"
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm font-medium text-[#1a1a1a]">{label}</p>
|
||||
<p className="text-xs text-[#71717a] mt-0.5">{description}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-[#71717a]">
|
||||
Selezionare uno schema cancella le rate esistenti e crea le nuove in base al totale corrente.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state — project has no payments yet */}
|
||||
{projectId && payments.length === 0 && (
|
||||
<div className="bg-white border border-dashed border-gray-300 rounded-lg p-6 text-center">
|
||||
<p className="text-sm text-[#71717a] mb-4">
|
||||
Nessun pagamento configurato. Crea le rate standard Acconto e Saldo.
|
||||
Nessun pagamento configurato. Scegli uno schema sopra o crea le rate standard.
|
||||
</p>
|
||||
<form
|
||||
action={async () => {
|
||||
"use server";
|
||||
await initProjectPayments(projectId);
|
||||
}}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => handleSetPlan("two")}
|
||||
disabled={planLoading !== null}
|
||||
>
|
||||
<Button type="submit" size="sm">
|
||||
Crea Acconto & Saldo
|
||||
</Button>
|
||||
</form>
|
||||
Crea Acconto & Saldo (50/50)
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment rows */}
|
||||
{payments.map((p) => {
|
||||
const amount = parseFloat(p.amount);
|
||||
const pct = p.percent !== null && p.percent !== undefined
|
||||
? parseFloat(String(p.percent))
|
||||
: null;
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className="bg-white border border-gray-200 rounded-lg p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="font-medium text-gray-900">{p.label}</h3>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900">{p.label}</h3>
|
||||
{pct !== null && (
|
||||
<p className="text-xs text-[#71717a]">{pct}% del totale</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">
|
||||
€{" "}
|
||||
@@ -101,16 +220,14 @@ export async function PaymentsTab({ payments, acceptedTotal, clientId, projectId
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
action={async (fd: FormData) => {
|
||||
"use server";
|
||||
await updatePaymentStatus(p.id, clientId, fd.get("status") as string);
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={p.status}
|
||||
disabled={statusLoading === p.id}
|
||||
onChange={async (e) => {
|
||||
await handleStatusUpdate(p.id, e.target.value);
|
||||
}}
|
||||
className="text-sm border border-gray-200 rounded px-2 py-1.5 bg-white flex-1"
|
||||
>
|
||||
{Object.entries(statusLabels).map(([val, label]) => (
|
||||
@@ -119,13 +236,13 @@ export async function PaymentsTab({ payments, acceptedTotal, clientId, projectId
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button type="submit" size="sm" variant="outline">
|
||||
Aggiorna
|
||||
</Button>
|
||||
</form>
|
||||
{statusLoading === p.id && (
|
||||
<span className="text-xs text-[#71717a]">...</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ const taskStatusOptions = [
|
||||
];
|
||||
|
||||
const phaseStatusOptions = [
|
||||
{ value: "upcoming", label: "In arrivo" },
|
||||
{ value: "active", label: "Attiva" },
|
||||
{ value: "upcoming", label: "Da iniziare" },
|
||||
{ value: "active", label: "In corso" },
|
||||
{ value: "done", label: "Completata" },
|
||||
];
|
||||
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
import { TimerCell } from "@/components/admin/TimerCell";
|
||||
import { ProfitabilityCard } from "@/components/admin/ProfitabilityCard";
|
||||
import { ManualTimeEntry } from "@/components/admin/ManualTimeEntry";
|
||||
|
||||
type TimeEntry = {
|
||||
id: string;
|
||||
started_at: Date;
|
||||
ended_at: Date | null;
|
||||
duration_seconds: number | null;
|
||||
};
|
||||
|
||||
type TimerTabProps = {
|
||||
projectId: string;
|
||||
@@ -10,6 +18,7 @@ type TimerTabProps = {
|
||||
activeTimerStartedAt: Date | null;
|
||||
totalTrackedSeconds: number;
|
||||
targetHourlyRate: number;
|
||||
recentEntries: TimeEntry[];
|
||||
};
|
||||
|
||||
export function TimerTab({
|
||||
@@ -19,6 +28,7 @@ export function TimerTab({
|
||||
activeTimerStartedAt,
|
||||
totalTrackedSeconds,
|
||||
targetHourlyRate,
|
||||
recentEntries,
|
||||
}: TimerTabProps) {
|
||||
return (
|
||||
<div className="space-y-6 max-w-sm">
|
||||
@@ -33,6 +43,8 @@ export function TimerTab({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ManualTimeEntry projectId={projectId} recentEntries={recentEntries} />
|
||||
|
||||
<ProfitabilityCard
|
||||
acceptedTotal={acceptedTotal}
|
||||
totalTrackedSeconds={totalTrackedSeconds}
|
||||
@@ -40,4 +52,4 @@ export function TimerTab({
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user