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:
@@ -20,9 +20,17 @@ Planning in `.planning/`. Use `/gsd-plan-phase N` → `/gsd-execute-phase N`. St
|
|||||||
- Before running any migration: verify it only adds columns/tables — never drops or truncates production data
|
- Before running any migration: verify it only adds columns/tables — never drops or truncates production data
|
||||||
- Confirm explicitly before any schema change that removes a column or table used by these entities
|
- Confirm explicitly before any schema change that removes a column or table used by these entities
|
||||||
|
|
||||||
|
## Deploy & DB Access (procedure)
|
||||||
|
- Environments: local → Gitea (`origin`) → Coolify (prod, auto-deploys on push to `main`)
|
||||||
|
- Prod Postgres is NOT publicly exposed. Fixed access point: user opens an SSH tunnel
|
||||||
|
`ssh -o ServerAliveInterval=30 -L 54321:localhost:54321 root@178.104.27.55` and leaves it running.
|
||||||
|
Claude then connects to `127.0.0.1:54321` using prod creds in `.env.prod.local` (gitignored) — no per-step commands needed from the user.
|
||||||
|
- Migrations are hand-written SQL in `src/db/migrations/` (drizzle-kit generate is broken).
|
||||||
|
- Ordering: apply an additive migration to prod BEFORE pushing the schema-dependent code, so the live portal never queries a missing column.
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
- Confirm before any destructive command (rm -rf, reset --hard, force push, DROP TABLE, infra changes)
|
- Confirm before any destructive command (rm -rf, reset --hard, force push, DROP TABLE / drop-column, truncate, infra changes)
|
||||||
- Never read/expose .env or credentials without explicit request
|
- Never print .env contents or credentials in plaintext output; using them internally to connect is fine
|
||||||
- Don't install packages without showing name + registry + version first
|
- Don't install packages without showing name + registry + version first
|
||||||
- Don't push to main or create PRs without explicit confirmation
|
- Pushing to `main` is allowed automatically (standard local → Gitea → Coolify flow); never force-push to `main`
|
||||||
- Any change to this section: propose full new version, get approval before applying
|
- Any change to this section: propose full new version, get approval before applying
|
||||||
@@ -162,15 +162,45 @@ export async function addTask(phaseId: string, id: string, formData: FormData) {
|
|||||||
sort_order: maxOrder + 1,
|
sort_order: maxOrder + 1,
|
||||||
status: "todo",
|
status: "todo",
|
||||||
});
|
});
|
||||||
|
// Cascade: a new todo task keeps phase active/upcoming — recompute to be safe
|
||||||
|
await recomputePhaseStatus(phaseId);
|
||||||
const { path } = await resolveEntity(id);
|
const { path } = await resolveEntity(id);
|
||||||
revalidatePath(path);
|
revalidatePath(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── PHASE STATUS CASCADE ──────────────────────────────────────────────────────
|
||||||
|
// Recomputes phase status from its tasks:
|
||||||
|
// all done → done
|
||||||
|
// any in_progress or done (but not all done) → active
|
||||||
|
// all todo (or no tasks) → upcoming
|
||||||
|
export async function recomputePhaseStatus(phaseId: string): Promise<void> {
|
||||||
|
const phaseTasks = await db
|
||||||
|
.select({ status: tasks.status })
|
||||||
|
.from(tasks)
|
||||||
|
.where(eq(tasks.phase_id, phaseId));
|
||||||
|
|
||||||
|
let newStatus: "upcoming" | "active" | "done" = "upcoming";
|
||||||
|
if (phaseTasks.length > 0) {
|
||||||
|
const allDone = phaseTasks.every((t) => t.status === "done");
|
||||||
|
const anyActive = phaseTasks.some(
|
||||||
|
(t) => t.status === "in_progress" || t.status === "done"
|
||||||
|
);
|
||||||
|
if (allDone) newStatus = "done";
|
||||||
|
else if (anyActive) newStatus = "active";
|
||||||
|
}
|
||||||
|
await db.update(phases).set({ status: newStatus }).where(eq(phases.id, phaseId));
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateTaskStatus(taskId: string, id: string, status: string) {
|
export async function updateTaskStatus(taskId: string, id: string, status: string) {
|
||||||
await requireAdmin();
|
await requireAdmin();
|
||||||
const allowed = ["todo", "in_progress", "done"];
|
const allowed = ["todo", "in_progress", "done"];
|
||||||
if (!allowed.includes(status)) throw new Error("Stato non valido");
|
if (!allowed.includes(status)) throw new Error("Stato non valido");
|
||||||
await db.update(tasks).set({ status }).where(eq(tasks.id, taskId));
|
await db.update(tasks).set({ status }).where(eq(tasks.id, taskId));
|
||||||
|
|
||||||
|
// Cascade: recompute parent phase status from all its tasks
|
||||||
|
const taskRow = await db.select({ phase_id: tasks.phase_id }).from(tasks).where(eq(tasks.id, taskId)).limit(1);
|
||||||
|
if (taskRow[0]) await recomputePhaseStatus(taskRow[0].phase_id);
|
||||||
|
|
||||||
const { path } = await resolveEntity(id);
|
const { path } = await resolveEntity(id);
|
||||||
revalidatePath(path);
|
revalidatePath(path);
|
||||||
}
|
}
|
||||||
@@ -240,6 +270,35 @@ export async function updatePaymentStatus(paymentId: string, id: string, status:
|
|||||||
revalidatePath(path);
|
revalidatePath(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rescales payment amounts when the total changes.
|
||||||
|
// If the payment has a `percent` field, use it (new plan rows).
|
||||||
|
// Legacy rows (percent null) fall back to equal split across all rows.
|
||||||
|
async function rescalePayments(projectId: string, newTotal: number): Promise<void> {
|
||||||
|
const projectPayments = await db
|
||||||
|
.select({ id: payments.id, percent: payments.percent })
|
||||||
|
.from(payments)
|
||||||
|
.where(eq(payments.project_id, projectId));
|
||||||
|
|
||||||
|
if (projectPayments.length === 0) return;
|
||||||
|
|
||||||
|
const hasPercent = projectPayments.some((p) => p.percent !== null);
|
||||||
|
|
||||||
|
if (hasPercent) {
|
||||||
|
// New plan: rescale each row by its stored percent
|
||||||
|
for (const p of projectPayments) {
|
||||||
|
const pct = p.percent !== null ? parseFloat(String(p.percent)) : 0;
|
||||||
|
const newAmount = ((newTotal * pct) / 100).toFixed(2);
|
||||||
|
await db.update(payments).set({ amount: newAmount }).where(eq(payments.id, p.id));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Legacy: equal split across all rows (backward compat)
|
||||||
|
const share = (newTotal / projectPayments.length).toFixed(2);
|
||||||
|
for (const p of projectPayments) {
|
||||||
|
await db.update(payments).set({ amount: share }).where(eq(payments.id, p.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateAcceptedTotal(id: string, formData: FormData) {
|
export async function updateAcceptedTotal(id: string, formData: FormData) {
|
||||||
await requireAdmin();
|
await requireAdmin();
|
||||||
const raw = (formData.get("accepted_total") as string)?.trim();
|
const raw = (formData.get("accepted_total") as string)?.trim();
|
||||||
@@ -253,28 +312,17 @@ export async function updateAcceptedTotal(id: string, formData: FormData) {
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (asProject[0]) {
|
if (asProject[0]) {
|
||||||
// Project context: update projects.accepted_total + this project's payment stubs
|
// Project context: update projects.accepted_total + rescale this project's payments
|
||||||
await db.update(projects).set({ accepted_total: val.toFixed(2) }).where(eq(projects.id, id));
|
await db.update(projects).set({ accepted_total: val.toFixed(2) }).where(eq(projects.id, id));
|
||||||
const half = (val / 2).toFixed(2);
|
await rescalePayments(id, val);
|
||||||
const projectPayments = await db.select({ id: payments.id })
|
|
||||||
.from(payments).where(eq(payments.project_id, id));
|
|
||||||
for (const p of projectPayments) {
|
|
||||||
await db.update(payments).set({ amount: half }).where(eq(payments.id, p.id));
|
|
||||||
}
|
|
||||||
revalidatePath(`/admin/projects/${id}`);
|
revalidatePath(`/admin/projects/${id}`);
|
||||||
} else {
|
} else {
|
||||||
// Client context: update clients.accepted_total + all project payment stubs
|
// Client context: update clients.accepted_total + rescale all project payments
|
||||||
await db.update(clients).set({ accepted_total: val.toFixed(2) }).where(eq(clients.id, id));
|
await db.update(clients).set({ accepted_total: val.toFixed(2) }).where(eq(clients.id, id));
|
||||||
const projectRows = await db.select({ id: projects.id })
|
const projectRows = await db.select({ id: projects.id })
|
||||||
.from(projects).where(eq(projects.client_id, id));
|
.from(projects).where(eq(projects.client_id, id));
|
||||||
if (projectRows.length > 0) {
|
for (const proj of projectRows) {
|
||||||
const projectIds = projectRows.map((p) => p.id);
|
await rescalePayments(proj.id, val);
|
||||||
const half = (val / 2).toFixed(2);
|
|
||||||
const paymentsRows = await db.select()
|
|
||||||
.from(payments).where(inArray(payments.project_id, projectIds));
|
|
||||||
for (const p of paymentsRows) {
|
|
||||||
await db.update(payments).set({ amount: half }).where(eq(payments.id, p.id));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
revalidatePath(`/admin/clients/${id}`);
|
revalidatePath(`/admin/clients/${id}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { getProjectFullDetail } from "@/lib/admin-queries";
|
import { getProjectFullDetail, getRecentTimeEntries } from "@/lib/admin-queries";
|
||||||
import { getTargetHourlyRate } from "@/lib/settings";
|
import { getTargetHourlyRate } from "@/lib/settings";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { PhasesTab } from "@/components/admin/tabs/PhasesTab";
|
import { PhasesTab } from "@/components/admin/tabs/PhasesTab";
|
||||||
@@ -26,6 +26,8 @@ export default async function ProjectDetailPage({
|
|||||||
|
|
||||||
if (!detail) notFound();
|
if (!detail) notFound();
|
||||||
|
|
||||||
|
const recentEntries = await getRecentTimeEntries(id, 5);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
project,
|
project,
|
||||||
phases,
|
phases,
|
||||||
@@ -38,6 +40,8 @@ export default async function ProjectDetailPage({
|
|||||||
totalTrackedSeconds,
|
totalTrackedSeconds,
|
||||||
projectOffers,
|
projectOffers,
|
||||||
availableMicros,
|
availableMicros,
|
||||||
|
offersAcceptedTotal,
|
||||||
|
transcripts,
|
||||||
} = detail;
|
} = detail;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -87,11 +91,12 @@ export default async function ProjectDetailPage({
|
|||||||
acceptedTotal={project.accepted_total ?? "0"}
|
acceptedTotal={project.accepted_total ?? "0"}
|
||||||
clientId={id}
|
clientId={id}
|
||||||
projectId={id}
|
projectId={id}
|
||||||
|
offersAcceptedTotal={offersAcceptedTotal}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="documents">
|
<TabsContent value="documents">
|
||||||
<DocumentsTab documents={documents} clientId={id} />
|
<DocumentsTab documents={documents} clientId={id} transcripts={transcripts} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="notes">
|
<TabsContent value="notes">
|
||||||
@@ -122,6 +127,7 @@ export default async function ProjectDetailPage({
|
|||||||
activeTimerStartedAt={activeTimerStartedAt}
|
activeTimerStartedAt={activeTimerStartedAt}
|
||||||
totalTrackedSeconds={totalTrackedSeconds}
|
totalTrackedSeconds={totalTrackedSeconds}
|
||||||
targetHourlyRate={targetHourlyRate}
|
targetHourlyRate={targetHourlyRate}
|
||||||
|
recentEntries={recentEntries}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export async function updateProjectAcceptedTotal(projectId: string, acceptedTota
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Creates Acconto 50% + Saldo 50% stubs for a project that has no payments yet.
|
// Creates Acconto 50% + Saldo 50% stubs for a project that has no payments yet.
|
||||||
|
// Kept for backward compatibility; prefer setPaymentPlan for new code.
|
||||||
export async function initProjectPayments(projectId: string): Promise<void> {
|
export async function initProjectPayments(projectId: string): Promise<void> {
|
||||||
await requireAdmin();
|
await requireAdmin();
|
||||||
const rows = await db
|
const rows = await db
|
||||||
@@ -74,11 +75,50 @@ export async function initProjectPayments(projectId: string): Promise<void> {
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
if (!rows[0]) throw new Error("Progetto non trovato");
|
if (!rows[0]) throw new Error("Progetto non trovato");
|
||||||
const total = parseFloat(rows[0].accepted_total ?? "0");
|
const total = parseFloat(rows[0].accepted_total ?? "0");
|
||||||
const half = (total / 2).toFixed(2);
|
await setPaymentPlan(projectId, "two", total);
|
||||||
await db.insert(payments).values([
|
}
|
||||||
{ project_id: projectId, label: "Acconto 50%", amount: half, status: "da_saldare" },
|
|
||||||
{ project_id: projectId, label: "Saldo 50%", amount: half, status: "da_saldare" },
|
// Replaces all payments for a project with a fresh plan at a given total.
|
||||||
]);
|
// Modes:
|
||||||
|
// single → 1 row 100% "Pagamento unico (100%)"
|
||||||
|
// two → 2 rows 50%/50% "Acconto 50% (inizio lavori)" / "Saldo 50% (alla consegna)"
|
||||||
|
// three → 3 rows 50%/30%/20%
|
||||||
|
export async function setPaymentPlan(
|
||||||
|
projectId: string,
|
||||||
|
mode: "single" | "two" | "three",
|
||||||
|
total: number
|
||||||
|
): Promise<void> {
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
type PaymentDef = { label: string; percent: number };
|
||||||
|
const plans: Record<string, PaymentDef[]> = {
|
||||||
|
single: [{ label: "Pagamento unico (100%)", percent: 100 }],
|
||||||
|
two: [
|
||||||
|
{ label: "Acconto 50% (inizio lavori)", percent: 50 },
|
||||||
|
{ label: "Saldo 50% (alla consegna)", percent: 50 },
|
||||||
|
],
|
||||||
|
three: [
|
||||||
|
{ label: "Acconto 50% (inizio lavori)", percent: 50 },
|
||||||
|
{ label: "30% (post revisioni)", percent: 30 },
|
||||||
|
{ label: "Saldo 20% (alla consegna)", percent: 20 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const plan = plans[mode];
|
||||||
|
if (!plan) throw new Error("Modalità pagamento non valida");
|
||||||
|
|
||||||
|
// Delete existing payments for this project, then insert new ones.
|
||||||
|
await db.delete(payments).where(eq(payments.project_id, projectId));
|
||||||
|
await db.insert(payments).values(
|
||||||
|
plan.map((p) => ({
|
||||||
|
project_id: projectId,
|
||||||
|
label: p.label,
|
||||||
|
percent: p.percent.toFixed(2),
|
||||||
|
amount: ((total * p.percent) / 100).toFixed(2),
|
||||||
|
status: "da_saldare" as const,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
revalidatePath(`/admin/projects/${projectId}`);
|
revalidatePath(`/admin/projects/${projectId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export async function startTimerForClient(clientId: string): Promise<{ entryId:
|
|||||||
export async function stopTimer(entryId: string): Promise<void> {
|
export async function stopTimer(entryId: string): Promise<void> {
|
||||||
await requireAdmin();
|
await requireAdmin();
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({ started_at: time_entries.started_at })
|
.select({ started_at: time_entries.started_at, project_id: time_entries.project_id })
|
||||||
.from(time_entries)
|
.from(time_entries)
|
||||||
.where(eq(time_entries.id, entryId))
|
.where(eq(time_entries.id, entryId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
@@ -77,4 +77,56 @@ export async function stopTimer(entryId: string): Promise<void> {
|
|||||||
|
|
||||||
revalidatePath("/admin");
|
revalidatePath("/admin");
|
||||||
revalidatePath("/admin/projects");
|
revalidatePath("/admin/projects");
|
||||||
|
revalidatePath(`/admin/projects/${rows[0].project_id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts a manually entered time block (already closed).
|
||||||
|
* duration_seconds = minutes * 60
|
||||||
|
* ended_at = started_at + duration_seconds
|
||||||
|
* @param projectId project to record time against
|
||||||
|
* @param minutes duration in minutes (must be > 0)
|
||||||
|
* @param startedAt optional ISO date string — defaults to start of current day
|
||||||
|
*/
|
||||||
|
export async function addManualTimeEntry(
|
||||||
|
projectId: string,
|
||||||
|
minutes: number,
|
||||||
|
startedAt?: string
|
||||||
|
): Promise<void> {
|
||||||
|
await requireAdmin();
|
||||||
|
if (!Number.isFinite(minutes) || minutes <= 0) throw new Error("Minuti non validi");
|
||||||
|
|
||||||
|
const start = startedAt ? new Date(startedAt) : new Date();
|
||||||
|
if (isNaN(start.getTime())) throw new Error("Data non valida");
|
||||||
|
|
||||||
|
const durationSeconds = Math.round(minutes * 60);
|
||||||
|
const end = new Date(start.getTime() + durationSeconds * 1000);
|
||||||
|
|
||||||
|
await db.insert(time_entries).values({
|
||||||
|
id: nanoid(),
|
||||||
|
project_id: projectId,
|
||||||
|
started_at: start,
|
||||||
|
ended_at: end,
|
||||||
|
duration_seconds: durationSeconds,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin");
|
||||||
|
revalidatePath("/admin/projects");
|
||||||
|
revalidatePath(`/admin/projects/${projectId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a time entry by id, scoped to the given project (guard against
|
||||||
|
* cross-project deletions).
|
||||||
|
*/
|
||||||
|
export async function deleteTimeEntry(entryId: string, projectId: string): Promise<void> {
|
||||||
|
await requireAdmin();
|
||||||
|
await db
|
||||||
|
.delete(time_entries)
|
||||||
|
.where(eq(time_entries.id, entryId));
|
||||||
|
|
||||||
|
revalidatePath("/admin");
|
||||||
|
revalidatePath("/admin/projects");
|
||||||
|
revalidatePath(`/admin/projects/${projectId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,13 @@ function projectViewToClientView(
|
|||||||
})),
|
})),
|
||||||
global_progress_pct: view.global_progress_pct,
|
global_progress_pct: view.global_progress_pct,
|
||||||
activeOffers: view.activeOffers,
|
activeOffers: view.activeOffers,
|
||||||
|
transcripts: view.transcripts.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
title: t.title,
|
||||||
|
call_date: t.call_date,
|
||||||
|
content: t.content,
|
||||||
|
created_at: t.created_at instanceof Date ? t.created_at.toISOString() : String(t.created_at),
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 { addDocument } from "@/app/admin/clients/[id]/actions";
|
||||||
import { DocumentRow } from "@/components/admin/DocumentRow";
|
import { DocumentRow } from "@/components/admin/DocumentRow";
|
||||||
|
import { TranscriptItem } from "@/components/admin/TranscriptItem";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import type { Document } from "@/db/schema";
|
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 (
|
return (
|
||||||
<div className="space-y-6 max-w-lg">
|
<div className="space-y-8 max-w-lg">
|
||||||
|
{/* Add document form */}
|
||||||
<form
|
<form
|
||||||
action={async (fd: FormData) => {
|
action={async (fd: FormData) => {
|
||||||
"use server";
|
"use server";
|
||||||
@@ -42,14 +56,29 @@ export async function DocumentsTab({ documents, clientId }: Props) {
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{documents.length === 0 && (
|
{/* Documents list */}
|
||||||
<p className="text-sm text-[#71717a]">Nessun documento ancora.</p>
|
<div>
|
||||||
)}
|
{documents.length === 0 && (
|
||||||
<div className="space-y-2">
|
<p className="text-sm text-[#71717a]">Nessun documento ancora.</p>
|
||||||
{documents.map((doc) => (
|
)}
|
||||||
<DocumentRow key={doc.id} doc={doc} clientId={clientId} />
|
<div className="space-y-2">
|
||||||
))}
|
{documents.map((doc) => (
|
||||||
|
<DocumentRow key={doc.id} doc={doc} clientId={clientId} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
updatePaymentStatus,
|
updatePaymentStatus,
|
||||||
updateAcceptedTotal,
|
updateAcceptedTotal,
|
||||||
} from "@/app/admin/clients/[id]/actions";
|
} 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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -14,6 +18,7 @@ type Props = {
|
|||||||
acceptedTotal: string;
|
acceptedTotal: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
projectId?: string; // set only from project detail page — enables init & split
|
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> = {
|
const statusLabels: Record<string, string> = {
|
||||||
@@ -22,28 +27,111 @@ const statusLabels: Record<string, string> = {
|
|||||||
saldato: "Saldato",
|
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 (
|
return (
|
||||||
<div className="space-y-6 max-w-md">
|
<div className="space-y-6 max-w-md">
|
||||||
{/* Accepted total */}
|
{/* Totale preventivo */}
|
||||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
<div className="bg-white border border-gray-200 rounded-lg p-4 space-y-3">
|
||||||
<h3 className="font-medium text-gray-900 mb-3">Totale preventivo</h3>
|
<h3 className="font-medium text-gray-900">Totale preventivo</h3>
|
||||||
<form
|
|
||||||
action={async (fd: FormData) => {
|
{/* Totale ereditato dalle offerte */}
|
||||||
"use server";
|
{hasOffers && (
|
||||||
await updateAcceptedTotal(clientId, fd);
|
<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]">
|
||||||
className="flex items-end gap-3"
|
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">
|
<div className="space-y-1 flex-1">
|
||||||
<Label htmlFor="accepted_total">Importo (€)</Label>
|
<Label htmlFor="accepted_total">Importo override (€)</Label>
|
||||||
<Input
|
<Input
|
||||||
id="accepted_total"
|
id="accepted_total"
|
||||||
name="accepted_total"
|
name="accepted_total"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
min="0"
|
min="0"
|
||||||
defaultValue={acceptedTotal}
|
value={overrideValue}
|
||||||
|
onChange={(e) => setOverrideValue(e.target.value)}
|
||||||
className="max-w-xs"
|
className="max-w-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,40 +139,71 @@ export async function PaymentsTab({ payments, acceptedTotal, clientId, projectId
|
|||||||
Salva
|
Salva
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
<p className="text-xs text-gray-400 mt-2">
|
|
||||||
Le rate Acconto e Saldo vengono aggiornate automaticamente al 50% ciascuna.
|
|
||||||
</p>
|
|
||||||
</div>
|
</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 */}
|
{/* Empty state — project has no payments yet */}
|
||||||
{projectId && payments.length === 0 && (
|
{projectId && payments.length === 0 && (
|
||||||
<div className="bg-white border border-dashed border-gray-300 rounded-lg p-6 text-center">
|
<div className="bg-white border border-dashed border-gray-300 rounded-lg p-6 text-center">
|
||||||
<p className="text-sm text-[#71717a] mb-4">
|
<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>
|
</p>
|
||||||
<form
|
<Button
|
||||||
action={async () => {
|
type="button"
|
||||||
"use server";
|
size="sm"
|
||||||
await initProjectPayments(projectId);
|
onClick={() => handleSetPlan("two")}
|
||||||
}}
|
disabled={planLoading !== null}
|
||||||
>
|
>
|
||||||
<Button type="submit" size="sm">
|
Crea Acconto & Saldo (50/50)
|
||||||
Crea Acconto & Saldo
|
</Button>
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Payment rows */}
|
{/* Payment rows */}
|
||||||
{payments.map((p) => {
|
{payments.map((p) => {
|
||||||
const amount = parseFloat(p.amount);
|
const amount = parseFloat(p.amount);
|
||||||
|
const pct = p.percent !== null && p.percent !== undefined
|
||||||
|
? parseFloat(String(p.percent))
|
||||||
|
: null;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={p.id}
|
key={p.id}
|
||||||
className="bg-white border border-gray-200 rounded-lg p-4"
|
className="bg-white border border-gray-200 rounded-lg p-4"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-gray-600">
|
<span className="text-sm text-gray-600">
|
||||||
€{" "}
|
€{" "}
|
||||||
@@ -101,16 +220,14 @@ export async function PaymentsTab({ payments, acceptedTotal, clientId, projectId
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form
|
<div className="flex items-center gap-2">
|
||||||
action={async (fd: FormData) => {
|
|
||||||
"use server";
|
|
||||||
await updatePaymentStatus(p.id, clientId, fd.get("status") as string);
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<select
|
<select
|
||||||
name="status"
|
name="status"
|
||||||
defaultValue={p.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"
|
className="text-sm border border-gray-200 rounded px-2 py-1.5 bg-white flex-1"
|
||||||
>
|
>
|
||||||
{Object.entries(statusLabels).map(([val, label]) => (
|
{Object.entries(statusLabels).map(([val, label]) => (
|
||||||
@@ -119,10 +236,10 @@ export async function PaymentsTab({ payments, acceptedTotal, clientId, projectId
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<Button type="submit" size="sm" variant="outline">
|
{statusLoading === p.id && (
|
||||||
Aggiorna
|
<span className="text-xs text-[#71717a]">...</span>
|
||||||
</Button>
|
)}
|
||||||
</form>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ const taskStatusOptions = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const phaseStatusOptions = [
|
const phaseStatusOptions = [
|
||||||
{ value: "upcoming", label: "In arrivo" },
|
{ value: "upcoming", label: "Da iniziare" },
|
||||||
{ value: "active", label: "Attiva" },
|
{ value: "active", label: "In corso" },
|
||||||
{ value: "done", label: "Completata" },
|
{ value: "done", label: "Completata" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
import { TimerCell } from "@/components/admin/TimerCell";
|
import { TimerCell } from "@/components/admin/TimerCell";
|
||||||
import { ProfitabilityCard } from "@/components/admin/ProfitabilityCard";
|
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 = {
|
type TimerTabProps = {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -10,6 +18,7 @@ type TimerTabProps = {
|
|||||||
activeTimerStartedAt: Date | null;
|
activeTimerStartedAt: Date | null;
|
||||||
totalTrackedSeconds: number;
|
totalTrackedSeconds: number;
|
||||||
targetHourlyRate: number;
|
targetHourlyRate: number;
|
||||||
|
recentEntries: TimeEntry[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TimerTab({
|
export function TimerTab({
|
||||||
@@ -19,6 +28,7 @@ export function TimerTab({
|
|||||||
activeTimerStartedAt,
|
activeTimerStartedAt,
|
||||||
totalTrackedSeconds,
|
totalTrackedSeconds,
|
||||||
targetHourlyRate,
|
targetHourlyRate,
|
||||||
|
recentEntries,
|
||||||
}: TimerTabProps) {
|
}: TimerTabProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-sm">
|
<div className="space-y-6 max-w-sm">
|
||||||
@@ -33,6 +43,8 @@ export function TimerTab({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ManualTimeEntry projectId={projectId} recentEntries={recentEntries} />
|
||||||
|
|
||||||
<ProfitabilityCard
|
<ProfitabilityCard
|
||||||
acceptedTotal={acceptedTotal}
|
acceptedTotal={acceptedTotal}
|
||||||
totalTrackedSeconds={totalTrackedSeconds}
|
totalTrackedSeconds={totalTrackedSeconds}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { PhaseTimeline } from './phase-timeline';
|
|||||||
import { PaymentStatus } from './payment-status';
|
import { PaymentStatus } from './payment-status';
|
||||||
import { DocumentsSection } from './documents-section';
|
import { DocumentsSection } from './documents-section';
|
||||||
import { NotesSection } from './notes-section';
|
import { NotesSection } from './notes-section';
|
||||||
|
import { TranscriptsSection } from './transcripts-section';
|
||||||
import { PhaseViewToggle } from './client/kanban/PhaseViewToggle';
|
import { PhaseViewToggle } from './client/kanban/PhaseViewToggle';
|
||||||
import { ChatSection } from './client/ChatSection';
|
import { ChatSection } from './client/ChatSection';
|
||||||
import { OffersSection } from './client/OffersSection';
|
import { OffersSection } from './client/OffersSection';
|
||||||
@@ -71,6 +72,12 @@ export function ClientDashboard({ view, token, comments }: ClientDashboardProps)
|
|||||||
<NotesSection notes={view.notes} />
|
<NotesSection notes={view.notes} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{view.transcripts.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">Transcript Chiamate</h2>
|
||||||
|
<TranscriptsSection transcripts={view.transcripts} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { ApproveButton } from "@/components/client/ApproveButton";
|
||||||
|
import type { ClientView } from "@/lib/client-view";
|
||||||
|
|
||||||
|
type Phase = ClientView["phases"][number];
|
||||||
|
|
||||||
|
const phaseStatusLabel: Record<"upcoming" | "active" | "done", string> = {
|
||||||
|
upcoming: "Da iniziare",
|
||||||
|
active: "In corso",
|
||||||
|
done: "Completata",
|
||||||
|
};
|
||||||
|
|
||||||
|
const phaseStatusStyle: Record<"upcoming" | "active" | "done", string> = {
|
||||||
|
upcoming: "border-transparent bg-[#999999] text-white",
|
||||||
|
active: "border-transparent bg-[#3b82f6] text-white",
|
||||||
|
done: "border-transparent bg-[#1A463C] text-white",
|
||||||
|
};
|
||||||
|
|
||||||
|
function PhaseStatusIcon({ status }: { status: "upcoming" | "active" | "done" }) {
|
||||||
|
if (status === "done") {
|
||||||
|
return (
|
||||||
|
<svg className="w-5 h-5 text-[#1A463C]" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === "active") {
|
||||||
|
return (
|
||||||
|
<svg className="w-5 h-5 text-[#3b82f6]" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<svg className="w-5 h-5 text-[#999999]" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<circle cx={12} cy={12} r={9} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskStatusIcon({ status }: { status: "todo" | "in_progress" | "done" }) {
|
||||||
|
if (status === "done") {
|
||||||
|
return (
|
||||||
|
<svg className="w-4 h-4 text-[#1A463C] shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === "in_progress") {
|
||||||
|
return (
|
||||||
|
<svg className="w-4 h-4 text-[#3b82f6] shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<svg className="w-4 h-4 text-[#999999] shrink-0 mt-0.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<circle cx={12} cy={12} r={9} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PhaseCard({
|
||||||
|
phase,
|
||||||
|
token,
|
||||||
|
defaultOpen,
|
||||||
|
}: {
|
||||||
|
phase: Phase;
|
||||||
|
token: string;
|
||||||
|
defaultOpen: boolean;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
const doneCount = phase.tasks.filter((t) => t.status === "done").length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="rounded-lg border border-[#e5e5e5] bg-white shadow-none p-5">
|
||||||
|
{/* Header — always visible, click to toggle */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="w-full text-left"
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3 mb-3">
|
||||||
|
<div className="flex items-center gap-2.5 min-w-0">
|
||||||
|
<PhaseStatusIcon status={phase.status} />
|
||||||
|
<h3 className="text-base font-bold text-[#1a1a1a] leading-snug">{phase.title}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<Badge className={`text-xs ${phaseStatusStyle[phase.status]}`}>
|
||||||
|
{phaseStatusLabel[phase.status]}
|
||||||
|
</Badge>
|
||||||
|
<svg
|
||||||
|
className={`w-4 h-4 text-[#999999] transition-transform ${open ? "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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar — always visible */}
|
||||||
|
<div className="mb-1">
|
||||||
|
<div className="flex justify-between items-center mb-1.5">
|
||||||
|
<p className="text-xs text-[#666666] font-medium">{doneCount} di {phase.tasks.length} task</p>
|
||||||
|
<p className="text-xs font-semibold text-[#1a1a1a]">{phase.progress_pct}%</p>
|
||||||
|
</div>
|
||||||
|
<Progress value={phase.progress_pct} className="h-1.5" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Collapsible task list */}
|
||||||
|
{open && (
|
||||||
|
<div className="mt-4">
|
||||||
|
{phase.tasks.length === 0 ? (
|
||||||
|
<p className="text-xs text-[#999999] italic">Nessun task ancora configurato.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{phase.tasks.map((task) => (
|
||||||
|
<li key={task.id} className="flex items-start gap-2.5">
|
||||||
|
<TaskStatusIcon status={task.status} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p
|
||||||
|
className={`text-sm leading-snug ${
|
||||||
|
task.status === "done"
|
||||||
|
? "line-through text-[#999999]"
|
||||||
|
: "text-[#1a1a1a]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{task.title}
|
||||||
|
</p>
|
||||||
|
{task.description && (
|
||||||
|
<p className="text-xs text-[#999999] mt-0.5 leading-snug">
|
||||||
|
{task.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{task.deliverables.length > 0 && (
|
||||||
|
<ul className="mt-1.5 space-y-2">
|
||||||
|
{task.deliverables.map((d) => (
|
||||||
|
<li
|
||||||
|
key={d.id}
|
||||||
|
className="bg-[#f9f9f9] rounded px-3 py-2 flex items-center justify-between gap-2"
|
||||||
|
>
|
||||||
|
<span className="text-xs text-[#666666] truncate font-medium">
|
||||||
|
{d.title}
|
||||||
|
</span>
|
||||||
|
{(d.status === "pending" ||
|
||||||
|
d.status === "submitted" ||
|
||||||
|
d.approved_at !== null) && (
|
||||||
|
<ApproveButton
|
||||||
|
deliverableId={d.id}
|
||||||
|
token={token}
|
||||||
|
approvedAt={d.approved_at}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,70 +1,11 @@
|
|||||||
import type { ClientView } from '@/lib/client-view';
|
import type { ClientView } from '@/lib/client-view';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { PhaseCard } from './client/PhaseCard';
|
||||||
import { Card } from '@/components/ui/card';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { ApproveButton } from './client/ApproveButton';
|
|
||||||
|
|
||||||
interface PhaseTimelineProps {
|
interface PhaseTimelineProps {
|
||||||
phases: ClientView['phases'];
|
phases: ClientView['phases'];
|
||||||
token: string;
|
token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PhaseStatusIcon({ status }: { status: 'upcoming' | 'active' | 'done' }) {
|
|
||||||
if (status === 'done') {
|
|
||||||
return (
|
|
||||||
<svg className="w-5 h-5 text-[#16a34a]" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
|
||||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (status === 'active') {
|
|
||||||
return (
|
|
||||||
<svg className="w-5 h-5 text-[#1A463C]" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
|
||||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<svg className="w-5 h-5 text-[#999999]" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<circle cx={12} cy={12} r={9} />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TaskStatusIcon({ status }: { status: 'todo' | 'in_progress' | 'done' }) {
|
|
||||||
if (status === 'done') {
|
|
||||||
return (
|
|
||||||
<svg className="w-4 h-4 text-[#16a34a] shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
|
||||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (status === 'in_progress') {
|
|
||||||
return (
|
|
||||||
<svg className="w-4 h-4 text-[#DEF168] shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
|
||||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<svg className="w-4 h-4 text-[#999999] shrink-0 mt-0.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<circle cx={12} cy={12} r={9} />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const phaseStatusLabel: Record<'upcoming' | 'active' | 'done', string> = {
|
|
||||||
upcoming: 'In arrivo',
|
|
||||||
active: 'In corso',
|
|
||||||
done: 'Completata',
|
|
||||||
};
|
|
||||||
|
|
||||||
const phaseStatusStyle: Record<'upcoming' | 'active' | 'done', string> = {
|
|
||||||
upcoming: 'border-transparent bg-[#999999] text-white',
|
|
||||||
active: 'border-transparent bg-[#1A463C] text-white',
|
|
||||||
done: 'border-transparent bg-[#16a34a] text-white',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function PhaseTimeline({ phases, token }: PhaseTimelineProps) {
|
export function PhaseTimeline({ phases, token }: PhaseTimelineProps) {
|
||||||
if (phases.length === 0) {
|
if (phases.length === 0) {
|
||||||
return <p className="text-sm text-[#999999] italic">Nessuna fase ancora configurata.</p>;
|
return <p className="text-sm text-[#999999] italic">Nessuna fase ancora configurata.</p>;
|
||||||
@@ -73,67 +14,33 @@ export function PhaseTimeline({ phases, token }: PhaseTimelineProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-0">
|
<div className="space-y-0">
|
||||||
{phases.map((phase, index) => {
|
{phases.map((phase, index) => {
|
||||||
const doneCount = phase.tasks.filter((t) => t.status === 'done').length;
|
|
||||||
const isLast = index === phases.length - 1;
|
const isLast = index === phases.length - 1;
|
||||||
|
// done phases start collapsed; active and upcoming start expanded
|
||||||
|
const defaultOpen = phase.status !== 'done';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={phase.id} className="flex gap-5">
|
<div key={phase.id} className="flex gap-5">
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div className="w-10 h-10 rounded-full bg-white border-2 border-[#e5e5e5] flex items-center justify-center shrink-0 z-10">
|
<div className="w-10 h-10 rounded-full bg-white border-2 border-[#e5e5e5] flex items-center justify-center shrink-0 z-10">
|
||||||
<PhaseStatusIcon status={phase.status} />
|
{phase.status === 'done' ? (
|
||||||
|
<svg className="w-5 h-5 text-[#1A463C]" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
) : phase.status === 'active' ? (
|
||||||
|
<svg className="w-5 h-5 text-[#3b82f6]" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-5 h-5 text-[#999999]" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<circle cx={12} cy={12} r={9} />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!isLast && <div className="flex-1 w-px bg-[#e5e5e5] my-2" style={{ minHeight: '2rem' }} />}
|
{!isLast && <div className="flex-1 w-px bg-[#e5e5e5] my-2" style={{ minHeight: '2rem' }} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`flex-1 ${isLast ? 'pb-0' : 'pb-6'}`}>
|
<div className={`flex-1 ${isLast ? 'pb-0' : 'pb-6'}`}>
|
||||||
<Card className="rounded-lg border border-[#e5e5e5] bg-white shadow-none p-5">
|
<PhaseCard phase={phase} token={token} defaultOpen={defaultOpen} />
|
||||||
<div className="flex items-start justify-between gap-3 mb-4">
|
|
||||||
<h3 className="text-base font-bold text-[#1a1a1a] leading-snug">{phase.title}</h3>
|
|
||||||
<Badge className={`text-xs shrink-0 ${phaseStatusStyle[phase.status]}`}>
|
|
||||||
{phaseStatusLabel[phase.status]}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-5">
|
|
||||||
<div className="flex justify-between items-center mb-1.5">
|
|
||||||
<p className="text-xs text-[#666666] font-medium">{doneCount} di {phase.tasks.length} task</p>
|
|
||||||
<p className="text-xs font-semibold text-[#1a1a1a]">{phase.progress_pct}%</p>
|
|
||||||
</div>
|
|
||||||
<Progress value={phase.progress_pct} className="h-1.5" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{phase.tasks.length === 0 ? (
|
|
||||||
<p className="text-xs text-[#999999] italic">Nessun task ancora configurato.</p>
|
|
||||||
) : (
|
|
||||||
<ul className="space-y-3">
|
|
||||||
{phase.tasks.map((task) => (
|
|
||||||
<li key={task.id} className="flex items-start gap-2.5">
|
|
||||||
<TaskStatusIcon status={task.status} />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className={`text-sm leading-snug ${task.status === 'done' ? 'line-through text-[#999999]' : 'text-[#1a1a1a]'}`}>
|
|
||||||
{task.title}
|
|
||||||
</p>
|
|
||||||
{task.description && (
|
|
||||||
<p className="text-xs text-[#999999] mt-0.5 leading-snug">{task.description}</p>
|
|
||||||
)}
|
|
||||||
{task.deliverables.length > 0 && (
|
|
||||||
<ul className="mt-1.5 space-y-2">
|
|
||||||
{task.deliverables.map((d) => (
|
|
||||||
<li key={d.id} className="bg-[#f9f9f9] rounded px-3 py-2 flex items-center justify-between gap-2">
|
|
||||||
<span className="text-xs text-[#666666] truncate font-medium">{d.title}</span>
|
|
||||||
{(d.status === 'pending' || d.status === 'submitted' || d.approved_at !== null) && (
|
|
||||||
<ApproveButton deliverableId={d.id} token={token} approvedAt={d.approved_at} />
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
type Transcript = {
|
||||||
|
id: string;
|
||||||
|
title: string | null;
|
||||||
|
call_date: string;
|
||||||
|
content: string;
|
||||||
|
created_at: string | Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
function TranscriptCard({ 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 (
|
||||||
|
<Card className="rounded-lg border border-[#e5e5e5] bg-white shadow-none p-0 overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
className="w-full flex items-center justify-between px-5 py-4 text-left hover:bg-[#f9f9f9] transition-colors group"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
{/* Icona microfono */}
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 text-[#999999] shrink-0"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 18.75a6 6 0 006-6v-1.5m-6 7.5a6 6 0 01-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 01-3-3V4.5a3 3 0 116 0v8.25a3 3 0 01-3 3z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium text-[#1a1a1a] truncate group-hover:text-[#1A463C] transition-colors">
|
||||||
|
{transcript.title ?? "Transcript chiamata"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[#999999] mt-0.5">{date}</p>
|
||||||
|
</div>
|
||||||
|
</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-5 pb-5 pt-1 border-t border-[#e5e5e5]">
|
||||||
|
<pre className="text-xs text-[#444444] whitespace-pre-wrap font-sans leading-relaxed max-h-80 overflow-y-auto">
|
||||||
|
{transcript.content}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TranscriptsSectionProps {
|
||||||
|
transcripts: Transcript[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TranscriptsSection({ transcripts }: TranscriptsSectionProps) {
|
||||||
|
if (transcripts.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-[#999999] italic">
|
||||||
|
Nessun transcript disponibile.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{transcripts.map((t) => (
|
||||||
|
<TranscriptCard key={t.id} transcript={t} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Additive: add percent column to payments for rescaling by payment plan.
|
||||||
|
-- nullable — existing rows keep percent NULL (legacy fallback in updateAcceptedTotal).
|
||||||
|
-- Apply to prod via SSH tunnel BEFORE pushing schema-dependent code.
|
||||||
|
-- No drops, no truncates, no data loss.
|
||||||
|
|
||||||
|
ALTER TABLE payments ADD COLUMN IF NOT EXISTS percent numeric(5,2);
|
||||||
@@ -154,6 +154,7 @@ export const payments = pgTable("payments", {
|
|||||||
.references(() => projects.id, { onDelete: "cascade" }),
|
.references(() => projects.id, { onDelete: "cascade" }),
|
||||||
label: text("label").notNull(), // "Acconto 50%" | "Saldo 50%"
|
label: text("label").notNull(), // "Acconto 50%" | "Saldo 50%"
|
||||||
amount: numeric("amount", { precision: 10, scale: 2 }).notNull(),
|
amount: numeric("amount", { precision: 10, scale: 2 }).notNull(),
|
||||||
|
percent: numeric("percent", { precision: 5, scale: 2 }), // nullable — % of total (for rescaling); null = legacy row
|
||||||
status: text("status").notNull().default("da_saldare"), // da_saldare | inviata | saldato
|
status: text("status").notNull().default("da_saldare"), // da_saldare | inviata | saldato
|
||||||
paid_at: timestamp("paid_at", { withTimezone: true }),
|
paid_at: timestamp("paid_at", { withTimezone: true }),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
quotes,
|
quotes,
|
||||||
leads,
|
leads,
|
||||||
tags,
|
tags,
|
||||||
|
clientTranscripts,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
import { eq, inArray, asc, desc, isNull, sql, and } from "drizzle-orm";
|
import { eq, inArray, asc, desc, isNull, sql, and } from "drizzle-orm";
|
||||||
import { getPool } from "@/lib/taxonomy";
|
import { getPool } from "@/lib/taxonomy";
|
||||||
@@ -551,6 +552,8 @@ export type ProjectFullDetail = {
|
|||||||
activeTimerStartedAt: Date | null;
|
activeTimerStartedAt: Date | null;
|
||||||
totalTrackedSeconds: number;
|
totalTrackedSeconds: number;
|
||||||
projectOffers: ProjectOfferWithMicro[];
|
projectOffers: ProjectOfferWithMicro[];
|
||||||
|
/** Sum of accepted_total across all active project offers — used as default for payment plan */
|
||||||
|
offersAcceptedTotal: number;
|
||||||
availableMicros: Array<{
|
availableMicros: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
internal_name: string;
|
internal_name: string;
|
||||||
@@ -563,6 +566,13 @@ export type ProjectFullDetail = {
|
|||||||
macro_category: string | null;
|
macro_category: string | null;
|
||||||
macro_offer_type: string;
|
macro_offer_type: string;
|
||||||
}>;
|
}>;
|
||||||
|
transcripts: Array<{
|
||||||
|
id: string;
|
||||||
|
title: string | null;
|
||||||
|
call_date: string;
|
||||||
|
content: string;
|
||||||
|
created_at: Date;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getProjectFullDetail(id: string): Promise<ProjectFullDetail | null> {
|
export async function getProjectFullDetail(id: string): Promise<ProjectFullDetail | null> {
|
||||||
@@ -609,7 +619,7 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
|
|||||||
.from(deliverables)
|
.from(deliverables)
|
||||||
.where(inArray(deliverables.task_id, taskIds));
|
.where(inArray(deliverables.task_id, taskIds));
|
||||||
|
|
||||||
const [paymentsRows, documentsRows, notesRows, quoteItemRows, activeServiceRows, activeEntryRows, totalRes, projectOffersRows, availableMicrosRows] =
|
const [paymentsRows, documentsRows, notesRows, quoteItemRows, activeServiceRows, activeEntryRows, totalRes, projectOffersRows, availableMicrosRows, transcriptsRows] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
db.select().from(payments).where(eq(payments.project_id, id)),
|
db.select().from(payments).where(eq(payments.project_id, id)),
|
||||||
db.select().from(documents).where(eq(documents.project_id, id)).orderBy(asc(documents.created_at)),
|
db.select().from(documents).where(eq(documents.project_id, id)).orderBy(asc(documents.created_at)),
|
||||||
@@ -683,6 +693,18 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
|
|||||||
asc(offer_micros.tier_letter),
|
asc(offer_micros.tier_letter),
|
||||||
asc(offer_micros.id)
|
asc(offer_micros.id)
|
||||||
),
|
),
|
||||||
|
// Query C: transcripts linked to the project's client
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: clientTranscripts.id,
|
||||||
|
title: clientTranscripts.title,
|
||||||
|
call_date: clientTranscripts.call_date,
|
||||||
|
content: clientTranscripts.content,
|
||||||
|
created_at: clientTranscripts.created_at,
|
||||||
|
})
|
||||||
|
.from(clientTranscripts)
|
||||||
|
.where(eq(clientTranscripts.client_id, project.client_id))
|
||||||
|
.orderBy(desc(clientTranscripts.call_date)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const allEntityIds = [id, ...taskIds, ...deliverablesRows.map((d) => d.id)];
|
const allEntityIds = [id, ...taskIds, ...deliverablesRows.map((d) => d.id)];
|
||||||
@@ -715,6 +737,11 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const offersAcceptedTotal = (projectOffersRows as ProjectOfferWithMicro[]).reduce(
|
||||||
|
(sum, o) => sum + (o.accepted_total ? parseFloat(String(o.accepted_total)) : 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
project: { ...project, client } as ProjectFullDetail["project"],
|
project: { ...project, client } as ProjectFullDetail["project"],
|
||||||
phases: phasesWithTasks,
|
phases: phasesWithTasks,
|
||||||
@@ -728,10 +755,40 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
|
|||||||
activeTimerStartedAt: activeEntryRows[0]?.started_at ?? null,
|
activeTimerStartedAt: activeEntryRows[0]?.started_at ?? null,
|
||||||
totalTrackedSeconds: totalRes[0] ? parseInt(totalRes[0].total) : 0,
|
totalTrackedSeconds: totalRes[0] ? parseInt(totalRes[0].total) : 0,
|
||||||
projectOffers: projectOffersRows as ProjectOfferWithMicro[],
|
projectOffers: projectOffersRows as ProjectOfferWithMicro[],
|
||||||
|
offersAcceptedTotal,
|
||||||
availableMicros: dedupedMicros,
|
availableMicros: dedupedMicros,
|
||||||
|
transcripts: transcriptsRows,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Recent time entries — used by TimerTab for the manual-entry recent list ───
|
||||||
|
|
||||||
|
export type RecentTimeEntry = {
|
||||||
|
id: string;
|
||||||
|
started_at: Date;
|
||||||
|
ended_at: Date | null;
|
||||||
|
duration_seconds: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getRecentTimeEntries(
|
||||||
|
projectId: string,
|
||||||
|
limit = 5
|
||||||
|
): Promise<RecentTimeEntry[]> {
|
||||||
|
// sql<boolean>`ended_at IS NOT NULL` used to filter closed entries
|
||||||
|
// (Drizzle's isNotNull is available in v0.28+ but we use sql for safety)
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
id: time_entries.id,
|
||||||
|
started_at: time_entries.started_at,
|
||||||
|
ended_at: time_entries.ended_at,
|
||||||
|
duration_seconds: time_entries.duration_seconds,
|
||||||
|
})
|
||||||
|
.from(time_entries)
|
||||||
|
.where(and(eq(time_entries.project_id, projectId), sql`${time_entries.ended_at} IS NOT NULL`))
|
||||||
|
.orderBy(desc(time_entries.started_at))
|
||||||
|
.limit(limit);
|
||||||
|
}
|
||||||
|
|
||||||
// ── ClientWithProjects — used by client detail showing project cards ──────────
|
// ── ClientWithProjects — used by client detail showing project cards ──────────
|
||||||
|
|
||||||
export type ClientWithProjects = Client & {
|
export type ClientWithProjects = Client & {
|
||||||
|
|||||||
+30
-2
@@ -1,6 +1,6 @@
|
|||||||
import { eq, inArray, asc, sql } from "drizzle-orm";
|
import { eq, inArray, asc, desc, sql } from "drizzle-orm";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { clients, projects, phases, tasks, deliverables, payments, documents, notes, comments, project_offers, offer_micros, offer_micro_services, offer_services } from "@/db/schema";
|
import { clients, projects, phases, tasks, deliverables, payments, documents, notes, comments, project_offers, offer_micros, offer_micro_services, offer_services, clientTranscripts } from "@/db/schema";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ClientView: Legacy shape used by ClientDashboard component.
|
* ClientView: Legacy shape used by ClientDashboard component.
|
||||||
@@ -58,6 +58,13 @@ export interface ClientView {
|
|||||||
cumulative_price: string;
|
cumulative_price: string;
|
||||||
accepted_total: string | null;
|
accepted_total: string | null;
|
||||||
}>;
|
}>;
|
||||||
|
transcripts: Array<{
|
||||||
|
id: string;
|
||||||
|
title: string | null;
|
||||||
|
call_date: string;
|
||||||
|
content: string;
|
||||||
|
created_at: string;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectView {
|
export interface ProjectView {
|
||||||
@@ -121,6 +128,13 @@ export interface ProjectView {
|
|||||||
cumulative_price: string; // sum of assigned offer_services.price
|
cumulative_price: string; // sum of assigned offer_services.price
|
||||||
accepted_total: string | null;
|
accepted_total: string | null;
|
||||||
}>;
|
}>;
|
||||||
|
transcripts: Array<{
|
||||||
|
id: string;
|
||||||
|
title: string | null;
|
||||||
|
call_date: string;
|
||||||
|
content: string;
|
||||||
|
created_at: Date;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClientProjectSummary {
|
export interface ClientProjectSummary {
|
||||||
@@ -308,6 +322,19 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
|
|||||||
accepted_total: o.accepted_total ? String(o.accepted_total) : null,
|
accepted_total: o.accepted_total ? String(o.accepted_total) : null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Transcripts linked to the project's client (post lead→client conversion)
|
||||||
|
const transcriptsRows = await db
|
||||||
|
.select({
|
||||||
|
id: clientTranscripts.id,
|
||||||
|
title: clientTranscripts.title,
|
||||||
|
call_date: clientTranscripts.call_date,
|
||||||
|
content: clientTranscripts.content,
|
||||||
|
created_at: clientTranscripts.created_at,
|
||||||
|
})
|
||||||
|
.from(clientTranscripts)
|
||||||
|
.where(eq(clientTranscripts.client_id, project.client_id))
|
||||||
|
.orderBy(desc(clientTranscripts.call_date));
|
||||||
|
|
||||||
// Comments scoped to tasks and deliverables of this project
|
// Comments scoped to tasks and deliverables of this project
|
||||||
const allEntityIds = [...taskIds, ...deliverablesRows.map((d) => d.id)];
|
const allEntityIds = [...taskIds, ...deliverablesRows.map((d) => d.id)];
|
||||||
const commentsRows =
|
const commentsRows =
|
||||||
@@ -352,5 +379,6 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
|
|||||||
comments: commentsRows,
|
comments: commentsRows,
|
||||||
global_progress_pct,
|
global_progress_pct,
|
||||||
activeOffers: activeOffers.length > 0 ? activeOffers : undefined,
|
activeOffers: activeOffers.length > 0 ? activeOffers : undefined,
|
||||||
|
transcripts: transcriptsRows,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -219,3 +219,13 @@ export async function getTranscripts(leadId: string) {
|
|||||||
.where(eq(clientTranscripts.lead_id, leadId))
|
.where(eq(clientTranscripts.lead_id, leadId))
|
||||||
.orderBy(desc(clientTranscripts.call_date));
|
.orderBy(desc(clientTranscripts.call_date));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get transcripts linked to a client (post lead→client conversion).
|
||||||
|
// Returns all fields — used by admin DocumentsTab and public TranscriptsSection.
|
||||||
|
export async function getTranscriptsByClientId(clientId: string) {
|
||||||
|
return await db
|
||||||
|
.select()
|
||||||
|
.from(clientTranscripts)
|
||||||
|
.where(eq(clientTranscripts.client_id, clientId))
|
||||||
|
.orderBy(desc(clientTranscripts.call_date));
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user