Merge branch 'main' of https://github.com/ArnasDon/wacrm into feat/inbox-template-picker

# Conflicts:
#	src/components/inbox/message-thread.tsx
This commit is contained in:
Arnas Donauskas 2026-05-18 15:43:39 +03:00
commit fbb95eb2a7
No known key found for this signature in database
3 changed files with 136 additions and 47 deletions

View File

@ -256,6 +256,26 @@ export default function InboxPage() {
[activeConversation] [activeConversation]
); );
const handleAssignChange = useCallback(
(conversationId: string, assignedAgentId: string | null) => {
setConversations((prev) =>
prev.map((c) =>
c.id === conversationId
? { ...c, assigned_agent_id: assignedAgentId ?? undefined }
: c
)
);
if (activeConversation?.id === conversationId) {
setActiveConversation((prev) =>
prev
? { ...prev, assigned_agent_id: assignedAgentId ?? undefined }
: prev
);
}
},
[activeConversation]
);
// On mobile (<lg) we show a SINGLE pane — either the list or the // On mobile (<lg) we show a SINGLE pane — either the list or the
// thread — rather than cramming both side-by-side. Selecting a // thread — rather than cramming both side-by-side. Selecting a
// conversation slides the thread in; the thread's back button pops // conversation slides the thread in; the thread's back button pops
@ -312,6 +332,7 @@ export default function InboxPage() {
onNewMessage={handleNewMessage} onNewMessage={handleNewMessage}
onUpdateMessage={handleUpdateMessage} onUpdateMessage={handleUpdateMessage}
onStatusChange={handleStatusChange} onStatusChange={handleStatusChange}
onAssignChange={handleAssignChange}
onBack={handleCloseConversation} onBack={handleCloseConversation}
/> />
</div> </div>

View File

@ -14,7 +14,7 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { ArrowLeft, Send, Clock, Loader2, Users, Lock, Save } from 'lucide-react'; import { ArrowLeft, Send, Loader2, Users, Save } from 'lucide-react';
interface AudienceConfig { interface AudienceConfig {
type: string; type: string;
@ -45,7 +45,6 @@ export function Step4ScheduleSend({
isProcessing, isProcessing,
progress, progress,
}: Step4Props) { }: Step4Props) {
const [timing, setTiming] = useState<'now' | 'later'>('now');
const [showConfirm, setShowConfirm] = useState(false); const [showConfirm, setShowConfirm] = useState(false);
const [estimatedReach, setEstimatedReach] = useState<number>(0); const [estimatedReach, setEstimatedReach] = useState<number>(0);
const [loadingReach, setLoadingReach] = useState(true); const [loadingReach, setLoadingReach] = useState(true);
@ -143,41 +142,6 @@ export function Step4ScheduleSend({
</div> </div>
</div> </div>
{/* Delivery Timing */}
<div className="space-y-3">
<p className="text-sm font-medium text-white">Delivery Timing</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<button
onClick={() => setTiming('now')}
className={`flex items-start gap-3 rounded-xl border p-4 text-left transition-all ${
timing === 'now'
? 'border-violet-500 bg-violet-500/5 ring-1 ring-violet-500/30'
: 'border-slate-800 bg-slate-900/50 hover:border-slate-700'
}`}
>
<Send className={`mt-0.5 h-4 w-4 ${timing === 'now' ? 'text-violet-400' : 'text-slate-400'}`} />
<div>
<p className="text-sm font-medium text-white">Send Immediately</p>
<p className="mt-0.5 text-xs text-slate-400">Start sending right away</p>
</div>
</button>
<div className="relative flex items-start gap-3 rounded-xl border border-slate-800 bg-slate-900/30 p-4 opacity-60">
<Clock className="mt-0.5 h-4 w-4 text-slate-500" />
<div>
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-slate-400">Schedule for Later</p>
<span className="inline-flex items-center gap-1 rounded-full bg-slate-800 px-2 py-0.5 text-[10px] font-medium text-slate-400">
<Lock className="h-2.5 w-2.5" />
Pro
</span>
</div>
<p className="mt-0.5 text-xs text-slate-500">Pick a date and time</p>
</div>
</div>
</div>
</div>
{/* Processing overlay */} {/* Processing overlay */}
{isProcessing && ( {isProcessing && (
<div className="rounded-xl border border-violet-500/20 bg-violet-500/5 p-4"> <div className="rounded-xl border border-violet-500/20 bg-violet-500/5 p-4">

View File

@ -2,6 +2,7 @@
import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { createClient } from "@/lib/supabase/client"; import { createClient } from "@/lib/supabase/client";
import { useAuth } from "@/hooks/use-auth";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { import type {
Conversation, Conversation,
@ -9,21 +10,23 @@ import type {
Contact, Contact,
ConversationStatus, ConversationStatus,
MessageTemplate, MessageTemplate,
Profile,
} from "@/types"; } from "@/types";
import { import {
MessageSquare, MessageSquare,
ChevronDown, ChevronDown,
UserPlus, UserPlus,
Check,
Clock, Clock,
ArrowLeft, ArrowLeft,
} from "lucide-react"; } from "lucide-react";
import { format, isToday, isYesterday, differenceInHours } from "date-fns"; import { format, isToday, isYesterday, differenceInHours } from "date-fns";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
@ -47,6 +50,10 @@ interface MessageThreadProps {
onNewMessage: (message: Message) => void; onNewMessage: (message: Message) => void;
onUpdateMessage: (id: string, updates: Partial<Message>) => void; onUpdateMessage: (id: string, updates: Partial<Message>) => void;
onStatusChange: (conversationId: string, status: ConversationStatus) => void; onStatusChange: (conversationId: string, status: ConversationStatus) => void;
onAssignChange: (
conversationId: string,
assignedAgentId: string | null,
) => void;
/** /**
* On mobile, the thread is shown full-screen with the conversation list * On mobile, the thread is shown full-screen with the conversation list
* hidden. This callback lets the page deselect the active conversation * hidden. This callback lets the page deselect the active conversation
@ -94,11 +101,37 @@ export function MessageThread({
onNewMessage, onNewMessage,
onUpdateMessage, onUpdateMessage,
onStatusChange, onStatusChange,
onAssignChange,
onBack, onBack,
}: MessageThreadProps) { }: MessageThreadProps) {
const { user } = useAuth();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const [templateModalOpen, setTemplateModalOpen] = useState(false); const [templateModalOpen, setTemplateModalOpen] = useState(false);
const [profiles, setProfiles] = useState<Profile[]>([]);
// Profiles are bounded by RLS to rows the current user is allowed to
// see — today that's just the current user, but the dropdown keeps the
// shape ready for shared-team workspaces without a refactor.
useEffect(() => {
let cancelled = false;
const supabase = createClient();
supabase
.from("profiles")
.select("*")
.order("full_name")
.then(({ data, error }) => {
if (cancelled) return;
if (error) {
console.error("Failed to fetch profiles:", error);
return;
}
setProfiles((data as Profile[]) ?? []);
});
return () => {
cancelled = true;
};
}, []);
// 24-hour session timer // 24-hour session timer
const sessionInfo = useMemo(() => { const sessionInfo = useMemo(() => {
@ -332,6 +365,27 @@ export function MessageThread({
[conversation, onNewMessage, onUpdateMessage], [conversation, onNewMessage, onUpdateMessage],
); );
const handleAssignChange = useCallback(
async (agentId: string | null) => {
if (!conversation) return;
const supabase = createClient();
const { error } = await supabase
.from("conversations")
.update({ assigned_agent_id: agentId })
.eq("id", conversation.id);
if (error) {
console.error("Failed to update assignment:", error);
toast.error("Failed to update assignment");
return;
}
onAssignChange(conversation.id, agentId);
},
[conversation, onAssignChange],
);
// Empty state // Empty state
if (!conversation || !contact) { if (!conversation || !contact) {
return ( return (
@ -354,6 +408,11 @@ export function MessageThread({
const currentStatus = STATUS_OPTIONS.find( const currentStatus = STATUS_OPTIONS.find(
(s) => s.value === conversation.status (s) => s.value === conversation.status
); );
const assignedAgentId = conversation.assigned_agent_id ?? null;
const currentAssignee = profiles.find((p) => p.user_id === assignedAgentId);
const assignLabel = assignedAgentId
? (currentAssignee?.full_name ?? "Assigned")
: "Assign";
return ( return (
<div className="flex flex-1 flex-col bg-slate-950"> <div className="flex flex-1 flex-col bg-slate-950">
@ -419,15 +478,60 @@ export function MessageThread({
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{/* Assign button */} {/* Assign dropdown */}
<Button <DropdownMenu>
variant="ghost" <DropdownMenuTrigger
size="sm" className={cn(
className="h-7 gap-1 text-xs text-slate-400 hover:text-white" "inline-flex items-center justify-center h-7 gap-1 px-2 text-xs rounded-md hover:bg-slate-800",
> assignedAgentId ? "text-violet-400" : "text-slate-400"
<UserPlus className="h-3 w-3" /> )}
Assign >
</Button> <UserPlus className="h-3 w-3" />
<span className="hidden sm:inline">{assignLabel}</span>
<ChevronDown className="h-3 w-3" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="border-slate-700 bg-slate-800"
>
{profiles.length === 0 ? (
<DropdownMenuItem disabled className="text-sm text-slate-500">
No teammates available
</DropdownMenuItem>
) : (
profiles.map((p) => {
const isSelected = p.user_id === assignedAgentId;
return (
<DropdownMenuItem
key={p.id}
onClick={() => handleAssignChange(p.user_id)}
className={cn(
"text-sm",
isSelected ? "text-violet-400" : "text-slate-300"
)}
>
<span className="flex-1">
{p.full_name}
{p.user_id === user?.id ? " (me)" : ""}
</span>
{isSelected && <Check className="ml-2 h-3 w-3" />}
</DropdownMenuItem>
);
})
)}
{assignedAgentId && (
<>
<DropdownMenuSeparator className="bg-slate-700" />
<DropdownMenuItem
onClick={() => handleAssignChange(null)}
className="text-sm text-slate-400"
>
Unassign
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</div> </div>