mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Send push notifications when partner disconnects, resets progress, or deletes relationship. Update Matches screen with new styling and remove legacy premium elements. Remove message content preview from admin User Activity page.
674 lines
30 KiB
TypeScript
674 lines
30 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import { supabase } from '@/config';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
|
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table';
|
|
import {
|
|
Tabs,
|
|
TabsContent,
|
|
TabsList,
|
|
TabsTrigger,
|
|
} from '@/components/ui/tabs';
|
|
import { formatDistanceToNow, format } from 'date-fns';
|
|
import { toast } from 'sonner';
|
|
import {
|
|
Activity,
|
|
ThumbsUp,
|
|
ThumbsDown,
|
|
Minus,
|
|
Heart,
|
|
MessageCircle,
|
|
UserPlus,
|
|
ExternalLink,
|
|
} from 'lucide-react';
|
|
|
|
// Types
|
|
interface ResponseActivity {
|
|
id: string;
|
|
user_id: string;
|
|
answer: 'yes' | 'no' | 'maybe';
|
|
created_at: string;
|
|
profile: {
|
|
id: string;
|
|
name: string | null;
|
|
email: string | null;
|
|
} | null;
|
|
question: {
|
|
id: string;
|
|
text: string;
|
|
pack: {
|
|
name: string;
|
|
} | null;
|
|
} | null;
|
|
}
|
|
|
|
interface MatchActivity {
|
|
id: string;
|
|
couple_id: string;
|
|
match_type: 'yes_yes' | 'yes_maybe' | 'maybe_maybe';
|
|
is_new: boolean;
|
|
created_at: string;
|
|
question: {
|
|
id: string;
|
|
text: string;
|
|
} | null;
|
|
coupleProfiles?: Array<{
|
|
id: string;
|
|
name: string | null;
|
|
email: string | null;
|
|
}>;
|
|
}
|
|
|
|
interface MessageActivity {
|
|
id: string;
|
|
user_id: string;
|
|
content: string | null;
|
|
media_path: string | null;
|
|
media_type: 'image' | 'video' | null;
|
|
created_at: string;
|
|
profile: {
|
|
id: string;
|
|
name: string | null;
|
|
email: string | null;
|
|
} | null;
|
|
match: {
|
|
id: string;
|
|
question: {
|
|
text: string;
|
|
} | null;
|
|
} | null;
|
|
}
|
|
|
|
interface SignupActivity {
|
|
id: string;
|
|
name: string | null;
|
|
email: string | null;
|
|
avatar_url: string | null;
|
|
created_at: string;
|
|
onboarding_completed: boolean | null;
|
|
couple_id: string | null;
|
|
}
|
|
|
|
type ActivityTab = 'responses' | 'matches' | 'messages' | 'signups';
|
|
|
|
// Answer icons
|
|
const answerConfig = {
|
|
yes: { icon: <ThumbsUp className="h-4 w-4" />, color: 'text-green-500', bg: 'bg-green-500/10' },
|
|
no: { icon: <ThumbsDown className="h-4 w-4" />, color: 'text-red-500', bg: 'bg-red-500/10' },
|
|
maybe: { icon: <Minus className="h-4 w-4" />, color: 'text-yellow-500', bg: 'bg-yellow-500/10' },
|
|
};
|
|
|
|
const matchTypeLabels = {
|
|
yes_yes: { label: 'Both Yes!', variant: 'default' as const },
|
|
yes_maybe: { label: 'Yes + Maybe', variant: 'secondary' as const },
|
|
maybe_maybe: { label: 'Both Maybe', variant: 'outline' as const },
|
|
};
|
|
|
|
export function UserActivityPage() {
|
|
const [activeTab, setActiveTab] = useState<ActivityTab>('responses');
|
|
const [responses, setResponses] = useState<ResponseActivity[]>([]);
|
|
const [matches, setMatches] = useState<MatchActivity[]>([]);
|
|
const [messages, setMessages] = useState<MessageActivity[]>([]);
|
|
const [signups, setSignups] = useState<SignupActivity[]>([]);
|
|
const [loading, setLoading] = useState({
|
|
responses: true,
|
|
matches: true,
|
|
messages: true,
|
|
signups: true,
|
|
});
|
|
|
|
// Fetch responses
|
|
const fetchResponses = useCallback(async () => {
|
|
setLoading(prev => ({ ...prev, responses: true }));
|
|
try {
|
|
const { data, error } = await supabase
|
|
.from('responses')
|
|
.select(`
|
|
id, user_id, answer, created_at,
|
|
profile:profiles!responses_user_id_fkey(id, name, email),
|
|
question:questions(
|
|
id, text,
|
|
pack:question_packs(name)
|
|
)
|
|
`)
|
|
.order('created_at', { ascending: false })
|
|
.limit(50);
|
|
|
|
if (error) throw error;
|
|
|
|
// Transform data to match our types (Supabase returns nested objects, not arrays for 1:1 relations)
|
|
const transformed: ResponseActivity[] = (data || []).map((item: any) => ({
|
|
id: item.id,
|
|
user_id: item.user_id,
|
|
answer: item.answer,
|
|
created_at: item.created_at,
|
|
profile: item.profile,
|
|
question: item.question ? {
|
|
id: item.question.id,
|
|
text: item.question.text,
|
|
pack: item.question.pack,
|
|
} : null,
|
|
}));
|
|
setResponses(transformed);
|
|
} catch (error) {
|
|
console.error('Failed to load responses:', error);
|
|
toast.error("Failed to load responses");
|
|
} finally {
|
|
setLoading(prev => ({ ...prev, responses: false }));
|
|
}
|
|
}, []);
|
|
|
|
// Fetch matches with couple profiles
|
|
const fetchMatches = useCallback(async () => {
|
|
setLoading(prev => ({ ...prev, matches: true }));
|
|
try {
|
|
const { data: matchesData, error: matchesError } = await supabase
|
|
.from('matches')
|
|
.select(`
|
|
id, couple_id, match_type, is_new, created_at,
|
|
question:questions(id, text)
|
|
`)
|
|
.order('created_at', { ascending: false })
|
|
.limit(50);
|
|
|
|
if (matchesError) throw matchesError;
|
|
|
|
// Get unique couple IDs and fetch profiles
|
|
const coupleIds = [...new Set((matchesData || []).map((m: any) => m.couple_id))];
|
|
if (coupleIds.length > 0) {
|
|
const { data: profiles, error: profilesError } = await supabase
|
|
.from('profiles')
|
|
.select('id, name, email, couple_id')
|
|
.in('couple_id', coupleIds);
|
|
|
|
if (profilesError) throw profilesError;
|
|
|
|
// Attach profiles to matches
|
|
const matchesWithProfiles: MatchActivity[] = (matchesData || []).map((match: any) => ({
|
|
id: match.id,
|
|
couple_id: match.couple_id,
|
|
match_type: match.match_type,
|
|
is_new: match.is_new,
|
|
created_at: match.created_at,
|
|
question: match.question,
|
|
coupleProfiles: (profiles || []).filter((p: any) => p.couple_id === match.couple_id),
|
|
}));
|
|
setMatches(matchesWithProfiles);
|
|
} else {
|
|
const transformed: MatchActivity[] = (matchesData || []).map((match: any) => ({
|
|
id: match.id,
|
|
couple_id: match.couple_id,
|
|
match_type: match.match_type,
|
|
is_new: match.is_new,
|
|
created_at: match.created_at,
|
|
question: match.question,
|
|
coupleProfiles: [],
|
|
}));
|
|
setMatches(transformed);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load matches:', error);
|
|
toast.error("Failed to load matches");
|
|
} finally {
|
|
setLoading(prev => ({ ...prev, matches: false }));
|
|
}
|
|
}, []);
|
|
|
|
// Fetch messages
|
|
const fetchMessages = useCallback(async () => {
|
|
setLoading(prev => ({ ...prev, messages: true }));
|
|
try {
|
|
// Fetch messages with match data
|
|
const { data: messagesData, error: messagesError } = await supabase
|
|
.from('messages')
|
|
.select(`
|
|
id, user_id, content, media_path, media_type, created_at,
|
|
match:matches(
|
|
id,
|
|
question:questions(text)
|
|
)
|
|
`)
|
|
.order('created_at', { ascending: false })
|
|
.limit(50);
|
|
|
|
if (messagesError) throw messagesError;
|
|
|
|
// Get unique user IDs and fetch profiles
|
|
const userIds = [...new Set((messagesData || []).map((m: any) => m.user_id))];
|
|
let profilesMap: Record<string, { id: string; name: string | null; email: string | null }> = {};
|
|
|
|
if (userIds.length > 0) {
|
|
const { data: profiles, error: profilesError } = await supabase
|
|
.from('profiles')
|
|
.select('id, name, email')
|
|
.in('id', userIds);
|
|
|
|
if (profilesError) throw profilesError;
|
|
|
|
profilesMap = (profiles || []).reduce((acc: any, p: any) => {
|
|
acc[p.id] = p;
|
|
return acc;
|
|
}, {});
|
|
}
|
|
|
|
// Transform data to match our types
|
|
const transformed: MessageActivity[] = (messagesData || []).map((item: any) => ({
|
|
id: item.id,
|
|
user_id: item.user_id,
|
|
content: item.content,
|
|
media_path: item.media_path,
|
|
media_type: item.media_type,
|
|
created_at: item.created_at,
|
|
profile: profilesMap[item.user_id] || null,
|
|
match: item.match ? {
|
|
id: item.match.id,
|
|
question: item.match.question,
|
|
} : null,
|
|
}));
|
|
setMessages(transformed);
|
|
} catch (error) {
|
|
console.error('Failed to load messages:', error);
|
|
toast.error("Failed to load messages");
|
|
} finally {
|
|
setLoading(prev => ({ ...prev, messages: false }));
|
|
}
|
|
}, []);
|
|
|
|
// Fetch signups
|
|
const fetchSignups = useCallback(async () => {
|
|
setLoading(prev => ({ ...prev, signups: true }));
|
|
try {
|
|
const { data, error } = await supabase
|
|
.from('profiles')
|
|
.select('id, name, email, avatar_url, created_at, onboarding_completed, couple_id')
|
|
.order('created_at', { ascending: false })
|
|
.limit(50);
|
|
|
|
if (error) throw error;
|
|
setSignups(data || []);
|
|
} catch (error) {
|
|
console.error('Failed to load signups:', error);
|
|
toast.error("Failed to load signups");
|
|
} finally {
|
|
setLoading(prev => ({ ...prev, signups: false }));
|
|
}
|
|
}, []);
|
|
|
|
// Real-time subscriptions
|
|
const { status: responsesStatus } = useRealtimeSubscription({
|
|
table: 'responses',
|
|
onInsert: useCallback(() => fetchResponses(), [fetchResponses]),
|
|
insertToast: { enabled: true, message: () => 'New response', type: 'info' },
|
|
});
|
|
|
|
const { status: matchesStatus } = useRealtimeSubscription({
|
|
table: 'matches',
|
|
onInsert: useCallback(() => fetchMatches(), [fetchMatches]),
|
|
insertToast: { enabled: true, message: () => 'New match!', type: 'success' },
|
|
});
|
|
|
|
const { status: messagesStatus } = useRealtimeSubscription({
|
|
table: 'messages',
|
|
onInsert: useCallback(() => fetchMessages(), [fetchMessages]),
|
|
insertToast: { enabled: true, message: () => 'New message', type: 'info' },
|
|
});
|
|
|
|
const { status: signupsStatus } = useRealtimeSubscription({
|
|
table: 'profiles',
|
|
onInsert: useCallback(() => fetchSignups(), [fetchSignups]),
|
|
insertToast: { enabled: true, message: () => 'New user signed up!', type: 'success' },
|
|
});
|
|
|
|
// Fetch all data on mount
|
|
useEffect(() => {
|
|
fetchResponses();
|
|
fetchMatches();
|
|
fetchMessages();
|
|
fetchSignups();
|
|
}, [fetchResponses, fetchMatches, fetchMessages, fetchSignups]);
|
|
|
|
// Get current realtime status based on active tab
|
|
const getCurrentRealtimeStatus = () => {
|
|
switch (activeTab) {
|
|
case 'responses': return responsesStatus;
|
|
case 'matches': return matchesStatus;
|
|
case 'messages': return messagesStatus;
|
|
case 'signups': return signupsStatus;
|
|
}
|
|
};
|
|
|
|
// Render user link
|
|
const renderUserLink = (profile: { id: string; name: string | null; email: string | null } | null) => {
|
|
if (!profile) return <span className="text-muted-foreground">Unknown User</span>;
|
|
return (
|
|
<Link
|
|
to={`/users/${profile.id}`}
|
|
className="text-primary hover:underline"
|
|
>
|
|
{profile.name || profile.email || 'Unknown'}
|
|
</Link>
|
|
);
|
|
};
|
|
|
|
// Render answer badge
|
|
const renderAnswerBadge = (answer: 'yes' | 'no' | 'maybe') => {
|
|
const config = answerConfig[answer];
|
|
return (
|
|
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-md ${config.bg} ${config.color}`}>
|
|
{config.icon}
|
|
<span className="text-sm font-medium capitalize">{answer}</span>
|
|
</span>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="flex items-center gap-3">
|
|
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
|
<Activity className="h-8 w-8 text-primary" />
|
|
User Activity
|
|
</h1>
|
|
<RealtimeStatusIndicator status={getCurrentRealtimeStatus()} showLabel />
|
|
</div>
|
|
<p className="text-muted-foreground">
|
|
Monitor recent user activity across the platform
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as ActivityTab)}>
|
|
<TabsList className="grid w-full grid-cols-4 max-w-[600px]">
|
|
<TabsTrigger value="responses" className="gap-2">
|
|
<ThumbsUp className="h-4 w-4" />
|
|
Responses
|
|
</TabsTrigger>
|
|
<TabsTrigger value="matches" className="gap-2">
|
|
<Heart className="h-4 w-4" />
|
|
Matches
|
|
</TabsTrigger>
|
|
<TabsTrigger value="messages" className="gap-2">
|
|
<MessageCircle className="h-4 w-4" />
|
|
Messages
|
|
</TabsTrigger>
|
|
<TabsTrigger value="signups" className="gap-2">
|
|
<UserPlus className="h-4 w-4" />
|
|
Signups
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{/* Responses Tab */}
|
|
<TabsContent value="responses" className="mt-6">
|
|
<div className="rounded-md border">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>User</TableHead>
|
|
<TableHead>Question</TableHead>
|
|
<TableHead>Pack</TableHead>
|
|
<TableHead>Answer</TableHead>
|
|
<TableHead>Time</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{loading.responses ? (
|
|
<TableRow>
|
|
<TableCell colSpan={5} className="text-center py-8">
|
|
<div className="flex justify-center"><Skeleton className="h-6 w-32" /></div>
|
|
</TableCell>
|
|
</TableRow>
|
|
) : responses.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
|
|
No responses yet.
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
responses.map((item) => (
|
|
<TableRow key={item.id}>
|
|
<TableCell>{renderUserLink(item.profile)}</TableCell>
|
|
<TableCell className="max-w-[300px]">
|
|
<span className="truncate block">
|
|
{item.question?.text || 'Unknown question'}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge variant="outline">
|
|
{item.question?.pack?.name || 'Unknown'}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell>{renderAnswerBadge(item.answer)}</TableCell>
|
|
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
|
|
{formatDistanceToNow(new Date(item.created_at), { addSuffix: true })}
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* Matches Tab */}
|
|
<TabsContent value="matches" className="mt-6">
|
|
<div className="rounded-md border">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Couple</TableHead>
|
|
<TableHead>Question</TableHead>
|
|
<TableHead>Match Type</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead>Time</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{loading.matches ? (
|
|
<TableRow>
|
|
<TableCell colSpan={5} className="text-center py-8">
|
|
<div className="flex justify-center"><Skeleton className="h-6 w-32" /></div>
|
|
</TableCell>
|
|
</TableRow>
|
|
) : matches.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
|
|
No matches yet.
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
matches.map((item) => (
|
|
<TableRow key={item.id}>
|
|
<TableCell>
|
|
<div className="flex items-center gap-1">
|
|
{item.coupleProfiles && item.coupleProfiles.length > 0 ? (
|
|
<>
|
|
{renderUserLink(item.coupleProfiles[0])}
|
|
{item.coupleProfiles.length > 1 && (
|
|
<>
|
|
<span className="text-muted-foreground mx-1">&</span>
|
|
{renderUserLink(item.coupleProfiles[1])}
|
|
</>
|
|
)}
|
|
</>
|
|
) : (
|
|
<span className="text-muted-foreground">Unknown couple</span>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="max-w-[300px]">
|
|
<span className="truncate block">
|
|
{item.question?.text || 'Unknown question'}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge variant={matchTypeLabels[item.match_type]?.variant || 'secondary'}>
|
|
{matchTypeLabels[item.match_type]?.label || item.match_type}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell>
|
|
{item.is_new ? (
|
|
<Badge variant="destructive">New</Badge>
|
|
) : (
|
|
<Badge variant="outline">Viewed</Badge>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
|
|
{formatDistanceToNow(new Date(item.created_at), { addSuffix: true })}
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* Messages Tab */}
|
|
<TabsContent value="messages" className="mt-6">
|
|
<div className="rounded-md border">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>User</TableHead>
|
|
<TableHead>Context</TableHead>
|
|
<TableHead>Time</TableHead>
|
|
<TableHead>Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{loading.messages ? (
|
|
<TableRow>
|
|
<TableCell colSpan={4} className="text-center py-8">
|
|
<div className="flex justify-center"><Skeleton className="h-6 w-32" /></div>
|
|
</TableCell>
|
|
</TableRow>
|
|
) : messages.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
|
|
No messages yet.
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
messages.map((item) => (
|
|
<TableRow key={item.id}>
|
|
<TableCell>{renderUserLink(item.profile)}</TableCell>
|
|
<TableCell className="max-w-[200px]">
|
|
<span className="truncate block text-muted-foreground text-sm">
|
|
{item.match?.question?.text || 'Unknown match'}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
|
|
{formatDistanceToNow(new Date(item.created_at), { addSuffix: true })}
|
|
</TableCell>
|
|
<TableCell>
|
|
{item.match && item.profile ? (
|
|
<Link
|
|
to={`/users/${item.profile.id}/matches/${item.match.id}`}
|
|
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
|
|
>
|
|
<ExternalLink className="h-3 w-3" />
|
|
View Chat
|
|
</Link>
|
|
) : (
|
|
<span className="text-muted-foreground text-sm">-</span>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* Signups Tab */}
|
|
<TabsContent value="signups" className="mt-6">
|
|
<div className="rounded-md border">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>User</TableHead>
|
|
<TableHead>Email</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead>Paired</TableHead>
|
|
<TableHead>Joined</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{loading.signups ? (
|
|
<TableRow>
|
|
<TableCell colSpan={5} className="text-center py-8">
|
|
<div className="flex justify-center"><Skeleton className="h-6 w-32" /></div>
|
|
</TableCell>
|
|
</TableRow>
|
|
) : signups.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
|
|
No signups yet.
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
signups.map((item) => (
|
|
<TableRow key={item.id}>
|
|
<TableCell>
|
|
<Link
|
|
to={`/users/${item.id}`}
|
|
className="flex items-center gap-2 hover:opacity-80"
|
|
>
|
|
<Avatar className="h-8 w-8">
|
|
<AvatarImage src={item.avatar_url || undefined} />
|
|
<AvatarFallback className="bg-primary/10 text-primary">
|
|
{(item.name || item.email || '?').charAt(0).toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<span className="text-primary hover:underline">
|
|
{item.name || 'No name'}
|
|
</span>
|
|
</Link>
|
|
</TableCell>
|
|
<TableCell className="text-muted-foreground">
|
|
{item.email || 'No email'}
|
|
</TableCell>
|
|
<TableCell>
|
|
{item.onboarding_completed ? (
|
|
<Badge variant="default">Onboarded</Badge>
|
|
) : (
|
|
<Badge variant="secondary">Pending</Badge>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
{item.couple_id ? (
|
|
<Badge variant="default">Paired</Badge>
|
|
) : (
|
|
<Badge variant="outline">Single</Badge>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
|
|
{format(new Date(item.created_at), 'MMM d, yyyy')}
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
);
|
|
}
|