diff --git a/package-lock.json b/package-lock.json index 7f33658..a87cb03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "drizzle-orm": "^0.45.2", @@ -2816,6 +2817,30 @@ } } }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-primitive": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", @@ -2863,6 +2888,93 @@ } } }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-select": { "version": "2.2.6", "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", @@ -3003,6 +3115,92 @@ } } }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", diff --git a/package.json b/package.json index f6f0566..d9eabc6 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "drizzle-orm": "^0.45.2", diff --git a/src/app/admin/clients/[id]/actions.ts b/src/app/admin/clients/[id]/actions.ts new file mode 100644 index 0000000..2bf835a --- /dev/null +++ b/src/app/admin/clients/[id]/actions.ts @@ -0,0 +1,175 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { db } from "@/db"; +import { + phases, + tasks, + deliverables, + documents, + payments, + clients, + comments, +} from "@/db/schema"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; + +// ── PHASES ──────────────────────────────────────────────────────────────────── + +export async function addPhase(clientId: string, formData: FormData) { + const title = (formData.get("title") as string)?.trim(); + if (!title) throw new Error("Titolo fase richiesto"); + + const existingPhases = await db + .select({ sort_order: phases.sort_order }) + .from(phases) + .where(eq(phases.client_id, clientId)); + const maxOrder = existingPhases.reduce((max, p) => Math.max(max, p.sort_order), -1); + + await db.insert(phases).values({ + client_id: clientId, + title, + sort_order: maxOrder + 1, + status: "upcoming", + }); + revalidatePath(`/admin/clients/${clientId}`); +} + +export async function updatePhaseStatus( + phaseId: string, + clientId: string, + status: string +) { + const allowed = ["upcoming", "active", "done"]; + if (!allowed.includes(status)) throw new Error("Stato non valido"); + await db.update(phases).set({ status }).where(eq(phases.id, phaseId)); + revalidatePath(`/admin/clients/${clientId}`); +} + +// ── TASKS ───────────────────────────────────────────────────────────────────── + +export async function addTask( + phaseId: string, + clientId: string, + formData: FormData +) { + const title = (formData.get("title") as string)?.trim(); + if (!title) throw new Error("Titolo task richiesto"); + + const existingTasks = await db + .select({ sort_order: tasks.sort_order }) + .from(tasks) + .where(eq(tasks.phase_id, phaseId)); + const maxOrder = existingTasks.reduce((max, t) => Math.max(max, t.sort_order), -1); + + await db.insert(tasks).values({ + phase_id: phaseId, + title, + description: (formData.get("description") as string)?.trim() || null, + sort_order: maxOrder + 1, + status: "todo", + }); + revalidatePath(`/admin/clients/${clientId}`); +} + +export async function updateTaskStatus( + taskId: string, + clientId: string, + status: string +) { + 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)); + revalidatePath(`/admin/clients/${clientId}`); +} + +// ── DELIVERABLES ────────────────────────────────────────────────────────────── + +export async function addDeliverable( + taskId: string, + clientId: string, + formData: FormData +) { + const title = (formData.get("title") as string)?.trim(); + const url = (formData.get("url") as string)?.trim() || null; + if (!title) throw new Error("Titolo deliverable richiesto"); + // approved_at is intentionally omitted — immutable constraint: never set by admin here + await db + .insert(deliverables) + .values({ task_id: taskId, title, url, status: "pending" }); + revalidatePath(`/admin/clients/${clientId}`); +} + +// ── DOCUMENTS ───────────────────────────────────────────────────────────────── + +const docSchema = z.object({ + label: z.string().min(1, "Etichetta richiesta"), + url: z.string().url("URL non valido"), +}); + +export async function addDocument(clientId: string, formData: FormData) { + const parsed = docSchema.safeParse({ + label: formData.get("label"), + url: formData.get("url"), + }); + if (!parsed.success) throw new Error(parsed.error.issues[0].message); + await db.insert(documents).values({ client_id: clientId, ...parsed.data }); + revalidatePath(`/admin/clients/${clientId}`); +} + +export async function deleteDocument(documentId: string, clientId: string) { + await db.delete(documents).where(eq(documents.id, documentId)); + revalidatePath(`/admin/clients/${clientId}`); +} + +// ── PAYMENTS ────────────────────────────────────────────────────────────────── + +export async function updatePaymentStatus( + paymentId: string, + clientId: string, + status: string +) { + const allowed = ["da_saldare", "inviata", "saldato"]; + if (!allowed.includes(status)) throw new Error("Stato pagamento non valido"); + const paid_at = status === "saldato" ? new Date() : null; + await db + .update(payments) + .set({ status, paid_at }) + .where(eq(payments.id, paymentId)); + revalidatePath(`/admin/clients/${clientId}`); +} + +export async function updateAcceptedTotal(clientId: string, formData: FormData) { + const raw = (formData.get("accepted_total") as string)?.trim(); + const val = parseFloat(raw); + if (isNaN(val) || val < 0) throw new Error("Importo non valido"); + // Update accepted_total on client row — denormalized field, quote_items never exposed + await db + .update(clients) + .set({ accepted_total: val.toFixed(2) }) + .where(eq(clients.id, clientId)); + // Split evenly between two payment rows (Acconto 50% + Saldo 50%) + const half = (val / 2).toFixed(2); + const paymentsRows = await db + .select() + .from(payments) + .where(eq(payments.client_id, clientId)); + for (const p of paymentsRows) { + await db.update(payments).set({ amount: half }).where(eq(payments.id, p.id)); + } + revalidatePath(`/admin/clients/${clientId}`); +} + +// ── COMMENTS (admin reply) ──────────────────────────────────────────────────── + +export async function postAdminComment(clientId: string, formData: FormData) { + const entity = formData.get("entity") as string; + 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"]; + if (!allowedTypes.includes(entity_type)) throw new Error("entity_type non valido"); + await db.insert(comments).values({ entity_type, entity_id, author: "admin", body }); + revalidatePath(`/admin/clients/${clientId}`); +} \ No newline at end of file diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx new file mode 100644 index 0000000..26eb109 --- /dev/null +++ b/src/components/ui/tabs.tsx @@ -0,0 +1,55 @@ +"use client" + +import * as React from "react" +import * as TabsPrimitive from "@radix-ui/react-tabs" + +import { cn } from "@/lib/utils" + +const Tabs = TabsPrimitive.Root + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/src/lib/admin-queries.ts b/src/lib/admin-queries.ts index d04c110..8777edb 100644 --- a/src/lib/admin-queries.ts +++ b/src/lib/admin-queries.ts @@ -1,6 +1,25 @@ import { db } from "@/db"; -import { clients, payments } from "@/db/schema"; -import { eq } from "drizzle-orm"; +import { + clients, + payments, + phases, + tasks, + deliverables, + comments, + documents, + notes, +} from "@/db/schema"; +import { eq, inArray, asc } from "drizzle-orm"; +import type { + Client, + Phase, + Task, + Deliverable, + Payment, + Document, + Note, + Comment, +} from "@/db/schema"; export type ClientWithPayments = { id: string; @@ -55,3 +74,94 @@ export async function getClientById(id: string) { .limit(1); return rows[0] ?? null; } + +// ── ClientFullDetail — used by /admin/clients/[id] workspace ───────────────── + +export type ClientFullDetail = { + client: Client; + phases: Array }>; + payments: Payment[]; + documents: Document[]; + notes: Note[]; + comments: Comment[]; +}; + +export async function getClientFullDetail(id: string): Promise { + const clientRows = await db.select().from(clients).where(eq(clients.id, id)).limit(1); + if (clientRows.length === 0) return null; + const client = clientRows[0]; + + const phasesRows = await db + .select() + .from(phases) + .where(eq(phases.client_id, id)) + .orderBy(asc(phases.sort_order)); + + const phaseIds = phasesRows.map((p) => p.id); + + const tasksRows = + phaseIds.length === 0 + ? [] + : await db + .select() + .from(tasks) + .where(inArray(tasks.phase_id, phaseIds)) + .orderBy(asc(tasks.sort_order)); + + const taskIds = tasksRows.map((t) => t.id); + + const deliverablesRows = + taskIds.length === 0 + ? [] + : await db + .select() + .from(deliverables) + .where(inArray(deliverables.task_id, taskIds)); + + const paymentsRows = await db + .select() + .from(payments) + .where(eq(payments.client_id, id)); + + const documentsRows = await db + .select() + .from(documents) + .where(eq(documents.client_id, id)) + .orderBy(asc(documents.created_at)); + + const notesRows = await db + .select() + .from(notes) + .where(eq(notes.client_id, id)) + .orderBy(asc(notes.created_at)); + + // Fetch all comments for tasks and deliverables belonging to this client + const allEntityIds = [...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 phaseTasks = tasksRows + .filter((t) => t.phase_id === phase.id) + .map((task) => ({ + ...task, + deliverables: deliverablesRows.filter((d) => d.task_id === task.id), + })); + return { ...phase, tasks: phaseTasks }; + }); + + return { + client, + phases: phasesWithTasks, + payments: paymentsRows, + documents: documentsRows, + notes: notesRows, + comments: commentsRows, + }; +}