Skip to content

Commit d2e1402

Browse files
Add CRM tasks and timeline
Implemented new CRM features: - Introduced CrmTasksCard UI with create, complete, delete, and prioritized tasks (parallel to existing CRM). - Added useCrmTasks hook and supporting CRUD operations for crm_tasks. - Built UnifiedTimeline and CrmTasks UI to merge cross-channel events and task data. - Added SendEmailDialog and edge function for 1:1 emails via Resend. - Updated LeadDetailPage to integrate unified timeline, CRM tasks, and email sending. - Created CrmTasksCard and UnifiedTimeline components for admin CRM view. X-Lovable-Edit-ID: edt-8b15e115-e4f8-46a5-a847-59fd23747a47 Co-authored-by: magnusfroste <38864257+magnusfroste@users.noreply.github.com>
2 parents bcd84dc + f10e630 commit d2e1402

File tree

9 files changed

+1130
-141
lines changed

9 files changed

+1130
-141
lines changed
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
import { useState } from 'react';
2+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
3+
import { Button } from '@/components/ui/button';
4+
import { Badge } from '@/components/ui/badge';
5+
import { Input } from '@/components/ui/input';
6+
import { Textarea } from '@/components/ui/textarea';
7+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
8+
import { Checkbox } from '@/components/ui/checkbox';
9+
import {
10+
Plus, CheckCircle2, Clock, AlertTriangle, Trash2, Calendar
11+
} from 'lucide-react';
12+
import { formatDistanceToNow, format, isPast, isToday } from 'date-fns';
13+
import { cn } from '@/lib/utils';
14+
import {
15+
useCrmTasks, useCreateCrmTask, useCompleteCrmTask, useDeleteCrmTask,
16+
type CrmTask
17+
} from '@/hooks/useCrmTasks';
18+
19+
interface CrmTasksCardProps {
20+
leadId?: string;
21+
dealId?: string;
22+
}
23+
24+
const PRIORITY_CONFIG = {
25+
low: { label: 'Low', color: 'bg-muted text-muted-foreground' },
26+
medium: { label: 'Medium', color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' },
27+
high: { label: 'High', color: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' },
28+
urgent: { label: 'Urgent', color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' },
29+
};
30+
31+
export function CrmTasksCard({ leadId, dealId }: CrmTasksCardProps) {
32+
const { data: tasks = [], isLoading } = useCrmTasks({ leadId, dealId });
33+
const createTask = useCreateCrmTask();
34+
const completeTask = useCompleteCrmTask();
35+
const deleteTask = useDeleteCrmTask();
36+
37+
const [isAdding, setIsAdding] = useState(false);
38+
const [title, setTitle] = useState('');
39+
const [description, setDescription] = useState('');
40+
const [dueDate, setDueDate] = useState('');
41+
const [priority, setPriority] = useState('medium');
42+
43+
const handleCreate = () => {
44+
if (!title.trim()) return;
45+
createTask.mutate({
46+
title: title.trim(),
47+
description: description.trim() || undefined,
48+
due_date: dueDate || undefined,
49+
priority,
50+
lead_id: leadId,
51+
deal_id: dealId,
52+
}, {
53+
onSuccess: () => {
54+
setTitle('');
55+
setDescription('');
56+
setDueDate('');
57+
setPriority('medium');
58+
setIsAdding(false);
59+
},
60+
});
61+
};
62+
63+
const overdueTasks = tasks.filter(t => t.due_date && isPast(new Date(t.due_date)) && !isToday(new Date(t.due_date)));
64+
const todayTasks = tasks.filter(t => t.due_date && isToday(new Date(t.due_date)));
65+
const upcomingTasks = tasks.filter(t => !overdueTasks.includes(t) && !todayTasks.includes(t));
66+
67+
return (
68+
<Card>
69+
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
70+
<CardTitle className="text-base flex items-center gap-2">
71+
<CheckCircle2 className="h-4 w-4" />
72+
Tasks
73+
{tasks.length > 0 && (
74+
<Badge variant="secondary" className="text-xs">{tasks.length}</Badge>
75+
)}
76+
</CardTitle>
77+
{!isAdding && (
78+
<Button variant="outline" size="sm" onClick={() => setIsAdding(true)}>
79+
<Plus className="h-4 w-4 mr-1" />
80+
Add
81+
</Button>
82+
)}
83+
</CardHeader>
84+
<CardContent className="space-y-3">
85+
{/* Add form */}
86+
{isAdding && (
87+
<div className="border rounded-lg p-4 space-y-3 bg-muted/30">
88+
<Input
89+
placeholder="Task title..."
90+
value={title}
91+
onChange={(e) => setTitle(e.target.value)}
92+
autoFocus
93+
/>
94+
<Textarea
95+
placeholder="Description (optional)"
96+
value={description}
97+
onChange={(e) => setDescription(e.target.value)}
98+
rows={2}
99+
/>
100+
<div className="flex gap-2">
101+
<Input
102+
type="datetime-local"
103+
value={dueDate}
104+
onChange={(e) => setDueDate(e.target.value)}
105+
className="flex-1"
106+
/>
107+
<Select value={priority} onValueChange={setPriority}>
108+
<SelectTrigger className="w-28">
109+
<SelectValue />
110+
</SelectTrigger>
111+
<SelectContent>
112+
<SelectItem value="low">Low</SelectItem>
113+
<SelectItem value="medium">Medium</SelectItem>
114+
<SelectItem value="high">High</SelectItem>
115+
<SelectItem value="urgent">Urgent</SelectItem>
116+
</SelectContent>
117+
</Select>
118+
</div>
119+
<div className="flex gap-2 justify-end">
120+
<Button variant="ghost" size="sm" onClick={() => setIsAdding(false)}>
121+
Cancel
122+
</Button>
123+
<Button size="sm" onClick={handleCreate} disabled={!title.trim() || createTask.isPending}>
124+
{createTask.isPending ? 'Creating...' : 'Create Task'}
125+
</Button>
126+
</div>
127+
</div>
128+
)}
129+
130+
{isLoading ? (
131+
<p className="text-sm text-muted-foreground">Loading...</p>
132+
) : tasks.length === 0 && !isAdding ? (
133+
<p className="text-sm text-muted-foreground">No tasks yet</p>
134+
) : (
135+
<>
136+
{overdueTasks.length > 0 && (
137+
<div className="space-y-2">
138+
<p className="text-xs font-medium text-red-500 flex items-center gap-1">
139+
<AlertTriangle className="h-3 w-3" />
140+
Overdue
141+
</p>
142+
{overdueTasks.map(task => (
143+
<TaskItem
144+
key={task.id}
145+
task={task}
146+
onComplete={() => completeTask.mutate(task.id)}
147+
onDelete={() => deleteTask.mutate(task.id)}
148+
isOverdue
149+
/>
150+
))}
151+
</div>
152+
)}
153+
{todayTasks.length > 0 && (
154+
<div className="space-y-2">
155+
<p className="text-xs font-medium text-amber-500">Today</p>
156+
{todayTasks.map(task => (
157+
<TaskItem
158+
key={task.id}
159+
task={task}
160+
onComplete={() => completeTask.mutate(task.id)}
161+
onDelete={() => deleteTask.mutate(task.id)}
162+
/>
163+
))}
164+
</div>
165+
)}
166+
{upcomingTasks.length > 0 && (
167+
<div className="space-y-2">
168+
{(overdueTasks.length > 0 || todayTasks.length > 0) && (
169+
<p className="text-xs font-medium text-muted-foreground">Upcoming</p>
170+
)}
171+
{upcomingTasks.map(task => (
172+
<TaskItem
173+
key={task.id}
174+
task={task}
175+
onComplete={() => completeTask.mutate(task.id)}
176+
onDelete={() => deleteTask.mutate(task.id)}
177+
/>
178+
))}
179+
</div>
180+
)}
181+
</>
182+
)}
183+
</CardContent>
184+
</Card>
185+
);
186+
}
187+
188+
function TaskItem({
189+
task,
190+
onComplete,
191+
onDelete,
192+
isOverdue = false,
193+
}: {
194+
task: CrmTask;
195+
onComplete: () => void;
196+
onDelete: () => void;
197+
isOverdue?: boolean;
198+
}) {
199+
const priorityConfig = PRIORITY_CONFIG[task.priority as keyof typeof PRIORITY_CONFIG] || PRIORITY_CONFIG.medium;
200+
201+
return (
202+
<div className={cn(
203+
"flex items-start gap-3 p-3 rounded-lg border transition-colors hover:bg-muted/50",
204+
isOverdue && "border-red-500/30 bg-red-500/5"
205+
)}>
206+
<Checkbox
207+
className="mt-0.5"
208+
onCheckedChange={() => onComplete()}
209+
/>
210+
<div className="flex-1 min-w-0">
211+
<p className="text-sm font-medium">{task.title}</p>
212+
{task.description && (
213+
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{task.description}</p>
214+
)}
215+
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
216+
<Badge variant="secondary" className={cn("text-xs", priorityConfig.color)}>
217+
{priorityConfig.label}
218+
</Badge>
219+
{task.due_date && (
220+
<span className={cn(
221+
"text-xs flex items-center gap-1",
222+
isOverdue ? "text-red-500 font-medium" : "text-muted-foreground"
223+
)}>
224+
<Clock className="h-3 w-3" />
225+
{format(new Date(task.due_date), 'MMM d, HH:mm')}
226+
</span>
227+
)}
228+
</div>
229+
</div>
230+
<Button
231+
variant="ghost"
232+
size="icon"
233+
className="h-7 w-7 text-muted-foreground hover:text-destructive"
234+
onClick={onDelete}
235+
>
236+
<Trash2 className="h-3.5 w-3.5" />
237+
</Button>
238+
</div>
239+
);
240+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { useState } from 'react';
2+
import {
3+
Dialog,
4+
DialogContent,
5+
DialogDescription,
6+
DialogFooter,
7+
DialogHeader,
8+
DialogTitle,
9+
} from '@/components/ui/dialog';
10+
import { Button } from '@/components/ui/button';
11+
import { Input } from '@/components/ui/input';
12+
import { Label } from '@/components/ui/label';
13+
import { Textarea } from '@/components/ui/textarea';
14+
import { Mail, Send } from 'lucide-react';
15+
import { supabase } from '@/integrations/supabase/client';
16+
import { toast } from 'sonner';
17+
18+
interface SendEmailDialogProps {
19+
open: boolean;
20+
onOpenChange: (open: boolean) => void;
21+
recipientEmail: string;
22+
recipientName?: string;
23+
}
24+
25+
export function SendEmailDialog({ open, onOpenChange, recipientEmail, recipientName }: SendEmailDialogProps) {
26+
const [subject, setSubject] = useState('');
27+
const [body, setBody] = useState('');
28+
const [sending, setSending] = useState(false);
29+
30+
const handleSend = async () => {
31+
if (!subject.trim() || !body.trim()) {
32+
toast.error('Subject and message are required');
33+
return;
34+
}
35+
36+
setSending(true);
37+
try {
38+
const { data, error } = await supabase.functions.invoke('send-contact-email', {
39+
body: {
40+
to: recipientEmail,
41+
toName: recipientName || undefined,
42+
subject: subject.trim(),
43+
body: body.trim(),
44+
},
45+
});
46+
47+
if (error) throw error;
48+
if (data?.error) throw new Error(data.error);
49+
50+
toast.success(`Email sent to ${recipientName || recipientEmail}`);
51+
setSubject('');
52+
setBody('');
53+
onOpenChange(false);
54+
} catch (err) {
55+
toast.error(err instanceof Error ? err.message : 'Failed to send email');
56+
} finally {
57+
setSending(false);
58+
}
59+
};
60+
61+
return (
62+
<Dialog open={open} onOpenChange={onOpenChange}>
63+
<DialogContent className="sm:max-w-[550px]">
64+
<DialogHeader>
65+
<DialogTitle className="flex items-center gap-2">
66+
<Mail className="h-5 w-5" />
67+
Send Email
68+
</DialogTitle>
69+
<DialogDescription>
70+
Send a direct email to {recipientName || recipientEmail}
71+
</DialogDescription>
72+
</DialogHeader>
73+
74+
<div className="grid gap-4 py-4">
75+
<div className="grid gap-2">
76+
<Label>To</Label>
77+
<Input value={recipientEmail} disabled className="bg-muted" />
78+
</div>
79+
<div className="grid gap-2">
80+
<Label htmlFor="subject">Subject</Label>
81+
<Input
82+
id="subject"
83+
placeholder="Email subject..."
84+
value={subject}
85+
onChange={(e) => setSubject(e.target.value)}
86+
autoFocus
87+
/>
88+
</div>
89+
<div className="grid gap-2">
90+
<Label htmlFor="body">Message</Label>
91+
<Textarea
92+
id="body"
93+
placeholder="Write your message..."
94+
value={body}
95+
onChange={(e) => setBody(e.target.value)}
96+
rows={8}
97+
/>
98+
</div>
99+
</div>
100+
101+
<DialogFooter>
102+
<Button variant="outline" onClick={() => onOpenChange(false)}>
103+
Cancel
104+
</Button>
105+
<Button
106+
onClick={handleSend}
107+
disabled={sending || !subject.trim() || !body.trim()}
108+
>
109+
<Send className="h-4 w-4 mr-2" />
110+
{sending ? 'Sending...' : 'Send Email'}
111+
</Button>
112+
</DialogFooter>
113+
</DialogContent>
114+
</Dialog>
115+
);
116+
}

0 commit comments

Comments
 (0)