mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-23 23:51:11 -05:00
feat: Implement ticket merging
This commit is contained in:
@@ -25,6 +25,7 @@ import {
|
||||
ArchiveRestore,
|
||||
Trash2,
|
||||
AlertTriangle,
|
||||
Merge,
|
||||
} from 'lucide-react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -67,6 +68,10 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { DialogFooter } from '@/components/ui/dialog';
|
||||
import { useTheme } from '@/components/theme/ThemeProvider';
|
||||
import { useUserRole } from '@/hooks/useUserRole';
|
||||
import { handleError, handleSuccess } from '@/lib/errorHandler';
|
||||
@@ -110,6 +115,7 @@ interface ContactSubmission {
|
||||
ticket_number: string;
|
||||
archived_at: string | null;
|
||||
archived_by: string | null;
|
||||
merged_ticket_numbers: string[] | null;
|
||||
}
|
||||
|
||||
interface EmailThread {
|
||||
@@ -141,6 +147,13 @@ export default function AdminContact() {
|
||||
const [activeTab, setActiveTab] = useState<string>('details');
|
||||
const [replyStatus, setReplyStatus] = useState<string>('');
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
|
||||
// Merge mode state
|
||||
const [selectedForMerge, setSelectedForMerge] = useState<string[]>([]);
|
||||
const [mergeMode, setMergeMode] = useState(false);
|
||||
const [showMergeDialog, setShowMergeDialog] = useState(false);
|
||||
const [primaryTicketId, setPrimaryTicketId] = useState<string | null>(null);
|
||||
const [mergeReason, setMergeReason] = useState('');
|
||||
|
||||
// Fetch contact submissions
|
||||
const { data: submissions, isLoading } = useQuery({
|
||||
@@ -451,6 +464,57 @@ export default function AdminContact() {
|
||||
});
|
||||
};
|
||||
|
||||
// Merge tickets mutation
|
||||
const mergeTicketsMutation = useMutation({
|
||||
mutationFn: async ({ primaryId, mergeIds, reason }: {
|
||||
primaryId: string;
|
||||
mergeIds: string[];
|
||||
reason?: string;
|
||||
}) => {
|
||||
const { data, error } = await invokeWithTracking(
|
||||
'merge-contact-tickets',
|
||||
{
|
||||
primaryTicketId: primaryId,
|
||||
mergeTicketIds: mergeIds,
|
||||
mergeReason: reason
|
||||
},
|
||||
undefined
|
||||
);
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
onSuccess: (data: any) => {
|
||||
handleSuccess(
|
||||
'Tickets Merged Successfully',
|
||||
`Merged ${data.mergedCount} tickets into ${data.primaryTicketNumber}. ${data.threadsConsolidated} email threads consolidated.`
|
||||
);
|
||||
|
||||
// Reset merge mode
|
||||
setMergeMode(false);
|
||||
setSelectedForMerge([]);
|
||||
setShowMergeDialog(false);
|
||||
setPrimaryTicketId(null);
|
||||
setMergeReason('');
|
||||
|
||||
// Refresh data
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-contact-submissions'] });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
handleError(error, { action: 'Merge Tickets' });
|
||||
}
|
||||
});
|
||||
|
||||
const handleConfirmMerge = () => {
|
||||
if (!primaryTicketId || selectedForMerge.length < 2) return;
|
||||
|
||||
const mergeIds = selectedForMerge.filter(id => id !== primaryTicketId);
|
||||
mergeTicketsMutation.mutate({
|
||||
primaryId: primaryTicketId,
|
||||
mergeIds,
|
||||
reason: mergeReason || undefined
|
||||
});
|
||||
};
|
||||
|
||||
// Show loading state while roles are being fetched
|
||||
if (rolesLoading) {
|
||||
return (
|
||||
@@ -536,6 +600,17 @@ export default function AdminContact() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={mergeMode ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setMergeMode(!mergeMode);
|
||||
setSelectedForMerge([]);
|
||||
}}
|
||||
>
|
||||
<Merge className="h-4 w-4 mr-2" />
|
||||
{mergeMode ? 'Cancel Merge' : 'Merge Tickets'}
|
||||
</Button>
|
||||
<Button
|
||||
variant={showArchived ? "default" : "outline"}
|
||||
onClick={() => setShowArchived(!showArchived)}
|
||||
@@ -657,12 +732,29 @@ export default function AdminContact() {
|
||||
key={submission.id}
|
||||
className="cursor-pointer hover:border-primary transition-colors"
|
||||
onClick={() => {
|
||||
setSelectedSubmission(submission);
|
||||
setAdminNotes(submission.admin_notes || '');
|
||||
if (!mergeMode) {
|
||||
setSelectedSubmission(submission);
|
||||
setAdminNotes(submission.admin_notes || '');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
{mergeMode && (
|
||||
<div className="pt-1">
|
||||
<Checkbox
|
||||
checked={selectedForMerge.includes(submission.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
setSelectedForMerge(prev =>
|
||||
checked
|
||||
? [...prev, submission.id]
|
||||
: prev.filter(id => id !== submission.id)
|
||||
);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h3 className="font-semibold text-lg">{submission.subject}</h3>
|
||||
@@ -688,6 +780,12 @@ export default function AdminContact() {
|
||||
Archived
|
||||
</Badge>
|
||||
)}
|
||||
{submission.merged_ticket_numbers && submission.merged_ticket_numbers.length > 0 && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Merge className="h-3 w-3" />
|
||||
Consolidated ({submission.merged_ticket_numbers.length})
|
||||
</Badge>
|
||||
)}
|
||||
{submission.response_count > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
📧 {submission.response_count} {submission.response_count === 1 ? 'reply' : 'replies'}
|
||||
@@ -712,19 +810,21 @@ export default function AdminContact() {
|
||||
</div>
|
||||
<p className="text-sm line-clamp-2">{submission.message}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => handleQuickReply(e, submission)}
|
||||
disabled={sendReplyMutation.isPending}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Reply className="h-4 w-4" />
|
||||
Reply
|
||||
</Button>
|
||||
<ChevronDown className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
{!mergeMode && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => handleQuickReply(e, submission)}
|
||||
disabled={sendReplyMutation.isPending}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Reply className="h-4 w-4" />
|
||||
Reply
|
||||
</Button>
|
||||
<ChevronDown className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -741,6 +841,113 @@ export default function AdminContact() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Floating Merge Button */}
|
||||
{mergeMode && selectedForMerge.length >= 2 && (
|
||||
<div className="fixed bottom-6 right-6 z-50">
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => {
|
||||
setPrimaryTicketId(selectedForMerge[0]); // Default to first selected
|
||||
setShowMergeDialog(true);
|
||||
}}
|
||||
className="shadow-lg"
|
||||
>
|
||||
<Merge className="h-5 w-5 mr-2" />
|
||||
Merge Selected ({selectedForMerge.length})
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Merge Confirmation Dialog */}
|
||||
<Dialog open={showMergeDialog} onOpenChange={setShowMergeDialog}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Merge Contact Tickets</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose a primary ticket to keep. All others will be permanently deleted after their data is merged.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* List selected tickets with radio buttons */}
|
||||
<div className="space-y-2">
|
||||
{selectedForMerge.map(ticketId => {
|
||||
const ticket = submissions?.find(s => s.id === ticketId);
|
||||
if (!ticket) return null;
|
||||
|
||||
return (
|
||||
<div key={ticketId} className="flex items-start gap-3 p-3 border rounded">
|
||||
<input
|
||||
type="radio"
|
||||
name="primary"
|
||||
value={ticketId}
|
||||
checked={primaryTicketId === ticketId}
|
||||
onChange={() => setPrimaryTicketId(ticketId)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{ticket.ticket_number}</div>
|
||||
<div className="text-sm text-muted-foreground">{ticket.subject}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{ticket.email} • {ticket.response_count} email threads
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Optional merge reason */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="merge-reason">Reason for merge (optional)</Label>
|
||||
<Textarea
|
||||
id="merge-reason"
|
||||
placeholder="e.g., Duplicate submissions about the same issue"
|
||||
value={mergeReason}
|
||||
onChange={(e) => setMergeReason(e.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Summary Alert */}
|
||||
<Alert>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<strong>This will:</strong>
|
||||
<ul className="list-disc pl-5 mt-2 space-y-1">
|
||||
<li>Keep <strong>{submissions?.find(s => s.id === primaryTicketId)?.ticket_number}</strong> active</li>
|
||||
<li>Move all email threads to primary ticket</li>
|
||||
<li>Consolidate admin notes</li>
|
||||
<li><strong className="text-destructive">Permanently delete {selectedForMerge.length - 1} other ticket(s)</strong></li>
|
||||
</ul>
|
||||
<p className="mt-2 font-semibold">This action cannot be undone.</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowMergeDialog(false);
|
||||
setMergeReason('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleConfirmMerge}
|
||||
disabled={!primaryTicketId || mergeTicketsMutation.isPending}
|
||||
>
|
||||
{mergeTicketsMutation.isPending ? (
|
||||
<><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Merging...</>
|
||||
) : (
|
||||
'Confirm Merge & Delete'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Submission Detail Dialog */}
|
||||
<Dialog
|
||||
open={!!selectedSubmission}
|
||||
@@ -930,6 +1137,31 @@ export default function AdminContact() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Merge History Section */}
|
||||
{selectedSubmission.merged_ticket_numbers && selectedSubmission.merged_ticket_numbers.length > 0 && (
|
||||
<div className="mt-6 pt-6 border-t">
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger className="flex items-center gap-2 text-sm font-semibold hover:text-primary transition-colors">
|
||||
<Merge className="h-4 w-4" />
|
||||
Merge History ({selectedSubmission.merged_ticket_numbers.length} tickets consolidated)
|
||||
<ChevronDown className="h-4 w-4 ml-1" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="mt-3 space-y-1 text-sm text-muted-foreground">
|
||||
<p className="mb-2">The following tickets were merged and deleted into this primary ticket:</p>
|
||||
{selectedSubmission.merged_ticket_numbers.map(ticketNum => (
|
||||
<div key={ticketNum} className="flex items-center gap-2 py-1">
|
||||
<XCircle className="h-3 w-3 text-destructive" />
|
||||
<span className="font-mono">{ticketNum}</span>
|
||||
<span className="text-xs">(deleted)</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Danger Zone - Archive/Delete */}
|
||||
<div className="mt-8 pt-6 border-t border-destructive/20">
|
||||
<h4 className="text-sm font-semibold mb-3 text-destructive flex items-center gap-2">
|
||||
|
||||
Reference in New Issue
Block a user