From 2d9cf1ac9db68eec4079e9a394e7516a907da35c Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Thu, 11 Jun 2026 12:32:07 +0200 Subject: [PATCH] feat(10-02): implement lead management UI with list, detail, and form pages - Create /admin/leads list page with LeadTable component showing all leads - Implement /admin/leads/[id] detail page with activity log and reminders - Add LeadForm component (CreateLeadModal and EditLeadModal) for CRUD operations - Create server actions (createLead, updateLead, deleteLead) with Zod validation - Add missing UI components (dialog.tsx, form.tsx) from shadcn - Install date-fns for date formatting with Italian locale support - All routes compile successfully with npm run build Co-Authored-By: Claude Haiku 4.5 --- package-lock.json | 367 +++++++++++++++++++++- package.json | 4 +- src/app/admin/leads/[id]/page.tsx | 18 ++ src/app/admin/leads/actions.ts | 82 +++++ src/app/admin/leads/page.tsx | 26 ++ src/components/admin/leads/LeadDetail.tsx | 166 ++++++++++ src/components/admin/leads/LeadForm.tsx | 342 ++++++++++++++++++++ src/components/admin/leads/LeadTable.tsx | 81 +++++ src/components/ui/dialog.tsx | 122 +++++++ src/components/ui/form.tsx | 170 ++++++++++ 10 files changed, 1372 insertions(+), 6 deletions(-) create mode 100644 src/app/admin/leads/[id]/page.tsx create mode 100644 src/app/admin/leads/actions.ts create mode 100644 src/app/admin/leads/page.tsx create mode 100644 src/components/admin/leads/LeadDetail.tsx create mode 100644 src/components/admin/leads/LeadForm.tsx create mode 100644 src/components/admin/leads/LeadTable.tsx create mode 100644 src/components/ui/dialog.tsx create mode 100644 src/components/ui/form.tsx diff --git a/package-lock.json b/package-lock.json index 242a7a9..35903d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,14 +12,16 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-dialog": "^1.1.16", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", - "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-slot": "^1.2.5", "@radix-ui/react-tabs": "^1.1.13", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.4.0", "drizzle-orm": "^0.45.2", "lucide-react": "^1.14.0", "nanoid": "^5.1.11", @@ -2515,6 +2517,318 @@ } } }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.16.tgz", + "integrity": "sha512-l9ok83YBclEZhbjgzt76Hw733e6cvRKPNgO6GJ/IETlufXG9p+fRu2wlvpImQvR6xdJ8h7J8J2DBvsPEiEsKMw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.12", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.9", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-portal": "1.1.11", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-slot": "1.2.5", + "@radix-ui/react-use-controllable-state": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.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-dialog/node_modules/@radix-ui/primitive": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz", + "integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", + "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "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-dialog/node_modules/@radix-ui/react-context": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz", + "integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==", + "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-dialog/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.12.tgz", + "integrity": "sha512-MhoruH6xEzsbvOmo4TNgMfmtvRGyDZw4MDSdf4ybMHfezjqwzv6hyd4lsMzBp8K9Sn6sGzCF62x1I7BYUECXOg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-escape-keydown": "1.1.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-dialog/node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz", + "integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==", + "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-dialog/node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.9.tgz", + "integrity": "sha512-9Se8t+Zry+1rEOL7Y6l/4ANYU/TOtAtf8O2fKdwLltcaMcm6kOqYGbzO4tMFQ0bvzO920pRAoHpFZ4W85S3keQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-callback-ref": "1.1.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-dialog/node_modules/@radix-ui/react-id": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz", + "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "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-dialog/node_modules/@radix-ui/react-portal": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.11.tgz", + "integrity": "sha512-UEytdjgEh2tJGgD/gZK4FUx6t1rNIlM3U0DENhSrG7I75FGm1DnaDuVUWF1pWAWUwGmn1sCJ1VGHn8LhN1aTOw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-layout-effect": "1.1.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-dialog/node_modules/@radix-ui/react-presence": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz", + "integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.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-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.5.tgz", + "integrity": "sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.5" + }, + "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-dialog/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz", + "integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==", + "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-dialog/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz", + "integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-layout-effect": "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-dialog/node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", + "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "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-dialog/node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.2.tgz", + "integrity": "sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "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-dialog/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", + "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "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-direction": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", @@ -2920,6 +3234,24 @@ } } }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "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-progress": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.8.tgz", @@ -3154,12 +3486,12 @@ } }, "node_modules/@radix-ui/react-slot": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", - "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.5.tgz", + "integrity": "sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" + "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", @@ -3171,6 +3503,21 @@ } } }, + "node_modules/@radix-ui/react-slot/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", + "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "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": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", @@ -5050,6 +5397,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", diff --git a/package.json b/package.json index 793a753..9a4a8e1 100644 --- a/package.json +++ b/package.json @@ -13,14 +13,16 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-dialog": "^1.1.16", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", - "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-slot": "^1.2.5", "@radix-ui/react-tabs": "^1.1.13", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.4.0", "drizzle-orm": "^0.45.2", "lucide-react": "^1.14.0", "nanoid": "^5.1.11", diff --git a/src/app/admin/leads/[id]/page.tsx b/src/app/admin/leads/[id]/page.tsx new file mode 100644 index 0000000..86f676a --- /dev/null +++ b/src/app/admin/leads/[id]/page.tsx @@ -0,0 +1,18 @@ +import { notFound } from "next/navigation"; +import { getLeadById, getActivityLog, getUpcomingReminders } from "@/lib/lead-service"; +import { LeadDetail } from "@/components/admin/leads/LeadDetail"; + +export default async function LeadDetailPage({ params }: { params: { id: string } }) { + const lead = await getLeadById(params.id); + + if (!lead) { + notFound(); + } + + const activities = await getActivityLog(params.id); + const reminders = await getUpcomingReminders(params.id); + + return ( + + ); +} diff --git a/src/app/admin/leads/actions.ts b/src/app/admin/leads/actions.ts new file mode 100644 index 0000000..659a641 --- /dev/null +++ b/src/app/admin/leads/actions.ts @@ -0,0 +1,82 @@ +"use server"; + +import { z } from "zod"; +import { db } from "@/db"; +import { leads } from "@/db/schema"; +import { eq } from "drizzle-orm"; +import { createLeadSchema, updateLeadSchema } from "@/lib/lead-validators"; +import { revalidatePath } from "next/cache"; + +export async function createLead(data: z.infer) { + const parsed = createLeadSchema.safeParse(data); + + if (!parsed.success) { + return { success: false, error: parsed.error.issues[0].message }; + } + + try { + const [lead] = await db + .insert(leads) + .values({ + name: parsed.data.name, + email: parsed.data.email || null, + phone: parsed.data.phone || null, + company: parsed.data.company || null, + status: parsed.data.status || "contacted", + notes: parsed.data.notes || null, + }) + .returning(); + + revalidatePath("/admin/leads"); + return { success: true, lead }; + } catch (error) { + console.error("createLead error:", error); + return { success: false, error: "Errore nella creazione del lead" }; + } +} + +export async function updateLead( + id: string, + data: z.infer +) { + const parsed = updateLeadSchema.safeParse(data); + + if (!parsed.success) { + return { success: false, error: parsed.error.issues[0].message }; + } + + try { + const updateData: Record = { updated_at: new Date() }; + + if (parsed.data.name !== undefined) updateData.name = parsed.data.name; + if (parsed.data.email !== undefined) updateData.email = parsed.data.email || null; + if (parsed.data.phone !== undefined) updateData.phone = parsed.data.phone || null; + if (parsed.data.company !== undefined) updateData.company = parsed.data.company || null; + if (parsed.data.status !== undefined) updateData.status = parsed.data.status; + if (parsed.data.notes !== undefined) updateData.notes = parsed.data.notes || null; + + const [updated] = await db + .update(leads) + .set(updateData) + .where(eq(leads.id, id)) + .returning(); + + revalidatePath("/admin/leads"); + revalidatePath(`/admin/leads/${id}`); + return { success: true, lead: updated }; + } catch (error) { + console.error("updateLead error:", error); + return { success: false, error: "Errore nell'aggiornamento" }; + } +} + +export async function deleteLead(id: string) { + try { + await db.delete(leads).where(eq(leads.id, id)); + revalidatePath("/admin/leads"); + return { success: true }; + } catch (error) { + console.error("deleteLead error:", error); + return { success: false, error: "Errore nell'eliminazione" }; + } +} diff --git a/src/app/admin/leads/page.tsx b/src/app/admin/leads/page.tsx new file mode 100644 index 0000000..386d1d2 --- /dev/null +++ b/src/app/admin/leads/page.tsx @@ -0,0 +1,26 @@ +import { Suspense } from "react"; +import { getAllLeads } from "@/lib/lead-service"; +import { LeadTable } from "@/components/admin/leads/LeadTable"; +import { CreateLeadModal } from "@/components/admin/leads/LeadForm"; +import { Button } from "@/components/ui/button"; + +async function LeadsList() { + const leads = await getAllLeads(); + + return ( +
+
+

Lead Pipeline

+ +
+ + Loading...
}> + + + + ); +} + +export default function LeadsPage() { + return ; +} diff --git a/src/components/admin/leads/LeadDetail.tsx b/src/components/admin/leads/LeadDetail.tsx new file mode 100644 index 0000000..a5fe2b5 --- /dev/null +++ b/src/components/admin/leads/LeadDetail.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { Lead, Activity, Reminder } from "@/db/schema"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { formatDistanceToNow, format } from "date-fns"; +import { it } from "date-fns/locale"; +import { EditLeadModal } from "./LeadForm"; + +const ACTIVITY_ICON: Record = { + call: "πŸ“ž", + email: "πŸ“§", + meeting: "πŸ“…", + note: "πŸ“", +}; + +const STAGE_COLOR: Record = { + contacted: "bg-blue-100 text-blue-800", + qualified: "bg-purple-100 text-purple-800", + proposal_sent: "bg-amber-100 text-amber-800", + negotiating: "bg-orange-100 text-orange-800", + won: "bg-green-100 text-green-800", + lost: "bg-red-100 text-red-800", +}; + +export function LeadDetail({ + lead, + activities, + reminders, +}: { + lead: Lead; + activities: Activity[]; + reminders: Reminder[]; +}) { + return ( +
+ {/* Header + Actions */} +
+
+

{lead.name}

+

{lead.company || "Azienda non specificata"}

+
+
+ + +
+
+ +
+ {/* Lead Profile Card */} + + + Profilo + + +
+ + + {lead.status.replace(/_/g, " ")} + +
+
+ +

{lead.email || "β€”"}

+
+
+ +

{lead.phone || "β€”"}

+
+
+ +

+ {lead.last_contact_date + ? formatDistanceToNow(new Date(lead.last_contact_date), { + addSuffix: true, + locale: it, + }) + : "β€”"} +

+
+
+ +

{lead.next_action || "β€”"}

+
+
+
+ + {/* Upcoming Reminders Card */} + + + Prossimi Follow-up + + + {reminders.length > 0 ? ( +
    + {reminders.map((r) => ( +
  • +

    {r.title}

    +

    + {format(new Date(r.due_date), "dd MMM yyyy", { locale: it })} +

    +
  • + ))} +
+ ) : ( +

Nessun reminder

+ )} +
+
+ + {/* Notes Card */} + + + Note + + +

{lead.notes || "Nessuna nota"}

+
+
+
+ + {/* Activity Log */} + + + Storico AttivitΓ  + + + {activities.length > 0 ? ( +
+ {activities.map((activity) => ( +
+
+ + {ACTIVITY_ICON[activity.type] || "πŸ“‹"} + + + {activity.type} + + + {formatDistanceToNow(new Date(activity.activity_date), { + addSuffix: true, + locale: it, + })} + +
+

{activity.notes}

+ {activity.duration_minutes && ( +

+ Durata: {activity.duration_minutes} minuti +

+ )} +
+ ))} +
+ ) : ( +

Nessuna attivitΓ  registrata

+ )} +
+
+
+ ); +} diff --git a/src/components/admin/leads/LeadForm.tsx b/src/components/admin/leads/LeadForm.tsx new file mode 100644 index 0000000..8f2bae8 --- /dev/null +++ b/src/components/admin/leads/LeadForm.tsx @@ -0,0 +1,342 @@ +"use client"; + +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { createLeadSchema, updateLeadSchema, LEAD_STAGES } from "@/lib/lead-validators"; +import { Lead } from "@/db/schema"; +import { createLead, updateLead } from "@/app/admin/leads/actions"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Button } from "@/components/ui/button"; + +type CreateLeadInput = z.infer; + +export function CreateLeadModal() { + const [open, setOpen] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const form = useForm({ + resolver: zodResolver(createLeadSchema), + defaultValues: { name: "", email: "", phone: "", company: "", status: "contacted", notes: "" }, + }); + + async function onSubmit(data: CreateLeadInput) { + setIsSubmitting(true); + try { + const result = await createLead(data); + if (result.success) { + setOpen(false); + form.reset(); + } else { + form.setError("root", { message: result.error }); + } + } finally { + setIsSubmitting(false); + } + } + + return ( + + + + + + + Nuovo Lead + +
+ + ( + + Nome + + + + + + )} + /> + + ( + + Email + + + + + + )} + /> + + ( + + Telefono + + + + + + )} + /> + + ( + + Azienda + + + + + + )} + /> + + ( + + Stato + + + + )} + /> + + ( + + Note + +