refactor(progetti): via il tab Commenti e il timer dalla lista
Due rimozioni chieste esplicitamente, che tolgono due doppioni. Il tab "Commenti" del progetto duplicava /admin/conversazioni. Non era una scorciatoia: era la seconda copia. `buildEntityMap()` in conversations-queries cammina clienti -> progetti -> fasi -> task -> deliverable e raccoglie TUTTI i commenti con l'etichetta dell'entita' di origine, quindi l'inbox e' un sovrainsieme stretto di quel tab. La lettura non perde niente. Una cosa la perde, e va detta: dal tab si poteva rispondere sulla singola entita', mentre `replyToConversation` salva sempre sul thread generale. Non e' una regressione introdotta qui — e' una scelta di prodotto gia' presa e gia' annotata in conversazioni/actions.ts — ma da oggi e' l'unica via, e il commento la' sopra ora lo dice. Il timer nella lista progetti era l'altro doppione: si avvia e si ferma dentro il progetto, dove c'e' il contesto per sapere su cosa stai lavorando. Toglierlo elimina anche una query per pagina (la scansione delle entry aperte). Cade di conseguenza il codice rimasto senza chiamanti: CommentsTab.tsx, `postAdminComment`, il campo `comments` di ProjectFullDetail con la sua query, e i due campi activeTimer* di ProjectWithPayments. `totalTrackedSeconds` resta: serve al calcolo del EUR/h, che in lista ci sta ancora. Build e lint puliti. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,7 +18,6 @@ import {
|
|||||||
payments,
|
payments,
|
||||||
clients,
|
clients,
|
||||||
projects,
|
projects,
|
||||||
comments,
|
|
||||||
client_emails,
|
client_emails,
|
||||||
otp_codes,
|
otp_codes,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
@@ -368,21 +367,11 @@ export async function updateAcceptedTotal(id: string, formData: FormData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── COMMENTS (admin reply) ────────────────────────────────────────────────────
|
// ── COMMENTS ──────────────────────────────────────────────────────────────────
|
||||||
|
// La risposta dell'admin vive in `replyToConversation`
|
||||||
export async function postAdminComment(id: string, formData: FormData) {
|
// (src/app/admin/conversazioni/actions.ts): unica inbox, un solo punto di
|
||||||
await requireAdmin();
|
// scrittura. Qui c'era `postAdminComment`, che rispondeva sulla singola entità
|
||||||
const entity = formData.get("entity") as string;
|
// dal tab Commenti del progetto — tab rimosso, action con lui.
|
||||||
const body = (formData.get("body") as string)?.trim();
|
|
||||||
if (!body || !entity) throw new Error("Dati mancanti");
|
|
||||||
const [entity_type, entity_id] = entity.split(":");
|
|
||||||
if (!entity_type || !entity_id) throw new Error("Formato entity non valido");
|
|
||||||
const allowedTypes = ["task", "deliverable", "phase", "general"];
|
|
||||||
if (!allowedTypes.includes(entity_type)) throw new Error("entity_type non valido");
|
|
||||||
await db.insert(comments).values({ entity_type, entity_id, author: "admin", body });
|
|
||||||
const { path } = await resolveEntity(id);
|
|
||||||
revalidatePath(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── ACCESSI PORTALE (whitelist OTP) ──────────────────────────────────────────
|
// ── ACCESSI PORTALE (whitelist OTP) ──────────────────────────────────────────
|
||||||
// La whitelist è l'unico modo per entrare nel portale: nessuna auto-registrazione.
|
// La whitelist è l'unico modo per entrare nel portale: nessuna auto-registrazione.
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ async function requireAdmin() {
|
|||||||
/**
|
/**
|
||||||
* Admin reply from the Conversazioni inbox. Per project decision, replies are
|
* Admin reply from the Conversazioni inbox. Per project decision, replies are
|
||||||
* saved as a "general" comment on the client (entity_id = clientId), so they
|
* saved as a "general" comment on the client (entity_id = clientId), so they
|
||||||
* surface in the client's general chat and in the client detail CommentsTab.
|
* surface in the client's general chat.
|
||||||
|
*
|
||||||
|
* Questa è l'UNICA via di risposta dell'admin da quando il tab Commenti del
|
||||||
|
* progetto è stato rimosso: i messaggi su fase/task/deliverable si leggono qui
|
||||||
|
* con la loro etichetta, ma la risposta torna sempre sul thread generale.
|
||||||
*/
|
*/
|
||||||
export async function replyToConversation(clientId: string, formData: FormData) {
|
export async function replyToConversation(clientId: string, formData: FormData) {
|
||||||
await requireAdmin();
|
await requireAdmin();
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|||||||
import { PhasesTab } from "@/components/admin/tabs/PhasesTab";
|
import { PhasesTab } from "@/components/admin/tabs/PhasesTab";
|
||||||
import { PaymentsTab } from "@/components/admin/tabs/PaymentsTab";
|
import { PaymentsTab } from "@/components/admin/tabs/PaymentsTab";
|
||||||
import { DocumentsTab } from "@/components/admin/tabs/DocumentsTab";
|
import { DocumentsTab } from "@/components/admin/tabs/DocumentsTab";
|
||||||
import { CommentsTab } from "@/components/admin/tabs/CommentsTab";
|
|
||||||
import { TimerTab } from "@/components/admin/tabs/TimerTab";
|
import { TimerTab } from "@/components/admin/tabs/TimerTab";
|
||||||
import { OffersTab } from "@/components/admin/tabs/OffersTab";
|
import { OffersTab } from "@/components/admin/tabs/OffersTab";
|
||||||
import { PhasesViewToggle } from "@/components/admin/kanban/PhasesViewToggle";
|
import { PhasesViewToggle } from "@/components/admin/kanban/PhasesViewToggle";
|
||||||
@@ -34,7 +33,6 @@ export default async function ProjectDetailPage({
|
|||||||
payments,
|
payments,
|
||||||
documents,
|
documents,
|
||||||
notes,
|
notes,
|
||||||
comments,
|
|
||||||
activeTimerEntryId,
|
activeTimerEntryId,
|
||||||
activeTimerStartedAt,
|
activeTimerStartedAt,
|
||||||
totalTrackedSeconds,
|
totalTrackedSeconds,
|
||||||
@@ -72,7 +70,6 @@ export default async function ProjectDetailPage({
|
|||||||
<TabsTrigger value="payments">Pagamenti</TabsTrigger>
|
<TabsTrigger value="payments">Pagamenti</TabsTrigger>
|
||||||
<TabsTrigger value="documents">Documenti</TabsTrigger>
|
<TabsTrigger value="documents">Documenti</TabsTrigger>
|
||||||
<TabsTrigger value="notes">Note</TabsTrigger>
|
<TabsTrigger value="notes">Note</TabsTrigger>
|
||||||
<TabsTrigger value="comments">Commenti</TabsTrigger>
|
|
||||||
<TabsTrigger value="timer">Timer</TabsTrigger>
|
<TabsTrigger value="timer">Timer</TabsTrigger>
|
||||||
<TabsTrigger value="offers">Offerte</TabsTrigger>
|
<TabsTrigger value="offers">Offerte</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
@@ -115,10 +112,6 @@ export default async function ProjectDetailPage({
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="comments">
|
|
||||||
<CommentsTab comments={comments} phases={phases} clientId={id} />
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="timer">
|
<TabsContent value="timer">
|
||||||
<TimerTab
|
<TimerTab
|
||||||
projectId={id}
|
projectId={id}
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ export default async function ProjectsPage() {
|
|||||||
<th className="py-4 px-6 text-right">Valore Totale</th>
|
<th className="py-4 px-6 text-right">Valore Totale</th>
|
||||||
<th className="py-4 px-6 text-center">Acconto</th>
|
<th className="py-4 px-6 text-center">Acconto</th>
|
||||||
<th className="py-4 px-6 text-center">Saldo</th>
|
<th className="py-4 px-6 text-center">Saldo</th>
|
||||||
<th className="py-4 px-6 text-center">Timer</th>
|
|
||||||
<th className="py-4 px-6 text-right">Redditività (€/H)</th>
|
<th className="py-4 px-6 text-right">Redditività (€/H)</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { TimerCell } from "@/components/admin/TimerCell";
|
|
||||||
import type { ProjectWithPayments } from "@/lib/admin-queries";
|
import type { ProjectWithPayments } from "@/lib/admin-queries";
|
||||||
|
|
||||||
const statusConfig: Record<string, { label: string; className: string }> = {
|
const statusConfig: Record<string, { label: string; className: string }> = {
|
||||||
@@ -80,18 +79,6 @@ export function ProjectRow({ project }: { project: ProjectWithPayments }) {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="py-4 px-6">
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<TimerCell
|
|
||||||
clientId={project.id}
|
|
||||||
projectId={project.id}
|
|
||||||
activeEntryId={project.activeTimerEntryId}
|
|
||||||
activeStartedAt={project.activeTimerStartedAt}
|
|
||||||
totalTrackedSeconds={project.totalTrackedSeconds}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className="py-4 px-6 text-right whitespace-nowrap">
|
<td className="py-4 px-6 text-right whitespace-nowrap">
|
||||||
{eurPerHour === null ? (
|
{eurPerHour === null ? (
|
||||||
<span className="text-muted-foreground/40 font-medium">—</span>
|
<span className="text-muted-foreground/40 font-medium">—</span>
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
import { postAdminComment } from "@/app/admin/clients/[id]/actions";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import type { Comment } from "@/db/schema";
|
|
||||||
import type { ClientFullDetail } from "@/lib/admin-queries";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
comments: Comment[];
|
|
||||||
phases: ClientFullDetail["phases"];
|
|
||||||
clientId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function CommentsTab({ comments, phases, clientId }: Props) {
|
|
||||||
// Build entity label map for display (phases, tasks, deliverables, and general)
|
|
||||||
const entityLabels: Record<string, string> = {
|
|
||||||
[clientId]: "Messaggio generale",
|
|
||||||
};
|
|
||||||
for (const phase of phases) {
|
|
||||||
entityLabels[phase.id] = `Fase: ${phase.title}`;
|
|
||||||
for (const task of phase.tasks) {
|
|
||||||
entityLabels[task.id] = `Task: ${task.title}`;
|
|
||||||
for (const d of task.deliverables) {
|
|
||||||
entityLabels[d.id] = `Deliverable: ${d.title}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build list of entities the admin can reply on
|
|
||||||
const entities: Array<{ id: string; type: string; label: string }> = [
|
|
||||||
{ id: clientId, type: "general", label: "Messaggio generale" },
|
|
||||||
];
|
|
||||||
for (const phase of phases) {
|
|
||||||
entities.push({ id: phase.id, type: "phase", label: `Fase: ${phase.title}` });
|
|
||||||
for (const task of phase.tasks) {
|
|
||||||
entities.push({ id: task.id, type: "task", label: `Task: ${task.title}` });
|
|
||||||
for (const d of task.deliverables) {
|
|
||||||
entities.push({
|
|
||||||
id: d.id,
|
|
||||||
type: "deliverable",
|
|
||||||
label: `Deliverable: ${d.title}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 max-w-lg">
|
|
||||||
{/* Comment list */}
|
|
||||||
{comments.length === 0 && (
|
|
||||||
<p className="text-sm text-gray-400">Nessun commento ancora.</p>
|
|
||||||
)}
|
|
||||||
<div className="space-y-3">
|
|
||||||
{comments.map((c) => (
|
|
||||||
<div
|
|
||||||
key={c.id}
|
|
||||||
className={`flex gap-3 ${c.author === "admin" ? "flex-row-reverse" : ""}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`rounded-lg px-3 py-2 text-sm max-w-xs ${
|
|
||||||
c.author === "admin"
|
|
||||||
? "bg-gray-900 text-white"
|
|
||||||
: "bg-white border border-gray-200 text-gray-800"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<p className="text-xs font-medium mb-1 opacity-60">
|
|
||||||
{c.author === "admin" ? "iamcavalli" : "Cliente"} —{" "}
|
|
||||||
{entityLabels[c.entity_id] ?? c.entity_id}
|
|
||||||
</p>
|
|
||||||
<p>{c.body}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Admin reply form */}
|
|
||||||
<form
|
|
||||||
action={async (fd: FormData) => {
|
|
||||||
"use server";
|
|
||||||
await postAdminComment(clientId, fd);
|
|
||||||
}}
|
|
||||||
className="bg-white border border-gray-200 rounded-lg p-4 space-y-3"
|
|
||||||
>
|
|
||||||
<h3 className="font-medium text-gray-900 text-sm">
|
|
||||||
Rispondi come admin
|
|
||||||
</h3>
|
|
||||||
<select
|
|
||||||
name="entity"
|
|
||||||
className="w-full text-sm border border-gray-200 rounded px-2 py-1.5 bg-white"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
{entities.map((e) => (
|
|
||||||
<option key={e.id} value={`${e.type}:${e.id}`}>
|
|
||||||
{e.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<Textarea
|
|
||||||
name="body"
|
|
||||||
placeholder="Scrivi un commento..."
|
|
||||||
rows={3}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<Button type="submit" size="sm">
|
|
||||||
Invia risposta
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -477,8 +477,8 @@ export type ProjectWithPayments = {
|
|||||||
archived: boolean;
|
archived: boolean;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
payments: Array<{ id: string; label: string; status: string; amount: string }>;
|
payments: Array<{ id: string; label: string; status: string; amount: string }>;
|
||||||
activeTimerEntryId: string | null;
|
// Niente timer attivo qui: la lista non lo mostra più, si avvia e si ferma
|
||||||
activeTimerStartedAt: Date | null;
|
// dentro il progetto. `totalTrackedSeconds` resta perché serve al €/h.
|
||||||
totalTrackedSeconds: number;
|
totalTrackedSeconds: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -506,21 +506,12 @@ export async function getAllProjectsWithPayments(
|
|||||||
const projectIds = visible.map((p) => p.id);
|
const projectIds = visible.map((p) => p.id);
|
||||||
const clientIds = [...new Set(visible.map((p) => p.client_id))];
|
const clientIds = [...new Set(visible.map((p) => p.client_id))];
|
||||||
|
|
||||||
const [allPayments, activeEntries, totals, parentClients] = await Promise.all([
|
const [allPayments, totals, parentClients] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.where(inArray(payments.project_id, projectIds)),
|
.where(inArray(payments.project_id, projectIds)),
|
||||||
|
|
||||||
db
|
|
||||||
.select({
|
|
||||||
id: time_entries.id,
|
|
||||||
project_id: time_entries.project_id,
|
|
||||||
started_at: time_entries.started_at,
|
|
||||||
})
|
|
||||||
.from(time_entries)
|
|
||||||
.where(isNull(time_entries.ended_at)),
|
|
||||||
|
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
project_id: time_entries.project_id,
|
project_id: time_entries.project_id,
|
||||||
@@ -538,7 +529,6 @@ export async function getAllProjectsWithPayments(
|
|||||||
|
|
||||||
return visible.map((project) => {
|
return visible.map((project) => {
|
||||||
const projectPayments = allPayments.filter((p) => p.project_id === project.id);
|
const projectPayments = allPayments.filter((p) => p.project_id === project.id);
|
||||||
const activeEntry = activeEntries.find((e) => e.project_id === project.id);
|
|
||||||
const totalRow = totals.find((t) => t.project_id === project.id);
|
const totalRow = totals.find((t) => t.project_id === project.id);
|
||||||
const parentClient = parentClients.find((c) => c.id === project.client_id);
|
const parentClient = parentClients.find((c) => c.id === project.client_id);
|
||||||
|
|
||||||
@@ -555,8 +545,6 @@ export async function getAllProjectsWithPayments(
|
|||||||
status: p.status,
|
status: p.status,
|
||||||
amount: String(p.amount),
|
amount: String(p.amount),
|
||||||
})),
|
})),
|
||||||
activeTimerEntryId: activeEntry?.id ?? null,
|
|
||||||
activeTimerStartedAt: activeEntry?.started_at ?? null,
|
|
||||||
totalTrackedSeconds: totalRow ? parseInt(totalRow.total) : 0,
|
totalTrackedSeconds: totalRow ? parseInt(totalRow.total) : 0,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -570,7 +558,8 @@ export type ProjectFullDetail = {
|
|||||||
payments: Payment[];
|
payments: Payment[];
|
||||||
documents: Document[];
|
documents: Document[];
|
||||||
notes: Note[];
|
notes: Note[];
|
||||||
comments: Comment[];
|
// I commenti NON stanno qui: si leggono e si risponde da /admin/conversazioni,
|
||||||
|
// che li aggrega per cliente con l'etichetta dell'entità di origine.
|
||||||
quoteItems: QuoteItemWithLabel[];
|
quoteItems: QuoteItemWithLabel[];
|
||||||
activeServices: Service[];
|
activeServices: Service[];
|
||||||
activeTimerEntryId: string | null;
|
activeTimerEntryId: string | null;
|
||||||
@@ -734,16 +723,6 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
|
|||||||
.orderBy(desc(clientTranscripts.call_date)),
|
.orderBy(desc(clientTranscripts.call_date)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const allEntityIds = [id, ...taskIds, ...deliverablesRows.map((d) => d.id)];
|
|
||||||
const commentsRows =
|
|
||||||
allEntityIds.length === 0
|
|
||||||
? []
|
|
||||||
: await db
|
|
||||||
.select()
|
|
||||||
.from(comments)
|
|
||||||
.where(inArray(comments.entity_id, allEntityIds))
|
|
||||||
.orderBy(asc(comments.created_at));
|
|
||||||
|
|
||||||
const phasesWithTasks = phasesRows.map((phase) => ({
|
const phasesWithTasks = phasesRows.map((phase) => ({
|
||||||
...phase,
|
...phase,
|
||||||
tasks: tasksRows
|
tasks: tasksRows
|
||||||
@@ -780,7 +759,6 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
|
|||||||
payments: paymentsRows,
|
payments: paymentsRows,
|
||||||
documents: documentsRows,
|
documents: documentsRows,
|
||||||
notes: notesRows,
|
notes: notesRows,
|
||||||
comments: commentsRows,
|
|
||||||
quoteItems: quoteItemRows as QuoteItemWithLabel[],
|
quoteItems: quoteItemRows as QuoteItemWithLabel[],
|
||||||
activeServices: activeServiceRows,
|
activeServices: activeServiceRows,
|
||||||
activeTimerEntryId: activeEntryRows[0]?.id ?? null,
|
activeTimerEntryId: activeEntryRows[0]?.id ?? null,
|
||||||
|
|||||||
Reference in New Issue
Block a user