Add admin CRUD for UserProfile, AppleSocialAuth, CompletionImage, DocumentImage

Complete admin interface coverage for all database models:
- New Go handlers with list, get, update, delete, bulk delete endpoints
- New Next.js pages with search, pagination, and bulk operations
- Updated sidebar navigation with new menu items

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-07 21:08:50 -06:00
parent a348f31a9e
commit 0ea0c766ea
11 changed files with 2592 additions and 0 deletions
@@ -0,0 +1,309 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import Link from 'next/link';
import { Apple, Trash2, Search, ChevronLeft, ChevronRight, Lock, Unlock } from 'lucide-react';
import { toast } from 'sonner';
import { appleSocialAuthApi, type AppleSocialAuth, type AppleSocialAuthListParams } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
export default function AppleSocialAuthPage() {
const queryClient = useQueryClient();
const [params, setParams] = useState<AppleSocialAuthListParams>({
page: 1,
per_page: 20,
});
const [search, setSearch] = useState('');
const [selectedRows, setSelectedRows] = useState<number[]>([]);
const { data, isLoading, error } = useQuery({
queryKey: ['apple-social-auth', params],
queryFn: () => appleSocialAuthApi.list(params),
});
const deleteMutation = useMutation({
mutationFn: (id: number) => appleSocialAuthApi.delete(id),
onSuccess: () => {
toast.success('Apple Sign In entry deleted successfully');
queryClient.invalidateQueries({ queryKey: ['apple-social-auth'] });
},
onError: () => {
toast.error('Failed to delete Apple Sign In entry');
},
});
const bulkDeleteMutation = useMutation({
mutationFn: (ids: number[]) => appleSocialAuthApi.bulkDelete(ids),
onSuccess: () => {
toast.success('Apple Sign In entries deleted successfully');
queryClient.invalidateQueries({ queryKey: ['apple-social-auth'] });
setSelectedRows([]);
},
onError: () => {
toast.error('Failed to delete Apple Sign In entries');
},
});
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setParams({ ...params, search, page: 1 });
};
const handleSelectAll = (checked: boolean) => {
if (checked && data?.data) {
setSelectedRows(data.data.map((a) => a.id));
} else {
setSelectedRows([]);
}
};
const handleSelectRow = (id: number, checked: boolean) => {
if (checked) {
setSelectedRows([...selectedRows, id]);
} else {
setSelectedRows(selectedRows.filter((rowId) => rowId !== id));
}
};
const totalPages = data ? Math.ceil(data.total / (params.per_page || 20)) : 0;
if (error) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-red-500">Failed to load Apple Sign In entries</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Apple Sign In</h1>
<p className="text-muted-foreground">
Manage Apple Sign In linked accounts
</p>
</div>
</div>
{/* Search and Bulk Actions */}
<div className="flex items-center justify-between gap-4">
<form onSubmit={handleSearch} className="flex gap-2 flex-1 max-w-md">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search by Apple ID, email, or username..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Button type="submit" variant="secondary">
Search
</Button>
</form>
{selectedRows.length > 0 && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">
<Trash2 className="mr-2 h-4 w-4" />
Delete Selected ({selectedRows.length})
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Selected Entries?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete {selectedRows.length} Apple Sign In link(s). Users will need to re-link their Apple accounts.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => bulkDeleteMutation.mutate(selectedRows)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
{/* Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">
<Checkbox
checked={
data?.data &&
data.data.length > 0 &&
selectedRows.length === data.data.length
}
onCheckedChange={handleSelectAll}
/>
</TableHead>
<TableHead>User</TableHead>
<TableHead>Apple ID</TableHead>
<TableHead>Apple Email</TableHead>
<TableHead>Private</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8">
Loading...
</TableCell>
</TableRow>
) : data?.data?.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8">
No Apple Sign In entries found
</TableCell>
</TableRow>
) : (
data?.data?.map((entry) => (
<TableRow key={entry.id}>
<TableCell>
<Checkbox
checked={selectedRows.includes(entry.id)}
onCheckedChange={(checked) =>
handleSelectRow(entry.id, checked as boolean)
}
/>
</TableCell>
<TableCell>
<Link
href={`/admin/users/${entry.user_id}`}
className="font-medium text-blue-600 hover:underline"
>
{entry.username}
</Link>
</TableCell>
<TableCell>
<code className="text-xs bg-muted px-2 py-1 rounded">
{entry.apple_id.substring(0, 20)}...
</code>
</TableCell>
<TableCell>{entry.email || '-'}</TableCell>
<TableCell>
{entry.is_private_email ? (
<span title="Private Relay Email">
<Lock className="h-4 w-4 text-amber-600" />
</span>
) : (
<span title="Real Email">
<Unlock className="h-4 w-4 text-gray-400" />
</span>
)}
</TableCell>
<TableCell>
{new Date(entry.created_at).toLocaleDateString()}
</TableCell>
<TableCell>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Apple Sign In Link?</AlertDialogTitle>
<AlertDialogDescription>
This will unlink the Apple account from {entry.username}. They will need to re-link their Apple account.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteMutation.mutate(entry.id)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{data && totalPages > 1 && (
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
Showing {((params.page || 1) - 1) * (params.per_page || 20) + 1} to{' '}
{Math.min(
(params.page || 1) * (params.per_page || 20),
data.total
)}{' '}
of {data.total} entries
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={(params.page || 1) <= 1}
onClick={() =>
setParams({ ...params, page: (params.page || 1) - 1 })
}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={(params.page || 1) >= totalPages}
onClick={() =>
setParams({ ...params, page: (params.page || 1) + 1 })
}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,329 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import Link from 'next/link';
import { Image, Trash2, Search, ChevronLeft, ChevronRight, ExternalLink } from 'lucide-react';
import { toast } from 'sonner';
import { completionImagesApi, type CompletionImage, type CompletionImageListParams } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
export default function CompletionImagesPage() {
const queryClient = useQueryClient();
const [params, setParams] = useState<CompletionImageListParams>({
page: 1,
per_page: 20,
});
const [search, setSearch] = useState('');
const [selectedRows, setSelectedRows] = useState<number[]>([]);
const { data, isLoading, error } = useQuery({
queryKey: ['completion-images', params],
queryFn: () => completionImagesApi.list(params),
});
const deleteMutation = useMutation({
mutationFn: (id: number) => completionImagesApi.delete(id),
onSuccess: () => {
toast.success('Completion image deleted successfully');
queryClient.invalidateQueries({ queryKey: ['completion-images'] });
},
onError: () => {
toast.error('Failed to delete completion image');
},
});
const bulkDeleteMutation = useMutation({
mutationFn: (ids: number[]) => completionImagesApi.bulkDelete(ids),
onSuccess: () => {
toast.success('Completion images deleted successfully');
queryClient.invalidateQueries({ queryKey: ['completion-images'] });
setSelectedRows([]);
},
onError: () => {
toast.error('Failed to delete completion images');
},
});
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setParams({ ...params, search, page: 1 });
};
const handleSelectAll = (checked: boolean) => {
if (checked && data?.data) {
setSelectedRows(data.data.map((img) => img.id));
} else {
setSelectedRows([]);
}
};
const handleSelectRow = (id: number, checked: boolean) => {
if (checked) {
setSelectedRows([...selectedRows, id]);
} else {
setSelectedRows(selectedRows.filter((rowId) => rowId !== id));
}
};
const totalPages = data ? Math.ceil(data.total / (params.per_page || 20)) : 0;
if (error) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-red-500">Failed to load completion images</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Completion Images</h1>
<p className="text-muted-foreground">
Manage images attached to task completions
</p>
</div>
</div>
{/* Search and Bulk Actions */}
<div className="flex items-center justify-between gap-4">
<form onSubmit={handleSearch} className="flex gap-2 flex-1 max-w-md">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search by URL or caption..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Button type="submit" variant="secondary">
Search
</Button>
</form>
{selectedRows.length > 0 && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">
<Trash2 className="mr-2 h-4 w-4" />
Delete Selected ({selectedRows.length})
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Selected Images?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete {selectedRows.length} image(s).
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => bulkDeleteMutation.mutate(selectedRows)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
{/* Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">
<Checkbox
checked={
data?.data &&
data.data.length > 0 &&
selectedRows.length === data.data.length
}
onCheckedChange={handleSelectAll}
/>
</TableHead>
<TableHead>Preview</TableHead>
<TableHead>Task</TableHead>
<TableHead>Caption</TableHead>
<TableHead>Completion ID</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8">
Loading...
</TableCell>
</TableRow>
) : data?.data?.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8">
No completion images found
</TableCell>
</TableRow>
) : (
data?.data?.map((img) => (
<TableRow key={img.id}>
<TableCell>
<Checkbox
checked={selectedRows.includes(img.id)}
onCheckedChange={(checked) =>
handleSelectRow(img.id, checked as boolean)
}
/>
</TableCell>
<TableCell>
{img.image_url ? (
<a href={img.image_url} target="_blank" rel="noopener noreferrer">
<img
src={img.image_url}
alt={img.caption || 'Completion image'}
className="w-12 h-12 object-cover rounded"
/>
</a>
) : (
<div className="w-12 h-12 bg-gray-100 rounded flex items-center justify-center">
<Image className="h-6 w-6 text-gray-400" />
</div>
)}
</TableCell>
<TableCell>
<Link
href={`/admin/tasks/${img.task_id}`}
className="font-medium text-blue-600 hover:underline"
>
{img.task_title || `Task #${img.task_id}`}
</Link>
</TableCell>
<TableCell>
<span className="truncate max-w-[200px] block">
{img.caption || '-'}
</span>
</TableCell>
<TableCell>
<Link
href={`/admin/completions/${img.completion_id}`}
className="text-blue-600 hover:underline"
>
#{img.completion_id}
</Link>
</TableCell>
<TableCell>
{new Date(img.created_at).toLocaleDateString()}
</TableCell>
<TableCell>
<div className="flex gap-1">
{img.image_url && (
<a href={img.image_url} target="_blank" rel="noopener noreferrer">
<Button variant="ghost" size="icon">
<ExternalLink className="h-4 w-4" />
</Button>
</a>
)}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Image?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete this completion image.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteMutation.mutate(img.id)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{data && totalPages > 1 && (
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
Showing {((params.page || 1) - 1) * (params.per_page || 20) + 1} to{' '}
{Math.min(
(params.page || 1) * (params.per_page || 20),
data.total
)}{' '}
of {data.total} images
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={(params.page || 1) <= 1}
onClick={() =>
setParams({ ...params, page: (params.page || 1) - 1 })
}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={(params.page || 1) >= totalPages}
onClick={() =>
setParams({ ...params, page: (params.page || 1) + 1 })
}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,329 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import Link from 'next/link';
import { ImagePlus, Trash2, Search, ChevronLeft, ChevronRight, ExternalLink } from 'lucide-react';
import { toast } from 'sonner';
import { documentImagesApi, type DocumentImage, type DocumentImageListParams } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
export default function DocumentImagesPage() {
const queryClient = useQueryClient();
const [params, setParams] = useState<DocumentImageListParams>({
page: 1,
per_page: 20,
});
const [search, setSearch] = useState('');
const [selectedRows, setSelectedRows] = useState<number[]>([]);
const { data, isLoading, error } = useQuery({
queryKey: ['document-images', params],
queryFn: () => documentImagesApi.list(params),
});
const deleteMutation = useMutation({
mutationFn: (id: number) => documentImagesApi.delete(id),
onSuccess: () => {
toast.success('Document image deleted successfully');
queryClient.invalidateQueries({ queryKey: ['document-images'] });
},
onError: () => {
toast.error('Failed to delete document image');
},
});
const bulkDeleteMutation = useMutation({
mutationFn: (ids: number[]) => documentImagesApi.bulkDelete(ids),
onSuccess: () => {
toast.success('Document images deleted successfully');
queryClient.invalidateQueries({ queryKey: ['document-images'] });
setSelectedRows([]);
},
onError: () => {
toast.error('Failed to delete document images');
},
});
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setParams({ ...params, search, page: 1 });
};
const handleSelectAll = (checked: boolean) => {
if (checked && data?.data) {
setSelectedRows(data.data.map((img) => img.id));
} else {
setSelectedRows([]);
}
};
const handleSelectRow = (id: number, checked: boolean) => {
if (checked) {
setSelectedRows([...selectedRows, id]);
} else {
setSelectedRows(selectedRows.filter((rowId) => rowId !== id));
}
};
const totalPages = data ? Math.ceil(data.total / (params.per_page || 20)) : 0;
if (error) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-red-500">Failed to load document images</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Document Images</h1>
<p className="text-muted-foreground">
Manage images attached to documents
</p>
</div>
</div>
{/* Search and Bulk Actions */}
<div className="flex items-center justify-between gap-4">
<form onSubmit={handleSearch} className="flex gap-2 flex-1 max-w-md">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search by URL or caption..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Button type="submit" variant="secondary">
Search
</Button>
</form>
{selectedRows.length > 0 && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">
<Trash2 className="mr-2 h-4 w-4" />
Delete Selected ({selectedRows.length})
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Selected Images?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete {selectedRows.length} image(s).
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => bulkDeleteMutation.mutate(selectedRows)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
{/* Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">
<Checkbox
checked={
data?.data &&
data.data.length > 0 &&
selectedRows.length === data.data.length
}
onCheckedChange={handleSelectAll}
/>
</TableHead>
<TableHead>Preview</TableHead>
<TableHead>Document</TableHead>
<TableHead>Residence</TableHead>
<TableHead>Caption</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8">
Loading...
</TableCell>
</TableRow>
) : data?.data?.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8">
No document images found
</TableCell>
</TableRow>
) : (
data?.data?.map((img) => (
<TableRow key={img.id}>
<TableCell>
<Checkbox
checked={selectedRows.includes(img.id)}
onCheckedChange={(checked) =>
handleSelectRow(img.id, checked as boolean)
}
/>
</TableCell>
<TableCell>
{img.image_url ? (
<a href={img.image_url} target="_blank" rel="noopener noreferrer">
<img
src={img.image_url}
alt={img.caption || 'Document image'}
className="w-12 h-12 object-cover rounded"
/>
</a>
) : (
<div className="w-12 h-12 bg-gray-100 rounded flex items-center justify-center">
<ImagePlus className="h-6 w-6 text-gray-400" />
</div>
)}
</TableCell>
<TableCell>
<Link
href={`/admin/documents/${img.document_id}`}
className="font-medium text-blue-600 hover:underline"
>
{img.document_title || `Document #${img.document_id}`}
</Link>
</TableCell>
<TableCell>
<Link
href={`/admin/residences/${img.residence_id}`}
className="text-blue-600 hover:underline"
>
{img.residence_name || `#${img.residence_id}`}
</Link>
</TableCell>
<TableCell>
<span className="truncate max-w-[200px] block">
{img.caption || '-'}
</span>
</TableCell>
<TableCell>
{new Date(img.created_at).toLocaleDateString()}
</TableCell>
<TableCell>
<div className="flex gap-1">
{img.image_url && (
<a href={img.image_url} target="_blank" rel="noopener noreferrer">
<Button variant="ghost" size="icon">
<ExternalLink className="h-4 w-4" />
</Button>
</a>
)}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Image?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete this document image.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteMutation.mutate(img.id)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{data && totalPages > 1 && (
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
Showing {((params.page || 1) - 1) * (params.per_page || 20) + 1} to{' '}
{Math.min(
(params.page || 1) * (params.per_page || 20),
data.total
)}{' '}
of {data.total} images
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={(params.page || 1) <= 1}
onClick={() =>
setParams({ ...params, page: (params.page || 1) - 1 })
}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={(params.page || 1) >= totalPages}
onClick={() =>
setParams({ ...params, page: (params.page || 1) + 1 })
}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,301 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import Link from 'next/link';
import { UserCircle, Trash2, Search, ChevronLeft, ChevronRight, CheckCircle, XCircle } from 'lucide-react';
import { toast } from 'sonner';
import { userProfilesApi, type UserProfile, type UserProfileListParams } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
export default function UserProfilesPage() {
const queryClient = useQueryClient();
const [params, setParams] = useState<UserProfileListParams>({
page: 1,
per_page: 20,
});
const [search, setSearch] = useState('');
const [selectedRows, setSelectedRows] = useState<number[]>([]);
const { data, isLoading, error } = useQuery({
queryKey: ['user-profiles', params],
queryFn: () => userProfilesApi.list(params),
});
const deleteMutation = useMutation({
mutationFn: (id: number) => userProfilesApi.delete(id),
onSuccess: () => {
toast.success('User profile deleted successfully');
queryClient.invalidateQueries({ queryKey: ['user-profiles'] });
},
onError: () => {
toast.error('Failed to delete user profile');
},
});
const bulkDeleteMutation = useMutation({
mutationFn: (ids: number[]) => userProfilesApi.bulkDelete(ids),
onSuccess: () => {
toast.success('User profiles deleted successfully');
queryClient.invalidateQueries({ queryKey: ['user-profiles'] });
setSelectedRows([]);
},
onError: () => {
toast.error('Failed to delete user profiles');
},
});
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setParams({ ...params, search, page: 1 });
};
const handleSelectAll = (checked: boolean) => {
if (checked && data?.data) {
setSelectedRows(data.data.map((p) => p.id));
} else {
setSelectedRows([]);
}
};
const handleSelectRow = (id: number, checked: boolean) => {
if (checked) {
setSelectedRows([...selectedRows, id]);
} else {
setSelectedRows(selectedRows.filter((rowId) => rowId !== id));
}
};
const totalPages = data ? Math.ceil(data.total / (params.per_page || 20)) : 0;
if (error) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-red-500">Failed to load user profiles</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">User Profiles</h1>
<p className="text-muted-foreground">
Manage user profile information
</p>
</div>
</div>
{/* Search and Bulk Actions */}
<div className="flex items-center justify-between gap-4">
<form onSubmit={handleSearch} className="flex gap-2 flex-1 max-w-md">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search by username, email, or phone..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Button type="submit" variant="secondary">
Search
</Button>
</form>
{selectedRows.length > 0 && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">
<Trash2 className="mr-2 h-4 w-4" />
Delete Selected ({selectedRows.length})
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Selected Profiles?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete {selectedRows.length} profile(s).
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => bulkDeleteMutation.mutate(selectedRows)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
{/* Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">
<Checkbox
checked={
data?.data &&
data.data.length > 0 &&
selectedRows.length === data.data.length
}
onCheckedChange={handleSelectAll}
/>
</TableHead>
<TableHead>User</TableHead>
<TableHead>Email</TableHead>
<TableHead>Phone</TableHead>
<TableHead>Verified</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8">
Loading...
</TableCell>
</TableRow>
) : data?.data?.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8">
No user profiles found
</TableCell>
</TableRow>
) : (
data?.data?.map((profile) => (
<TableRow key={profile.id}>
<TableCell>
<Checkbox
checked={selectedRows.includes(profile.id)}
onCheckedChange={(checked) =>
handleSelectRow(profile.id, checked as boolean)
}
/>
</TableCell>
<TableCell>
<Link
href={`/admin/users/${profile.user_id}`}
className="font-medium text-blue-600 hover:underline"
>
{profile.username}
</Link>
</TableCell>
<TableCell>{profile.email}</TableCell>
<TableCell>{profile.phone_number || '-'}</TableCell>
<TableCell>
{profile.verified ? (
<CheckCircle className="h-4 w-4 text-green-600" />
) : (
<XCircle className="h-4 w-4 text-gray-400" />
)}
</TableCell>
<TableCell>
{new Date(profile.created_at).toLocaleDateString()}
</TableCell>
<TableCell>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Profile?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete the profile for {profile.username}.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteMutation.mutate(profile.id)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{data && totalPages > 1 && (
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
Showing {((params.page || 1) - 1) * (params.per_page || 20) + 1} to{' '}
{Math.min(
(params.page || 1) * (params.per_page || 20),
data.total
)}{' '}
of {data.total} profiles
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={(params.page || 1) <= 1}
onClick={() =>
setParams({ ...params, page: (params.page || 1) - 1 })
}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={(params.page || 1) >= totalPages}
onClick={() =>
setParams({ ...params, page: (params.page || 1) + 1 })
}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
);
}
+8
View File
@@ -24,6 +24,10 @@ import {
KeyRound,
Smartphone,
LayoutTemplate,
UserCircle,
Apple,
Image,
ImagePlus,
} from 'lucide-react';
import { useRouter, usePathname } from 'next/navigation';
import { useAuthStore } from '@/store/auth';
@@ -44,6 +48,8 @@ import { Button } from '@/components/ui/button';
const menuItems = [
{ title: 'Dashboard', url: '/admin/', icon: Home },
{ title: 'Users', url: '/admin/users', icon: Users },
{ title: 'User Profiles', url: '/admin/user-profiles', icon: UserCircle },
{ title: 'Apple Sign In', url: '/admin/apple-social-auth', icon: Apple },
{ title: 'Auth Tokens', url: '/admin/auth-tokens', icon: Key },
{ title: 'Confirmation Codes', url: '/admin/confirmation-codes', icon: Mail },
{ title: 'Password Resets', url: '/admin/password-reset-codes', icon: KeyRound },
@@ -51,8 +57,10 @@ const menuItems = [
{ title: 'Share Codes', url: '/admin/share-codes', icon: Share2 },
{ title: 'Tasks', url: '/admin/tasks', icon: ClipboardList },
{ title: 'Completions', url: '/admin/completions', icon: CheckCircle },
{ title: 'Completion Images', url: '/admin/completion-images', icon: Image },
{ title: 'Contractors', url: '/admin/contractors', icon: Wrench },
{ title: 'Documents', url: '/admin/documents', icon: FileText },
{ title: 'Document Images', url: '/admin/document-images', icon: ImagePlus },
{ title: 'Notifications', url: '/admin/notifications', icon: Bell },
{ title: 'Notification Prefs', url: '/admin/notification-prefs', icon: BellRing },
{ title: 'Devices', url: '/admin/devices', icon: Smartphone },
+246
View File
@@ -1248,4 +1248,250 @@ export const taskTemplatesApi = {
},
};
// User Profile Types
export interface UserProfile {
id: number;
user_id: number;
username: string;
email: string;
verified: boolean;
bio: string;
phone_number: string;
date_of_birth: string | null;
profile_picture: string;
created_at: string;
updated_at: string;
}
export interface UserProfileListParams {
page?: number;
per_page?: number;
search?: string;
sort_by?: string;
sort_dir?: 'asc' | 'desc';
}
export interface UpdateUserProfileRequest {
verified?: boolean;
bio?: string;
phone_number?: string;
date_of_birth?: string;
profile_picture?: string;
}
// User Profiles API
export const userProfilesApi = {
list: async (params?: UserProfileListParams): Promise<PaginatedResponse<UserProfile>> => {
const response = await api.get<PaginatedResponse<UserProfile>>('/user-profiles', { params });
return response.data;
},
get: async (id: number): Promise<UserProfile> => {
const response = await api.get<UserProfile>(`/user-profiles/${id}`);
return response.data;
},
getByUser: async (userId: number): Promise<UserProfile> => {
const response = await api.get<UserProfile>(`/user-profiles/user/${userId}`);
return response.data;
},
update: async (id: number, data: UpdateUserProfileRequest): Promise<UserProfile> => {
const response = await api.put<UserProfile>(`/user-profiles/${id}`, data);
return response.data;
},
delete: async (id: number): Promise<void> => {
await api.delete(`/user-profiles/${id}`);
},
bulkDelete: async (ids: number[]): Promise<void> => {
await api.delete('/user-profiles/bulk', { data: { ids } });
},
};
// Apple Social Auth Types
export interface AppleSocialAuth {
id: number;
user_id: number;
username: string;
user_email: string;
apple_id: string;
email: string;
is_private_email: boolean;
created_at: string;
updated_at: string;
}
export interface AppleSocialAuthListParams {
page?: number;
per_page?: number;
search?: string;
sort_by?: string;
sort_dir?: 'asc' | 'desc';
}
export interface UpdateAppleSocialAuthRequest {
email?: string;
is_private_email?: boolean;
}
// Apple Social Auth API
export const appleSocialAuthApi = {
list: async (params?: AppleSocialAuthListParams): Promise<PaginatedResponse<AppleSocialAuth>> => {
const response = await api.get<PaginatedResponse<AppleSocialAuth>>('/apple-social-auth', { params });
return response.data;
},
get: async (id: number): Promise<AppleSocialAuth> => {
const response = await api.get<AppleSocialAuth>(`/apple-social-auth/${id}`);
return response.data;
},
getByUser: async (userId: number): Promise<AppleSocialAuth> => {
const response = await api.get<AppleSocialAuth>(`/apple-social-auth/user/${userId}`);
return response.data;
},
update: async (id: number, data: UpdateAppleSocialAuthRequest): Promise<AppleSocialAuth> => {
const response = await api.put<AppleSocialAuth>(`/apple-social-auth/${id}`, data);
return response.data;
},
delete: async (id: number): Promise<void> => {
await api.delete(`/apple-social-auth/${id}`);
},
bulkDelete: async (ids: number[]): Promise<void> => {
await api.delete('/apple-social-auth/bulk', { data: { ids } });
},
};
// Completion Image Types (for standalone management)
export interface CompletionImage {
id: number;
completion_id: number;
task_id: number;
task_title: string;
image_url: string;
caption: string;
created_at: string;
updated_at: string;
}
export interface CompletionImageListParams {
page?: number;
per_page?: number;
search?: string;
sort_by?: string;
sort_dir?: 'asc' | 'desc';
completion_id?: number;
}
export interface CreateCompletionImageRequest {
completion_id: number;
image_url: string;
caption?: string;
}
export interface UpdateCompletionImageRequest {
image_url?: string;
caption?: string;
}
// Completion Images API
export const completionImagesApi = {
list: async (params?: CompletionImageListParams): Promise<PaginatedResponse<CompletionImage>> => {
const response = await api.get<PaginatedResponse<CompletionImage>>('/completion-images', { params });
return response.data;
},
get: async (id: number): Promise<CompletionImage> => {
const response = await api.get<CompletionImage>(`/completion-images/${id}`);
return response.data;
},
create: async (data: CreateCompletionImageRequest): Promise<CompletionImage> => {
const response = await api.post<CompletionImage>('/completion-images', data);
return response.data;
},
update: async (id: number, data: UpdateCompletionImageRequest): Promise<CompletionImage> => {
const response = await api.put<CompletionImage>(`/completion-images/${id}`, data);
return response.data;
},
delete: async (id: number): Promise<void> => {
await api.delete(`/completion-images/${id}`);
},
bulkDelete: async (ids: number[]): Promise<void> => {
await api.delete('/completion-images/bulk', { data: { ids } });
},
};
// Document Image Types
export interface DocumentImage {
id: number;
document_id: number;
document_title: string;
residence_id: number;
residence_name: string;
image_url: string;
caption: string;
created_at: string;
updated_at: string;
}
export interface DocumentImageListParams {
page?: number;
per_page?: number;
search?: string;
sort_by?: string;
sort_dir?: 'asc' | 'desc';
document_id?: number;
}
export interface CreateDocumentImageRequest {
document_id: number;
image_url: string;
caption?: string;
}
export interface UpdateDocumentImageRequest {
image_url?: string;
caption?: string;
}
// Document Images API
export const documentImagesApi = {
list: async (params?: DocumentImageListParams): Promise<PaginatedResponse<DocumentImage>> => {
const response = await api.get<PaginatedResponse<DocumentImage>>('/document-images', { params });
return response.data;
},
get: async (id: number): Promise<DocumentImage> => {
const response = await api.get<DocumentImage>(`/document-images/${id}`);
return response.data;
},
create: async (data: CreateDocumentImageRequest): Promise<DocumentImage> => {
const response = await api.post<DocumentImage>('/document-images', data);
return response.data;
},
update: async (id: number, data: UpdateDocumentImageRequest): Promise<DocumentImage> => {
const response = await api.put<DocumentImage>(`/document-images/${id}`, data);
return response.data;
},
delete: async (id: number): Promise<void> => {
await api.delete(`/document-images/${id}`);
},
bulkDelete: async (ids: number[]): Promise<void> => {
await api.delete('/document-images/bulk', { data: { ids } });
},
};
export default api;