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
|
||||
- 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
|
||||
- Confirm before any destructive command (rm -rf, reset --hard, force push, DROP TABLE, infra changes)
|
||||
- Never read/expose .env or credentials without explicit request
|
||||
- Confirm before any destructive command (rm -rf, reset --hard, force push, DROP TABLE / drop-column, truncate, infra changes)
|
||||
- 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 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
|
||||
@@ -162,15 +162,45 @@ export async function addTask(phaseId: string, id: string, formData: FormData) {
|
||||
sort_order: maxOrder + 1,
|
||||
status: "todo",
|
||||
});
|
||||
// Cascade: a new todo task keeps phase active/upcoming — recompute to be safe
|
||||
await recomputePhaseStatus(phaseId);
|
||||
const { path } = await resolveEntity(id);
|
||||
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) {
|
||||
await requireAdmin();
|
||||
const allowed = ["todo", "in_progress", "done"];
|
||||
if (!allowed.includes(status)) throw new Error("Stato non valido");
|
||||
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);
|
||||
revalidatePath(path);
|
||||
}
|
||||
@@ -240,6 +270,35 @@ export async function updatePaymentStatus(paymentId: string, id: string, status:
|
||||
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) {
|
||||
await requireAdmin();
|
||||
const raw = (formData.get("accepted_total") as string)?.trim();
|
||||
@@ -253,28 +312,17 @@ export async function updateAcceptedTotal(id: string, formData: FormData) {
|
||||
.limit(1);
|
||||
|
||||
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));
|
||||
const half = (val / 2).toFixed(2);
|
||||
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));
|
||||
}
|
||||
await rescalePayments(id, val);
|
||||
revalidatePath(`/admin/projects/${id}`);
|
||||
} 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));
|
||||
const projectRows = await db.select({ id: projects.id })
|
||||
.from(projects).where(eq(projects.client_id, id));
|
||||
if (projectRows.length > 0) {
|
||||
const projectIds = projectRows.map((p) => p.id);
|
||||
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));
|
||||
}
|
||||
for (const proj of projectRows) {
|
||||
await rescalePayments(proj.id, val);
|
||||
}
|
||||
revalidatePath(`/admin/clients/${id}`);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getProjectFullDetail } from "@/lib/admin-queries";
|
||||
import { getProjectFullDetail, getRecentTimeEntries } from "@/lib/admin-queries";
|
||||
import { getTargetHourlyRate } from "@/lib/settings";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { PhasesTab } from "@/components/admin/tabs/PhasesTab";
|
||||
@@ -26,6 +26,8 @@ export default async function ProjectDetailPage({
|
||||
|
||||
if (!detail) notFound();
|
||||
|
||||
const recentEntries = await getRecentTimeEntries(id, 5);
|
||||
|
||||
const {
|
||||
project,
|
||||
phases,
|
||||
@@ -38,6 +40,8 @@ export default async function ProjectDetailPage({
|
||||
totalTrackedSeconds,
|
||||
projectOffers,
|
||||
availableMicros,
|
||||
offersAcceptedTotal,
|
||||
transcripts,
|
||||
} = detail;
|
||||
|
||||
return (
|
||||
@@ -87,11 +91,12 @@ export default async function ProjectDetailPage({
|
||||
acceptedTotal={project.accepted_total ?? "0"}
|
||||
clientId={id}
|
||||
projectId={id}
|
||||
offersAcceptedTotal={offersAcceptedTotal}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="documents">
|
||||
<DocumentsTab documents={documents} clientId={id} />
|
||||
<DocumentsTab documents={documents} clientId={id} transcripts={transcripts} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="notes">
|
||||
@@ -122,6 +127,7 @@ export default async function ProjectDetailPage({
|
||||
activeTimerStartedAt={activeTimerStartedAt}
|
||||
totalTrackedSeconds={totalTrackedSeconds}
|
||||
targetHourlyRate={targetHourlyRate}
|
||||
recentEntries={recentEntries}
|
||||
/>
|
||||
</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.
|
||||
// Kept for backward compatibility; prefer setPaymentPlan for new code.
|
||||
export async function initProjectPayments(projectId: string): Promise<void> {
|
||||
await requireAdmin();
|
||||
const rows = await db
|
||||
@@ -74,11 +75,50 @@ export async function initProjectPayments(projectId: string): Promise<void> {
|
||||
.limit(1);
|
||||
if (!rows[0]) throw new Error("Progetto non trovato");
|
||||
const total = parseFloat(rows[0].accepted_total ?? "0");
|
||||
const half = (total / 2).toFixed(2);
|
||||
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" },
|
||||
]);
|
||||
await setPaymentPlan(projectId, "two", total);
|
||||
}
|
||||
|
||||
// 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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ export async function startTimerForClient(clientId: string): Promise<{ entryId:
|
||||
export async function stopTimer(entryId: string): Promise<void> {
|
||||
await requireAdmin();
|
||||
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)
|
||||
.where(eq(time_entries.id, entryId))
|
||||
.limit(1);
|
||||
@@ -77,4 +77,56 @@ export async function stopTimer(entryId: string): Promise<void> {
|
||||
|
||||
revalidatePath("/admin");
|
||||
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,
|
||||
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 { 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PhaseTimeline } from './phase-timeline';
|
||||
import { PaymentStatus } from './payment-status';
|
||||
import { DocumentsSection } from './documents-section';
|
||||
import { NotesSection } from './notes-section';
|
||||
import { TranscriptsSection } from './transcripts-section';
|
||||
import { PhaseViewToggle } from './client/kanban/PhaseViewToggle';
|
||||
import { ChatSection } from './client/ChatSection';
|
||||
import { OffersSection } from './client/OffersSection';
|
||||
@@ -71,6 +72,12 @@ export function ClientDashboard({ view, token, comments }: ClientDashboardProps)
|
||||
<NotesSection notes={view.notes} />
|
||||
</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>
|
||||
</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 { Progress } from '@/components/ui/progress';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ApproveButton } from './client/ApproveButton';
|
||||
import { PhaseCard } from './client/PhaseCard';
|
||||
|
||||
interface PhaseTimelineProps {
|
||||
phases: ClientView['phases'];
|
||||
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) {
|
||||
if (phases.length === 0) {
|
||||
return <p className="text-sm text-[#999999] italic">Nessuna fase ancora configurata.</p>;
|
||||
@@ -73,71 +14,37 @@ export function PhaseTimeline({ phases, token }: PhaseTimelineProps) {
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
{phases.map((phase, index) => {
|
||||
const doneCount = phase.tasks.filter((t) => t.status === 'done').length;
|
||||
const isLast = index === phases.length - 1;
|
||||
// done phases start collapsed; active and upcoming start expanded
|
||||
const defaultOpen = phase.status !== 'done';
|
||||
|
||||
return (
|
||||
<div key={phase.id} className="flex gap-5">
|
||||
<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">
|
||||
<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>
|
||||
{!isLast && <div className="flex-1 w-px bg-[#e5e5e5] my-2" style={{ minHeight: '2rem' }} />}
|
||||
</div>
|
||||
|
||||
<div className={`flex-1 ${isLast ? 'pb-0' : 'pb-6'}`}>
|
||||
<Card className="rounded-lg border border-[#e5e5e5] bg-white shadow-none p-5">
|
||||
<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>
|
||||
<PhaseCard phase={phase} token={token} defaultOpen={defaultOpen} />
|
||||
</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" }),
|
||||
label: text("label").notNull(), // "Acconto 50%" | "Saldo 50%"
|
||||
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
|
||||
paid_at: timestamp("paid_at", { withTimezone: true }),
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
quotes,
|
||||
leads,
|
||||
tags,
|
||||
clientTranscripts,
|
||||
} from "@/db/schema";
|
||||
import { eq, inArray, asc, desc, isNull, sql, and } from "drizzle-orm";
|
||||
import { getPool } from "@/lib/taxonomy";
|
||||
@@ -551,6 +552,8 @@ export type ProjectFullDetail = {
|
||||
activeTimerStartedAt: Date | null;
|
||||
totalTrackedSeconds: number;
|
||||
projectOffers: ProjectOfferWithMicro[];
|
||||
/** Sum of accepted_total across all active project offers — used as default for payment plan */
|
||||
offersAcceptedTotal: number;
|
||||
availableMicros: Array<{
|
||||
id: string;
|
||||
internal_name: string;
|
||||
@@ -563,6 +566,13 @@ export type ProjectFullDetail = {
|
||||
macro_category: string | null;
|
||||
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> {
|
||||
@@ -609,7 +619,7 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
|
||||
.from(deliverables)
|
||||
.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([
|
||||
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)),
|
||||
@@ -683,6 +693,18 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
|
||||
asc(offer_micros.tier_letter),
|
||||
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)];
|
||||
@@ -715,6 +737,11 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
|
||||
return true;
|
||||
});
|
||||
|
||||
const offersAcceptedTotal = (projectOffersRows as ProjectOfferWithMicro[]).reduce(
|
||||
(sum, o) => sum + (o.accepted_total ? parseFloat(String(o.accepted_total)) : 0),
|
||||
0
|
||||
);
|
||||
|
||||
return {
|
||||
project: { ...project, client } as ProjectFullDetail["project"],
|
||||
phases: phasesWithTasks,
|
||||
@@ -728,10 +755,40 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
|
||||
activeTimerStartedAt: activeEntryRows[0]?.started_at ?? null,
|
||||
totalTrackedSeconds: totalRes[0] ? parseInt(totalRes[0].total) : 0,
|
||||
projectOffers: projectOffersRows as ProjectOfferWithMicro[],
|
||||
offersAcceptedTotal,
|
||||
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 ──────────
|
||||
|
||||
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 { 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.
|
||||
@@ -58,6 +58,13 @@ export interface ClientView {
|
||||
cumulative_price: string;
|
||||
accepted_total: string | null;
|
||||
}>;
|
||||
transcripts: Array<{
|
||||
id: string;
|
||||
title: string | null;
|
||||
call_date: string;
|
||||
content: string;
|
||||
created_at: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ProjectView {
|
||||
@@ -121,6 +128,13 @@ export interface ProjectView {
|
||||
cumulative_price: string; // sum of assigned offer_services.price
|
||||
accepted_total: string | null;
|
||||
}>;
|
||||
transcripts: Array<{
|
||||
id: string;
|
||||
title: string | null;
|
||||
call_date: string;
|
||||
content: string;
|
||||
created_at: Date;
|
||||
}>;
|
||||
}
|
||||
|
||||
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,
|
||||
}));
|
||||
|
||||
// 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
|
||||
const allEntityIds = [...taskIds, ...deliverablesRows.map((d) => d.id)];
|
||||
const commentsRows =
|
||||
@@ -352,5 +379,6 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
|
||||
comments: commentsRows,
|
||||
global_progress_pct,
|
||||
activeOffers: activeOffers.length > 0 ? activeOffers : undefined,
|
||||
transcripts: transcriptsRows,
|
||||
};
|
||||
}
|
||||
@@ -219,3 +219,13 @@ export async function getTranscripts(leadId: string) {
|
||||
.where(eq(clientTranscripts.lead_id, leadId))
|
||||
.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