Add accounts UI with list, create, edit, detail tabs for all sub-entities
Accounts list with paginated table, search, sort. Account detail page with tabs for members, payment methods, tax exemptions, and processor links. All sub-entities have create/edit dialogs and delete actions. Forms use shared Zod schemas via react-hook-form.
This commit is contained in:
96
packages/admin/src/components/accounts/account-form.tsx
Normal file
96
packages/admin/src/components/accounts/account-form.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { AccountCreateSchema } from '@forte/shared/schemas'
|
||||
import type { AccountCreateInput } from '@forte/shared/schemas'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import type { Account } from '@/types/account'
|
||||
|
||||
interface AccountFormProps {
|
||||
defaultValues?: Partial<Account>
|
||||
onSubmit: (data: AccountCreateInput) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function AccountForm({ defaultValues, onSubmit, loading }: AccountFormProps) {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<AccountCreateInput>({
|
||||
resolver: zodResolver(AccountCreateSchema),
|
||||
defaultValues: {
|
||||
name: defaultValues?.name ?? '',
|
||||
email: defaultValues?.email ?? undefined,
|
||||
phone: defaultValues?.phone ?? undefined,
|
||||
billingMode: defaultValues?.billingMode ?? 'consolidated',
|
||||
notes: defaultValues?.notes ?? undefined,
|
||||
address: defaultValues?.address ?? undefined,
|
||||
},
|
||||
})
|
||||
|
||||
const billingMode = watch('billingMode')
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 max-w-lg">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name *</Label>
|
||||
<Input id="name" {...register('name')} />
|
||||
{errors.name && <p className="text-sm text-destructive">{errors.name.message}</p>}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input id="email" type="email" {...register('email')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">Phone</Label>
|
||||
<Input id="phone" {...register('phone')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Billing Mode</Label>
|
||||
<Select
|
||||
value={billingMode}
|
||||
onValueChange={(v) => setValue('billingMode', v as 'consolidated' | 'split')}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="consolidated">Consolidated</SelectItem>
|
||||
<SelectItem value="split">Split</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Address</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Input placeholder="Street" {...register('address.street')} className="col-span-2" />
|
||||
<Input placeholder="City" {...register('address.city')} />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Input placeholder="State" {...register('address.state')} />
|
||||
<Input placeholder="ZIP" {...register('address.zip')} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="notes">Notes</Label>
|
||||
<Textarea id="notes" {...register('notes')} />
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Saving...' : defaultValues ? 'Update Account' : 'Create Account'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
42
packages/admin/src/components/accounts/account-table.tsx
Normal file
42
packages/admin/src/components/accounts/account-table.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { Column } from '@/components/shared/data-table'
|
||||
import type { Account } from '@/types/account'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
export const accountColumns: Column<Account>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Name',
|
||||
sortable: true,
|
||||
render: (row) => <span className="font-medium">{row.name}</span>,
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
header: 'Email',
|
||||
sortable: true,
|
||||
render: (row) => row.email ?? <span className="text-muted-foreground">-</span>,
|
||||
},
|
||||
{
|
||||
key: 'phone',
|
||||
header: 'Phone',
|
||||
render: (row) => row.phone ?? <span className="text-muted-foreground">-</span>,
|
||||
},
|
||||
{
|
||||
key: 'account_number',
|
||||
header: 'Account #',
|
||||
sortable: true,
|
||||
render: (row) => row.accountNumber ?? <span className="text-muted-foreground">-</span>,
|
||||
},
|
||||
{
|
||||
key: 'billingMode',
|
||||
header: 'Billing',
|
||||
render: (row) => (
|
||||
<Badge variant="secondary">{row.billingMode}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
header: 'Created',
|
||||
sortable: true,
|
||||
render: (row) => new Date(row.createdAt).toLocaleDateString(),
|
||||
},
|
||||
]
|
||||
74
packages/admin/src/components/accounts/member-form.tsx
Normal file
74
packages/admin/src/components/accounts/member-form.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { MemberCreateSchema } from '@forte/shared/schemas'
|
||||
import type { MemberCreateInput } from '@forte/shared/schemas'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import type { Member } from '@/types/account'
|
||||
|
||||
interface MemberFormProps {
|
||||
accountId: string
|
||||
defaultValues?: Partial<Member>
|
||||
onSubmit: (data: Record<string, unknown>) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function MemberForm({ accountId, defaultValues, onSubmit, loading }: MemberFormProps) {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<MemberCreateInput>({
|
||||
resolver: zodResolver(MemberCreateSchema),
|
||||
defaultValues: {
|
||||
accountId,
|
||||
firstName: defaultValues?.firstName ?? '',
|
||||
lastName: defaultValues?.lastName ?? '',
|
||||
dateOfBirth: defaultValues?.dateOfBirth ?? undefined,
|
||||
email: defaultValues?.email ?? undefined,
|
||||
phone: defaultValues?.phone ?? undefined,
|
||||
notes: defaultValues?.notes ?? undefined,
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<input type="hidden" {...register('accountId')} />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="firstName">First Name *</Label>
|
||||
<Input id="firstName" {...register('firstName')} />
|
||||
{errors.firstName && <p className="text-sm text-destructive">{errors.firstName.message}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lastName">Last Name *</Label>
|
||||
<Input id="lastName" {...register('lastName')} />
|
||||
{errors.lastName && <p className="text-sm text-destructive">{errors.lastName.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dateOfBirth">Date of Birth</Label>
|
||||
<Input id="dateOfBirth" type="date" {...register('dateOfBirth')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="memberEmail">Email</Label>
|
||||
<Input id="memberEmail" type="email" {...register('email')} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="memberPhone">Phone</Label>
|
||||
<Input id="memberPhone" {...register('phone')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="memberNotes">Notes</Label>
|
||||
<Textarea id="memberNotes" {...register('notes')} />
|
||||
</div>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Saving...' : defaultValues ? 'Update Member' : 'Add Member'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { PaymentMethodCreateSchema } from '@forte/shared/schemas'
|
||||
import type { PaymentMethodCreateInput } from '@forte/shared/schemas'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
|
||||
interface PaymentMethodFormProps {
|
||||
accountId: string
|
||||
onSubmit: (data: Record<string, unknown>) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function PaymentMethodForm({ accountId, onSubmit, loading }: PaymentMethodFormProps) {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<PaymentMethodCreateInput>({
|
||||
resolver: zodResolver(PaymentMethodCreateSchema),
|
||||
defaultValues: {
|
||||
accountId,
|
||||
processor: 'stripe',
|
||||
processorPaymentMethodId: '',
|
||||
isDefault: false,
|
||||
},
|
||||
})
|
||||
|
||||
const processor = watch('processor')
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Processor</Label>
|
||||
<Select
|
||||
value={processor}
|
||||
onValueChange={(v) => setValue('processor', v as 'stripe' | 'global_payments')}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="stripe">Stripe</SelectItem>
|
||||
<SelectItem value="global_payments">Global Payments</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pmId">Payment Method ID *</Label>
|
||||
<Input id="pmId" {...register('processorPaymentMethodId')} placeholder="pm_..." />
|
||||
{errors.processorPaymentMethodId && (
|
||||
<p className="text-sm text-destructive">{errors.processorPaymentMethodId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cardBrand">Card Brand</Label>
|
||||
<Input id="cardBrand" {...register('cardBrand')} placeholder="visa" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lastFour">Last Four</Label>
|
||||
<Input id="lastFour" {...register('lastFour')} placeholder="4242" maxLength={4} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="expMonth">Exp Month</Label>
|
||||
<Input id="expMonth" type="number" {...register('expMonth', { valueAsNumber: true })} min={1} max={12} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="expYear">Exp Year</Label>
|
||||
<Input id="expYear" type="number" {...register('expYear', { valueAsNumber: true })} min={2024} max={2100} />
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Saving...' : 'Add Payment Method'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { ProcessorLinkCreateSchema } from '@forte/shared/schemas'
|
||||
import type { ProcessorLinkCreateInput } from '@forte/shared/schemas'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
|
||||
interface ProcessorLinkFormProps {
|
||||
accountId: string
|
||||
onSubmit: (data: Record<string, unknown>) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function ProcessorLinkForm({ accountId, onSubmit, loading }: ProcessorLinkFormProps) {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<ProcessorLinkCreateInput>({
|
||||
resolver: zodResolver(ProcessorLinkCreateSchema),
|
||||
defaultValues: {
|
||||
accountId,
|
||||
processor: 'stripe',
|
||||
processorCustomerId: '',
|
||||
},
|
||||
})
|
||||
|
||||
const processor = watch('processor')
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Processor</Label>
|
||||
<Select
|
||||
value={processor}
|
||||
onValueChange={(v) => setValue('processor', v as 'stripe' | 'global_payments')}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="stripe">Stripe</SelectItem>
|
||||
<SelectItem value="global_payments">Global Payments</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="customerId">Customer ID *</Label>
|
||||
<Input id="customerId" {...register('processorCustomerId')} placeholder="cus_..." />
|
||||
{errors.processorCustomerId && (
|
||||
<p className="text-sm text-destructive">{errors.processorCustomerId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Saving...' : 'Add Processor Link'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { TaxExemptionCreateSchema } from '@forte/shared/schemas'
|
||||
import type { TaxExemptionCreateInput } from '@forte/shared/schemas'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
interface TaxExemptionFormProps {
|
||||
accountId: string
|
||||
onSubmit: (data: Record<string, unknown>) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function TaxExemptionForm({ accountId, onSubmit, loading }: TaxExemptionFormProps) {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<TaxExemptionCreateInput>({
|
||||
resolver: zodResolver(TaxExemptionCreateSchema),
|
||||
defaultValues: {
|
||||
accountId,
|
||||
certificateNumber: '',
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="certNumber">Certificate Number *</Label>
|
||||
<Input id="certNumber" {...register('certificateNumber')} />
|
||||
{errors.certificateNumber && (
|
||||
<p className="text-sm text-destructive">{errors.certificateNumber.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="certType">Certificate Type</Label>
|
||||
<Input id="certType" {...register('certificateType')} placeholder="resale" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="issuingState">Issuing State</Label>
|
||||
<Input id="issuingState" {...register('issuingState')} placeholder="TX" maxLength={2} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="expiresAt">Expires</Label>
|
||||
<Input id="expiresAt" type="date" {...register('expiresAt')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="exemptNotes">Notes</Label>
|
||||
<Textarea id="exemptNotes" {...register('notes')} />
|
||||
</div>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Saving...' : 'Add Tax Exemption'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
143
packages/admin/src/components/shared/data-table.tsx
Normal file
143
packages/admin/src/components/shared/data-table.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { ChevronLeft, ChevronRight, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'
|
||||
|
||||
export interface Column<T> {
|
||||
key: string
|
||||
header: string
|
||||
sortable?: boolean
|
||||
render: (row: T) => React.ReactNode
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
columns: Column<T>[]
|
||||
data: T[]
|
||||
loading?: boolean
|
||||
page: number
|
||||
totalPages: number
|
||||
total: number
|
||||
sort?: string
|
||||
order?: 'asc' | 'desc'
|
||||
onPageChange: (page: number) => void
|
||||
onSort?: (sort: string, order: 'asc' | 'desc') => void
|
||||
onRowClick?: (row: T) => void
|
||||
}
|
||||
|
||||
export function DataTable<T>({
|
||||
columns,
|
||||
data,
|
||||
loading,
|
||||
page,
|
||||
totalPages,
|
||||
total,
|
||||
sort,
|
||||
order,
|
||||
onPageChange,
|
||||
onSort,
|
||||
onRowClick,
|
||||
}: DataTableProps<T>) {
|
||||
function handleSort(key: string) {
|
||||
if (!onSort) return
|
||||
if (sort === key) {
|
||||
onSort(key, order === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
onSort(key, 'asc')
|
||||
}
|
||||
}
|
||||
|
||||
function SortIcon({ columnKey }: { columnKey: string }) {
|
||||
if (sort !== columnKey) return <ArrowUpDown className="ml-1 h-3 w-3 opacity-40" />
|
||||
return order === 'asc'
|
||||
? <ArrowUp className="ml-1 h-3 w-3" />
|
||||
: <ArrowDown className="ml-1 h-3 w-3" />
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{columns.map((col) => (
|
||||
<TableHead
|
||||
key={col.key}
|
||||
className={col.sortable ? 'cursor-pointer select-none' : ''}
|
||||
onClick={() => col.sortable && handleSort(col.key)}
|
||||
>
|
||||
<span className="flex items-center">
|
||||
{col.header}
|
||||
{col.sortable && <SortIcon columnKey={col.key} />}
|
||||
</span>
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="text-center py-8 text-muted-foreground">
|
||||
No results found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
data.map((row, i) => (
|
||||
<TableRow
|
||||
key={i}
|
||||
className={onRowClick ? 'cursor-pointer' : ''}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<TableCell key={col.key}>{col.render(row)}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>{total} total</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span>
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user