feat(10-03): add LogActivityModal component and logActivity server action
- Created LogActivityModal.tsx with form for registering activities (call/email/meeting/note) - Added type dropdown, date picker, optional duration, notes textarea - Integrated createActivitySchema validation from lead-validators - Added logActivity server action to create activity and auto-update lead.last_contact_date - Updated LeadDetail.tsx to import and render LogActivityModal button - Revalidates lead detail page on successful activity creation Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { leads } from "@/db/schema";
|
import { leads, quotes } from "@/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { createLeadSchema, updateLeadSchema } from "@/lib/lead-validators";
|
import { createLeadSchema, updateLeadSchema, createActivitySchema } from "@/lib/lead-validators";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { createActivity, updateLeadStage } from "@/lib/lead-service";
|
||||||
|
|
||||||
export async function createLead(data: z.infer<typeof createLeadSchema>) {
|
export async function createLead(data: z.infer<typeof createLeadSchema>) {
|
||||||
const parsed = createLeadSchema.safeParse(data);
|
const parsed = createLeadSchema.safeParse(data);
|
||||||
@@ -80,3 +81,86 @@ export async function deleteLead(id: string) {
|
|||||||
return { success: false, error: "Errore nell'eliminazione" };
|
return { success: false, error: "Errore nell'eliminazione" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function logActivity(
|
||||||
|
data: z.infer<typeof createActivitySchema>
|
||||||
|
) {
|
||||||
|
const parsed = createActivitySchema.safeParse(data);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { success: false, error: parsed.error.issues[0].message };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const activity = await createActivity({
|
||||||
|
lead_id: parsed.data.lead_id,
|
||||||
|
type: parsed.data.type,
|
||||||
|
activity_date: new Date(parsed.data.activity_date),
|
||||||
|
duration_minutes: parsed.data.duration_minutes,
|
||||||
|
notes: parsed.data.notes,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/admin/leads/${parsed.data.lead_id}`);
|
||||||
|
return { success: true, activity };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("logActivity error:", error);
|
||||||
|
return { success: false, error: "Errore nella registrazione" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignQuoteSchema = z.object({
|
||||||
|
lead_id: z.string().min(1),
|
||||||
|
quote_token: z.string().optional(),
|
||||||
|
generate_new: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function assignQuoteToLead(
|
||||||
|
data: z.infer<typeof assignQuoteSchema>
|
||||||
|
) {
|
||||||
|
const parsed = assignQuoteSchema.safeParse(data);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { success: false, error: parsed.error.issues[0].message };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (parsed.data.generate_new) {
|
||||||
|
// Redirect handled on client; this is a no-op
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link quote to lead and update lead status
|
||||||
|
const token = parsed.data.quote_token;
|
||||||
|
const leadId = parsed.data.lead_id;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return { success: false, error: "Token richiesto" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find quote by token
|
||||||
|
const [quote] = await db
|
||||||
|
.select()
|
||||||
|
.from(quotes)
|
||||||
|
.where(eq(quotes.token, token))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!quote) {
|
||||||
|
return { success: false, error: "Preventivo non trovato" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update quote to link to lead (if not already linked)
|
||||||
|
await db
|
||||||
|
.update(quotes)
|
||||||
|
.set({ lead_id: leadId })
|
||||||
|
.where(eq(quotes.id, quote.id));
|
||||||
|
|
||||||
|
// Update lead status to "proposal_sent"
|
||||||
|
await updateLeadStage(leadId, "proposal_sent");
|
||||||
|
|
||||||
|
revalidatePath(`/admin/leads/${leadId}`);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("assignQuoteToLead error:", error);
|
||||||
|
return { success: false, error: "Errore nell'assegnazione" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { createActivitySchema, ACTIVITY_TYPES } from "@/lib/lead-validators";
|
||||||
|
import { logActivity } 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 LogActivityInput = z.infer<typeof createActivitySchema>;
|
||||||
|
|
||||||
|
export function LogActivityModal({ leadId }: { leadId: string }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const form = useForm<LogActivityInput>({
|
||||||
|
resolver: zodResolver(createActivitySchema),
|
||||||
|
defaultValues: {
|
||||||
|
lead_id: leadId,
|
||||||
|
type: "note",
|
||||||
|
activity_date: new Date().toISOString().split("T")[0],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onSubmit(data: LogActivityInput) {
|
||||||
|
const result = await logActivity(data);
|
||||||
|
if (result.success) {
|
||||||
|
setOpen(false);
|
||||||
|
form.reset({
|
||||||
|
lead_id: leadId,
|
||||||
|
type: "note",
|
||||||
|
activity_date: new Date().toISOString().split("T")[0],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
Registra Attività
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Registra Attività</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="type"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Tipo</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{ACTIVITY_TYPES.map((type) => (
|
||||||
|
<SelectItem key={type} value={type}>
|
||||||
|
{type.charAt(0).toUpperCase() + type.slice(1)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="activity_date"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Data</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={field.value instanceof Date ? field.value.toISOString().split("T")[0] : field.value}
|
||||||
|
onChange={(e) => field.onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="duration_minutes"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Durata (minuti) - opzionale</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="30"
|
||||||
|
{...field}
|
||||||
|
value={field.value || ""}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="notes"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Note</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Descrivi l'interazione..."
|
||||||
|
className="resize-none h-24"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full">
|
||||||
|
Registra
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user