mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-01 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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user