Add admin panel pages for additional models
- Add confirmation codes management page - Add devices management page - Add feature benefits management page - Add password reset codes management page - Add promotions management page - Add share codes management page - Add corresponding backend handlers and routes - Update sidebar navigation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
324
admin/src/app/(dashboard)/confirmation-codes/page.tsx
Normal file
324
admin/src/app/(dashboard)/confirmation-codes/page.tsx
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Mail, Trash2, Search, ChevronLeft, ChevronRight, CheckCircle, XCircle } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { confirmationCodesApi, type ConfirmationCode, type ConfirmationCodeListParams } from '@/lib/api';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
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 ConfirmationCodesPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [params, setParams] = useState<ConfirmationCodeListParams>({
|
||||||
|
page: 1,
|
||||||
|
per_page: 20,
|
||||||
|
});
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [selectedRows, setSelectedRows] = useState<number[]>([]);
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['confirmation-codes', params],
|
||||||
|
queryFn: () => confirmationCodesApi.list(params),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => confirmationCodesApi.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Confirmation code deleted successfully');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['confirmation-codes'] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Failed to delete confirmation code');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const bulkDeleteMutation = useMutation({
|
||||||
|
mutationFn: (ids: number[]) => confirmationCodesApi.bulkDelete(ids),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Confirmation codes deleted successfully');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['confirmation-codes'] });
|
||||||
|
setSelectedRows([]);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Failed to delete confirmation codes');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setParams({ ...params, search, page: 1 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectAll = (checked: boolean) => {
|
||||||
|
if (checked && data?.data) {
|
||||||
|
setSelectedRows(data.data.map((c) => c.id));
|
||||||
|
} else {
|
||||||
|
setSelectedRows([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectRow = (id: number, checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
setSelectedRows([...selectedRows, id]);
|
||||||
|
} else {
|
||||||
|
setSelectedRows(selectedRows.filter((rowId) => rowId !== id));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isExpired = (expiresAt: string) => {
|
||||||
|
return new Date(expiresAt) < new Date();
|
||||||
|
};
|
||||||
|
|
||||||
|
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 confirmation codes</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Confirmation Codes</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Manage email verification codes
|
||||||
|
</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 code..."
|
||||||
|
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 Codes?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will permanently delete {selectedRows.length} confirmation code(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>Code</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Expires</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead className="w-24">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="text-center py-8">
|
||||||
|
Loading...
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : data?.data?.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="text-center py-8">
|
||||||
|
No confirmation codes found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
data?.data?.map((code) => (
|
||||||
|
<TableRow key={code.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedRows.includes(code.id)}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleSelectRow(code.id, checked as boolean)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Link
|
||||||
|
href={`/admin/users/${code.user_id}`}
|
||||||
|
className="font-medium text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
{code.username}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{code.email}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<code className="text-sm bg-muted px-2 py-1 rounded">
|
||||||
|
{code.code}
|
||||||
|
</code>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{code.is_used ? (
|
||||||
|
<Badge variant="secondary" className="gap-1">
|
||||||
|
<CheckCircle className="h-3 w-3" />
|
||||||
|
Used
|
||||||
|
</Badge>
|
||||||
|
) : isExpired(code.expires_at) ? (
|
||||||
|
<Badge variant="destructive" className="gap-1">
|
||||||
|
<XCircle className="h-3 w-3" />
|
||||||
|
Expired
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="default" className="gap-1">
|
||||||
|
Active
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{new Date(code.expires_at).toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{new Date(code.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 Code?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will permanently delete the confirmation code for {code.username}.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => deleteMutation.mutate(code.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} codes
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
411
admin/src/app/(dashboard)/devices/page.tsx
Normal file
411
admin/src/app/(dashboard)/devices/page.tsx
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Smartphone, Trash2, Search, ChevronLeft, ChevronRight, Apple, Tablet } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { devicesApi, type APNSDevice, type GCMDevice, type DeviceListParams } from '@/lib/api';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
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';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
|
||||||
|
export default function DevicesPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [activeTab, setActiveTab] = useState<'apns' | 'gcm'>('apns');
|
||||||
|
const [params, setParams] = useState<DeviceListParams>({
|
||||||
|
page: 1,
|
||||||
|
per_page: 20,
|
||||||
|
});
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [selectedRows, setSelectedRows] = useState<number[]>([]);
|
||||||
|
|
||||||
|
const { data: stats } = useQuery({
|
||||||
|
queryKey: ['device-stats'],
|
||||||
|
queryFn: () => devicesApi.getStats(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: apnsData, isLoading: apnsLoading } = useQuery({
|
||||||
|
queryKey: ['devices-apns', params],
|
||||||
|
queryFn: () => devicesApi.listAPNS(params),
|
||||||
|
enabled: activeTab === 'apns',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: gcmData, isLoading: gcmLoading } = useQuery({
|
||||||
|
queryKey: ['devices-gcm', params],
|
||||||
|
queryFn: () => devicesApi.listGCM(params),
|
||||||
|
enabled: activeTab === 'gcm',
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateAPNSMutation = useMutation({
|
||||||
|
mutationFn: ({ id, active }: { id: number; active: boolean }) =>
|
||||||
|
devicesApi.updateAPNS(id, { active }),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Device updated');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['devices-apns'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['device-stats'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateGCMMutation = useMutation({
|
||||||
|
mutationFn: ({ id, active }: { id: number; active: boolean }) =>
|
||||||
|
devicesApi.updateGCM(id, { active }),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Device updated');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['devices-gcm'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['device-stats'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteAPNSMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => devicesApi.deleteAPNS(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Device deleted');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['devices-apns'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['device-stats'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteGCMMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => devicesApi.deleteGCM(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Device deleted');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['devices-gcm'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['device-stats'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const bulkDeleteAPNSMutation = useMutation({
|
||||||
|
mutationFn: (ids: number[]) => devicesApi.bulkDeleteAPNS(ids),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Devices deleted');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['devices-apns'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['device-stats'] });
|
||||||
|
setSelectedRows([]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const bulkDeleteGCMMutation = useMutation({
|
||||||
|
mutationFn: (ids: number[]) => devicesApi.bulkDeleteGCM(ids),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Devices deleted');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['devices-gcm'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['device-stats'] });
|
||||||
|
setSelectedRows([]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setParams({ ...params, search, page: 1 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = activeTab === 'apns' ? apnsData : gcmData;
|
||||||
|
const isLoading = activeTab === 'apns' ? apnsLoading : gcmLoading;
|
||||||
|
const totalPages = data ? Math.ceil(data.total / (params.per_page || 20)) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Push Notification Devices</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Manage registered iOS and Android devices
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||||
|
<Apple className="h-4 w-4" />
|
||||||
|
iOS Devices
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats?.apns.total || 0}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{stats?.apns.active || 0} active
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||||
|
<Tablet className="h-4 w-4" />
|
||||||
|
Android Devices
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats?.gcm.total || 0}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{stats?.gcm.active || 0} active
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||||
|
<Smartphone className="h-4 w-4" />
|
||||||
|
Total Devices
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats?.total || 0}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant={activeTab === 'apns' ? 'default' : 'outline'}
|
||||||
|
onClick={() => { setActiveTab('apns'); setSelectedRows([]); setParams({ ...params, page: 1 }); }}
|
||||||
|
>
|
||||||
|
<Apple className="mr-2 h-4 w-4" />
|
||||||
|
iOS (APNS)
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={activeTab === 'gcm' ? 'default' : 'outline'}
|
||||||
|
onClick={() => { setActiveTab('gcm'); setSelectedRows([]); setParams({ ...params, page: 1 }); }}
|
||||||
|
>
|
||||||
|
<Tablet className="mr-2 h-4 w-4" />
|
||||||
|
Android (FCM)
|
||||||
|
</Button>
|
||||||
|
</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 name, device ID, or user..."
|
||||||
|
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 Devices?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will permanently delete {selectedRows.length} device(s).
|
||||||
|
Users will need to re-register for push notifications.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => activeTab === 'apns'
|
||||||
|
? bulkDeleteAPNSMutation.mutate(selectedRows)
|
||||||
|
: bulkDeleteGCMMutation.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={(checked) => {
|
||||||
|
if (checked && data?.data) {
|
||||||
|
setSelectedRows(data.data.map((d) => d.id));
|
||||||
|
} else {
|
||||||
|
setSelectedRows([]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>User</TableHead>
|
||||||
|
<TableHead>Device ID</TableHead>
|
||||||
|
<TableHead>Active</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 devices found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
data?.data?.map((device) => (
|
||||||
|
<TableRow key={device.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedRows.includes(device.id)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
if (checked) {
|
||||||
|
setSelectedRows([...selectedRows, device.id]);
|
||||||
|
} else {
|
||||||
|
setSelectedRows(selectedRows.filter((id) => id !== device.id));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium">{device.name || 'Unknown'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{device.user_id ? (
|
||||||
|
<Link
|
||||||
|
href={`/admin/users/${device.user_id}`}
|
||||||
|
className="text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
{device.username || `User #${device.user_id}`}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">Anonymous</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<code className="text-xs bg-muted px-2 py-1 rounded">
|
||||||
|
{device.device_id.substring(0, 12)}...
|
||||||
|
</code>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Switch
|
||||||
|
checked={device.active}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
activeTab === 'apns'
|
||||||
|
? updateAPNSMutation.mutate({ id: device.id, active: checked })
|
||||||
|
: updateGCMMutation.mutate({ id: device.id, active: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{new Date(device.date_created).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 Device?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will permanently delete this device registration.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() =>
|
||||||
|
activeTab === 'apns'
|
||||||
|
? deleteAPNSMutation.mutate(device.id)
|
||||||
|
: deleteGCMMutation.mutate(device.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}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
231
admin/src/app/(dashboard)/feature-benefits/page.tsx
Normal file
231
admin/src/app/(dashboard)/feature-benefits/page.tsx
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Star, Trash2, Search, Plus, Edit, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { featureBenefitsApi, type FeatureBenefit } from '@/lib/api';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
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';
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
||||||
|
export default function FeatureBenefitsPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [params, setParams] = useState({ page: 1, per_page: 20, search: '' });
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [editingBenefit, setEditingBenefit] = useState<FeatureBenefit | null>(null);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
feature_name: '',
|
||||||
|
free_tier_text: '',
|
||||||
|
pro_tier_text: '',
|
||||||
|
display_order: 0,
|
||||||
|
is_active: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['feature-benefits', params],
|
||||||
|
queryFn: () => featureBenefitsApi.list(params),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: featureBenefitsApi.create,
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Feature benefit created');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['feature-benefits'] });
|
||||||
|
setIsCreating(false);
|
||||||
|
resetForm();
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Failed to create feature benefit'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: number; data: any }) => featureBenefitsApi.update(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Feature benefit updated');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['feature-benefits'] });
|
||||||
|
setEditingBenefit(null);
|
||||||
|
resetForm();
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Failed to update feature benefit'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: featureBenefitsApi.delete,
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Feature benefit deleted');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['feature-benefits'] });
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Failed to delete feature benefit'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setFormData({ feature_name: '', free_tier_text: '', pro_tier_text: '', display_order: 0, is_active: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (benefit: FeatureBenefit) => {
|
||||||
|
setEditingBenefit(benefit);
|
||||||
|
setFormData({
|
||||||
|
feature_name: benefit.feature_name,
|
||||||
|
free_tier_text: benefit.free_tier_text,
|
||||||
|
pro_tier_text: benefit.pro_tier_text,
|
||||||
|
display_order: benefit.display_order,
|
||||||
|
is_active: benefit.is_active,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (editingBenefit) {
|
||||||
|
updateMutation.mutate({ id: editingBenefit.id, data: formData });
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(formData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalPages = data ? Math.ceil(data.total / params.per_page) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Feature Benefits</h1>
|
||||||
|
<p className="text-muted-foreground">Manage subscription tier comparison features</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => { setIsCreating(true); resetForm(); }}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Feature
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={(e) => { e.preventDefault(); setParams({ ...params, search, page: 1 }); }} className="flex gap-2 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..." value={search} onChange={(e) => setSearch(e.target.value)} className="pl-9" />
|
||||||
|
</div>
|
||||||
|
<Button type="submit" variant="secondary">Search</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Order</TableHead>
|
||||||
|
<TableHead>Feature</TableHead>
|
||||||
|
<TableHead>Free Tier</TableHead>
|
||||||
|
<TableHead>Pro Tier</TableHead>
|
||||||
|
<TableHead>Active</TableHead>
|
||||||
|
<TableHead className="w-24">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading ? (
|
||||||
|
<TableRow><TableCell colSpan={6} className="text-center py-8">Loading...</TableCell></TableRow>
|
||||||
|
) : data?.data?.length === 0 ? (
|
||||||
|
<TableRow><TableCell colSpan={6} className="text-center py-8">No feature benefits found</TableCell></TableRow>
|
||||||
|
) : (
|
||||||
|
data?.data?.map((benefit) => (
|
||||||
|
<TableRow key={benefit.id}>
|
||||||
|
<TableCell>{benefit.display_order}</TableCell>
|
||||||
|
<TableCell className="font-medium">{benefit.feature_name}</TableCell>
|
||||||
|
<TableCell>{benefit.free_tier_text}</TableCell>
|
||||||
|
<TableCell>{benefit.pro_tier_text}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={benefit.is_active ? 'default' : 'secondary'}>
|
||||||
|
{benefit.is_active ? 'Active' : 'Inactive'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => handleEdit(benefit)}>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="text-red-600 hover:text-red-700">
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Feature?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>This will permanently delete this feature benefit.</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={() => deleteMutation.mutate(benefit.id)} className="bg-destructive">Delete</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data && totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm text-muted-foreground">Page {params.page} of {totalPages}</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" disabled={params.page <= 1} onClick={() => setParams({ ...params, page: params.page - 1 })}>
|
||||||
|
<ChevronLeft className="h-4 w-4" /> Previous
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled={params.page >= totalPages} onClick={() => setParams({ ...params, page: params.page + 1 })}>
|
||||||
|
Next <ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create/Edit Dialog */}
|
||||||
|
<Dialog open={isCreating || !!editingBenefit} onOpenChange={(open) => { if (!open) { setIsCreating(false); setEditingBenefit(null); } }}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingBenefit ? 'Edit Feature Benefit' : 'Create Feature Benefit'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Feature Name</Label>
|
||||||
|
<Input value={formData.feature_name} onChange={(e) => setFormData({ ...formData, feature_name: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Free Tier Text</Label>
|
||||||
|
<Input value={formData.free_tier_text} onChange={(e) => setFormData({ ...formData, free_tier_text: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Pro Tier Text</Label>
|
||||||
|
<Input value={formData.pro_tier_text} onChange={(e) => setFormData({ ...formData, pro_tier_text: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Display Order</Label>
|
||||||
|
<Input type="number" value={formData.display_order} onChange={(e) => setFormData({ ...formData, display_order: parseInt(e.target.value) || 0 })} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch checked={formData.is_active} onCheckedChange={(checked) => setFormData({ ...formData, is_active: checked })} />
|
||||||
|
<Label>Active</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => { setIsCreating(false); setEditingBenefit(null); }}>Cancel</Button>
|
||||||
|
<Button onClick={handleSubmit}>{editingBenefit ? 'Update' : 'Create'}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
346
admin/src/app/(dashboard)/password-reset-codes/page.tsx
Normal file
346
admin/src/app/(dashboard)/password-reset-codes/page.tsx
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { KeyRound, Trash2, Search, ChevronLeft, ChevronRight, CheckCircle, XCircle, AlertTriangle } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { passwordResetCodesApi, type PasswordResetCode, type PasswordResetCodeListParams } from '@/lib/api';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
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 PasswordResetCodesPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [params, setParams] = useState<PasswordResetCodeListParams>({
|
||||||
|
page: 1,
|
||||||
|
per_page: 20,
|
||||||
|
});
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [selectedRows, setSelectedRows] = useState<number[]>([]);
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['password-reset-codes', params],
|
||||||
|
queryFn: () => passwordResetCodesApi.list(params),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => passwordResetCodesApi.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Password reset code deleted successfully');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['password-reset-codes'] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Failed to delete password reset code');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const bulkDeleteMutation = useMutation({
|
||||||
|
mutationFn: (ids: number[]) => passwordResetCodesApi.bulkDelete(ids),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Password reset codes deleted successfully');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['password-reset-codes'] });
|
||||||
|
setSelectedRows([]);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Failed to delete password reset codes');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setParams({ ...params, search, page: 1 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectAll = (checked: boolean) => {
|
||||||
|
if (checked && data?.data) {
|
||||||
|
setSelectedRows(data.data.map((c) => c.id));
|
||||||
|
} else {
|
||||||
|
setSelectedRows([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectRow = (id: number, checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
setSelectedRows([...selectedRows, id]);
|
||||||
|
} else {
|
||||||
|
setSelectedRows(selectedRows.filter((rowId) => rowId !== id));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isExpired = (expiresAt: string) => {
|
||||||
|
return new Date(expiresAt) < new Date();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (code: PasswordResetCode) => {
|
||||||
|
if (code.used) {
|
||||||
|
return (
|
||||||
|
<Badge variant="secondary" className="gap-1">
|
||||||
|
<CheckCircle className="h-3 w-3" />
|
||||||
|
Used
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (code.attempts >= code.max_attempts) {
|
||||||
|
return (
|
||||||
|
<Badge variant="destructive" className="gap-1">
|
||||||
|
<AlertTriangle className="h-3 w-3" />
|
||||||
|
Max Attempts
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isExpired(code.expires_at)) {
|
||||||
|
return (
|
||||||
|
<Badge variant="destructive" className="gap-1">
|
||||||
|
<XCircle className="h-3 w-3" />
|
||||||
|
Expired
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Badge variant="default" className="gap-1">
|
||||||
|
Active
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 password reset codes</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Password Reset Codes</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Manage password reset requests
|
||||||
|
</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 token..."
|
||||||
|
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 Codes?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will permanently delete {selectedRows.length} password reset code(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>Token</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Attempts</TableHead>
|
||||||
|
<TableHead>Expires</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead className="w-24">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={9} className="text-center py-8">
|
||||||
|
Loading...
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : data?.data?.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={9} className="text-center py-8">
|
||||||
|
No password reset codes found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
data?.data?.map((code) => (
|
||||||
|
<TableRow key={code.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedRows.includes(code.id)}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleSelectRow(code.id, checked as boolean)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Link
|
||||||
|
href={`/admin/users/${code.user_id}`}
|
||||||
|
className="font-medium text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
{code.username}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{code.email}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<code className="text-xs bg-muted px-2 py-1 rounded">
|
||||||
|
{code.reset_token}
|
||||||
|
</code>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{getStatusBadge(code)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className={code.attempts >= code.max_attempts ? 'text-red-600 font-medium' : ''}>
|
||||||
|
{code.attempts} / {code.max_attempts}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{new Date(code.expires_at).toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{new Date(code.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 Code?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will permanently delete the password reset code for {code.username}.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => deleteMutation.mutate(code.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} codes
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
282
admin/src/app/(dashboard)/promotions/page.tsx
Normal file
282
admin/src/app/(dashboard)/promotions/page.tsx
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Megaphone, Trash2, Search, Plus, Edit, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { promotionsApi, type Promotion } from '@/lib/api';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
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';
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
|
||||||
|
export default function PromotionsPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [params, setParams] = useState({ page: 1, per_page: 20, search: '' });
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [editingPromotion, setEditingPromotion] = useState<Promotion | null>(null);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
promotion_id: '',
|
||||||
|
title: '',
|
||||||
|
message: '',
|
||||||
|
link: '',
|
||||||
|
start_date: '',
|
||||||
|
end_date: '',
|
||||||
|
target_tier: 'free' as 'free' | 'pro',
|
||||||
|
is_active: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['promotions', params],
|
||||||
|
queryFn: () => promotionsApi.list(params),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: promotionsApi.create,
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Promotion created');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['promotions'] });
|
||||||
|
setIsCreating(false);
|
||||||
|
resetForm();
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Failed to create promotion'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: number; data: any }) => promotionsApi.update(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Promotion updated');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['promotions'] });
|
||||||
|
setEditingPromotion(null);
|
||||||
|
resetForm();
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Failed to update promotion'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: promotionsApi.delete,
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Promotion deleted');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['promotions'] });
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Failed to delete promotion'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setFormData({
|
||||||
|
promotion_id: '', title: '', message: '', link: '',
|
||||||
|
start_date: '', end_date: '', target_tier: 'free', is_active: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (promo: Promotion) => {
|
||||||
|
setEditingPromotion(promo);
|
||||||
|
setFormData({
|
||||||
|
promotion_id: promo.promotion_id,
|
||||||
|
title: promo.title,
|
||||||
|
message: promo.message,
|
||||||
|
link: promo.link || '',
|
||||||
|
start_date: promo.start_date.split('T')[0],
|
||||||
|
end_date: promo.end_date.split('T')[0],
|
||||||
|
target_tier: promo.target_tier,
|
||||||
|
is_active: promo.is_active,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
const submitData = {
|
||||||
|
...formData,
|
||||||
|
link: formData.link || undefined,
|
||||||
|
};
|
||||||
|
if (editingPromotion) {
|
||||||
|
updateMutation.mutate({ id: editingPromotion.id, data: submitData });
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(submitData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isActive = (promo: Promotion) => {
|
||||||
|
const now = new Date();
|
||||||
|
return promo.is_active && new Date(promo.start_date) <= now && new Date(promo.end_date) >= now;
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalPages = data ? Math.ceil(data.total / params.per_page) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Promotions</h1>
|
||||||
|
<p className="text-muted-foreground">Manage promotional content and banners</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => { setIsCreating(true); resetForm(); }}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Promotion
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={(e) => { e.preventDefault(); setParams({ ...params, search, page: 1 }); }} className="flex gap-2 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..." value={search} onChange={(e) => setSearch(e.target.value)} className="pl-9" />
|
||||||
|
</div>
|
||||||
|
<Button type="submit" variant="secondary">Search</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>ID</TableHead>
|
||||||
|
<TableHead>Title</TableHead>
|
||||||
|
<TableHead>Target</TableHead>
|
||||||
|
<TableHead>Date Range</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="w-24">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading ? (
|
||||||
|
<TableRow><TableCell colSpan={6} className="text-center py-8">Loading...</TableCell></TableRow>
|
||||||
|
) : data?.data?.length === 0 ? (
|
||||||
|
<TableRow><TableCell colSpan={6} className="text-center py-8">No promotions found</TableCell></TableRow>
|
||||||
|
) : (
|
||||||
|
data?.data?.map((promo) => (
|
||||||
|
<TableRow key={promo.id}>
|
||||||
|
<TableCell><code className="text-xs bg-muted px-2 py-1 rounded">{promo.promotion_id}</code></TableCell>
|
||||||
|
<TableCell className="font-medium">{promo.title}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={promo.target_tier === 'pro' ? 'default' : 'secondary'}>
|
||||||
|
{promo.target_tier === 'pro' ? 'Pro Users' : 'Free Users'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{new Date(promo.start_date).toLocaleDateString()} - {new Date(promo.end_date).toLocaleDateString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{isActive(promo) ? (
|
||||||
|
<Badge variant="default" className="bg-green-600">Active</Badge>
|
||||||
|
) : promo.is_active ? (
|
||||||
|
<Badge variant="secondary">Scheduled</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="destructive">Inactive</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => handleEdit(promo)}>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="text-red-600 hover:text-red-700">
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Promotion?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>This will permanently delete this promotion.</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={() => deleteMutation.mutate(promo.id)} className="bg-destructive">Delete</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data && totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm text-muted-foreground">Page {params.page} of {totalPages}</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" disabled={params.page <= 1} onClick={() => setParams({ ...params, page: params.page - 1 })}>
|
||||||
|
<ChevronLeft className="h-4 w-4" /> Previous
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled={params.page >= totalPages} onClick={() => setParams({ ...params, page: params.page + 1 })}>
|
||||||
|
Next <ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create/Edit Dialog */}
|
||||||
|
<Dialog open={isCreating || !!editingPromotion} onOpenChange={(open) => { if (!open) { setIsCreating(false); setEditingPromotion(null); } }}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingPromotion ? 'Edit Promotion' : 'Create Promotion'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Promotion ID</Label>
|
||||||
|
<Input value={formData.promotion_id} onChange={(e) => setFormData({ ...formData, promotion_id: e.target.value })} placeholder="e.g., summer_sale_2024" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Title</Label>
|
||||||
|
<Input value={formData.title} onChange={(e) => setFormData({ ...formData, title: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Message</Label>
|
||||||
|
<Input value={formData.message} onChange={(e) => setFormData({ ...formData, message: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Link (optional)</Label>
|
||||||
|
<Input value={formData.link} onChange={(e) => setFormData({ ...formData, link: e.target.value })} placeholder="https://..." />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Start Date</Label>
|
||||||
|
<Input type="date" value={formData.start_date} onChange={(e) => setFormData({ ...formData, start_date: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>End Date</Label>
|
||||||
|
<Input type="date" value={formData.end_date} onChange={(e) => setFormData({ ...formData, end_date: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Target Tier</Label>
|
||||||
|
<Select value={formData.target_tier} onValueChange={(value: 'free' | 'pro') => setFormData({ ...formData, target_tier: value })}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="free">Free Users</SelectItem>
|
||||||
|
<SelectItem value="pro">Pro Users</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch checked={formData.is_active} onCheckedChange={(checked) => setFormData({ ...formData, is_active: checked })} />
|
||||||
|
<Label>Active</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => { setIsCreating(false); setEditingPromotion(null); }}>Cancel</Button>
|
||||||
|
<Button onClick={handleSubmit}>{editingPromotion ? 'Update' : 'Create'}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
349
admin/src/app/(dashboard)/share-codes/page.tsx
Normal file
349
admin/src/app/(dashboard)/share-codes/page.tsx
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Share2, Trash2, Search, ChevronLeft, ChevronRight, CheckCircle, XCircle } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { shareCodesApi, type ShareCode, type ShareCodeListParams } from '@/lib/api';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
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 ShareCodesPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [params, setParams] = useState<ShareCodeListParams>({
|
||||||
|
page: 1,
|
||||||
|
per_page: 20,
|
||||||
|
});
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [selectedRows, setSelectedRows] = useState<number[]>([]);
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['share-codes', params],
|
||||||
|
queryFn: () => shareCodesApi.list(params),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, is_active }: { id: number; is_active: boolean }) =>
|
||||||
|
shareCodesApi.update(id, { is_active }),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Share code updated successfully');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['share-codes'] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Failed to update share code');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => shareCodesApi.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Share code deleted successfully');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['share-codes'] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Failed to delete share code');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const bulkDeleteMutation = useMutation({
|
||||||
|
mutationFn: (ids: number[]) => shareCodesApi.bulkDelete(ids),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Share codes deleted successfully');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['share-codes'] });
|
||||||
|
setSelectedRows([]);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Failed to delete share codes');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setParams({ ...params, search, page: 1 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectAll = (checked: boolean) => {
|
||||||
|
if (checked && data?.data) {
|
||||||
|
setSelectedRows(data.data.map((c) => c.id));
|
||||||
|
} else {
|
||||||
|
setSelectedRows([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectRow = (id: number, checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
setSelectedRows([...selectedRows, id]);
|
||||||
|
} else {
|
||||||
|
setSelectedRows(selectedRows.filter((rowId) => rowId !== id));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isExpired = (expiresAt: string | null) => {
|
||||||
|
if (!expiresAt) return false;
|
||||||
|
return new Date(expiresAt) < new Date();
|
||||||
|
};
|
||||||
|
|
||||||
|
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 share codes</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Share Codes</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Manage residence sharing codes
|
||||||
|
</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 code, residence, or creator..."
|
||||||
|
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 Codes?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will permanently delete {selectedRows.length} share code(s).
|
||||||
|
Users who have already joined will remain on the residence.
|
||||||
|
</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>Code</TableHead>
|
||||||
|
<TableHead>Residence</TableHead>
|
||||||
|
<TableHead>Created By</TableHead>
|
||||||
|
<TableHead>Active</TableHead>
|
||||||
|
<TableHead>Expires</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead className="w-24">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="text-center py-8">
|
||||||
|
Loading...
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : data?.data?.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="text-center py-8">
|
||||||
|
No share codes found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
data?.data?.map((code) => (
|
||||||
|
<TableRow key={code.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedRows.includes(code.id)}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleSelectRow(code.id, checked as boolean)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<code className="text-sm bg-muted px-2 py-1 rounded font-mono">
|
||||||
|
{code.code}
|
||||||
|
</code>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Link
|
||||||
|
href={`/admin/residences/${code.residence_id}`}
|
||||||
|
className="font-medium text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
{code.residence_name}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Link
|
||||||
|
href={`/admin/users/${code.created_by_id}`}
|
||||||
|
className="text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
{code.created_by}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Switch
|
||||||
|
checked={code.is_active}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
updateMutation.mutate({ id: code.id, is_active: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{code.expires_at ? (
|
||||||
|
isExpired(code.expires_at) ? (
|
||||||
|
<Badge variant="destructive" className="gap-1">
|
||||||
|
<XCircle className="h-3 w-3" />
|
||||||
|
Expired
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
new Date(code.expires_at).toLocaleDateString()
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">Never</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{new Date(code.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 Code?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will permanently delete the share code for {code.residence_name}.
|
||||||
|
Users who have already joined will remain on the residence.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => deleteMutation.mutate(code.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} codes
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,6 +19,9 @@ import {
|
|||||||
Shield,
|
Shield,
|
||||||
Layers,
|
Layers,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
Mail,
|
||||||
|
Share2,
|
||||||
|
KeyRound,
|
||||||
} 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';
|
||||||
@@ -40,7 +43,10 @@ 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: '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: 'Password Resets', url: '/admin/password-reset-codes', icon: KeyRound },
|
||||||
{ title: 'Residences', url: '/admin/residences', icon: Building2 },
|
{ title: 'Residences', url: '/admin/residences', icon: Building2 },
|
||||||
|
{ 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: 'Contractors', url: '/admin/contractors', icon: Wrench },
|
{ title: 'Contractors', url: '/admin/contractors', icon: Wrench },
|
||||||
|
|||||||
@@ -795,4 +795,338 @@ export const limitationsApi = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Confirmation Codes Types
|
||||||
|
export interface ConfirmationCode {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
code: string;
|
||||||
|
expires_at: string;
|
||||||
|
is_used: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfirmationCodeListParams {
|
||||||
|
page?: number;
|
||||||
|
per_page?: number;
|
||||||
|
search?: string;
|
||||||
|
sort_by?: string;
|
||||||
|
sort_dir?: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirmation Codes API
|
||||||
|
export const confirmationCodesApi = {
|
||||||
|
list: async (params?: ConfirmationCodeListParams): Promise<PaginatedResponse<ConfirmationCode>> => {
|
||||||
|
const response = await api.get<PaginatedResponse<ConfirmationCode>>('/confirmation-codes', { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
get: async (id: number): Promise<ConfirmationCode> => {
|
||||||
|
const response = await api.get<ConfirmationCode>(`/confirmation-codes/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id: number): Promise<void> => {
|
||||||
|
await api.delete(`/confirmation-codes/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
bulkDelete: async (ids: number[]): Promise<void> => {
|
||||||
|
await api.delete('/confirmation-codes/bulk', { data: { ids } });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Share Codes Types
|
||||||
|
export interface ShareCode {
|
||||||
|
id: number;
|
||||||
|
residence_id: number;
|
||||||
|
residence_name: string;
|
||||||
|
code: string;
|
||||||
|
created_by_id: number;
|
||||||
|
created_by: string;
|
||||||
|
is_active: boolean;
|
||||||
|
expires_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShareCodeListParams {
|
||||||
|
page?: number;
|
||||||
|
per_page?: number;
|
||||||
|
search?: string;
|
||||||
|
sort_by?: string;
|
||||||
|
sort_dir?: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateShareCodeRequest {
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Share Codes API
|
||||||
|
export const shareCodesApi = {
|
||||||
|
list: async (params?: ShareCodeListParams): Promise<PaginatedResponse<ShareCode>> => {
|
||||||
|
const response = await api.get<PaginatedResponse<ShareCode>>('/share-codes', { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
get: async (id: number): Promise<ShareCode> => {
|
||||||
|
const response = await api.get<ShareCode>(`/share-codes/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (id: number, data: UpdateShareCodeRequest): Promise<ShareCode> => {
|
||||||
|
const response = await api.put<ShareCode>(`/share-codes/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id: number): Promise<void> => {
|
||||||
|
await api.delete(`/share-codes/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
bulkDelete: async (ids: number[]): Promise<void> => {
|
||||||
|
await api.delete('/share-codes/bulk', { data: { ids } });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Password Reset Codes Types
|
||||||
|
export interface PasswordResetCode {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
reset_token: string;
|
||||||
|
expires_at: string;
|
||||||
|
used: boolean;
|
||||||
|
attempts: number;
|
||||||
|
max_attempts: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PasswordResetCodeListParams {
|
||||||
|
page?: number;
|
||||||
|
per_page?: number;
|
||||||
|
search?: string;
|
||||||
|
sort_by?: string;
|
||||||
|
sort_dir?: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password Reset Codes API
|
||||||
|
export const passwordResetCodesApi = {
|
||||||
|
list: async (params?: PasswordResetCodeListParams): Promise<PaginatedResponse<PasswordResetCode>> => {
|
||||||
|
const response = await api.get<PaginatedResponse<PasswordResetCode>>('/password-reset-codes', { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
get: async (id: number): Promise<PasswordResetCode> => {
|
||||||
|
const response = await api.get<PasswordResetCode>(`/password-reset-codes/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id: number): Promise<void> => {
|
||||||
|
await api.delete(`/password-reset-codes/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
bulkDelete: async (ids: number[]): Promise<void> => {
|
||||||
|
await api.delete('/password-reset-codes/bulk', { data: { ids } });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Device Types
|
||||||
|
export interface APNSDevice {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
active: boolean;
|
||||||
|
user_id: number | null;
|
||||||
|
username: string | null;
|
||||||
|
device_id: string;
|
||||||
|
registration_id: string;
|
||||||
|
date_created: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GCMDevice {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
active: boolean;
|
||||||
|
user_id: number | null;
|
||||||
|
username: string | null;
|
||||||
|
device_id: string;
|
||||||
|
registration_id: string;
|
||||||
|
cloud_message_type: string;
|
||||||
|
date_created: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceListParams {
|
||||||
|
page?: number;
|
||||||
|
per_page?: number;
|
||||||
|
search?: string;
|
||||||
|
sort_by?: string;
|
||||||
|
sort_dir?: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceStats {
|
||||||
|
apns: { total: number; active: number };
|
||||||
|
gcm: { total: number; active: number };
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Devices API
|
||||||
|
export const devicesApi = {
|
||||||
|
getStats: async (): Promise<DeviceStats> => {
|
||||||
|
const response = await api.get<DeviceStats>('/devices/stats');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
listAPNS: async (params?: DeviceListParams): Promise<PaginatedResponse<APNSDevice>> => {
|
||||||
|
const response = await api.get<PaginatedResponse<APNSDevice>>('/devices/apns', { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
listGCM: async (params?: DeviceListParams): Promise<PaginatedResponse<GCMDevice>> => {
|
||||||
|
const response = await api.get<PaginatedResponse<GCMDevice>>('/devices/gcm', { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateAPNS: async (id: number, data: { active: boolean }): Promise<void> => {
|
||||||
|
await api.put(`/devices/apns/${id}`, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateGCM: async (id: number, data: { active: boolean }): Promise<void> => {
|
||||||
|
await api.put(`/devices/gcm/${id}`, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteAPNS: async (id: number): Promise<void> => {
|
||||||
|
await api.delete(`/devices/apns/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteGCM: async (id: number): Promise<void> => {
|
||||||
|
await api.delete(`/devices/gcm/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
bulkDeleteAPNS: async (ids: number[]): Promise<void> => {
|
||||||
|
await api.delete('/devices/apns/bulk', { data: { ids } });
|
||||||
|
},
|
||||||
|
|
||||||
|
bulkDeleteGCM: async (ids: number[]): Promise<void> => {
|
||||||
|
await api.delete('/devices/gcm/bulk', { data: { ids } });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Feature Benefit Types
|
||||||
|
export interface FeatureBenefit {
|
||||||
|
id: number;
|
||||||
|
feature_name: string;
|
||||||
|
free_tier_text: string;
|
||||||
|
pro_tier_text: string;
|
||||||
|
display_order: number;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateFeatureBenefitRequest {
|
||||||
|
feature_name: string;
|
||||||
|
free_tier_text: string;
|
||||||
|
pro_tier_text: string;
|
||||||
|
display_order?: number;
|
||||||
|
is_active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateFeatureBenefitRequest {
|
||||||
|
feature_name?: string;
|
||||||
|
free_tier_text?: string;
|
||||||
|
pro_tier_text?: string;
|
||||||
|
display_order?: number;
|
||||||
|
is_active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feature Benefits API
|
||||||
|
export const featureBenefitsApi = {
|
||||||
|
list: async (params?: { page?: number; per_page?: number; search?: string }): Promise<PaginatedResponse<FeatureBenefit>> => {
|
||||||
|
const response = await api.get<PaginatedResponse<FeatureBenefit>>('/feature-benefits', { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
get: async (id: number): Promise<FeatureBenefit> => {
|
||||||
|
const response = await api.get<FeatureBenefit>(`/feature-benefits/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (data: CreateFeatureBenefitRequest): Promise<FeatureBenefit> => {
|
||||||
|
const response = await api.post<FeatureBenefit>('/feature-benefits', data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (id: number, data: UpdateFeatureBenefitRequest): Promise<FeatureBenefit> => {
|
||||||
|
const response = await api.put<FeatureBenefit>(`/feature-benefits/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id: number): Promise<void> => {
|
||||||
|
await api.delete(`/feature-benefits/${id}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Promotion Types
|
||||||
|
export interface Promotion {
|
||||||
|
id: number;
|
||||||
|
promotion_id: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
link: string | null;
|
||||||
|
start_date: string;
|
||||||
|
end_date: string;
|
||||||
|
target_tier: 'free' | 'pro';
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePromotionRequest {
|
||||||
|
promotion_id: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
link?: string;
|
||||||
|
start_date: string;
|
||||||
|
end_date: string;
|
||||||
|
target_tier?: 'free' | 'pro';
|
||||||
|
is_active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePromotionRequest {
|
||||||
|
promotion_id?: string;
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
link?: string;
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
target_tier?: 'free' | 'pro';
|
||||||
|
is_active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promotions API
|
||||||
|
export const promotionsApi = {
|
||||||
|
list: async (params?: { page?: number; per_page?: number; search?: string }): Promise<PaginatedResponse<Promotion>> => {
|
||||||
|
const response = await api.get<PaginatedResponse<Promotion>>('/promotions', { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
get: async (id: number): Promise<Promotion> => {
|
||||||
|
const response = await api.get<Promotion>(`/promotions/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (data: CreatePromotionRequest): Promise<Promotion> => {
|
||||||
|
const response = await api.post<Promotion>('/promotions', data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (id: number, data: UpdatePromotionRequest): Promise<Promotion> => {
|
||||||
|
const response = await api.put<Promotion>(`/promotions/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id: number): Promise<void> => {
|
||||||
|
await api.delete(`/promotions/${id}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export default api;
|
export default api;
|
||||||
|
|||||||
164
internal/admin/handlers/confirmation_code_handler.go
Normal file
164
internal/admin/handlers/confirmation_code_handler.go
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminConfirmationCodeHandler handles admin confirmation code management endpoints
|
||||||
|
type AdminConfirmationCodeHandler struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAdminConfirmationCodeHandler creates a new admin confirmation code handler
|
||||||
|
func NewAdminConfirmationCodeHandler(db *gorm.DB) *AdminConfirmationCodeHandler {
|
||||||
|
return &AdminConfirmationCodeHandler{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfirmationCodeResponse represents a confirmation code in API responses
|
||||||
|
type ConfirmationCodeResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
UserID uint `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
ExpiresAt string `json:"expires_at"`
|
||||||
|
IsUsed bool `json:"is_used"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// List handles GET /api/admin/confirmation-codes
|
||||||
|
func (h *AdminConfirmationCodeHandler) 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 codes []models.ConfirmationCode
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
query := h.db.Model(&models.ConfirmationCode{}).Preload("User")
|
||||||
|
|
||||||
|
// Apply search (search by user info or code)
|
||||||
|
if filters.Search != "" {
|
||||||
|
search := "%" + filters.Search + "%"
|
||||||
|
query = query.Joins("JOIN auth_user ON auth_user.id = user_confirmationcode.user_id").
|
||||||
|
Where(
|
||||||
|
"auth_user.username ILIKE ? OR auth_user.email ILIKE ? OR user_confirmationcode.code 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(&codes).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch confirmation codes"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build response
|
||||||
|
responses := make([]ConfirmationCodeResponse, len(codes))
|
||||||
|
for i, code := range codes {
|
||||||
|
responses[i] = ConfirmationCodeResponse{
|
||||||
|
ID: code.ID,
|
||||||
|
UserID: code.UserID,
|
||||||
|
Username: code.User.Username,
|
||||||
|
Email: code.User.Email,
|
||||||
|
Code: code.Code,
|
||||||
|
ExpiresAt: code.ExpiresAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
IsUsed: code.IsUsed,
|
||||||
|
CreatedAt: code.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get handles GET /api/admin/confirmation-codes/:id
|
||||||
|
func (h *AdminConfirmationCodeHandler) 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 code models.ConfirmationCode
|
||||||
|
if err := h.db.Preload("User").First(&code, id).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Confirmation code not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch confirmation code"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response := ConfirmationCodeResponse{
|
||||||
|
ID: code.ID,
|
||||||
|
UserID: code.UserID,
|
||||||
|
Username: code.User.Username,
|
||||||
|
Email: code.User.Email,
|
||||||
|
Code: code.Code,
|
||||||
|
ExpiresAt: code.ExpiresAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
IsUsed: code.IsUsed,
|
||||||
|
CreatedAt: code.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete handles DELETE /api/admin/confirmation-codes/:id
|
||||||
|
func (h *AdminConfirmationCodeHandler) 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
|
||||||
|
}
|
||||||
|
|
||||||
|
result := h.db.Delete(&models.ConfirmationCode{}, id)
|
||||||
|
if result.Error != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete confirmation code"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Confirmation code not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Confirmation code deleted successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BulkDelete handles DELETE /api/admin/confirmation-codes/bulk
|
||||||
|
func (h *AdminConfirmationCodeHandler) 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
|
||||||
|
}
|
||||||
|
|
||||||
|
result := h.db.Where("id IN ?", req.IDs).Delete(&models.ConfirmationCode{})
|
||||||
|
if result.Error != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete confirmation codes"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Confirmation codes deleted successfully", "count": result.RowsAffected})
|
||||||
|
}
|
||||||
340
internal/admin/handlers/device_handler.go
Normal file
340
internal/admin/handlers/device_handler.go
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminDeviceHandler handles admin device management endpoints
|
||||||
|
type AdminDeviceHandler struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAdminDeviceHandler creates a new admin device handler
|
||||||
|
func NewAdminDeviceHandler(db *gorm.DB) *AdminDeviceHandler {
|
||||||
|
return &AdminDeviceHandler{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// APNSDeviceResponse represents an iOS device in API responses
|
||||||
|
type APNSDeviceResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
UserID *uint `json:"user_id"`
|
||||||
|
Username *string `json:"username"`
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
RegistrationID string `json:"registration_id"`
|
||||||
|
DateCreated string `json:"date_created"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GCMDeviceResponse represents an Android device in API responses
|
||||||
|
type GCMDeviceResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
UserID *uint `json:"user_id"`
|
||||||
|
Username *string `json:"username"`
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
RegistrationID string `json:"registration_id"`
|
||||||
|
CloudMessageType string `json:"cloud_message_type"`
|
||||||
|
DateCreated string `json:"date_created"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAPNS handles GET /api/admin/devices/apns
|
||||||
|
func (h *AdminDeviceHandler) ListAPNS(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 devices []models.APNSDevice
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
query := h.db.Model(&models.APNSDevice{}).Preload("User")
|
||||||
|
|
||||||
|
// Apply search
|
||||||
|
if filters.Search != "" {
|
||||||
|
search := "%" + filters.Search + "%"
|
||||||
|
query = query.Joins("LEFT JOIN auth_user ON auth_user.id = push_notifications_apnsdevice.user_id").
|
||||||
|
Where(
|
||||||
|
"push_notifications_apnsdevice.name ILIKE ? OR push_notifications_apnsdevice.device_id ILIKE ? OR auth_user.username ILIKE ?",
|
||||||
|
search, search, search,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
query.Count(&total)
|
||||||
|
|
||||||
|
sortBy := "date_created"
|
||||||
|
if filters.SortBy != "" {
|
||||||
|
sortBy = filters.SortBy
|
||||||
|
}
|
||||||
|
query = query.Order(sortBy + " " + filters.GetSortDir())
|
||||||
|
query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage())
|
||||||
|
|
||||||
|
if err := query.Find(&devices).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch devices"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
responses := make([]APNSDeviceResponse, len(devices))
|
||||||
|
for i, device := range devices {
|
||||||
|
var username *string
|
||||||
|
if device.User != nil {
|
||||||
|
username = &device.User.Username
|
||||||
|
}
|
||||||
|
responses[i] = APNSDeviceResponse{
|
||||||
|
ID: device.ID,
|
||||||
|
Name: device.Name,
|
||||||
|
Active: device.Active,
|
||||||
|
UserID: device.UserID,
|
||||||
|
Username: username,
|
||||||
|
DeviceID: device.DeviceID,
|
||||||
|
RegistrationID: device.RegistrationID[:min(20, len(device.RegistrationID))] + "...",
|
||||||
|
DateCreated: device.DateCreated.Format("2006-01-02T15:04:05Z"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListGCM handles GET /api/admin/devices/gcm
|
||||||
|
func (h *AdminDeviceHandler) ListGCM(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 devices []models.GCMDevice
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
query := h.db.Model(&models.GCMDevice{}).Preload("User")
|
||||||
|
|
||||||
|
if filters.Search != "" {
|
||||||
|
search := "%" + filters.Search + "%"
|
||||||
|
query = query.Joins("LEFT JOIN auth_user ON auth_user.id = push_notifications_gcmdevice.user_id").
|
||||||
|
Where(
|
||||||
|
"push_notifications_gcmdevice.name ILIKE ? OR push_notifications_gcmdevice.device_id ILIKE ? OR auth_user.username ILIKE ?",
|
||||||
|
search, search, search,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
query.Count(&total)
|
||||||
|
|
||||||
|
sortBy := "date_created"
|
||||||
|
if filters.SortBy != "" {
|
||||||
|
sortBy = filters.SortBy
|
||||||
|
}
|
||||||
|
query = query.Order(sortBy + " " + filters.GetSortDir())
|
||||||
|
query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage())
|
||||||
|
|
||||||
|
if err := query.Find(&devices).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch devices"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
responses := make([]GCMDeviceResponse, len(devices))
|
||||||
|
for i, device := range devices {
|
||||||
|
var username *string
|
||||||
|
if device.User != nil {
|
||||||
|
username = &device.User.Username
|
||||||
|
}
|
||||||
|
responses[i] = GCMDeviceResponse{
|
||||||
|
ID: device.ID,
|
||||||
|
Name: device.Name,
|
||||||
|
Active: device.Active,
|
||||||
|
UserID: device.UserID,
|
||||||
|
Username: username,
|
||||||
|
DeviceID: device.DeviceID,
|
||||||
|
RegistrationID: device.RegistrationID[:min(20, len(device.RegistrationID))] + "...",
|
||||||
|
CloudMessageType: device.CloudMessageType,
|
||||||
|
DateCreated: device.DateCreated.Format("2006-01-02T15:04:05Z"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAPNS handles PUT /api/admin/devices/apns/:id
|
||||||
|
func (h *AdminDeviceHandler) UpdateAPNS(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 device models.APNSDevice
|
||||||
|
if err := h.db.First(&device, id).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Device not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch device"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Active bool `json:"active"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
device.Active = req.Active
|
||||||
|
if err := h.db.Save(&device).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update device"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Device updated successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateGCM handles PUT /api/admin/devices/gcm/:id
|
||||||
|
func (h *AdminDeviceHandler) UpdateGCM(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 device models.GCMDevice
|
||||||
|
if err := h.db.First(&device, id).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Device not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch device"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Active bool `json:"active"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
device.Active = req.Active
|
||||||
|
if err := h.db.Save(&device).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update device"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Device updated successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAPNS handles DELETE /api/admin/devices/apns/:id
|
||||||
|
func (h *AdminDeviceHandler) DeleteAPNS(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
|
||||||
|
}
|
||||||
|
|
||||||
|
result := h.db.Delete(&models.APNSDevice{}, id)
|
||||||
|
if result.Error != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete device"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Device not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Device deleted successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteGCM handles DELETE /api/admin/devices/gcm/:id
|
||||||
|
func (h *AdminDeviceHandler) DeleteGCM(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
|
||||||
|
}
|
||||||
|
|
||||||
|
result := h.db.Delete(&models.GCMDevice{}, id)
|
||||||
|
if result.Error != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete device"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Device not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Device deleted successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BulkDeleteAPNS handles DELETE /api/admin/devices/apns/bulk
|
||||||
|
func (h *AdminDeviceHandler) BulkDeleteAPNS(c *gin.Context) {
|
||||||
|
var req dto.BulkDeleteRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := h.db.Where("id IN ?", req.IDs).Delete(&models.APNSDevice{})
|
||||||
|
if result.Error != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete devices"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Devices deleted successfully", "count": result.RowsAffected})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BulkDeleteGCM handles DELETE /api/admin/devices/gcm/bulk
|
||||||
|
func (h *AdminDeviceHandler) BulkDeleteGCM(c *gin.Context) {
|
||||||
|
var req dto.BulkDeleteRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := h.db.Where("id IN ?", req.IDs).Delete(&models.GCMDevice{})
|
||||||
|
if result.Error != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete devices"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Devices deleted successfully", "count": result.RowsAffected})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStats handles GET /api/admin/devices/stats
|
||||||
|
func (h *AdminDeviceHandler) GetStats(c *gin.Context) {
|
||||||
|
var apnsTotal, apnsActive, gcmTotal, gcmActive int64
|
||||||
|
|
||||||
|
h.db.Model(&models.APNSDevice{}).Count(&apnsTotal)
|
||||||
|
h.db.Model(&models.APNSDevice{}).Where("active = ?", true).Count(&apnsActive)
|
||||||
|
h.db.Model(&models.GCMDevice{}).Count(&gcmTotal)
|
||||||
|
h.db.Model(&models.GCMDevice{}).Where("active = ?", true).Count(&gcmActive)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"apns": gin.H{
|
||||||
|
"total": apnsTotal,
|
||||||
|
"active": apnsActive,
|
||||||
|
},
|
||||||
|
"gcm": gin.H{
|
||||||
|
"total": gcmTotal,
|
||||||
|
"active": gcmActive,
|
||||||
|
},
|
||||||
|
"total": apnsTotal + gcmTotal,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func min(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
245
internal/admin/handlers/feature_benefit_handler.go
Normal file
245
internal/admin/handlers/feature_benefit_handler.go
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminFeatureBenefitHandler handles admin feature benefit management endpoints
|
||||||
|
type AdminFeatureBenefitHandler struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAdminFeatureBenefitHandler creates a new admin feature benefit handler
|
||||||
|
func NewAdminFeatureBenefitHandler(db *gorm.DB) *AdminFeatureBenefitHandler {
|
||||||
|
return &AdminFeatureBenefitHandler{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FeatureBenefitResponse represents a feature benefit in API responses
|
||||||
|
type FeatureBenefitResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
FeatureName string `json:"feature_name"`
|
||||||
|
FreeTierText string `json:"free_tier_text"`
|
||||||
|
ProTierText string `json:"pro_tier_text"`
|
||||||
|
DisplayOrder int `json:"display_order"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// List handles GET /api/admin/feature-benefits
|
||||||
|
func (h *AdminFeatureBenefitHandler) 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 benefits []models.FeatureBenefit
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
query := h.db.Model(&models.FeatureBenefit{})
|
||||||
|
|
||||||
|
if filters.Search != "" {
|
||||||
|
search := "%" + filters.Search + "%"
|
||||||
|
query = query.Where("feature_name ILIKE ? OR free_tier_text ILIKE ? OR pro_tier_text ILIKE ?", search, search, search)
|
||||||
|
}
|
||||||
|
|
||||||
|
query.Count(&total)
|
||||||
|
|
||||||
|
sortBy := "display_order"
|
||||||
|
if filters.SortBy != "" {
|
||||||
|
sortBy = filters.SortBy
|
||||||
|
}
|
||||||
|
query = query.Order(sortBy + " " + filters.GetSortDir())
|
||||||
|
query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage())
|
||||||
|
|
||||||
|
if err := query.Find(&benefits).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch feature benefits"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
responses := make([]FeatureBenefitResponse, len(benefits))
|
||||||
|
for i, b := range benefits {
|
||||||
|
responses[i] = FeatureBenefitResponse{
|
||||||
|
ID: b.ID,
|
||||||
|
FeatureName: b.FeatureName,
|
||||||
|
FreeTierText: b.FreeTierText,
|
||||||
|
ProTierText: b.ProTierText,
|
||||||
|
DisplayOrder: b.DisplayOrder,
|
||||||
|
IsActive: b.IsActive,
|
||||||
|
CreatedAt: b.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
UpdatedAt: b.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get handles GET /api/admin/feature-benefits/:id
|
||||||
|
func (h *AdminFeatureBenefitHandler) 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 benefit models.FeatureBenefit
|
||||||
|
if err := h.db.First(&benefit, id).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Feature benefit not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch feature benefit"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response := FeatureBenefitResponse{
|
||||||
|
ID: benefit.ID,
|
||||||
|
FeatureName: benefit.FeatureName,
|
||||||
|
FreeTierText: benefit.FreeTierText,
|
||||||
|
ProTierText: benefit.ProTierText,
|
||||||
|
DisplayOrder: benefit.DisplayOrder,
|
||||||
|
IsActive: benefit.IsActive,
|
||||||
|
CreatedAt: benefit.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
UpdatedAt: benefit.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create handles POST /api/admin/feature-benefits
|
||||||
|
func (h *AdminFeatureBenefitHandler) Create(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
FeatureName string `json:"feature_name" binding:"required"`
|
||||||
|
FreeTierText string `json:"free_tier_text" binding:"required"`
|
||||||
|
ProTierText string `json:"pro_tier_text" binding:"required"`
|
||||||
|
DisplayOrder int `json:"display_order"`
|
||||||
|
IsActive *bool `json:"is_active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
benefit := models.FeatureBenefit{
|
||||||
|
FeatureName: req.FeatureName,
|
||||||
|
FreeTierText: req.FreeTierText,
|
||||||
|
ProTierText: req.ProTierText,
|
||||||
|
DisplayOrder: req.DisplayOrder,
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.IsActive != nil {
|
||||||
|
benefit.IsActive = *req.IsActive
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.Create(&benefit).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create feature benefit"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, FeatureBenefitResponse{
|
||||||
|
ID: benefit.ID,
|
||||||
|
FeatureName: benefit.FeatureName,
|
||||||
|
FreeTierText: benefit.FreeTierText,
|
||||||
|
ProTierText: benefit.ProTierText,
|
||||||
|
DisplayOrder: benefit.DisplayOrder,
|
||||||
|
IsActive: benefit.IsActive,
|
||||||
|
CreatedAt: benefit.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
UpdatedAt: benefit.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update handles PUT /api/admin/feature-benefits/:id
|
||||||
|
func (h *AdminFeatureBenefitHandler) 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 benefit models.FeatureBenefit
|
||||||
|
if err := h.db.First(&benefit, id).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Feature benefit not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch feature benefit"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
FeatureName *string `json:"feature_name"`
|
||||||
|
FreeTierText *string `json:"free_tier_text"`
|
||||||
|
ProTierText *string `json:"pro_tier_text"`
|
||||||
|
DisplayOrder *int `json:"display_order"`
|
||||||
|
IsActive *bool `json:"is_active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.FeatureName != nil {
|
||||||
|
benefit.FeatureName = *req.FeatureName
|
||||||
|
}
|
||||||
|
if req.FreeTierText != nil {
|
||||||
|
benefit.FreeTierText = *req.FreeTierText
|
||||||
|
}
|
||||||
|
if req.ProTierText != nil {
|
||||||
|
benefit.ProTierText = *req.ProTierText
|
||||||
|
}
|
||||||
|
if req.DisplayOrder != nil {
|
||||||
|
benefit.DisplayOrder = *req.DisplayOrder
|
||||||
|
}
|
||||||
|
if req.IsActive != nil {
|
||||||
|
benefit.IsActive = *req.IsActive
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.Save(&benefit).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update feature benefit"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, FeatureBenefitResponse{
|
||||||
|
ID: benefit.ID,
|
||||||
|
FeatureName: benefit.FeatureName,
|
||||||
|
FreeTierText: benefit.FreeTierText,
|
||||||
|
ProTierText: benefit.ProTierText,
|
||||||
|
DisplayOrder: benefit.DisplayOrder,
|
||||||
|
IsActive: benefit.IsActive,
|
||||||
|
CreatedAt: benefit.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
UpdatedAt: benefit.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete handles DELETE /api/admin/feature-benefits/:id
|
||||||
|
func (h *AdminFeatureBenefitHandler) 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
|
||||||
|
}
|
||||||
|
|
||||||
|
result := h.db.Delete(&models.FeatureBenefit{}, id)
|
||||||
|
if result.Error != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete feature benefit"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Feature benefit not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Feature benefit deleted successfully"})
|
||||||
|
}
|
||||||
170
internal/admin/handlers/password_reset_code_handler.go
Normal file
170
internal/admin/handlers/password_reset_code_handler.go
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminPasswordResetCodeHandler handles admin password reset code management endpoints
|
||||||
|
type AdminPasswordResetCodeHandler struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAdminPasswordResetCodeHandler creates a new admin password reset code handler
|
||||||
|
func NewAdminPasswordResetCodeHandler(db *gorm.DB) *AdminPasswordResetCodeHandler {
|
||||||
|
return &AdminPasswordResetCodeHandler{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasswordResetCodeResponse represents a password reset code in API responses
|
||||||
|
type PasswordResetCodeResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
UserID uint `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
ResetToken string `json:"reset_token"`
|
||||||
|
ExpiresAt string `json:"expires_at"`
|
||||||
|
Used bool `json:"used"`
|
||||||
|
Attempts int `json:"attempts"`
|
||||||
|
MaxAttempts int `json:"max_attempts"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// List handles GET /api/admin/password-reset-codes
|
||||||
|
func (h *AdminPasswordResetCodeHandler) 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 codes []models.PasswordResetCode
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
query := h.db.Model(&models.PasswordResetCode{}).Preload("User")
|
||||||
|
|
||||||
|
// Apply search (search by user info or token)
|
||||||
|
if filters.Search != "" {
|
||||||
|
search := "%" + filters.Search + "%"
|
||||||
|
query = query.Joins("JOIN auth_user ON auth_user.id = user_passwordresetcode.user_id").
|
||||||
|
Where(
|
||||||
|
"auth_user.username ILIKE ? OR auth_user.email ILIKE ? OR user_passwordresetcode.reset_token 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(&codes).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch password reset codes"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build response
|
||||||
|
responses := make([]PasswordResetCodeResponse, len(codes))
|
||||||
|
for i, code := range codes {
|
||||||
|
responses[i] = PasswordResetCodeResponse{
|
||||||
|
ID: code.ID,
|
||||||
|
UserID: code.UserID,
|
||||||
|
Username: code.User.Username,
|
||||||
|
Email: code.User.Email,
|
||||||
|
ResetToken: code.ResetToken[:8] + "..." + code.ResetToken[len(code.ResetToken)-4:], // Truncate for display
|
||||||
|
ExpiresAt: code.ExpiresAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
Used: code.Used,
|
||||||
|
Attempts: code.Attempts,
|
||||||
|
MaxAttempts: code.MaxAttempts,
|
||||||
|
CreatedAt: code.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get handles GET /api/admin/password-reset-codes/:id
|
||||||
|
func (h *AdminPasswordResetCodeHandler) 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 code models.PasswordResetCode
|
||||||
|
if err := h.db.Preload("User").First(&code, id).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Password reset code not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch password reset code"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response := PasswordResetCodeResponse{
|
||||||
|
ID: code.ID,
|
||||||
|
UserID: code.UserID,
|
||||||
|
Username: code.User.Username,
|
||||||
|
Email: code.User.Email,
|
||||||
|
ResetToken: code.ResetToken[:8] + "..." + code.ResetToken[len(code.ResetToken)-4:],
|
||||||
|
ExpiresAt: code.ExpiresAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
Used: code.Used,
|
||||||
|
Attempts: code.Attempts,
|
||||||
|
MaxAttempts: code.MaxAttempts,
|
||||||
|
CreatedAt: code.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete handles DELETE /api/admin/password-reset-codes/:id
|
||||||
|
func (h *AdminPasswordResetCodeHandler) 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
|
||||||
|
}
|
||||||
|
|
||||||
|
result := h.db.Delete(&models.PasswordResetCode{}, id)
|
||||||
|
if result.Error != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete password reset code"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Password reset code not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Password reset code deleted successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BulkDelete handles DELETE /api/admin/password-reset-codes/bulk
|
||||||
|
func (h *AdminPasswordResetCodeHandler) 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
|
||||||
|
}
|
||||||
|
|
||||||
|
result := h.db.Where("id IN ?", req.IDs).Delete(&models.PasswordResetCode{})
|
||||||
|
if result.Error != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete password reset codes"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Password reset codes deleted successfully", "count": result.RowsAffected})
|
||||||
|
}
|
||||||
322
internal/admin/handlers/promotion_handler.go
Normal file
322
internal/admin/handlers/promotion_handler.go
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminPromotionHandler handles admin promotion management endpoints
|
||||||
|
type AdminPromotionHandler struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAdminPromotionHandler creates a new admin promotion handler
|
||||||
|
func NewAdminPromotionHandler(db *gorm.DB) *AdminPromotionHandler {
|
||||||
|
return &AdminPromotionHandler{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PromotionResponse represents a promotion in API responses
|
||||||
|
type PromotionResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
PromotionID string `json:"promotion_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Link *string `json:"link"`
|
||||||
|
StartDate string `json:"start_date"`
|
||||||
|
EndDate string `json:"end_date"`
|
||||||
|
TargetTier string `json:"target_tier"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// List handles GET /api/admin/promotions
|
||||||
|
func (h *AdminPromotionHandler) 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 promotions []models.Promotion
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
query := h.db.Model(&models.Promotion{})
|
||||||
|
|
||||||
|
if filters.Search != "" {
|
||||||
|
search := "%" + filters.Search + "%"
|
||||||
|
query = query.Where("promotion_id ILIKE ? OR title ILIKE ? OR message ILIKE ?", search, search, search)
|
||||||
|
}
|
||||||
|
|
||||||
|
query.Count(&total)
|
||||||
|
|
||||||
|
sortBy := "created_at"
|
||||||
|
if filters.SortBy != "" {
|
||||||
|
sortBy = filters.SortBy
|
||||||
|
}
|
||||||
|
query = query.Order(sortBy + " " + filters.GetSortDir())
|
||||||
|
query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage())
|
||||||
|
|
||||||
|
if err := query.Find(&promotions).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch promotions"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
responses := make([]PromotionResponse, len(promotions))
|
||||||
|
for i, p := range promotions {
|
||||||
|
responses[i] = PromotionResponse{
|
||||||
|
ID: p.ID,
|
||||||
|
PromotionID: p.PromotionID,
|
||||||
|
Title: p.Title,
|
||||||
|
Message: p.Message,
|
||||||
|
Link: p.Link,
|
||||||
|
StartDate: p.StartDate.Format("2006-01-02T15:04:05Z"),
|
||||||
|
EndDate: p.EndDate.Format("2006-01-02T15:04:05Z"),
|
||||||
|
TargetTier: string(p.TargetTier),
|
||||||
|
IsActive: p.IsActive,
|
||||||
|
CreatedAt: p.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
UpdatedAt: p.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get handles GET /api/admin/promotions/:id
|
||||||
|
func (h *AdminPromotionHandler) 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 promotion models.Promotion
|
||||||
|
if err := h.db.First(&promotion, id).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Promotion not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch promotion"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response := PromotionResponse{
|
||||||
|
ID: promotion.ID,
|
||||||
|
PromotionID: promotion.PromotionID,
|
||||||
|
Title: promotion.Title,
|
||||||
|
Message: promotion.Message,
|
||||||
|
Link: promotion.Link,
|
||||||
|
StartDate: promotion.StartDate.Format("2006-01-02T15:04:05Z"),
|
||||||
|
EndDate: promotion.EndDate.Format("2006-01-02T15:04:05Z"),
|
||||||
|
TargetTier: string(promotion.TargetTier),
|
||||||
|
IsActive: promotion.IsActive,
|
||||||
|
CreatedAt: promotion.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
UpdatedAt: promotion.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create handles POST /api/admin/promotions
|
||||||
|
func (h *AdminPromotionHandler) Create(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
PromotionID string `json:"promotion_id" binding:"required"`
|
||||||
|
Title string `json:"title" binding:"required"`
|
||||||
|
Message string `json:"message" binding:"required"`
|
||||||
|
Link *string `json:"link"`
|
||||||
|
StartDate string `json:"start_date" binding:"required"`
|
||||||
|
EndDate string `json:"end_date" binding:"required"`
|
||||||
|
TargetTier string `json:"target_tier"`
|
||||||
|
IsActive *bool `json:"is_active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
startDate, err := time.Parse("2006-01-02T15:04:05Z", req.StartDate)
|
||||||
|
if err != nil {
|
||||||
|
startDate, err = time.Parse("2006-01-02", req.StartDate)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid start_date format"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
endDate, err := time.Parse("2006-01-02T15:04:05Z", req.EndDate)
|
||||||
|
if err != nil {
|
||||||
|
endDate, err = time.Parse("2006-01-02", req.EndDate)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid end_date format"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
targetTier := models.TierFree
|
||||||
|
if req.TargetTier == "pro" {
|
||||||
|
targetTier = models.TierPro
|
||||||
|
}
|
||||||
|
|
||||||
|
promotion := models.Promotion{
|
||||||
|
PromotionID: req.PromotionID,
|
||||||
|
Title: req.Title,
|
||||||
|
Message: req.Message,
|
||||||
|
Link: req.Link,
|
||||||
|
StartDate: startDate,
|
||||||
|
EndDate: endDate,
|
||||||
|
TargetTier: targetTier,
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.IsActive != nil {
|
||||||
|
promotion.IsActive = *req.IsActive
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.Create(&promotion).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create promotion"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, PromotionResponse{
|
||||||
|
ID: promotion.ID,
|
||||||
|
PromotionID: promotion.PromotionID,
|
||||||
|
Title: promotion.Title,
|
||||||
|
Message: promotion.Message,
|
||||||
|
Link: promotion.Link,
|
||||||
|
StartDate: promotion.StartDate.Format("2006-01-02T15:04:05Z"),
|
||||||
|
EndDate: promotion.EndDate.Format("2006-01-02T15:04:05Z"),
|
||||||
|
TargetTier: string(promotion.TargetTier),
|
||||||
|
IsActive: promotion.IsActive,
|
||||||
|
CreatedAt: promotion.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
UpdatedAt: promotion.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update handles PUT /api/admin/promotions/:id
|
||||||
|
func (h *AdminPromotionHandler) 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 promotion models.Promotion
|
||||||
|
if err := h.db.First(&promotion, id).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Promotion not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch promotion"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
PromotionID *string `json:"promotion_id"`
|
||||||
|
Title *string `json:"title"`
|
||||||
|
Message *string `json:"message"`
|
||||||
|
Link *string `json:"link"`
|
||||||
|
StartDate *string `json:"start_date"`
|
||||||
|
EndDate *string `json:"end_date"`
|
||||||
|
TargetTier *string `json:"target_tier"`
|
||||||
|
IsActive *bool `json:"is_active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.PromotionID != nil {
|
||||||
|
promotion.PromotionID = *req.PromotionID
|
||||||
|
}
|
||||||
|
if req.Title != nil {
|
||||||
|
promotion.Title = *req.Title
|
||||||
|
}
|
||||||
|
if req.Message != nil {
|
||||||
|
promotion.Message = *req.Message
|
||||||
|
}
|
||||||
|
if req.Link != nil {
|
||||||
|
promotion.Link = req.Link
|
||||||
|
}
|
||||||
|
if req.StartDate != nil {
|
||||||
|
startDate, err := time.Parse("2006-01-02T15:04:05Z", *req.StartDate)
|
||||||
|
if err != nil {
|
||||||
|
startDate, err = time.Parse("2006-01-02", *req.StartDate)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid start_date format"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
promotion.StartDate = startDate
|
||||||
|
}
|
||||||
|
if req.EndDate != nil {
|
||||||
|
endDate, err := time.Parse("2006-01-02T15:04:05Z", *req.EndDate)
|
||||||
|
if err != nil {
|
||||||
|
endDate, err = time.Parse("2006-01-02", *req.EndDate)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid end_date format"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
promotion.EndDate = endDate
|
||||||
|
}
|
||||||
|
if req.TargetTier != nil {
|
||||||
|
if *req.TargetTier == "pro" {
|
||||||
|
promotion.TargetTier = models.TierPro
|
||||||
|
} else {
|
||||||
|
promotion.TargetTier = models.TierFree
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.IsActive != nil {
|
||||||
|
promotion.IsActive = *req.IsActive
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.Save(&promotion).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update promotion"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, PromotionResponse{
|
||||||
|
ID: promotion.ID,
|
||||||
|
PromotionID: promotion.PromotionID,
|
||||||
|
Title: promotion.Title,
|
||||||
|
Message: promotion.Message,
|
||||||
|
Link: promotion.Link,
|
||||||
|
StartDate: promotion.StartDate.Format("2006-01-02T15:04:05Z"),
|
||||||
|
EndDate: promotion.EndDate.Format("2006-01-02T15:04:05Z"),
|
||||||
|
TargetTier: string(promotion.TargetTier),
|
||||||
|
IsActive: promotion.IsActive,
|
||||||
|
CreatedAt: promotion.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
UpdatedAt: promotion.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete handles DELETE /api/admin/promotions/:id
|
||||||
|
func (h *AdminPromotionHandler) 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
|
||||||
|
}
|
||||||
|
|
||||||
|
result := h.db.Delete(&models.Promotion{}, id)
|
||||||
|
if result.Error != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete promotion"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Promotion not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Promotion deleted successfully"})
|
||||||
|
}
|
||||||
239
internal/admin/handlers/share_code_handler.go
Normal file
239
internal/admin/handlers/share_code_handler.go
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminShareCodeHandler handles admin share code management endpoints
|
||||||
|
type AdminShareCodeHandler struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAdminShareCodeHandler creates a new admin share code handler
|
||||||
|
func NewAdminShareCodeHandler(db *gorm.DB) *AdminShareCodeHandler {
|
||||||
|
return &AdminShareCodeHandler{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShareCodeResponse represents a share code in API responses
|
||||||
|
type ShareCodeResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
ResidenceID uint `json:"residence_id"`
|
||||||
|
ResidenceName string `json:"residence_name"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
CreatedByID uint `json:"created_by_id"`
|
||||||
|
CreatedBy string `json:"created_by"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
ExpiresAt *string `json:"expires_at"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// List handles GET /api/admin/share-codes
|
||||||
|
func (h *AdminShareCodeHandler) 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 codes []models.ResidenceShareCode
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
query := h.db.Model(&models.ResidenceShareCode{}).
|
||||||
|
Preload("Residence").
|
||||||
|
Preload("CreatedBy")
|
||||||
|
|
||||||
|
// Apply search (search by code or residence name)
|
||||||
|
if filters.Search != "" {
|
||||||
|
search := "%" + filters.Search + "%"
|
||||||
|
query = query.Joins("JOIN residence_residence ON residence_residence.id = residence_residencesharecode.residence_id").
|
||||||
|
Joins("JOIN auth_user ON auth_user.id = residence_residencesharecode.created_by_id").
|
||||||
|
Where(
|
||||||
|
"residence_residencesharecode.code ILIKE ? OR residence_residence.name ILIKE ? OR auth_user.username 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(&codes).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch share codes"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build response
|
||||||
|
responses := make([]ShareCodeResponse, len(codes))
|
||||||
|
for i, code := range codes {
|
||||||
|
var expiresAt *string
|
||||||
|
if code.ExpiresAt != nil {
|
||||||
|
formatted := code.ExpiresAt.Format("2006-01-02T15:04:05Z")
|
||||||
|
expiresAt = &formatted
|
||||||
|
}
|
||||||
|
|
||||||
|
responses[i] = ShareCodeResponse{
|
||||||
|
ID: code.ID,
|
||||||
|
ResidenceID: code.ResidenceID,
|
||||||
|
ResidenceName: code.Residence.Name,
|
||||||
|
Code: code.Code,
|
||||||
|
CreatedByID: code.CreatedByID,
|
||||||
|
CreatedBy: code.CreatedBy.Username,
|
||||||
|
IsActive: code.IsActive,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
CreatedAt: code.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get handles GET /api/admin/share-codes/:id
|
||||||
|
func (h *AdminShareCodeHandler) 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 code models.ResidenceShareCode
|
||||||
|
if err := h.db.Preload("Residence").Preload("CreatedBy").First(&code, id).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Share code not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch share code"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var expiresAt *string
|
||||||
|
if code.ExpiresAt != nil {
|
||||||
|
formatted := code.ExpiresAt.Format("2006-01-02T15:04:05Z")
|
||||||
|
expiresAt = &formatted
|
||||||
|
}
|
||||||
|
|
||||||
|
response := ShareCodeResponse{
|
||||||
|
ID: code.ID,
|
||||||
|
ResidenceID: code.ResidenceID,
|
||||||
|
ResidenceName: code.Residence.Name,
|
||||||
|
Code: code.Code,
|
||||||
|
CreatedByID: code.CreatedByID,
|
||||||
|
CreatedBy: code.CreatedBy.Username,
|
||||||
|
IsActive: code.IsActive,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
CreatedAt: code.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update handles PUT /api/admin/share-codes/:id
|
||||||
|
func (h *AdminShareCodeHandler) 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 code models.ResidenceShareCode
|
||||||
|
if err := h.db.First(&code, id).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Share code not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch share code"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
code.IsActive = req.IsActive
|
||||||
|
|
||||||
|
if err := h.db.Save(&code).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update share code"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload with relations
|
||||||
|
h.db.Preload("Residence").Preload("CreatedBy").First(&code, id)
|
||||||
|
|
||||||
|
var expiresAt *string
|
||||||
|
if code.ExpiresAt != nil {
|
||||||
|
formatted := code.ExpiresAt.Format("2006-01-02T15:04:05Z")
|
||||||
|
expiresAt = &formatted
|
||||||
|
}
|
||||||
|
|
||||||
|
response := ShareCodeResponse{
|
||||||
|
ID: code.ID,
|
||||||
|
ResidenceID: code.ResidenceID,
|
||||||
|
ResidenceName: code.Residence.Name,
|
||||||
|
Code: code.Code,
|
||||||
|
CreatedByID: code.CreatedByID,
|
||||||
|
CreatedBy: code.CreatedBy.Username,
|
||||||
|
IsActive: code.IsActive,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
CreatedAt: code.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete handles DELETE /api/admin/share-codes/:id
|
||||||
|
func (h *AdminShareCodeHandler) 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
|
||||||
|
}
|
||||||
|
|
||||||
|
result := h.db.Delete(&models.ResidenceShareCode{}, id)
|
||||||
|
if result.Error != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete share code"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Share code not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Share code deleted successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BulkDelete handles DELETE /api/admin/share-codes/bulk
|
||||||
|
func (h *AdminShareCodeHandler) 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
|
||||||
|
}
|
||||||
|
|
||||||
|
result := h.db.Where("id IN ?", req.IDs).Delete(&models.ResidenceShareCode{})
|
||||||
|
if result.Error != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete share codes"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Share codes deleted successfully", "count": result.RowsAffected})
|
||||||
|
}
|
||||||
@@ -171,6 +171,74 @@ func SetupRoutes(router *gin.Engine, db *gorm.DB, cfg *config.Config, deps *Depe
|
|||||||
completions.DELETE("/:id", completionHandler.Delete)
|
completions.DELETE("/:id", completionHandler.Delete)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Confirmation code management
|
||||||
|
confirmationCodeHandler := handlers.NewAdminConfirmationCodeHandler(db)
|
||||||
|
confirmationCodes := protected.Group("/confirmation-codes")
|
||||||
|
{
|
||||||
|
confirmationCodes.GET("", confirmationCodeHandler.List)
|
||||||
|
confirmationCodes.DELETE("/bulk", confirmationCodeHandler.BulkDelete)
|
||||||
|
confirmationCodes.GET("/:id", confirmationCodeHandler.Get)
|
||||||
|
confirmationCodes.DELETE("/:id", confirmationCodeHandler.Delete)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Share code management
|
||||||
|
shareCodeHandler := handlers.NewAdminShareCodeHandler(db)
|
||||||
|
shareCodes := protected.Group("/share-codes")
|
||||||
|
{
|
||||||
|
shareCodes.GET("", shareCodeHandler.List)
|
||||||
|
shareCodes.DELETE("/bulk", shareCodeHandler.BulkDelete)
|
||||||
|
shareCodes.GET("/:id", shareCodeHandler.Get)
|
||||||
|
shareCodes.PUT("/:id", shareCodeHandler.Update)
|
||||||
|
shareCodes.DELETE("/:id", shareCodeHandler.Delete)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password reset code management
|
||||||
|
passwordResetCodeHandler := handlers.NewAdminPasswordResetCodeHandler(db)
|
||||||
|
passwordResetCodes := protected.Group("/password-reset-codes")
|
||||||
|
{
|
||||||
|
passwordResetCodes.GET("", passwordResetCodeHandler.List)
|
||||||
|
passwordResetCodes.DELETE("/bulk", passwordResetCodeHandler.BulkDelete)
|
||||||
|
passwordResetCodes.GET("/:id", passwordResetCodeHandler.Get)
|
||||||
|
passwordResetCodes.DELETE("/:id", passwordResetCodeHandler.Delete)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push notification devices management
|
||||||
|
deviceHandler := handlers.NewAdminDeviceHandler(db)
|
||||||
|
devices := protected.Group("/devices")
|
||||||
|
{
|
||||||
|
devices.GET("/stats", deviceHandler.GetStats)
|
||||||
|
devices.GET("/apns", deviceHandler.ListAPNS)
|
||||||
|
devices.DELETE("/apns/bulk", deviceHandler.BulkDeleteAPNS)
|
||||||
|
devices.PUT("/apns/:id", deviceHandler.UpdateAPNS)
|
||||||
|
devices.DELETE("/apns/:id", deviceHandler.DeleteAPNS)
|
||||||
|
devices.GET("/gcm", deviceHandler.ListGCM)
|
||||||
|
devices.DELETE("/gcm/bulk", deviceHandler.BulkDeleteGCM)
|
||||||
|
devices.PUT("/gcm/:id", deviceHandler.UpdateGCM)
|
||||||
|
devices.DELETE("/gcm/:id", deviceHandler.DeleteGCM)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feature benefits management
|
||||||
|
featureBenefitHandler := handlers.NewAdminFeatureBenefitHandler(db)
|
||||||
|
featureBenefits := protected.Group("/feature-benefits")
|
||||||
|
{
|
||||||
|
featureBenefits.GET("", featureBenefitHandler.List)
|
||||||
|
featureBenefits.POST("", featureBenefitHandler.Create)
|
||||||
|
featureBenefits.GET("/:id", featureBenefitHandler.Get)
|
||||||
|
featureBenefits.PUT("/:id", featureBenefitHandler.Update)
|
||||||
|
featureBenefits.DELETE("/:id", featureBenefitHandler.Delete)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promotions management
|
||||||
|
promotionHandler := handlers.NewAdminPromotionHandler(db)
|
||||||
|
promotions := protected.Group("/promotions")
|
||||||
|
{
|
||||||
|
promotions.GET("", promotionHandler.List)
|
||||||
|
promotions.POST("", promotionHandler.Create)
|
||||||
|
promotions.GET("/:id", promotionHandler.Get)
|
||||||
|
promotions.PUT("/:id", promotionHandler.Update)
|
||||||
|
promotions.DELETE("/:id", promotionHandler.Delete)
|
||||||
|
}
|
||||||
|
|
||||||
// Lookup tables management
|
// Lookup tables management
|
||||||
lookupHandler := handlers.NewAdminLookupHandler(db)
|
lookupHandler := handlers.NewAdminLookupHandler(db)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user