mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Add admin pagination and update configs
This commit is contained in:
1 parent
b60b72a289
commit
aff0bc0888
21 files changed
+1793
-481
No files matched your search
@@ -0,0 +1,97 @@
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type PaginationControlsProps = {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalCount: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onPageSizeChange?: (pageSize: number) => void;
|
||||
pageSizeOptions?: number[];
|
||||
showPageSize?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function PaginationControls({
|
||||
page,
|
||||
pageSize,
|
||||
totalCount,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
pageSizeOptions = [10, 25, 50],
|
||||
showPageSize = true,
|
||||
className,
|
||||
}: PaginationControlsProps) {
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||
const safePage = Math.min(Math.max(page, 1), totalPages);
|
||||
const start = totalCount === 0 ? 0 : (safePage - 1) * pageSize + 1;
|
||||
const end = totalCount === 0 ? 0 : Math.min(safePage * pageSize, totalCount);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-2 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
{totalCount === 0
|
||||
? 'No results'
|
||||
: `Showing ${start} to ${end} of ${totalCount}`}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{showPageSize && onPageSizeChange && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs uppercase tracking-wide text-muted-foreground">Rows</span>
|
||||
<Select
|
||||
value={String(pageSize)}
|
||||
onValueChange={(value) => onPageSizeChange(Number(value))}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[90px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{pageSizeOptions.map((size) => (
|
||||
<SelectItem key={size} value={String(size)}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(Math.max(1, safePage - 1))}
|
||||
disabled={safePage <= 1}
|
||||
>
|
||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||
Prev
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Page {safePage} of {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(Math.min(totalPages, safePage + 1))}
|
||||
disabled={safePage >= totalPages}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -86,10 +86,10 @@ export const auditedSupabase = {
|
||||
/**
|
||||
* Update a single record with audit logging
|
||||
*/
|
||||
async update<T extends { id: string }>(
|
||||
async update<T extends object = Record<string, unknown>>(
|
||||
table: string,
|
||||
id: string,
|
||||
updates: Partial<T>
|
||||
updates: Partial<T> | Record<string, unknown>
|
||||
): Promise<SingleResult<T>> {
|
||||
// Fetch old values before update
|
||||
let oldValues: Record<string, unknown> | null = null;
|
||||
|
||||
@@ -43,8 +43,8 @@ import { useState, useCallback } from 'react';
|
||||
* ```
|
||||
*/
|
||||
export function useEntityForm<
|
||||
TFormData extends Record<string, unknown>,
|
||||
TEntity extends Record<string, unknown> = TFormData
|
||||
TFormData extends object,
|
||||
TEntity extends object = TFormData
|
||||
>(
|
||||
initialFormData: TFormData,
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { supabase } from '@/config';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { PaginationControls } from '@/components/ui/pagination';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -34,28 +35,42 @@ const actionColors: Record<string, string> = {
|
||||
export function AuditLogsPage() {
|
||||
const [logs, setLogs] = useState<AuditLog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const from = (page - 1) * pageSize;
|
||||
const to = from + pageSize - 1;
|
||||
|
||||
const { data, error, count } = await supabase
|
||||
.from('audit_logs')
|
||||
.select('*', { count: 'exact' })
|
||||
.order('created_at', { ascending: false })
|
||||
.range(from, to);
|
||||
|
||||
if (error) throw error;
|
||||
setLogs(data || []);
|
||||
setTotalCount(count || 0);
|
||||
} catch (error) {
|
||||
console.error('Failed to load audit logs:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLogs = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('audit_logs')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(100);
|
||||
|
||||
if (error) throw error;
|
||||
setLogs(data || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load audit logs:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchLogs();
|
||||
}, []);
|
||||
}, [fetchLogs]);
|
||||
|
||||
useEffect(() => {
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||
if (page > totalPages) {
|
||||
setPage(totalPages);
|
||||
}
|
||||
}, [page, pageSize, totalCount]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -141,6 +156,19 @@ export function AuditLogsPage() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{logs.length > 0 && (
|
||||
<PaginationControls
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
totalCount={totalCount}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setPage(1);
|
||||
setPageSize(size);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,9 @@ import { supabase } from '@/config';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { PaginationControls } from '@/components/ui/pagination';
|
||||
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
||||
@@ -84,9 +86,12 @@ const statusConfig: Record<FeedbackStatus, { label: string; variant: 'default' |
|
||||
export function FeedbackPage() {
|
||||
const [feedback, setFeedback] = useState<Feedback[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState<string>('all');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState<string>('all');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
|
||||
|
||||
// Detail Dialog State
|
||||
const [selectedFeedback, setSelectedFeedback] = useState<Feedback | null>(null);
|
||||
@@ -136,9 +141,14 @@ export function FeedbackPage() {
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchFeedback();
|
||||
}, [fetchFeedback]);
|
||||
useEffect(() => {
|
||||
fetchFeedback();
|
||||
}, [fetchFeedback]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [search, typeFilter, statusFilter]);
|
||||
|
||||
|
||||
const handleOpenDetail = (item: Feedback) => {
|
||||
setSelectedFeedback(item);
|
||||
@@ -173,19 +183,33 @@ export function FeedbackPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const filteredFeedback = feedback.filter(item => {
|
||||
if (typeFilter !== 'all' && item.type !== typeFilter) return false;
|
||||
if (statusFilter !== 'all' && item.status !== statusFilter) return false;
|
||||
if (!search) return true;
|
||||
|
||||
const query = search.toLowerCase();
|
||||
return (
|
||||
item.title.toLowerCase().includes(query) ||
|
||||
item.description.toLowerCase().includes(query) ||
|
||||
item.profile?.name?.toLowerCase().includes(query) ||
|
||||
item.profile?.email?.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
const filteredFeedback = feedback.filter(item => {
|
||||
if (typeFilter !== 'all' && item.type !== typeFilter) return false;
|
||||
if (statusFilter !== 'all' && item.status !== statusFilter) return false;
|
||||
if (!search) return true;
|
||||
|
||||
const query = search.toLowerCase();
|
||||
return (
|
||||
item.title.toLowerCase().includes(query) ||
|
||||
item.description.toLowerCase().includes(query) ||
|
||||
item.profile?.name?.toLowerCase().includes(query) ||
|
||||
item.profile?.email?.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
const totalCount = filteredFeedback.length;
|
||||
const paginatedFeedback = filteredFeedback.slice(
|
||||
(page - 1) * pageSize,
|
||||
page * pageSize
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||
if (page > totalPages) {
|
||||
setPage(totalPages);
|
||||
}
|
||||
}, [page, pageSize, totalCount]);
|
||||
|
||||
|
||||
const getTypeBadge = (type: FeedbackType) => {
|
||||
const config = feedbackTypeConfig[type];
|
||||
@@ -267,67 +291,80 @@ export function FeedbackPage() {
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="w-[100px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-8">
|
||||
<div className="flex justify-center"><Skeleton className="h-6 w-32" /></div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredFeedback.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
|
||||
{search || typeFilter !== 'all' || statusFilter !== 'all'
|
||||
? 'No feedback matches your filters.'
|
||||
: 'No feedback submissions yet.'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredFeedback.map((item) => (
|
||||
<TableRow key={item.id} className="cursor-pointer hover:bg-muted/50" onClick={() => handleOpenDetail(item)}>
|
||||
<TableCell>{getTypeBadge(item.type)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="max-w-[300px]">
|
||||
<div className="font-medium truncate">{item.title}</div>
|
||||
<div className="text-sm text-muted-foreground truncate">
|
||||
{item.description.slice(0, 80)}
|
||||
{item.description.length > 80 && '...'}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">{item.profile?.name || 'Unknown'}</div>
|
||||
<div className="text-muted-foreground">{item.profile?.email}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(item.status)}</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{format(new Date(item.created_at), 'MMM d, yyyy')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); handleOpenDetail(item); }}>
|
||||
View
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="w-[100px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-8">
|
||||
<div className="flex justify-center"><Skeleton className="h-6 w-32" /></div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredFeedback.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
|
||||
{search || typeFilter !== 'all' || statusFilter !== 'all'
|
||||
? 'No feedback matches your filters.'
|
||||
: 'No feedback submissions yet.'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
paginatedFeedback.map((item) => (
|
||||
<TableRow key={item.id} className="cursor-pointer hover:bg-muted/50" onClick={() => handleOpenDetail(item)}>
|
||||
<TableCell>{getTypeBadge(item.type)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="max-w-[300px]">
|
||||
<div className="font-medium truncate">{item.title}</div>
|
||||
<div className="text-sm text-muted-foreground truncate">
|
||||
{item.description.slice(0, 80)}
|
||||
{item.description.length > 80 && '...'}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">{item.profile?.name || 'Unknown'}</div>
|
||||
<div className="text-muted-foreground">{item.profile?.email}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(item.status)}</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{format(new Date(item.created_at), 'MMM d, yyyy')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); handleOpenDetail(item); }}>
|
||||
View
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{filteredFeedback.length > 0 && (
|
||||
<PaginationControls
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
totalCount={totalCount}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setPage(1);
|
||||
setPageSize(size);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Detail Dialog */}
|
||||
<Dialog open={!!selectedFeedback} onOpenChange={(open) => !open && setSelectedFeedback(null)}>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { supabase } from '@/config';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { PaginationControls } from '@/components/ui/pagination';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Table,
|
||||
@@ -65,10 +66,16 @@ export function FlaggedMessagesPage() {
|
||||
// AI Flagged state
|
||||
const [aiMessages, setAiMessages] = useState<FlaggedMessage[]>([]);
|
||||
const [aiLoading, setAiLoading] = useState(true);
|
||||
const [aiPage, setAiPage] = useState(1);
|
||||
const [aiPageSize, setAiPageSize] = useState(25);
|
||||
const [aiTotal, setAiTotal] = useState(0);
|
||||
|
||||
// User Reports state
|
||||
const [userReports, setUserReports] = useState<UserReport[]>([]);
|
||||
const [reportsLoading, setReportsLoading] = useState(true);
|
||||
const [reportsPage, setReportsPage] = useState(1);
|
||||
const [reportsPageSize, setReportsPageSize] = useState(25);
|
||||
const [reportsTotal, setReportsTotal] = useState(0);
|
||||
|
||||
// Shared state
|
||||
const [selectedMessage, setSelectedMessage] = useState<FlaggedMessage | null>(null);
|
||||
@@ -82,30 +89,38 @@ export function FlaggedMessagesPage() {
|
||||
const [describingInline, setDescribingInline] = useState<string | null>(null);
|
||||
|
||||
// Fetch AI flagged messages
|
||||
const fetchAiMessages = async () => {
|
||||
const fetchAiMessages = useCallback(async () => {
|
||||
setAiLoading(true);
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
const from = (aiPage - 1) * aiPageSize;
|
||||
const to = from + aiPageSize - 1;
|
||||
|
||||
const { data, error, count } = await supabase
|
||||
.from('messages')
|
||||
.select('id, created_at, user_id, match_id, moderation_status, flag_reason, content, media_path, media_type')
|
||||
.select('id, created_at, user_id, match_id, moderation_status, flag_reason, content, media_path, media_type', { count: 'exact' })
|
||||
.eq('moderation_status', 'flagged')
|
||||
.order('created_at', { ascending: false });
|
||||
.order('created_at', { ascending: false })
|
||||
.range(from, to);
|
||||
|
||||
if (error) throw error;
|
||||
setAiMessages(data || []);
|
||||
setAiTotal(count || 0);
|
||||
} catch (error) {
|
||||
console.error('Failed to load flagged messages:', error);
|
||||
toast.error('Failed to load messages');
|
||||
} finally {
|
||||
setAiLoading(false);
|
||||
}
|
||||
};
|
||||
}, [aiPage, aiPageSize]);
|
||||
|
||||
// Fetch user reports
|
||||
const fetchUserReports = async () => {
|
||||
const fetchUserReports = useCallback(async () => {
|
||||
setReportsLoading(true);
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
const from = (reportsPage - 1) * reportsPageSize;
|
||||
const to = from + reportsPageSize - 1;
|
||||
|
||||
const { data, error, count } = await supabase
|
||||
.from('message_reports')
|
||||
.select(`
|
||||
id,
|
||||
@@ -116,24 +131,40 @@ export function FlaggedMessagesPage() {
|
||||
created_at,
|
||||
message:messages(id, user_id, match_id, content, media_path, media_type),
|
||||
reporter_profile:profiles!reporter_id(name, email)
|
||||
`)
|
||||
`, { count: 'exact' })
|
||||
.eq('status', 'pending')
|
||||
.order('created_at', { ascending: false });
|
||||
.order('created_at', { ascending: false })
|
||||
.range(from, to);
|
||||
|
||||
if (error) throw error;
|
||||
setUserReports((data || []) as unknown as UserReport[]);
|
||||
setReportsTotal(count || 0);
|
||||
} catch (error) {
|
||||
console.error('Failed to load user reports:', error);
|
||||
toast.error('Failed to load reports');
|
||||
} finally {
|
||||
setReportsLoading(false);
|
||||
}
|
||||
};
|
||||
}, [reportsPage, reportsPageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAiMessages();
|
||||
fetchUserReports();
|
||||
}, []);
|
||||
}, [fetchAiMessages, fetchUserReports]);
|
||||
|
||||
useEffect(() => {
|
||||
const totalPages = Math.max(1, Math.ceil(aiTotal / aiPageSize));
|
||||
if (aiPage > totalPages) {
|
||||
setAiPage(totalPages);
|
||||
}
|
||||
}, [aiPage, aiPageSize, aiTotal]);
|
||||
|
||||
useEffect(() => {
|
||||
const totalPages = Math.max(1, Math.ceil(reportsTotal / reportsPageSize));
|
||||
if (reportsPage > totalPages) {
|
||||
setReportsPage(totalPages);
|
||||
}
|
||||
}, [reportsPage, reportsPageSize, reportsTotal]);
|
||||
|
||||
// Custom media fetcher because supabase.functions.invoke is JSON-centric
|
||||
const fetchDecryptedMedia = async (messageId: string) => {
|
||||
@@ -296,8 +327,8 @@ export function FlaggedMessagesPage() {
|
||||
if (error) throw error;
|
||||
|
||||
toast.success('Message marked as safe');
|
||||
setAiMessages(prev => prev.filter(m => m.id !== messageId));
|
||||
if (selectedMessage?.id === messageId) setSelectedMessage(null);
|
||||
fetchAiMessages();
|
||||
} catch (error) {
|
||||
toast.error('Failed to update status');
|
||||
} finally {
|
||||
@@ -316,8 +347,8 @@ export function FlaggedMessagesPage() {
|
||||
if (error) throw error;
|
||||
|
||||
toast.success('Report dismissed');
|
||||
setUserReports(prev => prev.filter(r => r.id !== reportId));
|
||||
if (selectedReport?.id === reportId) setSelectedReport(null);
|
||||
fetchUserReports();
|
||||
} catch (error) {
|
||||
toast.error('Failed to dismiss report');
|
||||
} finally {
|
||||
@@ -336,8 +367,8 @@ export function FlaggedMessagesPage() {
|
||||
if (error) throw error;
|
||||
|
||||
toast.success('Report marked as reviewed');
|
||||
setUserReports(prev => prev.filter(r => r.id !== reportId));
|
||||
if (selectedReport?.id === reportId) setSelectedReport(null);
|
||||
fetchUserReports();
|
||||
} catch (error) {
|
||||
toast.error('Failed to update report');
|
||||
} finally {
|
||||
@@ -491,6 +522,19 @@ export function FlaggedMessagesPage() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
{aiTotal > 0 && (
|
||||
<PaginationControls
|
||||
page={aiPage}
|
||||
pageSize={aiPageSize}
|
||||
totalCount={aiTotal}
|
||||
onPageChange={setAiPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setAiPage(1);
|
||||
setAiPageSize(size);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="user-reports" className="space-y-4">
|
||||
@@ -579,6 +623,19 @@ export function FlaggedMessagesPage() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
{reportsTotal > 0 && (
|
||||
<PaginationControls
|
||||
page={reportsPage}
|
||||
pageSize={reportsPageSize}
|
||||
totalCount={reportsTotal}
|
||||
onPageChange={setReportsPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setReportsPage(1);
|
||||
setReportsPageSize(size);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { PaginationControls } from '@/components/ui/pagination';
|
||||
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Table,
|
||||
@@ -61,9 +64,13 @@ function generateCode(length: number = 8): string {
|
||||
}
|
||||
|
||||
export function RedemptionCodesPage() {
|
||||
const [codes, setCodes] = useState<RedemptionCode[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [codes, setCodes] = useState<RedemptionCode[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
|
||||
// Add Code State
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
@@ -76,66 +83,114 @@ export function RedemptionCodesPage() {
|
||||
});
|
||||
|
||||
// View Redemptions State
|
||||
const [viewingCode, setViewingCode] = useState<RedemptionCode | null>(null);
|
||||
const [redemptions, setRedemptions] = useState<CodeRedemption[]>([]);
|
||||
const [loadingRedemptions, setLoadingRedemptions] = useState(false);
|
||||
const [viewingCode, setViewingCode] = useState<RedemptionCode | null>(null);
|
||||
const [redemptions, setRedemptions] = useState<CodeRedemption[]>([]);
|
||||
const [loadingRedemptions, setLoadingRedemptions] = useState(false);
|
||||
const [redemptionsPage, setRedemptionsPage] = useState(1);
|
||||
const [redemptionsPageSize, setRedemptionsPageSize] = useState(10);
|
||||
const [redemptionsTotal, setRedemptionsTotal] = useState(0);
|
||||
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
fetchCodes();
|
||||
}, []);
|
||||
|
||||
|
||||
const fetchCodes = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('redemption_codes')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false });
|
||||
const fetchCodes = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const from = (page - 1) * pageSize;
|
||||
const to = from + pageSize - 1;
|
||||
const trimmedSearch = search.trim();
|
||||
|
||||
let query = supabase
|
||||
.from('redemption_codes')
|
||||
.select('*', { count: 'exact' })
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (trimmedSearch) {
|
||||
query = query.or(`code.ilike.%${trimmedSearch}%,description.ilike.%${trimmedSearch}%`);
|
||||
}
|
||||
|
||||
const { data, error, count } = await query.range(from, to);
|
||||
|
||||
if (error) throw error;
|
||||
setCodes(data || []);
|
||||
setTotalCount(count || 0);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load codes:', error);
|
||||
toast.error("Failed to load redemption codes");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, pageSize, search]);
|
||||
|
||||
|
||||
if (error) throw error;
|
||||
setCodes(data || []);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load codes:', error);
|
||||
toast.error("Failed to load redemption codes");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRedemptions = async (codeId: string) => {
|
||||
setLoadingRedemptions(true);
|
||||
try {
|
||||
const { data: redemptionData, error: redemptionError } = await supabase
|
||||
.from('code_redemptions')
|
||||
.select('id, user_id, redeemed_at')
|
||||
.eq('code_id', codeId)
|
||||
.order('redeemed_at', { ascending: false });
|
||||
|
||||
if (redemptionError) throw redemptionError;
|
||||
|
||||
if (redemptionData && redemptionData.length > 0) {
|
||||
const userIds = redemptionData.map(r => r.user_id);
|
||||
const { data: profileData } = await supabase
|
||||
.from('profiles')
|
||||
.select('id, name, email')
|
||||
.in('id', userIds);
|
||||
|
||||
const joined = redemptionData.map(r => ({
|
||||
...r,
|
||||
profile: profileData?.find(p => p.id === r.user_id)
|
||||
}));
|
||||
setRedemptions(joined);
|
||||
} else {
|
||||
setRedemptions([]);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load redemptions:', error);
|
||||
toast.error("Failed to load redemptions");
|
||||
} finally {
|
||||
setLoadingRedemptions(false);
|
||||
}
|
||||
};
|
||||
const fetchRedemptions = useCallback(async (codeId: string) => {
|
||||
setLoadingRedemptions(true);
|
||||
try {
|
||||
const from = (redemptionsPage - 1) * redemptionsPageSize;
|
||||
const to = from + redemptionsPageSize - 1;
|
||||
|
||||
const { data: redemptionData, error: redemptionError, count } = await supabase
|
||||
.from('code_redemptions')
|
||||
.select('id, user_id, redeemed_at', { count: 'exact' })
|
||||
.eq('code_id', codeId)
|
||||
.order('redeemed_at', { ascending: false })
|
||||
.range(from, to);
|
||||
|
||||
if (redemptionError) throw redemptionError;
|
||||
|
||||
setRedemptionsTotal(count || 0);
|
||||
|
||||
if (redemptionData && redemptionData.length > 0) {
|
||||
const userIds = redemptionData.map(r => r.user_id);
|
||||
const { data: profileData } = await supabase
|
||||
.from('profiles')
|
||||
.select('id, name, email')
|
||||
.in('id', userIds);
|
||||
|
||||
const joined = redemptionData.map(r => ({
|
||||
...r,
|
||||
profile: profileData?.find(p => p.id === r.user_id)
|
||||
}));
|
||||
setRedemptions(joined);
|
||||
} else {
|
||||
setRedemptions([]);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load redemptions:', error);
|
||||
toast.error("Failed to load redemptions");
|
||||
} finally {
|
||||
setLoadingRedemptions(false);
|
||||
}
|
||||
}, [redemptionsPage, redemptionsPageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCodes();
|
||||
}, [fetchCodes]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [search]);
|
||||
|
||||
useEffect(() => {
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||
if (page > totalPages) {
|
||||
setPage(totalPages);
|
||||
}
|
||||
}, [page, pageSize, totalCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewingCode) {
|
||||
fetchRedemptions(viewingCode.id);
|
||||
}
|
||||
}, [viewingCode, fetchRedemptions]);
|
||||
|
||||
useEffect(() => {
|
||||
const totalPages = Math.max(1, Math.ceil(redemptionsTotal / redemptionsPageSize));
|
||||
if (redemptionsPage > totalPages) {
|
||||
setRedemptionsPage(totalPages);
|
||||
}
|
||||
}, [redemptionsPage, redemptionsPageSize, redemptionsTotal]);
|
||||
|
||||
|
||||
const handleAddCode = async () => {
|
||||
if (!formData.code.trim()) {
|
||||
@@ -224,19 +279,14 @@ export function RedemptionCodesPage() {
|
||||
setIsAddDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleViewRedemptions = (code: RedemptionCode) => {
|
||||
setViewingCode(code);
|
||||
fetchRedemptions(code.id);
|
||||
};
|
||||
const handleViewRedemptions = (code: RedemptionCode) => {
|
||||
setViewingCode(code);
|
||||
setRedemptionsPage(1);
|
||||
};
|
||||
|
||||
|
||||
const filteredCodes = codes.filter(code => {
|
||||
if (!search) return true;
|
||||
const query = search.toLowerCase();
|
||||
return (
|
||||
code.code.toLowerCase().includes(query) ||
|
||||
code.description?.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
const filteredCodes = codes;
|
||||
|
||||
|
||||
const getStatusBadge = (code: RedemptionCode) => {
|
||||
if (!code.is_active) {
|
||||
@@ -344,101 +394,115 @@ export function RedemptionCodesPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Code</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead>Usage</TableHead>
|
||||
<TableHead>Expires</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="w-[150px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8">
|
||||
<div className="flex justify-center"><Skeleton className="h-6 w-32" /></div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredCodes.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
||||
{search ? 'No codes match your search.' : 'No redemption codes yet. Generate your first one!'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredCodes.map((code) => (
|
||||
<TableRow key={code.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="font-mono font-medium text-sm bg-muted px-2 py-1 rounded">
|
||||
{code.code}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => copyToClipboard(code.code)}
|
||||
title="Copy code"
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{code.description || '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-medium">{code.current_uses}</span>
|
||||
<span className="text-muted-foreground"> / {code.max_uses}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{code.expires_at
|
||||
? format(new Date(code.expires_at), 'MMM d, yyyy HH:mm')
|
||||
: 'Never'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{getStatusBadge(code)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{format(new Date(code.created_at), 'MMM d, yyyy')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleViewRedemptions(code)}
|
||||
title="View redemptions"
|
||||
>
|
||||
<Users className="h-4 w-4" />
|
||||
</Button>
|
||||
<Switch
|
||||
checked={code.is_active}
|
||||
onCheckedChange={() => handleToggleActive(code)}
|
||||
title={code.is_active ? "Deactivate" : "Activate"}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleDeleteCode(code)}
|
||||
title="Delete code"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Code</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead>Usage</TableHead>
|
||||
<TableHead>Expires</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="w-[150px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8">
|
||||
<div className="flex justify-center"><Skeleton className="h-6 w-32" /></div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredCodes.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
||||
{search ? 'No codes match your search.' : 'No redemption codes yet. Generate your first one!'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredCodes.map((code) => (
|
||||
<TableRow key={code.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="font-mono font-medium text-sm bg-muted px-2 py-1 rounded">
|
||||
{code.code}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => copyToClipboard(code.code)}
|
||||
title="Copy code"
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{code.description || '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-medium">{code.current_uses}</span>
|
||||
<span className="text-muted-foreground"> / {code.max_uses}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{code.expires_at
|
||||
? format(new Date(code.expires_at), 'MMM d, yyyy HH:mm')
|
||||
: 'Never'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{getStatusBadge(code)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{format(new Date(code.created_at), 'MMM d, yyyy')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleViewRedemptions(code)}
|
||||
title="View redemptions"
|
||||
>
|
||||
<Users className="h-4 w-4" />
|
||||
</Button>
|
||||
<Switch
|
||||
checked={code.is_active}
|
||||
onCheckedChange={() => handleToggleActive(code)}
|
||||
title={code.is_active ? "Deactivate" : "Activate"}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleDeleteCode(code)}
|
||||
title="Delete code"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{filteredCodes.length > 0 && (
|
||||
<PaginationControls
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
totalCount={totalCount}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setPage(1);
|
||||
setPageSize(size);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{/* View Redemptions Dialog */}
|
||||
<Dialog open={!!viewingCode} onOpenChange={(open) => !open && setViewingCode(null)}>
|
||||
@@ -451,42 +515,56 @@ export function RedemptionCodesPage() {
|
||||
{viewingCode?.current_uses || 0} of {viewingCode?.max_uses} uses
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
{loadingRedemptions ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</div>
|
||||
) : redemptions.length === 0 ? (
|
||||
<p className="text-center py-8 text-muted-foreground">
|
||||
No one has redeemed this code yet.
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Redeemed At</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{redemptions.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{r.profile?.name || 'Unknown'}</div>
|
||||
<div className="text-xs text-muted-foreground">{r.profile?.email}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{format(new Date(r.redeemed_at), 'MMM d, yyyy HH:mm')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
{loadingRedemptions ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</div>
|
||||
) : redemptions.length === 0 ? (
|
||||
<p className="text-center py-8 text-muted-foreground">
|
||||
No one has redeemed this code yet.
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Redeemed At</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{redemptions.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{r.profile?.name || 'Unknown'}</div>
|
||||
<div className="text-xs text-muted-foreground">{r.profile?.email}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{format(new Date(r.redeemed_at), 'MMM d, yyyy HH:mm')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{redemptionsTotal > 0 && (
|
||||
<PaginationControls
|
||||
page={redemptionsPage}
|
||||
pageSize={redemptionsPageSize}
|
||||
totalCount={redemptionsTotal}
|
||||
onPageChange={setRedemptionsPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setRedemptionsPage(1);
|
||||
setRedemptionsPageSize(size);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<DialogFooter>
|
||||
|
||||
<Button variant="outline" onClick={() => setViewingCode(null)}>Close</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -6,6 +6,7 @@ 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 { PaginationControls } from '@/components/ui/pagination';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -121,6 +122,18 @@ export function UserActivityPage() {
|
||||
const [matches, setMatches] = useState<MatchActivity[]>([]);
|
||||
const [messages, setMessages] = useState<MessageActivity[]>([]);
|
||||
const [signups, setSignups] = useState<SignupActivity[]>([]);
|
||||
const [responsesPage, setResponsesPage] = useState(1);
|
||||
const [responsesPageSize, setResponsesPageSize] = useState(25);
|
||||
const [responsesTotal, setResponsesTotal] = useState(0);
|
||||
const [matchesPage, setMatchesPage] = useState(1);
|
||||
const [matchesPageSize, setMatchesPageSize] = useState(25);
|
||||
const [matchesTotal, setMatchesTotal] = useState(0);
|
||||
const [messagesPage, setMessagesPage] = useState(1);
|
||||
const [messagesPageSize, setMessagesPageSize] = useState(25);
|
||||
const [messagesTotal, setMessagesTotal] = useState(0);
|
||||
const [signupsPage, setSignupsPage] = useState(1);
|
||||
const [signupsPageSize, setSignupsPageSize] = useState(25);
|
||||
const [signupsTotal, setSignupsTotal] = useState(0);
|
||||
const [loading, setLoading] = useState({
|
||||
responses: true,
|
||||
matches: true,
|
||||
@@ -132,7 +145,10 @@ export function UserActivityPage() {
|
||||
const fetchResponses = useCallback(async () => {
|
||||
setLoading(prev => ({ ...prev, responses: true }));
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
const from = (responsesPage - 1) * responsesPageSize;
|
||||
const to = from + responsesPageSize - 1;
|
||||
|
||||
const { data, error, count } = await supabase
|
||||
.from('responses')
|
||||
.select(`
|
||||
id, user_id, answer, created_at,
|
||||
@@ -141,12 +157,14 @@ export function UserActivityPage() {
|
||||
id, text,
|
||||
pack:question_packs(name)
|
||||
)
|
||||
`)
|
||||
`, { count: 'exact' })
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(50);
|
||||
.range(from, to);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
setResponsesTotal(count || 0);
|
||||
|
||||
// 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,
|
||||
@@ -167,23 +185,28 @@ export function UserActivityPage() {
|
||||
} finally {
|
||||
setLoading(prev => ({ ...prev, responses: false }));
|
||||
}
|
||||
}, []);
|
||||
}, [responsesPage, responsesPageSize]);
|
||||
|
||||
// Fetch matches with couple profiles
|
||||
const fetchMatches = useCallback(async () => {
|
||||
setLoading(prev => ({ ...prev, matches: true }));
|
||||
try {
|
||||
const { data: matchesData, error: matchesError } = await supabase
|
||||
const from = (matchesPage - 1) * matchesPageSize;
|
||||
const to = from + matchesPageSize - 1;
|
||||
|
||||
const { data: matchesData, error: matchesError, count } = await supabase
|
||||
.from('matches')
|
||||
.select(`
|
||||
id, couple_id, match_type, is_new, created_at,
|
||||
question:questions(id, text)
|
||||
`)
|
||||
`, { count: 'exact' })
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(50);
|
||||
.range(from, to);
|
||||
|
||||
if (matchesError) throw matchesError;
|
||||
|
||||
setMatchesTotal(count || 0);
|
||||
|
||||
// Get unique couple IDs and fetch profiles
|
||||
const coupleIds = [...new Set((matchesData || []).map((m: any) => m.couple_id))];
|
||||
if (coupleIds.length > 0) {
|
||||
@@ -223,14 +246,17 @@ export function UserActivityPage() {
|
||||
} finally {
|
||||
setLoading(prev => ({ ...prev, matches: false }));
|
||||
}
|
||||
}, []);
|
||||
}, [matchesPage, matchesPageSize]);
|
||||
|
||||
// Fetch messages
|
||||
const fetchMessages = useCallback(async () => {
|
||||
setLoading(prev => ({ ...prev, messages: true }));
|
||||
try {
|
||||
const from = (messagesPage - 1) * messagesPageSize;
|
||||
const to = from + messagesPageSize - 1;
|
||||
|
||||
// Fetch messages with match data
|
||||
const { data: messagesData, error: messagesError } = await supabase
|
||||
const { data: messagesData, error: messagesError, count } = await supabase
|
||||
.from('messages')
|
||||
.select(`
|
||||
id, user_id, content, media_path, media_type, created_at,
|
||||
@@ -238,12 +264,14 @@ export function UserActivityPage() {
|
||||
id,
|
||||
question:questions(text)
|
||||
)
|
||||
`)
|
||||
`, { count: 'exact' })
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(50);
|
||||
.range(from, to);
|
||||
|
||||
if (messagesError) throw messagesError;
|
||||
|
||||
setMessagesTotal(count || 0);
|
||||
|
||||
// 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 }> = {};
|
||||
@@ -283,27 +311,31 @@ export function UserActivityPage() {
|
||||
} finally {
|
||||
setLoading(prev => ({ ...prev, messages: false }));
|
||||
}
|
||||
}, []);
|
||||
}, [messagesPage, messagesPageSize]);
|
||||
|
||||
// Fetch signups
|
||||
const fetchSignups = useCallback(async () => {
|
||||
setLoading(prev => ({ ...prev, signups: true }));
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
const from = (signupsPage - 1) * signupsPageSize;
|
||||
const to = from + signupsPageSize - 1;
|
||||
|
||||
const { data, error, count } = await supabase
|
||||
.from('profiles')
|
||||
.select('id, name, email, avatar_url, created_at, onboarding_completed, couple_id')
|
||||
.select('id, name, email, avatar_url, created_at, onboarding_completed, couple_id', { count: 'exact' })
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(50);
|
||||
.range(from, to);
|
||||
|
||||
if (error) throw error;
|
||||
setSignups(data || []);
|
||||
setSignupsTotal(count || 0);
|
||||
} catch (error) {
|
||||
console.error('Failed to load signups:', error);
|
||||
toast.error("Failed to load signups");
|
||||
} finally {
|
||||
setLoading(prev => ({ ...prev, signups: false }));
|
||||
}
|
||||
}, []);
|
||||
}, [signupsPage, signupsPageSize]);
|
||||
|
||||
// Real-time subscriptions
|
||||
const { status: responsesStatus } = useRealtimeSubscription({
|
||||
@@ -338,6 +370,34 @@ export function UserActivityPage() {
|
||||
fetchSignups();
|
||||
}, [fetchResponses, fetchMatches, fetchMessages, fetchSignups]);
|
||||
|
||||
useEffect(() => {
|
||||
const totalPages = Math.max(1, Math.ceil(responsesTotal / responsesPageSize));
|
||||
if (responsesPage > totalPages) {
|
||||
setResponsesPage(totalPages);
|
||||
}
|
||||
}, [responsesPage, responsesPageSize, responsesTotal]);
|
||||
|
||||
useEffect(() => {
|
||||
const totalPages = Math.max(1, Math.ceil(matchesTotal / matchesPageSize));
|
||||
if (matchesPage > totalPages) {
|
||||
setMatchesPage(totalPages);
|
||||
}
|
||||
}, [matchesPage, matchesPageSize, matchesTotal]);
|
||||
|
||||
useEffect(() => {
|
||||
const totalPages = Math.max(1, Math.ceil(messagesTotal / messagesPageSize));
|
||||
if (messagesPage > totalPages) {
|
||||
setMessagesPage(totalPages);
|
||||
}
|
||||
}, [messagesPage, messagesPageSize, messagesTotal]);
|
||||
|
||||
useEffect(() => {
|
||||
const totalPages = Math.max(1, Math.ceil(signupsTotal / signupsPageSize));
|
||||
if (signupsPage > totalPages) {
|
||||
setSignupsPage(totalPages);
|
||||
}
|
||||
}, [signupsPage, signupsPageSize, signupsTotal]);
|
||||
|
||||
// Get current realtime status based on active tab
|
||||
const getCurrentRealtimeStatus = () => {
|
||||
switch (activeTab) {
|
||||
@@ -461,6 +521,19 @@ export function UserActivityPage() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{responsesTotal > 0 && (
|
||||
<PaginationControls
|
||||
page={responsesPage}
|
||||
pageSize={responsesPageSize}
|
||||
totalCount={responsesTotal}
|
||||
onPageChange={setResponsesPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setResponsesPage(1);
|
||||
setResponsesPageSize(size);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Matches Tab */}
|
||||
@@ -535,6 +608,19 @@ export function UserActivityPage() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{matchesTotal > 0 && (
|
||||
<PaginationControls
|
||||
page={matchesPage}
|
||||
pageSize={matchesPageSize}
|
||||
totalCount={matchesTotal}
|
||||
onPageChange={setMatchesPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setMatchesPage(1);
|
||||
setMatchesPageSize(size);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Messages Tab */}
|
||||
@@ -593,6 +679,19 @@ export function UserActivityPage() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{messagesTotal > 0 && (
|
||||
<PaginationControls
|
||||
page={messagesPage}
|
||||
pageSize={messagesPageSize}
|
||||
totalCount={messagesTotal}
|
||||
onPageChange={setMessagesPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setMessagesPage(1);
|
||||
setMessagesPageSize(size);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Signups Tab */}
|
||||
@@ -666,6 +765,19 @@ export function UserActivityPage() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{signupsTotal > 0 && (
|
||||
<PaginationControls
|
||||
page={signupsPage}
|
||||
pageSize={signupsPageSize}
|
||||
totalCount={signupsTotal}
|
||||
onPageChange={setSignupsPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setSignupsPage(1);
|
||||
setSignupsPageSize(size);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { PaginationControls } from '@/components/ui/pagination';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
@@ -58,6 +59,8 @@ export function AdminsPage() {
|
||||
const [admins, setAdmins] = useState<AdminUser[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
|
||||
// Add Admin State
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
@@ -280,6 +283,23 @@ export function AdminsPage() {
|
||||
);
|
||||
});
|
||||
|
||||
const totalCount = filteredAdmins.length;
|
||||
const paginatedAdmins = filteredAdmins.slice(
|
||||
(page - 1) * pageSize,
|
||||
page * pageSize
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||
if (page > totalPages) {
|
||||
setPage(totalPages);
|
||||
}
|
||||
}, [page, pageSize, totalCount]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [search]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -509,7 +529,7 @@ export function AdminsPage() {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredAdmins.map((admin) => (
|
||||
paginatedAdmins.map((admin) => (
|
||||
<TableRow key={admin.user_id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -590,6 +610,19 @@ export function AdminsPage() {
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{filteredAdmins.length > 0 && (
|
||||
<PaginationControls
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
totalCount={totalCount}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setPage(1);
|
||||
setPageSize(size);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit Permissions Dialog */}
|
||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { IconPicker, IconPreview } from '@/components/ui/icon-picker';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { PaginationControls } from '@/components/ui/pagination';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -53,6 +54,9 @@ export function CategoriesPage() {
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [aiIdeasOpen, setAiIdeasOpen] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(12);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
// Form state using the new hook
|
||||
const form = useEntityForm<CategoryFormData, Category>(
|
||||
@@ -71,25 +75,34 @@ export function CategoriesPage() {
|
||||
const fetchCategories = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch categories with pack count
|
||||
const { data: cats, error } = await supabase
|
||||
const from = (page - 1) * pageSize;
|
||||
const to = from + pageSize - 1;
|
||||
|
||||
const { data: cats, error, count } = await supabase
|
||||
.from('categories')
|
||||
.select('*')
|
||||
.order('sort_order', { ascending: true });
|
||||
.select('*', { count: 'exact' })
|
||||
.order('sort_order', { ascending: true })
|
||||
.range(from, to);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Fetch pack counts per category
|
||||
const { data: packs } = await supabase
|
||||
.from('question_packs')
|
||||
.select('category_id');
|
||||
setTotalCount(count || 0);
|
||||
|
||||
const categoryIds = (cats || []).map(c => c.id);
|
||||
const packCounts: Record<string, number> = {};
|
||||
packs?.forEach(p => {
|
||||
if (p.category_id) {
|
||||
packCounts[p.category_id] = (packCounts[p.category_id] || 0) + 1;
|
||||
}
|
||||
});
|
||||
|
||||
if (categoryIds.length > 0) {
|
||||
const { data: packs } = await supabase
|
||||
.from('question_packs')
|
||||
.select('category_id')
|
||||
.in('category_id', categoryIds);
|
||||
|
||||
packs?.forEach(p => {
|
||||
if (p.category_id) {
|
||||
packCounts[p.category_id] = (packCounts[p.category_id] || 0) + 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setCategories(
|
||||
(cats || []).map(c => ({
|
||||
@@ -103,12 +116,19 @@ export function CategoriesPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [page, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories();
|
||||
}, [fetchCategories]);
|
||||
|
||||
useEffect(() => {
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||
if (page > totalPages) {
|
||||
setPage(totalPages);
|
||||
}
|
||||
}, [page, pageSize, totalCount]);
|
||||
|
||||
// =============================================================================
|
||||
// Handlers
|
||||
// =============================================================================
|
||||
@@ -143,7 +163,7 @@ export function CategoriesPage() {
|
||||
} else {
|
||||
const { error } = await auditedSupabase.insert('categories', {
|
||||
...data,
|
||||
sort_order: categories.length,
|
||||
sort_order: totalCount,
|
||||
});
|
||||
if (error) throw error;
|
||||
toast.success('Category created');
|
||||
@@ -406,6 +426,19 @@ export function CategoriesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{categories.length > 0 && (
|
||||
<PaginationControls
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
totalCount={totalCount}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setPage(1);
|
||||
setPageSize(size);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* AI Dialog */}
|
||||
<AiGeneratorDialog
|
||||
open={aiIdeasOpen}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { PaginationControls } from '@/components/ui/pagination';
|
||||
import { Plus, MessageCircle, Pencil, Trash2, Sparkles, Crown, Eye, EyeOff, Flame, Heart, Tags, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
|
||||
@@ -60,6 +61,9 @@ export function PacksPage() {
|
||||
const [extractTopicsPack, setExtractTopicsPack] = useState<QuestionPack | null>(null);
|
||||
const [allTopics, setAllTopics] = useState<Topic[]>([]);
|
||||
const [selectedTopicIds, setSelectedTopicIds] = useState<Set<string>>(new Set());
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(12);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
// Form state using the hook
|
||||
const form = useEntityForm<PackFormData, QuestionPack>(
|
||||
@@ -98,30 +102,39 @@ export function PacksPage() {
|
||||
setCategory(cat);
|
||||
}
|
||||
|
||||
const from = (page - 1) * pageSize;
|
||||
const to = from + pageSize - 1;
|
||||
|
||||
// Fetch packs
|
||||
let query = supabase
|
||||
.from('question_packs')
|
||||
.select('*')
|
||||
.order('sort_order', { ascending: true });
|
||||
.select('*', { count: 'exact' })
|
||||
.order('sort_order', { ascending: true })
|
||||
.range(from, to);
|
||||
|
||||
if (categoryId) {
|
||||
query = query.eq('category_id', categoryId);
|
||||
}
|
||||
|
||||
const { data: packData, error } = await query;
|
||||
const { data: packData, error, count } = await query;
|
||||
if (error) throw error;
|
||||
|
||||
setTotalCount(count || 0);
|
||||
|
||||
// Fetch question counts
|
||||
const packIds = (packData || []).map(p => p.id);
|
||||
const { data: questions } = await supabase
|
||||
.from('questions')
|
||||
.select('pack_id')
|
||||
.in('pack_id', packIds);
|
||||
|
||||
const questionCounts: Record<string, number> = {};
|
||||
questions?.forEach(q => {
|
||||
questionCounts[q.pack_id] = (questionCounts[q.pack_id] || 0) + 1;
|
||||
});
|
||||
|
||||
if (packIds.length > 0) {
|
||||
const { data: questions } = await supabase
|
||||
.from('questions')
|
||||
.select('pack_id')
|
||||
.in('pack_id', packIds);
|
||||
|
||||
questions?.forEach(q => {
|
||||
questionCounts[q.pack_id] = (questionCounts[q.pack_id] || 0) + 1;
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch all topics
|
||||
const { data: topicsData } = await supabase
|
||||
@@ -131,13 +144,17 @@ export function PacksPage() {
|
||||
setAllTopics(topicsData || []);
|
||||
|
||||
// Fetch pack_topics relationships
|
||||
const { data: packTopicsData } = await supabase
|
||||
.from('pack_topics')
|
||||
.select('pack_id, topic_id, topics(id, name)')
|
||||
.in('pack_id', packIds);
|
||||
let packTopicsData: any[] = [];
|
||||
if (packIds.length > 0) {
|
||||
const { data } = await supabase
|
||||
.from('pack_topics')
|
||||
.select('pack_id, topic_id, topics(id, name)')
|
||||
.in('pack_id', packIds);
|
||||
packTopicsData = data || [];
|
||||
}
|
||||
|
||||
const packTopicsMap: Record<string, Topic[]> = {};
|
||||
packTopicsData?.forEach((pt: any) => {
|
||||
packTopicsData.forEach((pt: any) => {
|
||||
if (!packTopicsMap[pt.pack_id]) {
|
||||
packTopicsMap[pt.pack_id] = [];
|
||||
}
|
||||
@@ -159,12 +176,19 @@ export function PacksPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [categoryId]);
|
||||
}, [categoryId, page, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||
if (page > totalPages) {
|
||||
setPage(totalPages);
|
||||
}
|
||||
}, [page, pageSize, totalCount]);
|
||||
|
||||
// =============================================================================
|
||||
// Handlers
|
||||
// =============================================================================
|
||||
@@ -211,7 +235,7 @@ export function PacksPage() {
|
||||
is_public: form.formData.is_public,
|
||||
is_explicit: form.formData.is_explicit,
|
||||
category_id: categoryId || null,
|
||||
sort_order: packs.length,
|
||||
sort_order: totalCount,
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
@@ -535,6 +559,19 @@ export function PacksPage() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{packs.length > 0 && (
|
||||
<PaginationControls
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
totalCount={totalCount}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setPage(1);
|
||||
setPageSize(size);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { PaginationControls } from '@/components/ui/pagination';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -70,6 +71,9 @@ export function QuestionsPage() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [bulkDeleting, setBulkDeleting] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState<{
|
||||
@@ -100,14 +104,19 @@ export function QuestionsPage() {
|
||||
|
||||
setPack(packData);
|
||||
|
||||
const from = (page - 1) * pageSize;
|
||||
const to = from + pageSize - 1;
|
||||
|
||||
// Fetch questions
|
||||
const { data: questionData, error } = await supabase
|
||||
const { data: questionData, error, count } = await supabase
|
||||
.from('questions')
|
||||
.select('*')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('pack_id', packId)
|
||||
.order('created_at', { ascending: true });
|
||||
.order('created_at', { ascending: true })
|
||||
.range(from, to);
|
||||
|
||||
if (error) throw error;
|
||||
setTotalCount(count || 0);
|
||||
setQuestions(questionData || []);
|
||||
} catch (error) {
|
||||
toast.error('Failed to load questions');
|
||||
@@ -115,33 +124,49 @@ export function QuestionsPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [packId]);
|
||||
}, [packId, page, pageSize]);
|
||||
|
||||
// Real-time subscription for questions in this pack
|
||||
const { status: realtimeStatus } = useRealtimeSubscription<Question>({
|
||||
table: 'questions',
|
||||
filter: packId ? `pack_id=eq.${packId}` : undefined,
|
||||
enabled: !!packId,
|
||||
onInsert: useCallback((newQuestion: Question) => {
|
||||
setQuestions(prev => [...prev, newQuestion]);
|
||||
}, []),
|
||||
onUpdate: useCallback(({ new: updated }: { old: Question; new: Question }) => {
|
||||
setQuestions(prev => prev.map(q => q.id === updated.id ? updated : q));
|
||||
}, []),
|
||||
onInsert: useCallback(() => {
|
||||
fetchData();
|
||||
}, [fetchData]),
|
||||
onUpdate: useCallback(() => {
|
||||
fetchData();
|
||||
}, [fetchData]),
|
||||
onDelete: useCallback((deleted: Question) => {
|
||||
setQuestions(prev => prev.filter(q => q.id !== deleted.id));
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(deleted.id);
|
||||
return next;
|
||||
});
|
||||
}, []),
|
||||
fetchData();
|
||||
}, [fetchData]),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||
if (page > totalPages) {
|
||||
setPage(totalPages);
|
||||
}
|
||||
}, [page, pageSize, totalCount]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
setSelectedIds(new Set());
|
||||
}, [packId]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIds(new Set());
|
||||
}, [page, pageSize]);
|
||||
|
||||
const openCreateDialog = () => {
|
||||
setEditingQuestion(null);
|
||||
setFormData({ text: '', partner_text: '', intensity: 1, allowed_couple_genders: [], target_user_genders: [] });
|
||||
@@ -319,7 +344,7 @@ export function QuestionsPage() {
|
||||
<RealtimeStatusIndicator status={realtimeStatus} showLabel />
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
{questions.length} question{questions.length !== 1 ? 's' : ''} in this pack
|
||||
{totalCount} question{totalCount !== 1 ? 's' : ''} in this pack
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@@ -615,7 +640,7 @@ export function QuestionsPage() {
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium text-muted-foreground">
|
||||
{index + 1}
|
||||
{(page - 1) * pageSize + index + 1}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="line-clamp-2">{question.text}</span>
|
||||
@@ -689,6 +714,19 @@ export function QuestionsPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{questions.length > 0 && (
|
||||
<PaginationControls
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
totalCount={totalCount}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setPage(1);
|
||||
setPageSize(size);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div >
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { format, isToday, isYesterday } from 'date-fns';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
||||
import { PaginationControls } from '@/components/ui/pagination';
|
||||
|
||||
interface Profile {
|
||||
id: string;
|
||||
@@ -331,79 +332,103 @@ export function MatchChatPage() {
|
||||
const [match, setMatch] = useState<Match | null>(null);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!userId || !matchId) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch user profile
|
||||
const { data: userData } = await supabase
|
||||
.from('profiles')
|
||||
.select('id, name, avatar_url, couple_id')
|
||||
.eq('id', userId)
|
||||
.single();
|
||||
|
||||
setUser(userData);
|
||||
|
||||
// Fetch partner
|
||||
if (userData?.couple_id) {
|
||||
const { data: partnerData } = await supabase
|
||||
.from('profiles')
|
||||
.select('id, name, avatar_url')
|
||||
.eq('couple_id', userData.couple_id)
|
||||
.neq('id', userId)
|
||||
.single();
|
||||
|
||||
setPartner(partnerData);
|
||||
} else {
|
||||
setPartner(null);
|
||||
}
|
||||
|
||||
// Fetch match with question
|
||||
const { data: matchData } = await supabase
|
||||
.from('matches')
|
||||
.select(`
|
||||
id, match_type,
|
||||
question:questions (id, text)
|
||||
`)
|
||||
.eq('id', matchId)
|
||||
.single();
|
||||
|
||||
setMatch(matchData as unknown as Match);
|
||||
|
||||
const from = (page - 1) * pageSize;
|
||||
const to = from + pageSize - 1;
|
||||
|
||||
// Fetch messages
|
||||
const { data: messageData, error: messagesError, count } = await supabase
|
||||
.from('messages')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('match_id', matchId)
|
||||
.order('created_at', { ascending: false })
|
||||
.range(from, to);
|
||||
|
||||
if (messagesError) throw messagesError;
|
||||
|
||||
setTotalCount(count || 0);
|
||||
const orderedMessages = (messageData || []).slice().reverse();
|
||||
setMessages(orderedMessages);
|
||||
} catch (error) {
|
||||
console.error('Failed to load chat data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [userId, matchId, page, pageSize]);
|
||||
|
||||
// Real-time subscription for messages in this chat
|
||||
const { status: realtimeStatus } = useRealtimeSubscription<Message>({
|
||||
table: 'messages',
|
||||
filter: matchId ? `match_id=eq.${matchId}` : undefined,
|
||||
enabled: !!matchId,
|
||||
onInsert: useCallback((newMessage: Message) => {
|
||||
setMessages(prev => [...prev, newMessage]);
|
||||
}, []),
|
||||
onUpdate: useCallback(({ new: updated }: { old: Message; new: Message }) => {
|
||||
setMessages(prev => prev.map(m => m.id === updated.id ? updated : m));
|
||||
}, []),
|
||||
onDelete: useCallback((deleted: Message) => {
|
||||
setMessages(prev => prev.filter(m => m.id !== deleted.id));
|
||||
}, []),
|
||||
onInsert: useCallback(() => {
|
||||
fetchData();
|
||||
}, [fetchData]),
|
||||
onUpdate: useCallback(() => {
|
||||
fetchData();
|
||||
}, [fetchData]),
|
||||
onDelete: useCallback(() => {
|
||||
fetchData();
|
||||
}, [fetchData]),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (!userId || !matchId) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch user profile
|
||||
const { data: userData } = await supabase
|
||||
.from('profiles')
|
||||
.select('id, name, avatar_url, couple_id')
|
||||
.eq('id', userId)
|
||||
.single();
|
||||
|
||||
setUser(userData);
|
||||
|
||||
// Fetch partner
|
||||
if (userData?.couple_id) {
|
||||
const { data: partnerData } = await supabase
|
||||
.from('profiles')
|
||||
.select('id, name, avatar_url')
|
||||
.eq('couple_id', userData.couple_id)
|
||||
.neq('id', userId)
|
||||
.single();
|
||||
|
||||
setPartner(partnerData);
|
||||
}
|
||||
|
||||
// Fetch match with question
|
||||
const { data: matchData } = await supabase
|
||||
.from('matches')
|
||||
.select(`
|
||||
id, match_type,
|
||||
question:questions (id, text)
|
||||
`)
|
||||
.eq('id', matchId)
|
||||
.single();
|
||||
|
||||
setMatch(matchData as unknown as Match);
|
||||
|
||||
// Fetch messages
|
||||
const { data: messageData } = await supabase
|
||||
.from('messages')
|
||||
.select('*')
|
||||
.eq('match_id', matchId)
|
||||
.order('created_at', { ascending: true });
|
||||
|
||||
setMessages(messageData || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load chat data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [userId, matchId]);
|
||||
}, [fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||
if (page > totalPages) {
|
||||
setPage(totalPages);
|
||||
}
|
||||
}, [page, pageSize, totalCount]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [matchId]);
|
||||
|
||||
const getProfile = (senderId: string): Profile | null => {
|
||||
if (senderId === user?.id) return user;
|
||||
@@ -497,7 +522,7 @@ export function MatchChatPage() {
|
||||
<Card className="min-h-[400px]">
|
||||
<CardHeader className="py-4 border-b">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Messages ({messages.length})
|
||||
Messages ({totalCount})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
@@ -580,6 +605,21 @@ export function MatchChatPage() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totalCount > 0 && (
|
||||
<div className="mt-4">
|
||||
<PaginationControls
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
totalCount={totalCount}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setPage(1);
|
||||
setPageSize(size);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { PaginationControls } from '@/components/ui/pagination';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
@@ -237,6 +238,15 @@ export function UserDetailPage() {
|
||||
const [responses, setResponses] = useState<Response[]>([]);
|
||||
const [matches, setMatches] = useState<Match[]>([]);
|
||||
const [mediaMessages, setMediaMessages] = useState<any[]>([]);
|
||||
const [responsesPage, setResponsesPage] = useState(1);
|
||||
const [responsesPageSize, setResponsesPageSize] = useState(10);
|
||||
const [responsesTotal, setResponsesTotal] = useState(0);
|
||||
const [matchesPage, setMatchesPage] = useState(1);
|
||||
const [matchesPageSize, setMatchesPageSize] = useState(10);
|
||||
const [matchesTotal, setMatchesTotal] = useState(0);
|
||||
const [mediaPage, setMediaPage] = useState(1);
|
||||
const [mediaPageSize, setMediaPageSize] = useState(12);
|
||||
const [mediaTotal, setMediaTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const canViewResponses = hasPermission(PERMISSION_KEYS.VIEW_RESPONSES);
|
||||
@@ -308,44 +318,121 @@ export function UserDetailPage() {
|
||||
setPartner(null);
|
||||
}
|
||||
|
||||
const { data: responseData } = await supabase.from('responses').select('id, answer, created_at, question:questions(id, text, pack:question_packs(name))').eq('user_id', userId).order('created_at', { ascending: false }).limit(50);
|
||||
const responsesFrom = (responsesPage - 1) * responsesPageSize;
|
||||
const responsesTo = responsesFrom + responsesPageSize - 1;
|
||||
|
||||
const { data: responseData, count: responsesCount, error: responsesError } = await supabase
|
||||
.from('responses')
|
||||
.select('id, answer, created_at, question:questions(id, text, pack:question_packs(name))', { count: 'exact' })
|
||||
.eq('user_id', userId)
|
||||
.order('created_at', { ascending: false })
|
||||
.range(responsesFrom, responsesTo);
|
||||
|
||||
if (responsesError) throw responsesError;
|
||||
|
||||
setResponses((responseData || []) as unknown as Response[]);
|
||||
setResponsesTotal(responsesCount || 0);
|
||||
|
||||
if (profileData?.couple_id) {
|
||||
const { data: matchData } = await supabase.from('matches').select('id, match_type, is_new, created_at, question:questions(id, text)').eq('couple_id', profileData.couple_id).order('created_at', { ascending: false }).limit(50);
|
||||
const matchesFrom = (matchesPage - 1) * matchesPageSize;
|
||||
const matchesTo = matchesFrom + matchesPageSize - 1;
|
||||
|
||||
const { data: matchData, count: matchesCount, error: matchesError } = await supabase
|
||||
.from('matches')
|
||||
.select('id, match_type, is_new, created_at, question:questions(id, text)', { count: 'exact' })
|
||||
.eq('couple_id', profileData.couple_id)
|
||||
.order('created_at', { ascending: false })
|
||||
.range(matchesFrom, matchesTo);
|
||||
|
||||
if (matchesError) throw matchesError;
|
||||
|
||||
setMatchesTotal(matchesCount || 0);
|
||||
|
||||
const matchIds = (matchData || []).map(m => m.id);
|
||||
if (matchIds.length > 0) {
|
||||
const { data: messages } = await supabase.from('messages').select('match_id').in('match_id', matchIds);
|
||||
const messageCounts: Record<string, number> = {};
|
||||
messages?.forEach(m => { messageCounts[m.match_id] = (messageCounts[m.match_id] || 0) + 1; });
|
||||
setMatches((matchData || []).map(m => ({ ...m, message_count: messageCounts[m.id] || 0 })) as unknown as Match[]);
|
||||
const { data: mediaMessagesData, error: mediaError } = await supabase.from('messages').select('id, user_id, media_path, media_type, version, created_at, keys_metadata, match_id').in('match_id', matchIds).not('media_path', 'is', null).order('created_at', { ascending: false });
|
||||
} else {
|
||||
setMatches([]);
|
||||
}
|
||||
|
||||
const { data: allMatchIdsData } = await supabase
|
||||
.from('matches')
|
||||
.select('id')
|
||||
.eq('couple_id', profileData.couple_id);
|
||||
|
||||
const allMatchIds = (allMatchIdsData || []).map(m => m.id);
|
||||
if (allMatchIds.length > 0) {
|
||||
const mediaFrom = (mediaPage - 1) * mediaPageSize;
|
||||
const mediaTo = mediaFrom + mediaPageSize - 1;
|
||||
|
||||
const { data: mediaMessagesData, error: mediaError, count: mediaCount } = await supabase
|
||||
.from('messages')
|
||||
.select('id, user_id, media_path, media_type, version, created_at, keys_metadata, match_id', { count: 'exact' })
|
||||
.in('match_id', allMatchIds)
|
||||
.not('media_path', 'is', null)
|
||||
.order('created_at', { ascending: false })
|
||||
.range(mediaFrom, mediaTo);
|
||||
|
||||
if (mediaError) {
|
||||
console.error("Error fetching media messages:", mediaError);
|
||||
setMediaMessages([]);
|
||||
setMediaTotal(0);
|
||||
} else {
|
||||
setMediaMessages(mediaMessagesData || []);
|
||||
setMediaTotal(mediaCount || 0);
|
||||
}
|
||||
} else {
|
||||
setMatches([]);
|
||||
setMediaMessages([]);
|
||||
setMediaTotal(0);
|
||||
}
|
||||
} else {
|
||||
setMatches([]);
|
||||
setMatchesTotal(0);
|
||||
setMediaMessages([]);
|
||||
setMediaTotal(0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load user data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [userId]);
|
||||
}, [userId, responsesPage, responsesPageSize, matchesPage, matchesPageSize, mediaPage, mediaPageSize]);
|
||||
|
||||
const { status: profileStatus } = useRealtimeSubscription<Profile>({ table: 'profiles', filter: userId ? `id=eq.${userId}` : undefined, enabled: !!userId, onUpdate: useCallback(({ new: updated }: { old: Profile; new: Profile }) => { setProfile(updated); }, []) });
|
||||
const { status: responsesStatus } = useRealtimeSubscription<Response>({ table: 'responses', filter: userId ? `user_id=eq.${userId}` : undefined, enabled: !!userId, onInsert: fetchData, onDelete: fetchData });
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
setResponsesPage(1);
|
||||
setMatchesPage(1);
|
||||
setMediaPage(1);
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
const totalPages = Math.max(1, Math.ceil(responsesTotal / responsesPageSize));
|
||||
if (responsesPage > totalPages) {
|
||||
setResponsesPage(totalPages);
|
||||
}
|
||||
}, [responsesPage, responsesPageSize, responsesTotal]);
|
||||
|
||||
useEffect(() => {
|
||||
const totalPages = Math.max(1, Math.ceil(matchesTotal / matchesPageSize));
|
||||
if (matchesPage > totalPages) {
|
||||
setMatchesPage(totalPages);
|
||||
}
|
||||
}, [matchesPage, matchesPageSize, matchesTotal]);
|
||||
|
||||
useEffect(() => {
|
||||
const totalPages = Math.max(1, Math.ceil(mediaTotal / mediaPageSize));
|
||||
if (mediaPage > totalPages) {
|
||||
setMediaPage(totalPages);
|
||||
}
|
||||
}, [mediaPage, mediaPageSize, mediaTotal]);
|
||||
|
||||
if (loading) return <div className="space-y-6"><Skeleton className="h-32" /><Skeleton className="h-96" /></div>;
|
||||
if (!profile) return <div className="flex flex-col items-center justify-center py-12"><p className="text-lg font-medium">User not found</p><Link to="/users"><Button variant="link">Back to users</Button></Link></div>;
|
||||
|
||||
@@ -378,19 +465,19 @@ export function UserDetailPage() {
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue={canViewResponses ? "responses" : canViewMatches ? "matches" : canViewMedia ? "media" : "responses"}>
|
||||
<TabsList>
|
||||
{canViewResponses && <TabsTrigger value="responses">Responses ({responses.length})</TabsTrigger>}
|
||||
{canViewMatches && <TabsTrigger value="matches" disabled={!profile.couple_id}>Matches ({matches.length})</TabsTrigger>}
|
||||
{canViewMedia && <TabsTrigger value="media">Media ({mediaMessages.length})</TabsTrigger>}
|
||||
{canViewResponses && <TabsTrigger value="responses">Responses ({responsesTotal})</TabsTrigger>}
|
||||
{canViewMatches && <TabsTrigger value="matches" disabled={!profile.couple_id}>Matches ({matchesTotal})</TabsTrigger>}
|
||||
{canViewMedia && <TabsTrigger value="media">Media ({mediaTotal})</TabsTrigger>}
|
||||
</TabsList>
|
||||
{canViewResponses && <TabsContent value="responses" className="mt-4">{responses.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No responses yet</p></Card> : <div className="rounded-md border"><Table><TableHeader><TableRow><TableHead>Question</TableHead><TableHead>Pack</TableHead><TableHead className="w-24">Answer</TableHead><TableHead className="w-32">Date</TableHead></TableRow></TableHeader><TableBody>{responses.map((r) => (<TableRow key={r.id}><TableCell className="max-w-md"><span className="line-clamp-2">{r.question.text}</span></TableCell><TableCell className="text-muted-foreground">{r.question.pack?.name || '—'}</TableCell><TableCell><div className="flex items-center gap-2">{answerIcons[r.answer]}<span className="capitalize">{r.answer}</span></div></TableCell><TableCell className="text-muted-foreground text-sm">{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}</TableCell></TableRow>))}</TableBody></Table></div>}</TabsContent>}
|
||||
{canViewMatches && <TabsContent value="matches" className="mt-4">{matches.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No matches yet</p></Card> : <div className="grid gap-4 md:grid-cols-2">{matches.map((match) => (<Card key={match.id} className="hover:shadow-md transition-shadow"><CardHeader className="pb-2"><div className="flex items-start justify-between"><Badge variant="secondary">{matchTypeLabels[match.match_type]}</Badge>{match.is_new && <Badge variant="default" className="text-xs">New</Badge>}</div><CardDescription className="line-clamp-2 mt-2">{match.question?.text || 'Unknown question'}</CardDescription></CardHeader><CardContent><div className="flex items-center justify-between"><div className="flex items-center gap-1 text-sm text-muted-foreground"><MessageCircle className="h-4 w-4" />{match.message_count} message{match.message_count !== 1 ? 's' : ''}</div>{canViewChats && <Link to={`/users/${userId}/matches/${match.id}`}><Button variant="ghost" size="sm">View Chat<ChevronRight className="ml-1 h-4 w-4" /></Button></Link>}</div></CardContent></Card>))}</div>}</TabsContent>}
|
||||
{canViewResponses && <TabsContent value="responses" className="mt-4">{responses.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No responses yet</p></Card> : <div className="space-y-4"><div className="rounded-md border"><Table><TableHeader><TableRow><TableHead>Question</TableHead><TableHead>Pack</TableHead><TableHead className="w-24">Answer</TableHead><TableHead className="w-32">Date</TableHead></TableRow></TableHeader><TableBody>{responses.map((r) => (<TableRow key={r.id}><TableCell className="max-w-md"><span className="line-clamp-2">{r.question.text}</span></TableCell><TableCell className="text-muted-foreground">{r.question.pack?.name || '—'}</TableCell><TableCell><div className="flex items-center gap-2">{answerIcons[r.answer]}<span className="capitalize">{r.answer}</span></div></TableCell><TableCell className="text-muted-foreground text-sm">{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}</TableCell></TableRow>))}</TableBody></Table></div><PaginationControls page={responsesPage} pageSize={responsesPageSize} totalCount={responsesTotal} onPageChange={setResponsesPage} onPageSizeChange={(size) => { setResponsesPage(1); setResponsesPageSize(size); }} /></div>}</TabsContent>}
|
||||
{canViewMatches && <TabsContent value="matches" className="mt-4">{matches.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No matches yet</p></Card> : <div className="space-y-4"><div className="grid gap-4 md:grid-cols-2">{matches.map((match) => (<Card key={match.id} className="hover:shadow-md transition-shadow"><CardHeader className="pb-2"><div className="flex items-start justify-between"><Badge variant="secondary">{matchTypeLabels[match.match_type]}</Badge>{match.is_new && <Badge variant="default" className="text-xs">New</Badge>}</div><CardDescription className="line-clamp-2 mt-2">{match.question?.text || 'Unknown question'}</CardDescription></CardHeader><CardContent><div className="flex items-center justify-between"><div className="flex items-center gap-1 text-sm text-muted-foreground"><MessageCircle className="h-4 w-4" />{match.message_count} message{match.message_count !== 1 ? 's' : ''}</div>{canViewChats && <Link to={`/users/${userId}/matches/${match.id}`}><Button variant="ghost" size="sm">View Chat<ChevronRight className="ml-1 h-4 w-4" /></Button></Link>}</div></CardContent></Card>))}</div><PaginationControls page={matchesPage} pageSize={matchesPageSize} totalCount={matchesTotal} onPageChange={setMatchesPage} onPageSizeChange={(size) => { setMatchesPage(1); setMatchesPageSize(size); }} /></div>}</TabsContent>}
|
||||
{canViewMedia && <TabsContent value="media" className="mt-4">{mediaMessages.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><Image className="h-12 w-12 text-muted-foreground mb-4" /><p className="text-muted-foreground">No media uploaded</p></Card> : <div className="space-y-4"><div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4">{mediaMessages.map((message) => (<Card key={message.id} className="overflow-hidden"><div className="aspect-square bg-muted relative">
|
||||
{message.media_type === 'video' ? (
|
||||
<AdminDecryptedVideo messageId={message.id} />
|
||||
) : (
|
||||
<AdminDecryptedImage messageId={message.id} alt={`Media from ${message.created_at}`} />
|
||||
)}
|
||||
</div><CardContent className="p-3"><div className="flex items-center justify-between text-sm"><div className="truncate text-muted-foreground">{message.media_type === 'video' ? 'Video' : 'Image'}</div>{canViewChats && message.match_id && <Link to={`/users/${userId}/matches/${message.match_id}`} className="text-primary hover:underline text-xs">View Chat</Link>}</div><div className="text-xs text-muted-foreground mt-1">{message.created_at ? format(new Date(message.created_at), 'MMM d, yyyy') : '—'}</div></CardContent></Card>))}</div></div>}</TabsContent>}
|
||||
</div><CardContent className="p-3"><div className="flex items-center justify-between text-sm"><div className="truncate text-muted-foreground">{message.media_type === 'video' ? 'Video' : 'Image'}</div>{canViewChats && message.match_id && <Link to={`/users/${userId}/matches/${message.match_id}`} className="text-primary hover:underline text-xs">View Chat</Link>}</div><div className="text-xs text-muted-foreground mt-1">{message.created_at ? format(new Date(message.created_at), 'MMM d, yyyy') : '—'}</div></CardContent></Card>))}</div><PaginationControls page={mediaPage} pageSize={mediaPageSize} totalCount={mediaTotal} onPageChange={setMediaPage} onPageSizeChange={(size) => { setMediaPage(1); setMediaPageSize(size); }} /></div>}</TabsContent>}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { PaginationControls } from '@/components/ui/pagination';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
||||
@@ -46,16 +47,20 @@ export function UsersPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [filteredProfiles, setFilteredProfiles] = useState<Profile[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
|
||||
const fetchProfiles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch profiles with auth info (includes last_sign_in_at)
|
||||
const { data: profilesData, error } = await supabase
|
||||
const { data: profilesDataRaw, error } = await supabase
|
||||
.rpc('get_profiles_with_auth_info');
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const profilesData = (profilesDataRaw || []) as Profile[];
|
||||
|
||||
// Fetch storage usage per user from storage.objects
|
||||
const { data: storageData } = await supabase
|
||||
.rpc('get_user_storage_usage');
|
||||
@@ -70,7 +75,7 @@ export function UsersPage() {
|
||||
|
||||
// Build a map of couple_id -> users with premium in that couple
|
||||
const couplePartners: Record<string, { id: string; is_premium: boolean }[]> = {};
|
||||
(profilesData || []).forEach(profile => {
|
||||
profilesData.forEach(profile => {
|
||||
if (profile.couple_id) {
|
||||
if (!couplePartners[profile.couple_id]) {
|
||||
couplePartners[profile.couple_id] = [];
|
||||
@@ -83,7 +88,7 @@ export function UsersPage() {
|
||||
});
|
||||
|
||||
// Merge storage data and partner premium status with profiles
|
||||
const profilesWithExtras = (profilesData || []).map(profile => {
|
||||
const profilesWithExtras = profilesData.map(profile => {
|
||||
// Check if partner has premium
|
||||
let partnerIsPremium = false;
|
||||
if (profile.couple_id && couplePartners[profile.couple_id]) {
|
||||
@@ -134,6 +139,7 @@ export function UsersPage() {
|
||||
}, [fetchProfiles]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
if (!search.trim()) {
|
||||
setFilteredProfiles(profiles);
|
||||
return;
|
||||
@@ -150,6 +156,19 @@ export function UsersPage() {
|
||||
);
|
||||
}, [search, profiles]);
|
||||
|
||||
const totalCount = filteredProfiles.length;
|
||||
const paginatedProfiles = filteredProfiles.slice(
|
||||
(page - 1) * pageSize,
|
||||
page * pageSize
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||
if (page > totalPages) {
|
||||
setPage(totalPages);
|
||||
}
|
||||
}, [page, pageSize, totalCount]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -209,7 +228,7 @@ export function UsersPage() {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredProfiles.map((profile) => (
|
||||
paginatedProfiles.map((profile) => (
|
||||
<TableRow key={profile.id}>
|
||||
<TableCell>
|
||||
<Link to={`/users/${profile.id}`} className="flex items-center gap-3 hover:opacity-80 transition-opacity">
|
||||
@@ -294,6 +313,19 @@ export function UsersPage() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{filteredProfiles.length > 0 && (
|
||||
<PaginationControls
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
totalCount={totalCount}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setPage(1);
|
||||
setPageSize(size);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -93,7 +93,7 @@ android {
|
||||
applicationId 'com.sauci.app'
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 17
|
||||
versionCode 21
|
||||
versionName "1.0.0"
|
||||
}
|
||||
signingConfigs {
|
||||
|
||||
@@ -176,7 +176,7 @@
|
||||
"backgroundColor": "#1a1a2e"
|
||||
},
|
||||
"package": "com.sauci.app",
|
||||
"versionCode": 20,
|
||||
"versionCode": 21,
|
||||
"googleServicesFile": "./google-services.json",
|
||||
"permissions": [
|
||||
"RECEIVE_BOOT_COMPLETED",
|
||||
|
||||
@@ -3,5 +3,6 @@
|
||||
NEXT_PUBLIC_POSTHOG_KEY=phc_your_project_key_here
|
||||
NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com
|
||||
|
||||
# Supabase
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
|
||||
# Supabase
|
||||
# Production: https://ckjcrkjpmhqhiucifukx.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
|
||||
+8
-6
@@ -3,12 +3,14 @@ FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Build args for Next.js public env vars (inlined at build time)
|
||||
ARG NEXT_PUBLIC_POSTHOG_KEY
|
||||
ARG NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com
|
||||
|
||||
ENV NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY
|
||||
ENV NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST
|
||||
# Build args for Next.js public env vars (inlined at build time)
|
||||
ARG NEXT_PUBLIC_POSTHOG_KEY
|
||||
ARG NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com
|
||||
ARG NEXT_PUBLIC_SUPABASE_URL=https://ckjcrkjpmhqhiucifukx.supabase.co
|
||||
|
||||
ENV NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY
|
||||
ENV NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST
|
||||
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
|
||||
|
||||
# Install dependencies
|
||||
COPY package*.json ./
|
||||
|
||||
@@ -5,9 +5,10 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
|
||||
- NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST:-https://eu.i.posthog.com}
|
||||
args:
|
||||
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
|
||||
- NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST:-https://eu.i.posthog.com}
|
||||
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL:-https://ckjcrkjpmhqhiucifukx.supabase.co}
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
Reference in new issue
Block a user