mirror of
https://github.com/wasp-lang/open-saas.git
synced 2025-04-11 21:39:03 +02:00
improve user table filters
This commit is contained in:
parent
af979e2cd0
commit
b6fd33f9e2
@ -363,6 +363,7 @@ job dailyStats {
|
||||
schedule: {
|
||||
// every hour
|
||||
cron: "0 * * * *"
|
||||
// cron: "* * * * *"
|
||||
},
|
||||
entities: [User, DailyStats, Logs]
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
const CheckboxOne = () => {
|
||||
const [isChecked, setIsChecked] = useState<boolean>(false);
|
||||
const [isChecked, setIsChecked] = useState<boolean>( false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
@ -1,47 +1,37 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
const CheckboxTwo = () => {
|
||||
const [isChecked, setIsChecked] = useState<boolean>(false);
|
||||
|
||||
const [enabled, setEnabled] = useState<boolean>(false);
|
||||
return (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="checkboxLabelTwo"
|
||||
className="flex cursor-pointer select-none items-center"
|
||||
>
|
||||
<div className="relative">
|
||||
<label htmlFor='checkboxLabelTwo' className='flex cursor-pointer text-sm text-gray-700 select-none items-center'>
|
||||
hasPaid:
|
||||
<div className='relative'>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="checkboxLabelTwo"
|
||||
className="sr-only"
|
||||
type='checkbox'
|
||||
id='checkboxLabelTwo'
|
||||
className='sr-only'
|
||||
onChange={() => {
|
||||
setIsChecked(!isChecked);
|
||||
setEnabled(!enabled);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={`mr-4 flex h-5 w-5 items-center justify-center rounded border ${
|
||||
isChecked && 'border-primary bg-gray dark:bg-transparent'
|
||||
className={`ml-2 flex h-5 w-5 items-center justify-center rounded border ${
|
||||
enabled && 'border-primary bg-gray dark:bg-transparent'
|
||||
}`}
|
||||
>
|
||||
<span className={`opacity-0 ${isChecked && '!opacity-100'}`}>
|
||||
<svg
|
||||
width="11"
|
||||
height="8"
|
||||
viewBox="0 0 11 8"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<span className={`opacity-0 ${enabled && '!opacity-100'}`}>
|
||||
<svg width='11' height='8' viewBox='0 0 11 8' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<path
|
||||
d="M10.0915 0.951972L10.0867 0.946075L10.0813 0.940568C9.90076 0.753564 9.61034 0.753146 9.42927 0.939309L4.16201 6.22962L1.58507 3.63469C1.40401 3.44841 1.11351 3.44879 0.932892 3.63584C0.755703 3.81933 0.755703 4.10875 0.932892 4.29224L0.932878 4.29225L0.934851 4.29424L3.58046 6.95832C3.73676 7.11955 3.94983 7.2 4.1473 7.2C4.36196 7.2 4.55963 7.11773 4.71406 6.9584L10.0468 1.60234C10.2436 1.4199 10.2421 1.1339 10.0915 0.951972ZM4.2327 6.30081L4.2317 6.2998C4.23206 6.30015 4.23237 6.30049 4.23269 6.30082L4.2327 6.30081Z"
|
||||
fill="#3056D3"
|
||||
stroke="#3056D3"
|
||||
strokeWidth="0.4"
|
||||
d='M10.0915 0.951972L10.0867 0.946075L10.0813 0.940568C9.90076 0.753564 9.61034 0.753146 9.42927 0.939309L4.16201 6.22962L1.58507 3.63469C1.40401 3.44841 1.11351 3.44879 0.932892 3.63584C0.755703 3.81933 0.755703 4.10875 0.932892 4.29224L0.932878 4.29225L0.934851 4.29424L3.58046 6.95832C3.73676 7.11955 3.94983 7.2 4.1473 7.2C4.36196 7.2 4.55963 7.11773 4.71406 6.9584L10.0468 1.60234C10.2436 1.4199 10.2421 1.1339 10.0915 0.951972ZM4.2327 6.30081L4.2317 6.2998C4.23206 6.30015 4.23237 6.30049 4.23269 6.30082L4.2327 6.30081Z'
|
||||
fill='#3056D3'
|
||||
stroke='#3056D3'
|
||||
strokeWidth='0.4'
|
||||
></path>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
Checkbox Text
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
|
@ -1,13 +1,9 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { User } from '@wasp/entities';
|
||||
|
||||
const SwitcherOne = ({ user, updateUserById}: { user?: Partial<User>, updateUserById?: any}) => {
|
||||
const [enabled, setEnabled] = useState<boolean>(user?.hasPaid || false);
|
||||
|
||||
// useEffect(() => {
|
||||
// console.table({ hasPaid: user?.hasPaid})
|
||||
// }, [user])
|
||||
|
||||
return (
|
||||
<div className='relative'>
|
||||
<label htmlFor={`toggle1-${user?.id}`} className='flex cursor-pointer select-none items-center'>
|
||||
|
@ -6,16 +6,17 @@ import getPaginatedUsers from '@wasp/queries/getPaginatedUsers';
|
||||
import updateUserById from '@wasp/actions/updateUserById';
|
||||
import Loader from '../common/Loader';
|
||||
|
||||
// TODO extract hasPaid to its own value
|
||||
type StatusOptions = 'hasPaid' | 'past_due' | 'canceled' | 'active';
|
||||
type StatusOptions = 'past_due' | 'canceled' | 'active';
|
||||
|
||||
const UsersTable = () => {
|
||||
const [skip, setskip] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [email, setEmail] = useState<string | undefined>(undefined);
|
||||
const [statusOptions, setStatusOptions] = useState<StatusOptions[]>([]);
|
||||
const [hasPaidFilter, setHasPaidFilter] = useState<boolean | undefined>(undefined);
|
||||
const { data, isLoading, error } = useQuery(getPaginatedUsers, {
|
||||
skip,
|
||||
hasPaidFilter: hasPaidFilter,
|
||||
emailContains: email,
|
||||
subscriptionStatus: statusOptions?.length > 0 ? statusOptions : undefined,
|
||||
});
|
||||
@ -31,110 +32,142 @@ const UsersTable = () => {
|
||||
return (
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='rounded-sm border border-stroke bg-white shadow-default dark:border-strokedark dark:bg-boxdark'>
|
||||
<div className='flex items-center justify-between gap-3 w-full py-6 px-4 md:px-6 xl:px-7.5'>
|
||||
<div className='relative flex items-center gap-3 p-4'>
|
||||
<span>Filters:</span>
|
||||
{/* <label className='block text-black dark:text-white whitespace-nowrap'>Search by Email</label> */}
|
||||
<input
|
||||
type='text'
|
||||
placeholder='dude@example.com'
|
||||
onChange={(e) => {
|
||||
setEmail(e.currentTarget.value);
|
||||
}}
|
||||
className='rounded border border-stroke bg-transparent py-2 px-5 outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'
|
||||
/>
|
||||
{/* <label className='mb-3 block text-black dark:text-white whitespace-nowrap'>Multiselect Dropdown</label> */}
|
||||
<div className='flex-grow relative z-20 rounded border border-stroke pr-8 outline-none transition focus:border-primary active:border-primary dark:border-form-strokedark dark:bg-form-input'>
|
||||
<div className='flex items-center'>
|
||||
{!!statusOptions && statusOptions.length > 0 ? (
|
||||
statusOptions.map((opt, idx) => (
|
||||
<span
|
||||
key={opt}
|
||||
className='z-30 flex items-center bg-transparent my-1 mx-2 py-1 px-2 outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'
|
||||
>
|
||||
{opt}
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setStatusOptions((prevValue) => {
|
||||
return prevValue?.filter((val) => val !== opt);
|
||||
});
|
||||
}}
|
||||
className='z-30 cursor-pointer pl-2 hover:text-danger'
|
||||
>
|
||||
<svg width='14' height='14' viewBox='0 0 12 12' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='M9.35355 3.35355C9.54882 3.15829 9.54882 2.84171 9.35355 2.64645C9.15829 2.45118 8.84171 2.45118 8.64645 2.64645L6 5.29289L3.35355 2.64645C3.15829 2.45118 2.84171 2.45118 2.64645 2.64645C2.45118 2.84171 2.45118 3.15829 2.64645 3.35355L5.29289 6L2.64645 8.64645C2.45118 8.84171 2.45118 9.15829 2.64645 9.35355C2.84171 9.54882 3.15829 9.54882 3.35355 9.35355L6 6.70711L8.64645 9.35355C8.84171 9.54882 9.15829 9.54882 9.35355 9.35355C9.54882 9.15829 9.54882 8.84171 9.35355 8.64645L6.70711 6L9.35355 3.35355Z'
|
||||
fill='currentColor'
|
||||
></path>
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className='bg-transparent text-gray-500 py-2 px-5 outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'>
|
||||
Select Payment Status Filters
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<select
|
||||
onChange={(e) => {
|
||||
setStatusOptions((prevValue) => {
|
||||
if (prevValue?.includes(e.target.value as StatusOptions)) {
|
||||
return prevValue?.filter((val) => val !== e.target.value);
|
||||
} else if (!!prevValue) {
|
||||
return [...prevValue, e.target.value as StatusOptions];
|
||||
} else {
|
||||
return [prevValue as StatusOptions];
|
||||
}
|
||||
});
|
||||
}}
|
||||
name='status'
|
||||
id='status'
|
||||
className='absolute top-0 left-0 z-20 h-full w-full bg-transparent opacity-0'
|
||||
>
|
||||
<option value=''>Select filters</option>
|
||||
{['hasPaid', 'past_due', 'canceled', 'active'].map((status) => {
|
||||
if (!statusOptions.includes(status as StatusOptions)) {
|
||||
return <option value={status}>{status}</option>;
|
||||
}
|
||||
})}
|
||||
</select>
|
||||
<span className='absolute top-1/2 right-4 z-10 -translate-y-1/2'>
|
||||
<svg width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<g opacity='0.8'>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='M5.29289 8.29289C5.68342 7.90237 6.31658 7.90237 6.70711 8.29289L12 13.5858L17.2929 8.29289C17.6834 7.90237 18.3166 7.90237 18.7071 8.29289C19.0976 8.68342 19.0976 9.31658 18.7071 9.70711L12.7071 15.7071C12.3166 16.0976 11.6834 16.0976 11.2929 15.7071L5.29289 9.70711C4.90237 9.31658 4.90237 8.68342 5.29289 8.29289Z'
|
||||
fill='#637381'
|
||||
></path>
|
||||
</g>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isLoading && (
|
||||
<div className='max-w-60'>
|
||||
<span className='text-md mr-2 text-black dark:text-white'>page</span>
|
||||
<div className='flex-col flex items-start justify-between p-6 gap-3 w-full '>
|
||||
<span className='text-sm font-semibold text-gray-700'>Filters:</span>
|
||||
<div className='flex items-center justify-between gap-3 w-full px-2'>
|
||||
<div className='relative flex items-center gap-3 '>
|
||||
<label htmlFor='email-filter' className='block text-sm text-gray-700 dark:text-white'>
|
||||
email:
|
||||
</label>
|
||||
<input
|
||||
type='number'
|
||||
value={page}
|
||||
min={1}
|
||||
max={data?.totalPages}
|
||||
type='text'
|
||||
id='email-filter'
|
||||
placeholder='dude@example.com'
|
||||
onChange={(e) => {
|
||||
setPage(parseInt(e.currentTarget.value));
|
||||
setEmail(e.currentTarget.value);
|
||||
}}
|
||||
className='rounded-md border-1 border-stroke bg-transparent px-4 font-medium outline-none transition focus:border-primary active:border-primary dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'
|
||||
className='rounded border border-stroke bg-transparent py-2 px-5 outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'
|
||||
/>
|
||||
<label htmlFor='status-filter' className='block text-sm ml-2 text-gray-700 dark:text-white'>
|
||||
status:
|
||||
</label>
|
||||
<div className='flex-grow relative z-20 rounded border border-stroke pr-8 outline-none transition focus:border-primary active:border-primary dark:border-form-strokedark dark:bg-form-input'>
|
||||
<div className='flex items-center'>
|
||||
{!!statusOptions && statusOptions.length > 0 ? (
|
||||
statusOptions.map((opt, idx) => (
|
||||
<span
|
||||
key={opt}
|
||||
className='z-30 flex items-center bg-transparent my-1 mx-2 py-1 px-2 outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'
|
||||
>
|
||||
{opt}
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setStatusOptions((prevValue) => {
|
||||
return prevValue?.filter((val) => val !== opt);
|
||||
});
|
||||
}}
|
||||
className='z-30 cursor-pointer pl-2 hover:text-danger'
|
||||
>
|
||||
<svg
|
||||
width='14'
|
||||
height='14'
|
||||
viewBox='0 0 12 12'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='M9.35355 3.35355C9.54882 3.15829 9.54882 2.84171 9.35355 2.64645C9.15829 2.45118 8.84171 2.45118 8.64645 2.64645L6 5.29289L3.35355 2.64645C3.15829 2.45118 2.84171 2.45118 2.64645 2.64645C2.45118 2.84171 2.45118 3.15829 2.64645 3.35355L5.29289 6L2.64645 8.64645C2.45118 8.84171 2.45118 9.15829 2.64645 9.35355C2.84171 9.54882 3.15829 9.54882 3.35355 9.35355L6 6.70711L8.64645 9.35355C8.84171 9.54882 9.15829 9.54882 9.35355 9.35355C9.54882 9.15829 9.54882 8.84171 9.35355 8.64645L6.70711 6L9.35355 3.35355Z'
|
||||
fill='currentColor'
|
||||
></path>
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className='bg-transparent text-gray-500 py-2 px-5 outline-none transition focus:border-primary active:border-primary disabled:cursor-default disabled:bg-whiter dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'>
|
||||
Select Status Filters
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<select
|
||||
onChange={(e) => {
|
||||
setStatusOptions((prevValue) => {
|
||||
if (prevValue?.includes(e.target.value as StatusOptions)) {
|
||||
return prevValue?.filter((val) => val !== e.target.value);
|
||||
} else if (!!prevValue) {
|
||||
return [...prevValue, e.target.value as StatusOptions];
|
||||
} else {
|
||||
return [prevValue as StatusOptions];
|
||||
}
|
||||
});
|
||||
}}
|
||||
name='status-filter'
|
||||
id='status-filter'
|
||||
className='absolute top-0 left-0 z-20 h-full w-full bg-transparent opacity-0'
|
||||
>
|
||||
<option value=''>Select filters</option>
|
||||
{['past_due', 'canceled', 'active'].map((status) => {
|
||||
if (!statusOptions.includes(status as StatusOptions)) {
|
||||
return <option value={status}>{status}</option>;
|
||||
}
|
||||
})}
|
||||
</select>
|
||||
<span className='absolute top-1/2 right-4 z-10 -translate-y-1/2'>
|
||||
<svg width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<g opacity='0.8'>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='M5.29289 8.29289C5.68342 7.90237 6.31658 7.90237 6.70711 8.29289L12 13.5858L17.2929 8.29289C17.6834 7.90237 18.3166 7.90237 18.7071 8.29289C19.0976 8.68342 19.0976 9.31658 18.7071 9.70711L12.7071 15.7071C12.3166 16.0976 11.6834 16.0976 11.2929 15.7071L5.29289 9.70711C4.90237 9.31658 4.90237 8.68342 5.29289 8.29289Z'
|
||||
fill='#637381'
|
||||
></path>
|
||||
</g>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<label htmlFor='hasPaid-filter' className='block text-sm ml-2 text-gray-700 dark:text-white'>
|
||||
hasPaid:
|
||||
</label>
|
||||
<select
|
||||
name='hasPaid-filter'
|
||||
onChange={(e) => {
|
||||
if (e.target.value === 'both') {
|
||||
setHasPaidFilter(undefined);
|
||||
} else {
|
||||
setHasPaidFilter(e.target.value === 'true');
|
||||
}
|
||||
}}
|
||||
className='relative z-20 w-full appearance-none rounded border border-stroke bg-transparent p-2 pl-4 pr-8 outline-none transition focus:border-primary active:border-primary dark:border-form-strokedark dark:bg-form-input'
|
||||
>
|
||||
<option value='both'>both</option>
|
||||
<option value='true'>true</option>
|
||||
<option value='false'>false</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && (
|
||||
<div className='max-w-60'>
|
||||
<span className='text-md mr-2 text-black dark:text-white'>page</span>
|
||||
<input
|
||||
type='number'
|
||||
value={page}
|
||||
min={1}
|
||||
max={data?.totalPages}
|
||||
onChange={(e) => {
|
||||
setPage(parseInt(e.currentTarget.value));
|
||||
}}
|
||||
className='rounded-md border-1 border-stroke bg-transparent px-4 font-medium outline-none transition focus:border-primary active:border-primary dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-12 border-t border-stroke py-4.5 px-4 dark:border-strokedark md:px-6 '>
|
||||
<div className='grid grid-cols-12 border-t-4 border-stroke py-4.5 px-4 dark:border-strokedark md:px-6 '>
|
||||
<div className='col-span-3 flex items-center'>
|
||||
<p className='font-medium'>Email</p>
|
||||
</div>
|
||||
|
@ -1,6 +1,5 @@
|
||||
import Breadcrumb from '../../components/Breadcrumb';
|
||||
import CheckboxOne from '../../components/CheckboxOne';
|
||||
import CheckboxTwo from '../../components/CheckboxTwo';
|
||||
import SwitcherOne from '../../components/SwitcherOne';
|
||||
import SwitcherTwo from '../../components/SwitcherTwo';
|
||||
import DefaultLayout from '../../layout/DefaultLayout';
|
||||
@ -188,7 +187,6 @@ const FormElements = () => {
|
||||
</div>
|
||||
<div className="flex flex-col gap-5.5 p-6.5">
|
||||
<CheckboxOne />
|
||||
<CheckboxTwo />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -6,7 +6,6 @@ const Users = () => {
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<Breadcrumb pageName="Users" />
|
||||
|
||||
<div className="flex flex-col gap-10">
|
||||
<UsersTable />
|
||||
</div>
|
||||
|
@ -65,6 +65,7 @@ export const getReferrerStats: GetReferrerStats<void, ReferrerWithSanitizedUsers
|
||||
type GetPaginatedUsersInput = {
|
||||
skip: number;
|
||||
cursor?: number | undefined;
|
||||
hasPaidFilter: boolean | undefined;
|
||||
emailContains?: string;
|
||||
subscriptionStatus?: string[]
|
||||
};
|
||||
@ -77,11 +78,6 @@ export const getPaginatedUsers: GetPaginatedUsers<GetPaginatedUsersInput, GetPag
|
||||
args,
|
||||
context
|
||||
) => {
|
||||
|
||||
let hasPaid = undefined
|
||||
if (!!args.subscriptionStatus && args.subscriptionStatus.includes('hasPaid')) {
|
||||
hasPaid = true
|
||||
}
|
||||
|
||||
let subscriptionStatus = args.subscriptionStatus?.filter((status) => status !== 'hasPaid')
|
||||
subscriptionStatus = subscriptionStatus?.length ? subscriptionStatus : undefined
|
||||
@ -94,7 +90,7 @@ export const getPaginatedUsers: GetPaginatedUsers<GetPaginatedUsersInput, GetPag
|
||||
contains: args.emailContains || undefined,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
hasPaid,
|
||||
hasPaid: args.hasPaidFilter,
|
||||
subscriptionStatus: {
|
||||
in: subscriptionStatus || undefined,
|
||||
},
|
||||
@ -117,7 +113,7 @@ export const getPaginatedUsers: GetPaginatedUsers<GetPaginatedUsersInput, GetPag
|
||||
email: {
|
||||
contains: args.emailContains || undefined,
|
||||
},
|
||||
hasPaid,
|
||||
hasPaid: args.hasPaidFilter,
|
||||
subscriptionStatus: {
|
||||
in: subscriptionStatus || undefined,
|
||||
},
|
||||
|
@ -43,7 +43,7 @@ export const calculateDailyStats: DailyStats<never, void> = async (_args, contex
|
||||
|
||||
const newRunningTotal = await calculateTotalRevenue(context);
|
||||
|
||||
await context.entities.DailyStats.upsert({
|
||||
const newDailyStat = await context.entities.DailyStats.upsert({
|
||||
where: {
|
||||
date: nowUTC,
|
||||
},
|
||||
@ -64,6 +64,8 @@ export const calculateDailyStats: DailyStats<never, void> = async (_args, contex
|
||||
},
|
||||
});
|
||||
|
||||
console.table({ newDailyStat })
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Error calculating daily stats: ', error);
|
||||
await context.entities.Logs.create({
|
||||
|
Loading…
x
Reference in New Issue
Block a user