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:
309
admin/src/app/(dashboard)/apple-social-auth/page.tsx
Normal file
309
admin/src/app/(dashboard)/apple-social-auth/page.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
329
admin/src/app/(dashboard)/completion-images/page.tsx
Normal file
329
admin/src/app/(dashboard)/completion-images/page.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
329
admin/src/app/(dashboard)/document-images/page.tsx
Normal file
329
admin/src/app/(dashboard)/document-images/page.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
301
admin/src/app/(dashboard)/user-profiles/page.tsx
Normal file
301
admin/src/app/(dashboard)/user-profiles/page.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,6 +24,10 @@ import {
|
|||||||
KeyRound,
|
KeyRound,
|
||||||
Smartphone,
|
Smartphone,
|
||||||
LayoutTemplate,
|
LayoutTemplate,
|
||||||
|
UserCircle,
|
||||||
|
Apple,
|
||||||
|
Image,
|
||||||
|
ImagePlus,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useRouter, usePathname } from 'next/navigation';
|
import { useRouter, usePathname } from 'next/navigation';
|
||||||
import { useAuthStore } from '@/store/auth';
|
import { useAuthStore } from '@/store/auth';
|
||||||
@@ -44,6 +48,8 @@ import { Button } from '@/components/ui/button';
|
|||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ title: 'Dashboard', url: '/admin/', icon: Home },
|
{ title: 'Dashboard', url: '/admin/', icon: Home },
|
||||||
{ title: 'Users', url: '/admin/users', icon: Users },
|
{ 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: 'Auth Tokens', url: '/admin/auth-tokens', icon: Key },
|
||||||
{ title: 'Confirmation Codes', url: '/admin/confirmation-codes', icon: Mail },
|
{ title: 'Confirmation Codes', url: '/admin/confirmation-codes', icon: Mail },
|
||||||
{ title: 'Password Resets', url: '/admin/password-reset-codes', icon: KeyRound },
|
{ 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: 'Share Codes', url: '/admin/share-codes', icon: Share2 },
|
||||||
{ title: 'Tasks', url: '/admin/tasks', icon: ClipboardList },
|
{ title: 'Tasks', url: '/admin/tasks', icon: ClipboardList },
|
||||||
{ title: 'Completions', url: '/admin/completions', icon: CheckCircle },
|
{ title: 'Completions', url: '/admin/completions', icon: CheckCircle },
|
||||||
|
{ title: 'Completion Images', url: '/admin/completion-images', icon: Image },
|
||||||
{ title: 'Contractors', url: '/admin/contractors', icon: Wrench },
|
{ title: 'Contractors', url: '/admin/contractors', icon: Wrench },
|
||||||
{ title: 'Documents', url: '/admin/documents', icon: FileText },
|
{ title: 'Documents', url: '/admin/documents', icon: FileText },
|
||||||
|
{ title: 'Document Images', url: '/admin/document-images', icon: ImagePlus },
|
||||||
{ title: 'Notifications', url: '/admin/notifications', icon: Bell },
|
{ title: 'Notifications', url: '/admin/notifications', icon: Bell },
|
||||||
{ title: 'Notification Prefs', url: '/admin/notification-prefs', icon: BellRing },
|
{ title: 'Notification Prefs', url: '/admin/notification-prefs', icon: BellRing },
|
||||||
{ title: 'Devices', url: '/admin/devices', icon: Smartphone },
|
{ title: 'Devices', url: '/admin/devices', icon: Smartphone },
|
||||||
|
|||||||
@@ -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;
|
export default api;
|
||||||
|
|||||||
233
internal/admin/handlers/apple_social_auth_handler.go
Normal file
233
internal/admin/handlers/apple_social_auth_handler.go
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/treytartt/casera-api/internal/admin/dto"
|
||||||
|
"github.com/treytartt/casera-api/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminAppleSocialAuthHandler handles admin Apple social auth management endpoints
|
||||||
|
type AdminAppleSocialAuthHandler struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAdminAppleSocialAuthHandler creates a new admin Apple social auth handler
|
||||||
|
func NewAdminAppleSocialAuthHandler(db *gorm.DB) *AdminAppleSocialAuthHandler {
|
||||||
|
return &AdminAppleSocialAuthHandler{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppleSocialAuthResponse represents the response for an Apple social auth entry
|
||||||
|
type AppleSocialAuthResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
UserID uint `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
UserEmail string `json:"user_email"`
|
||||||
|
AppleID string `json:"apple_id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
IsPrivateEmail bool `json:"is_private_email"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAppleSocialAuthRequest represents the request to update an Apple social auth entry
|
||||||
|
type UpdateAppleSocialAuthRequest struct {
|
||||||
|
Email *string `json:"email"`
|
||||||
|
IsPrivateEmail *bool `json:"is_private_email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// List handles GET /api/admin/apple-social-auth
|
||||||
|
func (h *AdminAppleSocialAuthHandler) List(c *gin.Context) {
|
||||||
|
var filters dto.PaginationParams
|
||||||
|
if err := c.ShouldBindQuery(&filters); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries []models.AppleSocialAuth
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
query := h.db.Model(&models.AppleSocialAuth{}).Preload("User")
|
||||||
|
|
||||||
|
// Apply search
|
||||||
|
if filters.Search != "" {
|
||||||
|
search := "%" + filters.Search + "%"
|
||||||
|
query = query.Joins("JOIN auth_user ON auth_user.id = user_applesocialauth.user_id").
|
||||||
|
Where("user_applesocialauth.apple_id ILIKE ? OR user_applesocialauth.email ILIKE ? OR auth_user.username ILIKE ? OR auth_user.email ILIKE ?",
|
||||||
|
search, search, search, search)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
query.Count(&total)
|
||||||
|
|
||||||
|
// Apply sorting
|
||||||
|
sortBy := "created_at"
|
||||||
|
if filters.SortBy != "" {
|
||||||
|
sortBy = filters.SortBy
|
||||||
|
}
|
||||||
|
query = query.Order(sortBy + " " + filters.GetSortDir())
|
||||||
|
|
||||||
|
// Apply pagination
|
||||||
|
query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage())
|
||||||
|
|
||||||
|
if err := query.Find(&entries).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch Apple social auth entries"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build response
|
||||||
|
responses := make([]AppleSocialAuthResponse, len(entries))
|
||||||
|
for i, entry := range entries {
|
||||||
|
responses[i] = h.toResponse(&entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get handles GET /api/admin/apple-social-auth/:id
|
||||||
|
func (h *AdminAppleSocialAuthHandler) Get(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry models.AppleSocialAuth
|
||||||
|
if err := h.db.Preload("User").First(&entry, id).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Apple social auth entry not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch Apple social auth entry"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, h.toResponse(&entry))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByUser handles GET /api/admin/apple-social-auth/user/:user_id
|
||||||
|
func (h *AdminAppleSocialAuthHandler) GetByUser(c *gin.Context) {
|
||||||
|
userID, err := strconv.ParseUint(c.Param("user_id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry models.AppleSocialAuth
|
||||||
|
if err := h.db.Preload("User").Where("user_id = ?", userID).First(&entry).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Apple social auth entry not found for user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch Apple social auth entry"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, h.toResponse(&entry))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update handles PUT /api/admin/apple-social-auth/:id
|
||||||
|
func (h *AdminAppleSocialAuthHandler) Update(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry models.AppleSocialAuth
|
||||||
|
if err := h.db.First(&entry, id).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Apple social auth entry not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch Apple social auth entry"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req UpdateAppleSocialAuthRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Email != nil {
|
||||||
|
entry.Email = *req.Email
|
||||||
|
}
|
||||||
|
if req.IsPrivateEmail != nil {
|
||||||
|
entry.IsPrivateEmail = *req.IsPrivateEmail
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.Save(&entry).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update Apple social auth entry"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.db.Preload("User").First(&entry, id)
|
||||||
|
c.JSON(http.StatusOK, h.toResponse(&entry))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete handles DELETE /api/admin/apple-social-auth/:id
|
||||||
|
func (h *AdminAppleSocialAuthHandler) Delete(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry models.AppleSocialAuth
|
||||||
|
if err := h.db.First(&entry, id).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Apple social auth entry not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch Apple social auth entry"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.Delete(&entry).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete Apple social auth entry"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Apple social auth entry deleted successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BulkDelete handles DELETE /api/admin/apple-social-auth/bulk
|
||||||
|
func (h *AdminAppleSocialAuthHandler) BulkDelete(c *gin.Context) {
|
||||||
|
var req dto.BulkDeleteRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.Where("id IN ?", req.IDs).Delete(&models.AppleSocialAuth{}).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete Apple social auth entries"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Apple social auth entries deleted successfully", "count": len(req.IDs)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// toResponse converts an AppleSocialAuth model to AppleSocialAuthResponse
|
||||||
|
func (h *AdminAppleSocialAuthHandler) toResponse(entry *models.AppleSocialAuth) AppleSocialAuthResponse {
|
||||||
|
response := AppleSocialAuthResponse{
|
||||||
|
ID: entry.ID,
|
||||||
|
UserID: entry.UserID,
|
||||||
|
AppleID: entry.AppleID,
|
||||||
|
Email: entry.Email,
|
||||||
|
IsPrivateEmail: entry.IsPrivateEmail,
|
||||||
|
CreatedAt: entry.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
UpdatedAt: entry.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.User.ID != 0 {
|
||||||
|
response.Username = entry.User.Username
|
||||||
|
response.UserEmail = entry.User.Email
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
262
internal/admin/handlers/completion_image_handler.go
Normal file
262
internal/admin/handlers/completion_image_handler.go
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/treytartt/casera-api/internal/admin/dto"
|
||||||
|
"github.com/treytartt/casera-api/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminCompletionImageHandler handles admin task completion image management endpoints
|
||||||
|
type AdminCompletionImageHandler struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAdminCompletionImageHandler creates a new admin completion image handler
|
||||||
|
func NewAdminCompletionImageHandler(db *gorm.DB) *AdminCompletionImageHandler {
|
||||||
|
return &AdminCompletionImageHandler{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminCompletionImageResponse represents the response for a task completion image in admin
|
||||||
|
type AdminCompletionImageResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
CompletionID uint `json:"completion_id"`
|
||||||
|
TaskID uint `json:"task_id"`
|
||||||
|
TaskTitle string `json:"task_title"`
|
||||||
|
ImageURL string `json:"image_url"`
|
||||||
|
Caption string `json:"caption"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCompletionImageRequest represents the request to create a completion image
|
||||||
|
type CreateCompletionImageRequest struct {
|
||||||
|
CompletionID uint `json:"completion_id" binding:"required"`
|
||||||
|
ImageURL string `json:"image_url" binding:"required"`
|
||||||
|
Caption string `json:"caption"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCompletionImageRequest represents the request to update a completion image
|
||||||
|
type UpdateCompletionImageRequest struct {
|
||||||
|
ImageURL *string `json:"image_url"`
|
||||||
|
Caption *string `json:"caption"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// List handles GET /api/admin/completion-images
|
||||||
|
func (h *AdminCompletionImageHandler) List(c *gin.Context) {
|
||||||
|
var filters dto.PaginationParams
|
||||||
|
if err := c.ShouldBindQuery(&filters); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional completion_id filter
|
||||||
|
completionIDStr := c.Query("completion_id")
|
||||||
|
|
||||||
|
var images []models.TaskCompletionImage
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
query := h.db.Model(&models.TaskCompletionImage{})
|
||||||
|
|
||||||
|
// Apply completion_id filter if provided
|
||||||
|
if completionIDStr != "" {
|
||||||
|
completionID, err := strconv.ParseUint(completionIDStr, 10, 32)
|
||||||
|
if err == nil {
|
||||||
|
query = query.Where("completion_id = ?", completionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply search
|
||||||
|
if filters.Search != "" {
|
||||||
|
search := "%" + filters.Search + "%"
|
||||||
|
query = query.Where("image_url ILIKE ? OR caption ILIKE ?", search, search)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
query.Count(&total)
|
||||||
|
|
||||||
|
// Apply sorting
|
||||||
|
sortBy := "created_at"
|
||||||
|
if filters.SortBy != "" {
|
||||||
|
sortBy = filters.SortBy
|
||||||
|
}
|
||||||
|
query = query.Order(sortBy + " " + filters.GetSortDir())
|
||||||
|
|
||||||
|
// Apply pagination
|
||||||
|
query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage())
|
||||||
|
|
||||||
|
if err := query.Find(&images).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch completion images"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build response with task info
|
||||||
|
responses := make([]AdminCompletionImageResponse, len(images))
|
||||||
|
for i, image := range images {
|
||||||
|
responses[i] = h.toResponse(&image)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get handles GET /api/admin/completion-images/:id
|
||||||
|
func (h *AdminCompletionImageHandler) Get(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid image ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var image models.TaskCompletionImage
|
||||||
|
if err := h.db.First(&image, id).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Completion image not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch completion image"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, h.toResponse(&image))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create handles POST /api/admin/completion-images
|
||||||
|
func (h *AdminCompletionImageHandler) Create(c *gin.Context) {
|
||||||
|
var req CreateCompletionImageRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify completion exists
|
||||||
|
var completion models.TaskCompletion
|
||||||
|
if err := h.db.First(&completion, req.CompletionID).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Task completion not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify completion"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
image := models.TaskCompletionImage{
|
||||||
|
CompletionID: req.CompletionID,
|
||||||
|
ImageURL: req.ImageURL,
|
||||||
|
Caption: req.Caption,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.Create(&image).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create completion image"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, h.toResponse(&image))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update handles PUT /api/admin/completion-images/:id
|
||||||
|
func (h *AdminCompletionImageHandler) Update(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid image ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var image models.TaskCompletionImage
|
||||||
|
if err := h.db.First(&image, id).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Completion image not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch completion image"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req UpdateCompletionImageRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.ImageURL != nil {
|
||||||
|
image.ImageURL = *req.ImageURL
|
||||||
|
}
|
||||||
|
if req.Caption != nil {
|
||||||
|
image.Caption = *req.Caption
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.Save(&image).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update completion image"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, h.toResponse(&image))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete handles DELETE /api/admin/completion-images/:id
|
||||||
|
func (h *AdminCompletionImageHandler) Delete(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid image ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var image models.TaskCompletionImage
|
||||||
|
if err := h.db.First(&image, id).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Completion image not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch completion image"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.Delete(&image).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete completion image"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Completion image deleted successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BulkDelete handles DELETE /api/admin/completion-images/bulk
|
||||||
|
func (h *AdminCompletionImageHandler) BulkDelete(c *gin.Context) {
|
||||||
|
var req dto.BulkDeleteRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.Where("id IN ?", req.IDs).Delete(&models.TaskCompletionImage{}).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete completion images"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Completion images deleted successfully", "count": len(req.IDs)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// toResponse converts a TaskCompletionImage model to AdminCompletionImageResponse
|
||||||
|
func (h *AdminCompletionImageHandler) toResponse(image *models.TaskCompletionImage) AdminCompletionImageResponse {
|
||||||
|
response := AdminCompletionImageResponse{
|
||||||
|
ID: image.ID,
|
||||||
|
CompletionID: image.CompletionID,
|
||||||
|
ImageURL: image.ImageURL,
|
||||||
|
Caption: image.Caption,
|
||||||
|
CreatedAt: image.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
UpdatedAt: image.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get task info via completion
|
||||||
|
var completion models.TaskCompletion
|
||||||
|
if err := h.db.Preload("Task").First(&completion, image.CompletionID).Error; err == nil {
|
||||||
|
response.TaskID = completion.TaskID
|
||||||
|
if completion.Task.ID != 0 {
|
||||||
|
response.TaskTitle = completion.Task.Title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
264
internal/admin/handlers/document_image_handler.go
Normal file
264
internal/admin/handlers/document_image_handler.go
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/treytartt/casera-api/internal/admin/dto"
|
||||||
|
"github.com/treytartt/casera-api/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminDocumentImageHandler handles admin document image management endpoints
|
||||||
|
type AdminDocumentImageHandler struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAdminDocumentImageHandler creates a new admin document image handler
|
||||||
|
func NewAdminDocumentImageHandler(db *gorm.DB) *AdminDocumentImageHandler {
|
||||||
|
return &AdminDocumentImageHandler{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DocumentImageResponse represents the response for a document image
|
||||||
|
type DocumentImageResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
DocumentID uint `json:"document_id"`
|
||||||
|
DocumentTitle string `json:"document_title"`
|
||||||
|
ResidenceID uint `json:"residence_id"`
|
||||||
|
ResidenceName string `json:"residence_name"`
|
||||||
|
ImageURL string `json:"image_url"`
|
||||||
|
Caption string `json:"caption"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateDocumentImageRequest represents the request to create a document image
|
||||||
|
type CreateDocumentImageRequest struct {
|
||||||
|
DocumentID uint `json:"document_id" binding:"required"`
|
||||||
|
ImageURL string `json:"image_url" binding:"required"`
|
||||||
|
Caption string `json:"caption"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateDocumentImageRequest represents the request to update a document image
|
||||||
|
type UpdateDocumentImageRequest struct {
|
||||||
|
ImageURL *string `json:"image_url"`
|
||||||
|
Caption *string `json:"caption"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// List handles GET /api/admin/document-images
|
||||||
|
func (h *AdminDocumentImageHandler) List(c *gin.Context) {
|
||||||
|
var filters dto.PaginationParams
|
||||||
|
if err := c.ShouldBindQuery(&filters); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional document_id filter
|
||||||
|
documentIDStr := c.Query("document_id")
|
||||||
|
|
||||||
|
var images []models.DocumentImage
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
query := h.db.Model(&models.DocumentImage{})
|
||||||
|
|
||||||
|
// Apply document_id filter if provided
|
||||||
|
if documentIDStr != "" {
|
||||||
|
documentID, err := strconv.ParseUint(documentIDStr, 10, 32)
|
||||||
|
if err == nil {
|
||||||
|
query = query.Where("document_id = ?", documentID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply search
|
||||||
|
if filters.Search != "" {
|
||||||
|
search := "%" + filters.Search + "%"
|
||||||
|
query = query.Where("image_url ILIKE ? OR caption ILIKE ?", search, search)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
query.Count(&total)
|
||||||
|
|
||||||
|
// Apply sorting
|
||||||
|
sortBy := "created_at"
|
||||||
|
if filters.SortBy != "" {
|
||||||
|
sortBy = filters.SortBy
|
||||||
|
}
|
||||||
|
query = query.Order(sortBy + " " + filters.GetSortDir())
|
||||||
|
|
||||||
|
// Apply pagination
|
||||||
|
query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage())
|
||||||
|
|
||||||
|
if err := query.Find(&images).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch document images"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build response with document info
|
||||||
|
responses := make([]DocumentImageResponse, len(images))
|
||||||
|
for i, image := range images {
|
||||||
|
responses[i] = h.toResponse(&image)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get handles GET /api/admin/document-images/:id
|
||||||
|
func (h *AdminDocumentImageHandler) Get(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid image ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var image models.DocumentImage
|
||||||
|
if err := h.db.First(&image, id).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Document image not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch document image"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, h.toResponse(&image))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create handles POST /api/admin/document-images
|
||||||
|
func (h *AdminDocumentImageHandler) Create(c *gin.Context) {
|
||||||
|
var req CreateDocumentImageRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify document exists
|
||||||
|
var document models.Document
|
||||||
|
if err := h.db.First(&document, req.DocumentID).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Document not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify document"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
image := models.DocumentImage{
|
||||||
|
DocumentID: req.DocumentID,
|
||||||
|
ImageURL: req.ImageURL,
|
||||||
|
Caption: req.Caption,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.Create(&image).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create document image"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, h.toResponse(&image))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update handles PUT /api/admin/document-images/:id
|
||||||
|
func (h *AdminDocumentImageHandler) Update(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid image ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var image models.DocumentImage
|
||||||
|
if err := h.db.First(&image, id).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Document image not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch document image"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req UpdateDocumentImageRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.ImageURL != nil {
|
||||||
|
image.ImageURL = *req.ImageURL
|
||||||
|
}
|
||||||
|
if req.Caption != nil {
|
||||||
|
image.Caption = *req.Caption
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.Save(&image).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update document image"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, h.toResponse(&image))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete handles DELETE /api/admin/document-images/:id
|
||||||
|
func (h *AdminDocumentImageHandler) Delete(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid image ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var image models.DocumentImage
|
||||||
|
if err := h.db.First(&image, id).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Document image not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch document image"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.Delete(&image).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete document image"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Document image deleted successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BulkDelete handles DELETE /api/admin/document-images/bulk
|
||||||
|
func (h *AdminDocumentImageHandler) BulkDelete(c *gin.Context) {
|
||||||
|
var req dto.BulkDeleteRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.Where("id IN ?", req.IDs).Delete(&models.DocumentImage{}).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete document images"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Document images deleted successfully", "count": len(req.IDs)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// toResponse converts a DocumentImage model to DocumentImageResponse
|
||||||
|
func (h *AdminDocumentImageHandler) toResponse(image *models.DocumentImage) DocumentImageResponse {
|
||||||
|
response := DocumentImageResponse{
|
||||||
|
ID: image.ID,
|
||||||
|
DocumentID: image.DocumentID,
|
||||||
|
ImageURL: image.ImageURL,
|
||||||
|
Caption: image.Caption,
|
||||||
|
CreatedAt: image.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
UpdatedAt: image.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get document info
|
||||||
|
var document models.Document
|
||||||
|
if err := h.db.Preload("Residence").First(&document, image.DocumentID).Error; err == nil {
|
||||||
|
response.DocumentTitle = document.Title
|
||||||
|
response.ResidenceID = document.ResidenceID
|
||||||
|
if document.Residence.ID != 0 {
|
||||||
|
response.ResidenceName = document.Residence.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
263
internal/admin/handlers/user_profile_handler.go
Normal file
263
internal/admin/handlers/user_profile_handler.go
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/treytartt/casera-api/internal/admin/dto"
|
||||||
|
"github.com/treytartt/casera-api/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminUserProfileHandler handles admin user profile management endpoints
|
||||||
|
type AdminUserProfileHandler struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAdminUserProfileHandler creates a new admin user profile handler
|
||||||
|
func NewAdminUserProfileHandler(db *gorm.DB) *AdminUserProfileHandler {
|
||||||
|
return &AdminUserProfileHandler{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserProfileResponse represents the response for a user profile
|
||||||
|
type UserProfileResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
UserID uint `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Verified bool `json:"verified"`
|
||||||
|
Bio string `json:"bio"`
|
||||||
|
PhoneNumber string `json:"phone_number"`
|
||||||
|
DateOfBirth *string `json:"date_of_birth"`
|
||||||
|
ProfilePicture string `json:"profile_picture"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUserProfileRequest represents the request to update a user profile
|
||||||
|
type UpdateUserProfileRequest struct {
|
||||||
|
Verified *bool `json:"verified"`
|
||||||
|
Bio *string `json:"bio"`
|
||||||
|
PhoneNumber *string `json:"phone_number"`
|
||||||
|
DateOfBirth *string `json:"date_of_birth"`
|
||||||
|
ProfilePicture *string `json:"profile_picture"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// List handles GET /api/admin/user-profiles
|
||||||
|
func (h *AdminUserProfileHandler) List(c *gin.Context) {
|
||||||
|
var filters dto.PaginationParams
|
||||||
|
if err := c.ShouldBindQuery(&filters); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var profiles []models.UserProfile
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
query := h.db.Model(&models.UserProfile{}).Preload("User")
|
||||||
|
|
||||||
|
// Apply search
|
||||||
|
if filters.Search != "" {
|
||||||
|
search := "%" + filters.Search + "%"
|
||||||
|
query = query.Joins("JOIN auth_user ON auth_user.id = user_userprofile.user_id").
|
||||||
|
Where("auth_user.username ILIKE ? OR auth_user.email ILIKE ? OR user_userprofile.phone_number ILIKE ?",
|
||||||
|
search, search, search)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
query.Count(&total)
|
||||||
|
|
||||||
|
// Apply sorting
|
||||||
|
sortBy := "created_at"
|
||||||
|
if filters.SortBy != "" {
|
||||||
|
sortBy = filters.SortBy
|
||||||
|
}
|
||||||
|
query = query.Order(sortBy + " " + filters.GetSortDir())
|
||||||
|
|
||||||
|
// Apply pagination
|
||||||
|
query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage())
|
||||||
|
|
||||||
|
if err := query.Find(&profiles).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch user profiles"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build response
|
||||||
|
responses := make([]UserProfileResponse, len(profiles))
|
||||||
|
for i, profile := range profiles {
|
||||||
|
responses[i] = h.toProfileResponse(&profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get handles GET /api/admin/user-profiles/:id
|
||||||
|
func (h *AdminUserProfileHandler) Get(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid profile ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var profile models.UserProfile
|
||||||
|
if err := h.db.Preload("User").First(&profile, id).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "User profile not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch user profile"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, h.toProfileResponse(&profile))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByUser handles GET /api/admin/user-profiles/user/:user_id
|
||||||
|
func (h *AdminUserProfileHandler) GetByUser(c *gin.Context) {
|
||||||
|
userID, err := strconv.ParseUint(c.Param("user_id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var profile models.UserProfile
|
||||||
|
if err := h.db.Preload("User").Where("user_id = ?", userID).First(&profile).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "User profile not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch user profile"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, h.toProfileResponse(&profile))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update handles PUT /api/admin/user-profiles/:id
|
||||||
|
func (h *AdminUserProfileHandler) Update(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid profile ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var profile models.UserProfile
|
||||||
|
if err := h.db.First(&profile, id).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "User profile not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch user profile"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req UpdateUserProfileRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Verified != nil {
|
||||||
|
profile.Verified = *req.Verified
|
||||||
|
}
|
||||||
|
if req.Bio != nil {
|
||||||
|
profile.Bio = *req.Bio
|
||||||
|
}
|
||||||
|
if req.PhoneNumber != nil {
|
||||||
|
profile.PhoneNumber = *req.PhoneNumber
|
||||||
|
}
|
||||||
|
if req.ProfilePicture != nil {
|
||||||
|
profile.ProfilePicture = *req.ProfilePicture
|
||||||
|
}
|
||||||
|
if req.DateOfBirth != nil {
|
||||||
|
if *req.DateOfBirth == "" {
|
||||||
|
profile.DateOfBirth = nil
|
||||||
|
} else {
|
||||||
|
dob, err := time.Parse("2006-01-02", *req.DateOfBirth)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid date format for date_of_birth, use YYYY-MM-DD"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
profile.DateOfBirth = &dob
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.Save(&profile).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user profile"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.db.Preload("User").First(&profile, id)
|
||||||
|
c.JSON(http.StatusOK, h.toProfileResponse(&profile))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete handles DELETE /api/admin/user-profiles/:id
|
||||||
|
func (h *AdminUserProfileHandler) Delete(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid profile ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var profile models.UserProfile
|
||||||
|
if err := h.db.First(&profile, id).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "User profile not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch user profile"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.Delete(&profile).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete user profile"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "User profile deleted successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BulkDelete handles DELETE /api/admin/user-profiles/bulk
|
||||||
|
func (h *AdminUserProfileHandler) BulkDelete(c *gin.Context) {
|
||||||
|
var req dto.BulkDeleteRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.Where("id IN ?", req.IDs).Delete(&models.UserProfile{}).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete user profiles"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "User profiles deleted successfully", "count": len(req.IDs)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// toProfileResponse converts a UserProfile model to UserProfileResponse
|
||||||
|
func (h *AdminUserProfileHandler) toProfileResponse(profile *models.UserProfile) UserProfileResponse {
|
||||||
|
response := UserProfileResponse{
|
||||||
|
ID: profile.ID,
|
||||||
|
UserID: profile.UserID,
|
||||||
|
Verified: profile.Verified,
|
||||||
|
Bio: profile.Bio,
|
||||||
|
PhoneNumber: profile.PhoneNumber,
|
||||||
|
ProfilePicture: profile.ProfilePicture,
|
||||||
|
CreatedAt: profile.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
UpdatedAt: profile.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if profile.DateOfBirth != nil {
|
||||||
|
dob := profile.DateOfBirth.Format("2006-01-02")
|
||||||
|
response.DateOfBirth = &dob
|
||||||
|
}
|
||||||
|
|
||||||
|
if profile.User.ID != 0 {
|
||||||
|
response.Username = profile.User.Username
|
||||||
|
response.Email = profile.User.Email
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
@@ -332,6 +332,54 @@ func SetupRoutes(router *gin.Engine, db *gorm.DB, cfg *config.Config, deps *Depe
|
|||||||
notifPrefs.GET("/user/:user_id", notifPrefsHandler.GetByUser)
|
notifPrefs.GET("/user/:user_id", notifPrefsHandler.GetByUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// User profile management
|
||||||
|
userProfileHandler := handlers.NewAdminUserProfileHandler(db)
|
||||||
|
userProfiles := protected.Group("/user-profiles")
|
||||||
|
{
|
||||||
|
userProfiles.GET("", userProfileHandler.List)
|
||||||
|
userProfiles.DELETE("/bulk", userProfileHandler.BulkDelete)
|
||||||
|
userProfiles.GET("/:id", userProfileHandler.Get)
|
||||||
|
userProfiles.PUT("/:id", userProfileHandler.Update)
|
||||||
|
userProfiles.DELETE("/:id", userProfileHandler.Delete)
|
||||||
|
userProfiles.GET("/user/:user_id", userProfileHandler.GetByUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apple social auth management
|
||||||
|
appleSocialAuthHandler := handlers.NewAdminAppleSocialAuthHandler(db)
|
||||||
|
appleSocialAuth := protected.Group("/apple-social-auth")
|
||||||
|
{
|
||||||
|
appleSocialAuth.GET("", appleSocialAuthHandler.List)
|
||||||
|
appleSocialAuth.DELETE("/bulk", appleSocialAuthHandler.BulkDelete)
|
||||||
|
appleSocialAuth.GET("/:id", appleSocialAuthHandler.Get)
|
||||||
|
appleSocialAuth.PUT("/:id", appleSocialAuthHandler.Update)
|
||||||
|
appleSocialAuth.DELETE("/:id", appleSocialAuthHandler.Delete)
|
||||||
|
appleSocialAuth.GET("/user/:user_id", appleSocialAuthHandler.GetByUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task completion images management
|
||||||
|
completionImageHandler := handlers.NewAdminCompletionImageHandler(db)
|
||||||
|
completionImages := protected.Group("/completion-images")
|
||||||
|
{
|
||||||
|
completionImages.GET("", completionImageHandler.List)
|
||||||
|
completionImages.POST("", completionImageHandler.Create)
|
||||||
|
completionImages.DELETE("/bulk", completionImageHandler.BulkDelete)
|
||||||
|
completionImages.GET("/:id", completionImageHandler.Get)
|
||||||
|
completionImages.PUT("/:id", completionImageHandler.Update)
|
||||||
|
completionImages.DELETE("/:id", completionImageHandler.Delete)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Document images management
|
||||||
|
documentImageHandler := handlers.NewAdminDocumentImageHandler(db)
|
||||||
|
documentImages := protected.Group("/document-images")
|
||||||
|
{
|
||||||
|
documentImages.GET("", documentImageHandler.List)
|
||||||
|
documentImages.POST("", documentImageHandler.Create)
|
||||||
|
documentImages.DELETE("/bulk", documentImageHandler.BulkDelete)
|
||||||
|
documentImages.GET("/:id", documentImageHandler.Get)
|
||||||
|
documentImages.PUT("/:id", documentImageHandler.Update)
|
||||||
|
documentImages.DELETE("/:id", documentImageHandler.Delete)
|
||||||
|
}
|
||||||
|
|
||||||
// System settings management
|
// System settings management
|
||||||
settingsHandler := handlers.NewAdminSettingsHandler(db)
|
settingsHandler := handlers.NewAdminSettingsHandler(db)
|
||||||
settings := protected.Group("/settings")
|
settings := protected.Group("/settings")
|
||||||
|
|||||||
Reference in New Issue
Block a user