in the middle of party-user frontend,
This commit is contained in:
@@ -34,6 +34,20 @@ async function partyUser() {
|
||||
emailVerified: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await prisma.partyUser.upsert({
|
||||
where: { email: `bob${i}@prisma.io` },
|
||||
update: {},
|
||||
create: {
|
||||
email: `bob${i}@prisma.io`,
|
||||
name: 'Bob',
|
||||
password: 'Aa12345678',
|
||||
emailVerified: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log('seed partyUser done');
|
||||
}
|
||||
|
||||
|
@@ -8,8 +8,6 @@
|
||||
// 2. Validates input data shape
|
||||
//
|
||||
|
||||
//
|
||||
|
||||
import { logger } from 'src/utils/logger';
|
||||
import { STATUS, response, handleError } from 'src/utils/response';
|
||||
|
||||
@@ -28,7 +26,7 @@ export async function GET() {
|
||||
|
||||
logger('[User] list', partyUsers.length);
|
||||
|
||||
return response({ users: partyUsers }, STATUS.OK);
|
||||
return response({ partyUsers }, STATUS.OK);
|
||||
} catch (error) {
|
||||
return handleError('Product - Get list', error);
|
||||
}
|
||||
|
195
03_source/frontend/src/actions/party-user.ts
Normal file
195
03_source/frontend/src/actions/party-user.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { useMemo } from 'react';
|
||||
import axiosInstance, { endpoints, fetcher } from 'src/lib/axios';
|
||||
import type { IPartyUserItem } from 'src/types/party-user';
|
||||
import type { IProductItem } from 'src/types/product';
|
||||
import type { IUserItem } from 'src/types/user';
|
||||
import type { SWRConfiguration } from 'swr';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const swrOptions: SWRConfiguration = {
|
||||
revalidateIfStale: false,
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type UsersData = {
|
||||
partyUsers: IUserItem[];
|
||||
};
|
||||
|
||||
// TODO: i want to refactor / tidy here
|
||||
export function useGetPartyUsers() {
|
||||
const url = `http://localhost:7272/api/party-user/list`;
|
||||
|
||||
const { data, isLoading, error, isValidating } = useSWR<UsersData>(url, fetcher, swrOptions);
|
||||
|
||||
const memoizedValue = useMemo(
|
||||
() => ({
|
||||
partyUsers: data?.partyUsers || [],
|
||||
partyUsersLoading: isLoading,
|
||||
partyUsersError: error,
|
||||
partyUsersValidating: isValidating,
|
||||
partyUsersEmpty: !isLoading && !isValidating && !data?.partyUsers.length,
|
||||
}),
|
||||
[data?.partyUsers, error, isLoading, isValidating]
|
||||
);
|
||||
|
||||
return memoizedValue;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type PartyUserData = {
|
||||
partyUser: IPartyUserItem;
|
||||
};
|
||||
|
||||
export function useGetPartyUser(partyUserId: string) {
|
||||
const url = partyUserId ? [endpoints.partyUser.details, { params: { partyUserId } }] : '';
|
||||
|
||||
const { data, isLoading, error, isValidating } = useSWR<PartyUserData>(url, fetcher, swrOptions);
|
||||
|
||||
const memoizedValue = useMemo(
|
||||
() => ({
|
||||
partyUser: data?.partyUser,
|
||||
partyUserLoading: isLoading,
|
||||
partyUserError: error,
|
||||
partyUserValidating: isValidating,
|
||||
}),
|
||||
[data?.partyUser, error, isLoading, isValidating]
|
||||
);
|
||||
|
||||
return memoizedValue;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type SearchResultsData = {
|
||||
results: IProductItem[];
|
||||
};
|
||||
|
||||
export function useSearchProducts(query: string) {
|
||||
const url = query ? [endpoints.product.search, { params: { query } }] : '';
|
||||
|
||||
const { data, isLoading, error, isValidating } = useSWR<SearchResultsData>(url, fetcher, {
|
||||
...swrOptions,
|
||||
keepPreviousData: true,
|
||||
});
|
||||
|
||||
const memoizedValue = useMemo(
|
||||
() => ({
|
||||
searchResults: data?.results || [],
|
||||
searchLoading: isLoading,
|
||||
searchError: error,
|
||||
searchValidating: isValidating,
|
||||
searchEmpty: !isLoading && !isValidating && !data?.results.length,
|
||||
}),
|
||||
[data?.results, error, isLoading, isValidating]
|
||||
);
|
||||
|
||||
return memoizedValue;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type SaveUserData = {
|
||||
name: string;
|
||||
city: string;
|
||||
role: string;
|
||||
email: string;
|
||||
state: string;
|
||||
status: string;
|
||||
address: string;
|
||||
country: string;
|
||||
zipCode: string;
|
||||
company: string;
|
||||
avatarUrl: string;
|
||||
phoneNumber: string;
|
||||
isVerified: boolean;
|
||||
//
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export async function saveUser(userId: string, saveUserData: SaveUserData) {
|
||||
// const url = userId ? [endpoints.user.details, { params: { userId } }] : '';
|
||||
|
||||
const res = await axiosInstance.post(
|
||||
//
|
||||
`http://localhost:7272/api/user/saveUser?userId=${userId}`,
|
||||
{
|
||||
data: saveUserData,
|
||||
}
|
||||
);
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function uploadUserImage(saveUserData: SaveUserData) {
|
||||
console.log('uploadUserImage ?');
|
||||
// const url = userId ? [endpoints.user.details, { params: { userId } }] : '';
|
||||
|
||||
const res = await axiosInstance.get('http://localhost:7272/api/product/helloworld');
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type CreateUserData = {
|
||||
name: string;
|
||||
city: string;
|
||||
role: string;
|
||||
email: string;
|
||||
state: string;
|
||||
status: string;
|
||||
address: string;
|
||||
country: string;
|
||||
zipCode: string;
|
||||
company: string;
|
||||
avatarUrl: string;
|
||||
phoneNumber: string;
|
||||
isVerified: boolean;
|
||||
//
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export async function createUser(createUserData: CreateUserData) {
|
||||
console.log('create product ?');
|
||||
// const url = productId ? [endpoints.product.details, { params: { productId } }] : '';
|
||||
|
||||
const res = await axiosInstance.post('http://localhost:7272/api/user/createUser', {
|
||||
data: createUserData,
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export async function deletePartyUser(partyUserId: string) {
|
||||
/**
|
||||
* Work on server
|
||||
*/
|
||||
const data = { partyUserId };
|
||||
await axiosInstance.patch(endpoints.partyUser.delete, data);
|
||||
|
||||
/**
|
||||
* Work in local
|
||||
*/
|
||||
|
||||
mutate(
|
||||
endpoints.partyUser.list,
|
||||
(currentData: any) => {
|
||||
const currentProducts: IPartyUserItem[] = currentData?.partyUsers;
|
||||
|
||||
const partyUsers = currentProducts.filter((partyUser) => partyUser.id !== partyUserId);
|
||||
|
||||
return { ...currentData, partyUsers };
|
||||
},
|
||||
false
|
||||
);
|
||||
}
|
@@ -111,6 +111,19 @@ export const navData: NavSectionProps['data'] = [
|
||||
{ title: 'Details', path: paths.dashboard.partyOrder.demo.details },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'party-user',
|
||||
path: paths.dashboard.partyUser.root,
|
||||
icon: ICONS.user,
|
||||
children: [
|
||||
{ title: 'Profile', path: paths.dashboard.partyUser.root },
|
||||
{ title: 'Cards', path: paths.dashboard.partyUser.cards },
|
||||
{ title: 'List', path: paths.dashboard.partyUser.list },
|
||||
{ title: 'Create', path: paths.dashboard.partyUser.new },
|
||||
{ title: 'Edit', path: paths.dashboard.partyUser.demo.edit },
|
||||
{ title: 'Account', path: paths.dashboard.partyUser.account },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Product',
|
||||
path: paths.dashboard.product.root,
|
||||
|
@@ -106,4 +106,12 @@ export const endpoints = {
|
||||
changeStatus: (partyOrderId: string) =>
|
||||
`/api/party-order/changeStatus?partyOrderId=${partyOrderId}`,
|
||||
},
|
||||
partyUser: {
|
||||
list: '/api/party-user/list',
|
||||
details: '/api/party-user/details',
|
||||
search: '/api/party-user/search',
|
||||
create: '/api/party-user/create',
|
||||
update: '/api/party-user/update',
|
||||
delete: '/api/party-user/delete',
|
||||
},
|
||||
};
|
||||
|
@@ -0,0 +1,16 @@
|
||||
import { CONFIG } from 'src/global-config';
|
||||
import { AccountBillingView } from 'src/sections/account/view';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const metadata = { title: `Account billing settings | Dashboard - ${CONFIG.appName}` };
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<title>{metadata.title}</title>
|
||||
|
||||
<AccountBillingView />
|
||||
</>
|
||||
);
|
||||
}
|
@@ -0,0 +1,16 @@
|
||||
import { CONFIG } from 'src/global-config';
|
||||
import { AccountChangePasswordView } from 'src/sections/account/view';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const metadata = { title: `Account change password settings | Dashboard - ${CONFIG.appName}` };
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<title>{metadata.title}</title>
|
||||
|
||||
<AccountChangePasswordView />
|
||||
</>
|
||||
);
|
||||
}
|
@@ -0,0 +1,16 @@
|
||||
import { CONFIG } from 'src/global-config';
|
||||
import { AccountGeneralView } from 'src/sections/account/view';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const metadata = { title: `Account general settings | Dashboard - ${CONFIG.appName}` };
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<title>{metadata.title}</title>
|
||||
|
||||
<AccountGeneralView />
|
||||
</>
|
||||
);
|
||||
}
|
@@ -0,0 +1,18 @@
|
||||
import { CONFIG } from 'src/global-config';
|
||||
import { AccountNotificationsView } from 'src/sections/account/view';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const metadata = {
|
||||
title: `Account notifications settings | Dashboard - ${CONFIG.appName}`,
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<title>{metadata.title}</title>
|
||||
|
||||
<AccountNotificationsView />
|
||||
</>
|
||||
);
|
||||
}
|
@@ -0,0 +1,16 @@
|
||||
import { CONFIG } from 'src/global-config';
|
||||
import { AccountSocialsView } from 'src/sections/account/view';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const metadata = { title: `Account socials settings | Dashboard - ${CONFIG.appName}` };
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<title>{metadata.title}</title>
|
||||
|
||||
<AccountSocialsView />
|
||||
</>
|
||||
);
|
||||
}
|
16
03_source/frontend/src/pages/dashboard/party-user/cards.tsx
Normal file
16
03_source/frontend/src/pages/dashboard/party-user/cards.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { CONFIG } from 'src/global-config';
|
||||
import { UserCardsView } from 'src/sections/user/view';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const metadata = { title: `User cards | Dashboard - ${CONFIG.appName}` };
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<title>{metadata.title}</title>
|
||||
|
||||
<UserCardsView />
|
||||
</>
|
||||
);
|
||||
}
|
25
03_source/frontend/src/pages/dashboard/party-user/edit.tsx
Normal file
25
03_source/frontend/src/pages/dashboard/party-user/edit.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
// import { _userList } from 'src/_mock/_user';
|
||||
import { useGetPartyUser } from 'src/actions/party-user';
|
||||
import { CONFIG } from 'src/global-config';
|
||||
import { useParams } from 'src/routes/hooks';
|
||||
import { PartyUserEditView } from 'src/sections/party-user/view';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const metadata = { title: `User edit | Dashboard - ${CONFIG.appName}` };
|
||||
|
||||
export default function Page() {
|
||||
const { id = '' } = useParams();
|
||||
|
||||
// TODO: remove unused code
|
||||
// const currentUser = _userList.find((user) => user.id === id);
|
||||
const { partyUser: user } = useGetPartyUser(id);
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>{metadata.title}</title>
|
||||
|
||||
<PartyUserEditView user={user} />
|
||||
</>
|
||||
);
|
||||
}
|
16
03_source/frontend/src/pages/dashboard/party-user/list.tsx
Normal file
16
03_source/frontend/src/pages/dashboard/party-user/list.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { CONFIG } from 'src/global-config';
|
||||
import { PartyUserListView } from 'src/sections/party-user/view';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const metadata = { title: `User list | Dashboard - ${CONFIG.appName}` };
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<title>{metadata.title}</title>
|
||||
|
||||
<PartyUserListView />
|
||||
</>
|
||||
);
|
||||
}
|
16
03_source/frontend/src/pages/dashboard/party-user/new.tsx
Normal file
16
03_source/frontend/src/pages/dashboard/party-user/new.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { CONFIG } from 'src/global-config';
|
||||
import { UserCreateView } from 'src/sections/user/view';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const metadata = { title: `Create a new user | Dashboard - ${CONFIG.appName}` };
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<title>{metadata.title}</title>
|
||||
|
||||
<UserCreateView />
|
||||
</>
|
||||
);
|
||||
}
|
@@ -0,0 +1,16 @@
|
||||
import { CONFIG } from 'src/global-config';
|
||||
import { UserProfileView } from 'src/sections/user/view';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const metadata = { title: `User profile | Dashboard - ${CONFIG.appName}` };
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<title>{metadata.title}</title>
|
||||
|
||||
<UserProfileView />
|
||||
</>
|
||||
);
|
||||
}
|
@@ -196,5 +196,15 @@ export const paths = {
|
||||
details: (id: string) => `${ROOTS.DASHBOARD}/party-order/${id}`,
|
||||
demo: { details: `${ROOTS.DASHBOARD}/party-order/${MOCK_ID}` },
|
||||
},
|
||||
partyUser: {
|
||||
root: `${ROOTS.DASHBOARD}/party-user`,
|
||||
new: `${ROOTS.DASHBOARD}/party-user/new`,
|
||||
list: `${ROOTS.DASHBOARD}/party-user/list`,
|
||||
cards: `${ROOTS.DASHBOARD}/party-user/cards`,
|
||||
profile: `${ROOTS.DASHBOARD}/party-user/profile`,
|
||||
account: `${ROOTS.DASHBOARD}/party-user/account`,
|
||||
edit: (id: string) => `${ROOTS.DASHBOARD}/party-user/${id}/edit`,
|
||||
demo: { edit: `${ROOTS.DASHBOARD}/party-user/${MOCK_ID}/edit` },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@@ -87,6 +87,13 @@ const PartyEventEditPage = lazy(() => import('src/pages/dashboard/party-event/ed
|
||||
const PartyOrderListPage = lazy(() => import('src/pages/dashboard/party-order/list'));
|
||||
const PartyOrderDetailsPage = lazy(() => import('src/pages/dashboard/party-order/details'));
|
||||
|
||||
// PartyUser
|
||||
const PartyUserProfilePage = lazy(() => import('src/pages/dashboard/party-user/profile'));
|
||||
const PartyUserCardsPage = lazy(() => import('src/pages/dashboard/party-user/cards'));
|
||||
const PartyUserListPage = lazy(() => import('src/pages/dashboard/party-user/list'));
|
||||
const PartyUserCreatePage = lazy(() => import('src/pages/dashboard/party-user/new'));
|
||||
const PartyUserEditPage = lazy(() => import('src/pages/dashboard/party-user/edit'));
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
function SuspenseOutlet() {
|
||||
@@ -229,6 +236,28 @@ export const dashboardRoutes: RouteObject[] = [
|
||||
{ path: ':id', element: <PartyOrderDetailsPage /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'party-user',
|
||||
children: [
|
||||
{ index: true, element: <PartyUserProfilePage /> },
|
||||
{ path: 'profile', element: <PartyUserProfilePage /> },
|
||||
{ path: 'cards', element: <PartyUserCardsPage /> },
|
||||
{ path: 'list', element: <PartyUserListPage /> },
|
||||
{ path: 'new', element: <PartyUserCreatePage /> },
|
||||
{ path: ':id/edit', element: <PartyUserEditPage /> },
|
||||
{
|
||||
path: 'account',
|
||||
element: accountLayout(),
|
||||
children: [
|
||||
{ index: true, element: <AccountGeneralPage /> },
|
||||
{ path: 'billing', element: <AccountBillingPage /> },
|
||||
{ path: 'notifications', element: <AccountNotificationsPage /> },
|
||||
{ path: 'socials', element: <AccountSocialsPage /> },
|
||||
{ path: 'change-password', element: <AccountChangePasswordPage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
@@ -1,3 +1,5 @@
|
||||
// AI: this file store page routeing of app
|
||||
//
|
||||
import { lazy, Suspense } from 'react';
|
||||
import type { RouteObject } from 'react-router';
|
||||
import { SplashScreen } from 'src/components/loading-screen';
|
||||
|
@@ -0,0 +1,47 @@
|
||||
import Box from '@mui/material/Box';
|
||||
import Pagination from '@mui/material/Pagination';
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { IPartyUserCard } from 'src/types/party-user';
|
||||
import { UserCard } from './party-user-card';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
users: IPartyUserCard[];
|
||||
};
|
||||
|
||||
export function UserCardList({ users }: Props) {
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const rowsPerPage = 12;
|
||||
|
||||
const handleChangePage = useCallback((event: React.ChangeEvent<unknown>, newPage: number) => {
|
||||
setPage(newPage);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
gap: 3,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: 'repeat(1, 1fr)', sm: 'repeat(2, 1fr)', md: 'repeat(3, 1fr)' },
|
||||
}}
|
||||
>
|
||||
{users
|
||||
.slice((page - 1) * rowsPerPage, (page - 1) * rowsPerPage + rowsPerPage)
|
||||
.map((user) => (
|
||||
<UserCard key={user.id} user={user} />
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Pagination
|
||||
page={page}
|
||||
shape="circular"
|
||||
count={Math.ceil(users.length / rowsPerPage)}
|
||||
onChange={handleChangePage}
|
||||
sx={{ mt: { xs: 5, md: 8 }, mx: 'auto' }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
119
03_source/frontend/src/sections/party-user/party-user-card.tsx
Normal file
119
03_source/frontend/src/sections/party-user/party-user-card.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Box from '@mui/material/Box';
|
||||
import type { CardProps } from '@mui/material/Card';
|
||||
import Card from '@mui/material/Card';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import { varAlpha } from 'minimal-shared/utils';
|
||||
import { _socials } from 'src/_mock';
|
||||
import { AvatarShape } from 'src/assets/illustrations';
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
import { Image } from 'src/components/image';
|
||||
import type { IPartyUserCard } from 'src/types/party-user';
|
||||
import { fShortenNumber } from 'src/utils/format-number';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = CardProps & {
|
||||
user: IPartyUserCard;
|
||||
};
|
||||
|
||||
export function UserCard({ user, sx, ...other }: Props) {
|
||||
return (
|
||||
<Card sx={[{ textAlign: 'center' }, ...(Array.isArray(sx) ? sx : [sx])]} {...other}>
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<AvatarShape
|
||||
sx={{
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 10,
|
||||
mx: 'auto',
|
||||
bottom: -26,
|
||||
position: 'absolute',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Avatar
|
||||
alt={user.name}
|
||||
src={user.avatarUrl}
|
||||
sx={{
|
||||
left: 0,
|
||||
right: 0,
|
||||
width: 64,
|
||||
height: 64,
|
||||
zIndex: 11,
|
||||
mx: 'auto',
|
||||
bottom: -32,
|
||||
position: 'absolute',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Image
|
||||
src={user.coverUrl}
|
||||
alt={user.coverUrl}
|
||||
ratio="16/9"
|
||||
slotProps={{
|
||||
overlay: {
|
||||
sx: (theme) => ({
|
||||
bgcolor: varAlpha(theme.vars.palette.common.blackChannel, 0.48),
|
||||
}),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<ListItemText
|
||||
sx={{ mt: 7, mb: 1 }}
|
||||
primary={user.name}
|
||||
secondary={user.role}
|
||||
slotProps={{
|
||||
primary: { sx: { typography: 'subtitle1' } },
|
||||
secondary: { sx: { mt: 0.5 } },
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
mb: 2.5,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{_socials.map((social) => (
|
||||
<IconButton key={social.label}>
|
||||
{social.value === 'twitter' && <Iconify icon="socials:twitter" />}
|
||||
{social.value === 'facebook' && <Iconify icon="socials:facebook" />}
|
||||
{social.value === 'instagram' && <Iconify icon="socials:instagram" />}
|
||||
{social.value === 'linkedin' && <Iconify icon="socials:linkedin" />}
|
||||
</IconButton>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ borderStyle: 'dashed' }} />
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
py: 3,
|
||||
display: 'grid',
|
||||
typography: 'subtitle1',
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ label: 'Follower', value: user.totalFollowers },
|
||||
{ label: 'Following', value: user.totalFollowing },
|
||||
{ label: 'Total post', value: user.totalPosts },
|
||||
].map((stat) => (
|
||||
<Box key={stat.label} sx={{ gap: 0.5, display: 'flex', flexDirection: 'column' }}>
|
||||
<Box component="span" sx={{ typography: 'caption', color: 'text.secondary' }}>
|
||||
{stat.label}
|
||||
</Box>
|
||||
{fShortenNumber(stat.value)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
@@ -0,0 +1,297 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Card from '@mui/material/Card';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { useState } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { isValidPhoneNumber } from 'react-phone-number-input/input';
|
||||
import { createUser, deletePartyUser, saveUser } from 'src/actions/party-user';
|
||||
import { Field, Form, schemaHelper } from 'src/components/hook-form';
|
||||
import { Label } from 'src/components/label';
|
||||
import { toast } from 'src/components/snackbar';
|
||||
import { useRouter } from 'src/routes/hooks';
|
||||
import { paths } from 'src/routes/paths';
|
||||
import type { IPartyUserItem } from 'src/types/party-user';
|
||||
import { fileToBase64 } from 'src/utils/file-to-base64';
|
||||
import { fData } from 'src/utils/format-number';
|
||||
import { z as zod } from 'zod';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export type NewUserSchemaType = zod.infer<typeof NewUserSchema>;
|
||||
|
||||
export const NewUserSchema = zod.object({
|
||||
name: zod.string().min(1, { message: 'Name is required!' }),
|
||||
city: zod.string().min(1, { message: 'City is required!' }),
|
||||
role: zod.string().min(1, { message: 'Role is required!' }),
|
||||
email: zod
|
||||
.string()
|
||||
.min(1, { message: 'Email is required!' })
|
||||
.email({ message: 'Email must be a valid email address!' }),
|
||||
state: zod.string().min(1, { message: 'State is required!' }),
|
||||
status: zod.string(),
|
||||
address: zod.string().min(1, { message: 'Address is required!' }),
|
||||
country: schemaHelper.nullableInput(zod.string().min(1, { message: 'Country is required!' }), {
|
||||
// message for null value
|
||||
message: 'Country is required!',
|
||||
}),
|
||||
zipCode: zod.string().min(1, { message: 'Zip code is required!' }),
|
||||
company: zod.string().min(1, { message: 'Company is required!' }),
|
||||
avatarUrl: schemaHelper.file({ message: 'Avatar is required!' }),
|
||||
phoneNumber: schemaHelper.phoneNumber({ isValid: isValidPhoneNumber }),
|
||||
isVerified: zod.boolean(),
|
||||
username: zod.string(),
|
||||
password: zod.string(),
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
currentUser?: IPartyUserItem;
|
||||
};
|
||||
|
||||
export function UserNewEditForm({ currentUser }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const defaultValues: NewUserSchemaType = {
|
||||
status: '',
|
||||
avatarUrl: null,
|
||||
isVerified: true,
|
||||
name: '新用戶名字',
|
||||
email: 'user@123.com',
|
||||
phoneNumber: '+85291234567',
|
||||
country: 'Hong Kong',
|
||||
state: 'HK',
|
||||
city: 'hong kong',
|
||||
address: 'Kwun Tong, Sau Mau Ping',
|
||||
zipCode: '00000',
|
||||
company: 'test company',
|
||||
role: 'user',
|
||||
//
|
||||
username: '',
|
||||
password: '',
|
||||
};
|
||||
|
||||
const methods = useForm<NewUserSchemaType>({
|
||||
mode: 'onSubmit',
|
||||
resolver: zodResolver(NewUserSchema),
|
||||
defaultValues,
|
||||
values: currentUser,
|
||||
});
|
||||
|
||||
const {
|
||||
reset,
|
||||
watch,
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = methods;
|
||||
|
||||
const values = watch();
|
||||
|
||||
const [disableDeleteUserButton, setDisableDeleteUserButton] = useState<boolean>(false);
|
||||
const handleDeleteUserClick = async () => {
|
||||
setDisableDeleteUserButton(true);
|
||||
try {
|
||||
if (currentUser) {
|
||||
await deletePartyUser(currentUser.id);
|
||||
toast.success(t('user deleted'));
|
||||
router.push(paths.dashboard.user.list);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
setDisableDeleteUserButton(false);
|
||||
};
|
||||
|
||||
const onSubmit = handleSubmit(async (data: any) => {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
reset();
|
||||
|
||||
const temp: any = data.avatarUrl;
|
||||
if (temp instanceof File) {
|
||||
data.avatarUrl = await fileToBase64(temp);
|
||||
}
|
||||
|
||||
if (currentUser) {
|
||||
// perform save
|
||||
await saveUser(currentUser.id, data);
|
||||
} else {
|
||||
// perform create
|
||||
await createUser(data);
|
||||
}
|
||||
|
||||
toast.success(currentUser ? t('Update success!') : t('Create success!'));
|
||||
|
||||
router.push(paths.dashboard.user.list);
|
||||
|
||||
// console.info('DATA', data);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Form methods={methods} onSubmit={onSubmit}>
|
||||
<Grid container spacing={3}>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Card sx={{ pt: 10, pb: 5, px: 3 }}>
|
||||
{currentUser && (
|
||||
<Label
|
||||
color={
|
||||
(values.status === 'active' && 'success') ||
|
||||
(values.status === 'banned' && 'error') ||
|
||||
'warning'
|
||||
}
|
||||
sx={{ position: 'absolute', top: 24, right: 24 }}
|
||||
>
|
||||
{values.status}
|
||||
</Label>
|
||||
)}
|
||||
|
||||
<Box sx={{ mb: 5 }}>
|
||||
<Field.UploadAvatar
|
||||
name="avatarUrl"
|
||||
maxSize={3145728}
|
||||
helperText={
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
mt: 3,
|
||||
mx: 'auto',
|
||||
display: 'block',
|
||||
textAlign: 'center',
|
||||
color: 'text.disabled',
|
||||
}}
|
||||
>
|
||||
{t('Allowed')} *.jpeg, *.jpg, *.png, *.gif
|
||||
<br /> {t('max size of')} {fData(3145728)}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{currentUser && (
|
||||
<FormControlLabel
|
||||
labelPlacement="start"
|
||||
control={
|
||||
<Controller
|
||||
name="status"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
{...field}
|
||||
checked={field.value !== 'active'}
|
||||
onChange={(event) =>
|
||||
field.onChange(event.target.checked ? 'banned' : 'active')
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<>
|
||||
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
|
||||
{t('Banned')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('Apply disable account')}
|
||||
</Typography>
|
||||
</>
|
||||
}
|
||||
sx={{
|
||||
mx: 0,
|
||||
mb: 3,
|
||||
width: 1,
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Field.Switch
|
||||
name="isVerified"
|
||||
labelPlacement="start"
|
||||
label={
|
||||
<>
|
||||
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
|
||||
{t('Email verified')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('Disabling this will automatically send the user a verification email')}
|
||||
</Typography>
|
||||
</>
|
||||
}
|
||||
sx={{ mx: 0, width: 1, justifyContent: 'space-between' }}
|
||||
/>
|
||||
|
||||
{currentUser && (
|
||||
<Stack sx={{ mt: 3, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Button
|
||||
disabled={disableDeleteUserButton}
|
||||
loading={disableDeleteUserButton}
|
||||
variant="soft"
|
||||
color="error"
|
||||
onClick={handleDeleteUserClick}
|
||||
>
|
||||
{t('Delete user')}
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 8 }}>
|
||||
<Card sx={{ p: 3 }}>
|
||||
<Box
|
||||
sx={{
|
||||
rowGap: 3,
|
||||
columnGap: 2,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: 'repeat(1, 1fr)', sm: 'repeat(2, 1fr)' },
|
||||
}}
|
||||
>
|
||||
<Field.Text name="name" label={t('Full name')} />
|
||||
<Field.Text name="email" label={t('Email address')} />
|
||||
<Field.Phone name="phoneNumber" label={t('Phone number')} country="HK" />
|
||||
|
||||
<Field.CountrySelect
|
||||
fullWidth
|
||||
name="country"
|
||||
label={t('Country')}
|
||||
placeholder={t('Choose a country')}
|
||||
/>
|
||||
|
||||
<Field.Text name="state" label={t('State/region')} />
|
||||
<Field.Text name="city" label={t('City')} />
|
||||
<Field.Text name="address" label={t('Address')} />
|
||||
<Field.Text name="zipCode" label={t('Zip/code')} />
|
||||
<Field.Text name="company" label={t('Company')} />
|
||||
<Field.Text name="role" label={t('Role')} />
|
||||
</Box>
|
||||
|
||||
<Stack sx={{ mt: 3, alignItems: 'flex-end' }}>
|
||||
<Button
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
type="submit"
|
||||
variant="contained"
|
||||
>
|
||||
{!currentUser ? t('Create user') : t('Save changes')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Form>
|
||||
);
|
||||
}
|
@@ -0,0 +1,173 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { isValidPhoneNumber } from 'react-phone-number-input/input';
|
||||
import { USER_STATUS_OPTIONS } from 'src/_mock';
|
||||
import { Field, Form, schemaHelper } from 'src/components/hook-form';
|
||||
import { toast } from 'src/components/snackbar';
|
||||
import { useTranslate } from 'src/locales';
|
||||
import type { IUserItem } from 'src/types/user';
|
||||
import { z as zod } from 'zod';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export type UserQuickEditSchemaType = zod.infer<typeof UserQuickEditSchema>;
|
||||
|
||||
export const UserQuickEditSchema = zod.object({
|
||||
name: zod.string().min(1, { message: 'Name is required!' }),
|
||||
email: zod
|
||||
.string()
|
||||
.min(1, { message: 'Email is required!' })
|
||||
.email({ message: 'Email must be a valid email address!' }),
|
||||
phoneNumber: schemaHelper.phoneNumber({ isValid: isValidPhoneNumber }),
|
||||
country: schemaHelper.nullableInput(zod.string().min(1, { message: 'Country is required!' }), {
|
||||
// message for null value
|
||||
message: 'Country is required!',
|
||||
}),
|
||||
state: zod.string().min(1, { message: 'State is required!' }),
|
||||
city: zod.string().min(1, { message: 'City is required!' }),
|
||||
address: zod.string().min(1, { message: 'Address is required!' }),
|
||||
zipCode: zod.string().min(1, { message: 'Zip code is required!' }),
|
||||
company: zod.string().min(1, { message: 'Company is required!' }),
|
||||
role: zod.string().min(1, { message: 'Role is required!' }),
|
||||
// Not required
|
||||
status: zod.string(),
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
currentUser?: IUserItem;
|
||||
};
|
||||
|
||||
export function UserQuickEditForm({ currentUser, open, onClose }: Props) {
|
||||
const { t } = useTranslate();
|
||||
|
||||
const defaultValues: UserQuickEditSchemaType = {
|
||||
name: '',
|
||||
email: '',
|
||||
phoneNumber: '',
|
||||
address: '',
|
||||
country: '',
|
||||
state: '',
|
||||
city: '',
|
||||
zipCode: '',
|
||||
status: '',
|
||||
company: '',
|
||||
role: '',
|
||||
};
|
||||
|
||||
const methods = useForm<UserQuickEditSchemaType>({
|
||||
mode: 'all',
|
||||
resolver: zodResolver(UserQuickEditSchema),
|
||||
defaultValues,
|
||||
values: currentUser,
|
||||
});
|
||||
|
||||
const {
|
||||
reset,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
} = methods;
|
||||
|
||||
const onSubmit = handleSubmit(async (data) => {
|
||||
const promise = new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
try {
|
||||
reset();
|
||||
onClose();
|
||||
|
||||
toast.promise(promise, {
|
||||
loading: 'Loading...',
|
||||
success: 'Update success!',
|
||||
error: 'Update error!',
|
||||
});
|
||||
|
||||
await promise;
|
||||
|
||||
console.info('DATA', data);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
fullWidth
|
||||
maxWidth={false}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: { maxWidth: 720 },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle>{t('Quick update')}</DialogTitle>
|
||||
|
||||
<Form methods={methods} onSubmit={onSubmit}>
|
||||
<DialogContent>
|
||||
<Alert variant="outlined" severity="info" sx={{ mb: 3 }}>
|
||||
Account is waiting for confirmation
|
||||
</Alert>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
rowGap: 3,
|
||||
columnGap: 2,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: 'repeat(1, 1fr)', sm: 'repeat(2, 1fr)' },
|
||||
}}
|
||||
>
|
||||
<Field.Select name="status" label="Status">
|
||||
{USER_STATUS_OPTIONS.map((status) => (
|
||||
<MenuItem key={status.value} value={status.value}>
|
||||
{status.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Field.Select>
|
||||
|
||||
<Box sx={{ display: { xs: 'none', sm: 'block' } }} />
|
||||
|
||||
<Field.Text name="name" label="Full name" />
|
||||
<Field.Text name="email" label="Email address" />
|
||||
<Field.Phone name="phoneNumber" label="Phone number" />
|
||||
|
||||
<Field.CountrySelect
|
||||
fullWidth
|
||||
name="country"
|
||||
label="Country"
|
||||
placeholder="Choose a country"
|
||||
/>
|
||||
|
||||
<Field.Text name="state" label="State/region" />
|
||||
<Field.Text name="city" label="City" />
|
||||
<Field.Text name="address" label="Address" />
|
||||
<Field.Text name="zipCode" label="Zip/code" />
|
||||
<Field.Text name="company" label="Company" />
|
||||
<Field.Text name="role" label="Role" />
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button variant="outlined" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button type="submit" variant="contained" loading={isSubmitting}>
|
||||
Update
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
@@ -0,0 +1,65 @@
|
||||
import Chip from '@mui/material/Chip';
|
||||
import type { UseSetStateReturn } from 'minimal-shared/hooks';
|
||||
import { useCallback } from 'react';
|
||||
import type { FiltersResultProps } from 'src/components/filters-result';
|
||||
import { chipProps, FiltersBlock, FiltersResult } from 'src/components/filters-result';
|
||||
import type { IPartyUserTableFilters } from 'src/types/party-user';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = FiltersResultProps & {
|
||||
onResetPage: () => void;
|
||||
filters: UseSetStateReturn<IPartyUserTableFilters>;
|
||||
};
|
||||
|
||||
export function UserTableFiltersResult({ filters, onResetPage, totalResults, sx }: Props) {
|
||||
const { state: currentFilters, setState: updateFilters, resetState: resetFilters } = filters;
|
||||
|
||||
const handleRemoveKeyword = useCallback(() => {
|
||||
onResetPage();
|
||||
updateFilters({ name: '' });
|
||||
}, [onResetPage, updateFilters]);
|
||||
|
||||
const handleRemoveStatus = useCallback(() => {
|
||||
onResetPage();
|
||||
updateFilters({ status: 'all' });
|
||||
}, [onResetPage, updateFilters]);
|
||||
|
||||
const handleRemoveRole = useCallback(
|
||||
(inputValue: string) => {
|
||||
const newValue = currentFilters.role.filter((item) => item !== inputValue);
|
||||
|
||||
onResetPage();
|
||||
updateFilters({ role: newValue });
|
||||
},
|
||||
[onResetPage, updateFilters, currentFilters.role]
|
||||
);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
onResetPage();
|
||||
resetFilters();
|
||||
}, [onResetPage, resetFilters]);
|
||||
|
||||
return (
|
||||
<FiltersResult totalResults={totalResults} onReset={handleReset} sx={sx}>
|
||||
<FiltersBlock label="Status:" isShow={currentFilters.status !== 'all'}>
|
||||
<Chip
|
||||
{...chipProps}
|
||||
label={currentFilters.status}
|
||||
onDelete={handleRemoveStatus}
|
||||
sx={{ textTransform: 'capitalize' }}
|
||||
/>
|
||||
</FiltersBlock>
|
||||
|
||||
<FiltersBlock label="Role:" isShow={!!currentFilters.role.length}>
|
||||
{currentFilters.role.map((item) => (
|
||||
<Chip {...chipProps} key={item} label={item} onDelete={() => handleRemoveRole(item)} />
|
||||
))}
|
||||
</FiltersBlock>
|
||||
|
||||
<FiltersBlock label="Keyword:" isShow={!!currentFilters.name}>
|
||||
<Chip {...chipProps} label={currentFilters.name} onDelete={handleRemoveKeyword} />
|
||||
</FiltersBlock>
|
||||
</FiltersResult>
|
||||
);
|
||||
}
|
@@ -0,0 +1,183 @@
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Link from '@mui/material/Link';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import MenuList from '@mui/material/MenuList';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import { useBoolean, usePopover } from 'minimal-shared/hooks';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ConfirmDialog } from 'src/components/custom-dialog';
|
||||
import { CustomPopover } from 'src/components/custom-popover';
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
import { Label } from 'src/components/label';
|
||||
import { RouterLink } from 'src/routes/components';
|
||||
import type { IPartyUserItem } from 'src/types/party-user';
|
||||
import { UserQuickEditForm } from './party-user-quick-edit-form';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
row: IPartyUserItem;
|
||||
selected: boolean;
|
||||
editHref: string;
|
||||
onSelectRow: () => void;
|
||||
onDeleteRow: () => void;
|
||||
};
|
||||
|
||||
export function UserTableRow({ row, selected, editHref, onSelectRow, onDeleteRow }: Props) {
|
||||
const menuActions = usePopover();
|
||||
const confirmDialog = useBoolean();
|
||||
const quickEditForm = useBoolean();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const renderQuickEditForm = () => (
|
||||
<UserQuickEditForm
|
||||
currentUser={row}
|
||||
open={quickEditForm.value}
|
||||
onClose={quickEditForm.onFalse}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderMenuActions = () => (
|
||||
<CustomPopover
|
||||
open={menuActions.open}
|
||||
anchorEl={menuActions.anchorEl}
|
||||
onClose={menuActions.onClose}
|
||||
slotProps={{ arrow: { placement: 'right-top' } }}
|
||||
>
|
||||
<MenuList>
|
||||
<li>
|
||||
<MenuItem component={RouterLink} href={editHref} onClick={() => menuActions.onClose()}>
|
||||
<Iconify icon="solar:pen-bold" />
|
||||
{t('Edit')}
|
||||
</MenuItem>
|
||||
</li>
|
||||
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
confirmDialog.onTrue();
|
||||
menuActions.onClose();
|
||||
}}
|
||||
sx={{ color: 'error.main' }}
|
||||
>
|
||||
<Iconify icon="solar:trash-bin-trash-bold" />
|
||||
{t('Delete')}
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</CustomPopover>
|
||||
);
|
||||
|
||||
const [disableDeleteButton, setDisableDeleteButton] = useState<boolean>(false);
|
||||
const renderConfirmDialog = () => (
|
||||
<ConfirmDialog
|
||||
open={confirmDialog.value}
|
||||
onClose={confirmDialog.onFalse}
|
||||
title={t('Delete')}
|
||||
content={t('Are you sure want to delete user?')}
|
||||
action={
|
||||
<Button
|
||||
disabled={disableDeleteButton}
|
||||
loading={disableDeleteButton}
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={() => {
|
||||
setDisableDeleteButton(true);
|
||||
onDeleteRow();
|
||||
}}
|
||||
>
|
||||
{t('Delete')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableRow hover selected={selected} aria-checked={selected} tabIndex={-1}>
|
||||
<TableCell padding="checkbox">
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
onClick={onSelectRow}
|
||||
slotProps={{
|
||||
input: {
|
||||
id: `${row.id}-checkbox`,
|
||||
'aria-label': `${row.id} checkbox`,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Box sx={{ gap: 2, display: 'flex', alignItems: 'center' }}>
|
||||
<Avatar alt={row.name} src={row.avatarUrl} />
|
||||
|
||||
<Stack sx={{ typography: 'body2', flex: '1 1 auto', alignItems: 'flex-start' }}>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
href={editHref}
|
||||
color="inherit"
|
||||
sx={{ cursor: 'pointer' }}
|
||||
>
|
||||
{row.name}
|
||||
</Link>
|
||||
<Box component="span" sx={{ color: 'text.disabled' }}>
|
||||
{row.email}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
</TableCell>
|
||||
|
||||
<TableCell sx={{ whiteSpace: 'nowrap' }}>{row.phoneNumber}</TableCell>
|
||||
|
||||
<TableCell sx={{ whiteSpace: 'nowrap' }}>{row.company}</TableCell>
|
||||
|
||||
<TableCell sx={{ whiteSpace: 'nowrap' }}>{row.role}</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Label
|
||||
variant="soft"
|
||||
color={
|
||||
(row.status === 'active' && 'success') ||
|
||||
(row.status === 'pending' && 'warning') ||
|
||||
(row.status === 'banned' && 'error') ||
|
||||
'default'
|
||||
}
|
||||
>
|
||||
{row.status}
|
||||
</Label>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Tooltip title={t('Quick Edit')} placement="top" arrow>
|
||||
<IconButton
|
||||
color={quickEditForm.value ? 'inherit' : 'default'}
|
||||
onClick={quickEditForm.onTrue}
|
||||
>
|
||||
<Iconify icon="solar:pen-bold" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<IconButton
|
||||
color={menuActions.open ? 'inherit' : 'default'}
|
||||
onClick={menuActions.onOpen}
|
||||
>
|
||||
<Iconify icon="eva:more-vertical-fill" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{renderQuickEditForm()}
|
||||
{renderMenuActions()}
|
||||
{renderConfirmDialog()}
|
||||
</>
|
||||
);
|
||||
}
|
@@ -0,0 +1,150 @@
|
||||
import Box from '@mui/material/Box';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import MenuList from '@mui/material/MenuList';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import type { SelectChangeEvent } from '@mui/material/Select';
|
||||
import Select from '@mui/material/Select';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import type { UseSetStateReturn } from 'minimal-shared/hooks';
|
||||
import { usePopover } from 'minimal-shared/hooks';
|
||||
import { useCallback } from 'react';
|
||||
import { CustomPopover } from 'src/components/custom-popover';
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
import type { IPartyUserTableFilters } from 'src/types/party-user';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
onResetPage: () => void;
|
||||
filters: UseSetStateReturn<IPartyUserTableFilters>;
|
||||
options: {
|
||||
roles: string[];
|
||||
};
|
||||
};
|
||||
|
||||
export function UserTableToolbar({ filters, options, onResetPage }: Props) {
|
||||
const menuActions = usePopover();
|
||||
|
||||
const { state: currentFilters, setState: updateFilters } = filters;
|
||||
|
||||
const handleFilterName = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onResetPage();
|
||||
updateFilters({ name: event.target.value });
|
||||
},
|
||||
[onResetPage, updateFilters]
|
||||
);
|
||||
|
||||
const handleFilterRole = useCallback(
|
||||
(event: SelectChangeEvent<string[]>) => {
|
||||
const newValue =
|
||||
typeof event.target.value === 'string' ? event.target.value.split(',') : event.target.value;
|
||||
|
||||
onResetPage();
|
||||
updateFilters({ role: newValue });
|
||||
},
|
||||
[onResetPage, updateFilters]
|
||||
);
|
||||
|
||||
const renderMenuActions = () => (
|
||||
<CustomPopover
|
||||
open={menuActions.open}
|
||||
anchorEl={menuActions.anchorEl}
|
||||
onClose={menuActions.onClose}
|
||||
slotProps={{ arrow: { placement: 'right-top' } }}
|
||||
>
|
||||
<MenuList>
|
||||
<MenuItem onClick={() => menuActions.onClose()}>
|
||||
<Iconify icon="solar:printer-minimalistic-bold" />
|
||||
Print
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem onClick={() => menuActions.onClose()}>
|
||||
<Iconify icon="solar:import-bold" />
|
||||
Import
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem onClick={() => menuActions.onClose()}>
|
||||
<Iconify icon="solar:export-bold" />
|
||||
Export
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</CustomPopover>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2.5,
|
||||
gap: 2,
|
||||
display: 'flex',
|
||||
pr: { xs: 2.5, md: 1 },
|
||||
flexDirection: { xs: 'column', md: 'row' },
|
||||
alignItems: { xs: 'flex-end', md: 'center' },
|
||||
}}
|
||||
>
|
||||
<FormControl sx={{ flexShrink: 0, width: { xs: 1, md: 200 } }}>
|
||||
<InputLabel htmlFor="filter-role-select">Role</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={currentFilters.role}
|
||||
onChange={handleFilterRole}
|
||||
input={<OutlinedInput label="Role" />}
|
||||
renderValue={(selected) => selected.map((value) => value).join(', ')}
|
||||
inputProps={{ id: 'filter-role-select' }}
|
||||
MenuProps={{ PaperProps: { sx: { maxHeight: 240 } } }}
|
||||
>
|
||||
{options.roles.map((option) => (
|
||||
<MenuItem key={option} value={option}>
|
||||
<Checkbox
|
||||
disableRipple
|
||||
size="small"
|
||||
checked={currentFilters.role.includes(option)}
|
||||
/>
|
||||
{option}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
gap: 2,
|
||||
width: 1,
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
fullWidth
|
||||
value={currentFilters.name}
|
||||
onChange={handleFilterName}
|
||||
placeholder="Search..."
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Iconify icon="eva:search-fill" sx={{ color: 'text.disabled' }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<IconButton onClick={menuActions.onOpen}>
|
||||
<Iconify icon="eva:more-vertical-fill" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{renderMenuActions()}
|
||||
</>
|
||||
);
|
||||
}
|
75
03_source/frontend/src/sections/party-user/profile-cover.tsx
Normal file
75
03_source/frontend/src/sections/party-user/profile-cover.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import type { BoxProps } from '@mui/material/Box';
|
||||
import Box from '@mui/material/Box';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import { varAlpha } from 'minimal-shared/utils';
|
||||
import type { IPartyUserProfileCover } from 'src/types/party-user';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export function ProfileCover({
|
||||
sx,
|
||||
name,
|
||||
role,
|
||||
coverUrl,
|
||||
avatarUrl,
|
||||
...other
|
||||
}: BoxProps & IPartyUserProfileCover) {
|
||||
return (
|
||||
<Box
|
||||
sx={[
|
||||
(theme) => ({
|
||||
...theme.mixins.bgGradient({
|
||||
images: [
|
||||
`linear-gradient(0deg, ${varAlpha(theme.vars.palette.primary.darkerChannel, 0.8)}, ${varAlpha(theme.vars.palette.primary.darkerChannel, 0.8)})`,
|
||||
`url(${coverUrl})`,
|
||||
],
|
||||
}),
|
||||
height: 1,
|
||||
color: 'common.white',
|
||||
}),
|
||||
...(Array.isArray(sx) ? sx : [sx]),
|
||||
]}
|
||||
{...other}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
left: { md: 24 },
|
||||
bottom: { md: 24 },
|
||||
zIndex: { md: 10 },
|
||||
pt: { xs: 6, md: 0 },
|
||||
position: { md: 'absolute' },
|
||||
flexDirection: { xs: 'column', md: 'row' },
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
alt={name}
|
||||
src={avatarUrl}
|
||||
sx={[
|
||||
(theme) => ({
|
||||
mx: 'auto',
|
||||
width: { xs: 64, md: 128 },
|
||||
height: { xs: 64, md: 128 },
|
||||
border: `solid 2px ${theme.vars.palette.common.white}`,
|
||||
}),
|
||||
]}
|
||||
>
|
||||
{name?.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
|
||||
<ListItemText
|
||||
primary={name}
|
||||
secondary={role}
|
||||
slotProps={{
|
||||
primary: { sx: { typography: 'h4' } },
|
||||
secondary: {
|
||||
sx: { mt: 0.5, opacity: 0.48, color: 'inherit' },
|
||||
},
|
||||
}}
|
||||
sx={{ mt: 3, ml: { md: 3 }, textAlign: { xs: 'center', md: 'unset' } }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
123
03_source/frontend/src/sections/party-user/profile-followers.tsx
Normal file
123
03_source/frontend/src/sections/party-user/profile-followers.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import type { CardProps } from '@mui/material/Card';
|
||||
import Card from '@mui/material/Card';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
import type { IPartyUserProfileFollower } from 'src/types/party-user';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
followers: IPartyUserProfileFollower[];
|
||||
};
|
||||
|
||||
export function ProfileFollowers({ followers }: Props) {
|
||||
const _mockFollowed = followers.slice(4, 8).map((i) => i.id);
|
||||
|
||||
const [followed, setFollowed] = useState<string[]>(_mockFollowed);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(item: string) => {
|
||||
const selected = followed.includes(item)
|
||||
? followed.filter((value) => value !== item)
|
||||
: [...followed, item];
|
||||
|
||||
setFollowed(selected);
|
||||
},
|
||||
[followed]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography variant="h4" sx={{ my: 5 }}>
|
||||
Followers
|
||||
</Typography>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
gap: 3,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: 'repeat(1, 1fr)', sm: 'repeat(2, 1fr)', md: 'repeat(3, 1fr)' },
|
||||
}}
|
||||
>
|
||||
{followers.map((follower) => (
|
||||
<CardItem
|
||||
key={follower.id}
|
||||
follower={follower}
|
||||
selected={followed.includes(follower.id)}
|
||||
onSelected={() => handleClick(follower.id)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type CardItemProps = CardProps & {
|
||||
selected: boolean;
|
||||
onSelected: () => void;
|
||||
follower: IPartyUserProfileFollower;
|
||||
};
|
||||
|
||||
function CardItem({ follower, selected, onSelected, sx, ...other }: CardItemProps) {
|
||||
return (
|
||||
<Card
|
||||
sx={[
|
||||
(theme) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
p: theme.spacing(3, 2, 3, 3),
|
||||
}),
|
||||
...(Array.isArray(sx) ? sx : [sx]),
|
||||
]}
|
||||
{...other}
|
||||
>
|
||||
<Avatar
|
||||
alt={follower?.name}
|
||||
src={follower?.avatarUrl}
|
||||
sx={{ width: 48, height: 48, mr: 2 }}
|
||||
/>
|
||||
|
||||
<ListItemText
|
||||
primary={follower?.name}
|
||||
secondary={
|
||||
<>
|
||||
<Iconify icon="mingcute:location-fill" width={16} sx={{ flexShrink: 0, mr: 0.5 }} />
|
||||
{follower?.country}
|
||||
</>
|
||||
}
|
||||
slotProps={{
|
||||
primary: { noWrap: true },
|
||||
secondary: {
|
||||
noWrap: true,
|
||||
sx: {
|
||||
mt: 0.5,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
typography: 'caption',
|
||||
color: 'text.disabled',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
variant={selected ? 'text' : 'outlined'}
|
||||
color={selected ? 'success' : 'inherit'}
|
||||
startIcon={
|
||||
selected ? <Iconify width={18} icon="eva:checkmark-fill" sx={{ mr: -0.75 }} /> : null
|
||||
}
|
||||
onClick={onSelected}
|
||||
sx={{ flexShrink: 0, ml: 1.5 }}
|
||||
>
|
||||
{selected ? 'Followed' : 'Follow'}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
183
03_source/frontend/src/sections/party-user/profile-friends.tsx
Normal file
183
03_source/frontend/src/sections/party-user/profile-friends.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Box from '@mui/material/Box';
|
||||
import Card from '@mui/material/Card';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import Link from '@mui/material/Link';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import MenuList from '@mui/material/MenuList';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { usePopover } from 'minimal-shared/hooks';
|
||||
import { _socials } from 'src/_mock';
|
||||
import { CustomPopover } from 'src/components/custom-popover';
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
import { SearchNotFound } from 'src/components/search-not-found';
|
||||
import type { IPartyUserProfileFriend } from 'src/types/party-user';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
searchFriends: string;
|
||||
friends: IPartyUserProfileFriend[];
|
||||
onSearchFriends: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
};
|
||||
|
||||
export function ProfileFriends({ friends, searchFriends, onSearchFriends }: Props) {
|
||||
const dataFiltered = applyFilter({ inputData: friends, query: searchFriends });
|
||||
|
||||
const notFound = !dataFiltered.length && !!searchFriends;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
my: 5,
|
||||
gap: 2,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
flexDirection: { xs: 'column', sm: 'row' },
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4">Friends</Typography>
|
||||
|
||||
<TextField
|
||||
value={searchFriends}
|
||||
onChange={onSearchFriends}
|
||||
placeholder="Search friends..."
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Iconify icon="eva:search-fill" sx={{ color: 'text.disabled' }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
sx={{ width: { xs: 1, sm: 260 } }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{notFound ? (
|
||||
<SearchNotFound query={searchFriends} sx={{ py: 10 }} />
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
gap: 3,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: {
|
||||
xs: 'repeat(1, 1fr)',
|
||||
sm: 'repeat(2, 1fr)',
|
||||
md: 'repeat(3, 1fr)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{dataFiltered.map((item) => (
|
||||
<FriendCard key={item.id} item={item} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type FriendCardProps = {
|
||||
item: IPartyUserProfileFriend;
|
||||
};
|
||||
|
||||
function FriendCard({ item }: FriendCardProps) {
|
||||
const menuActions = usePopover();
|
||||
|
||||
const handleDelete = () => {
|
||||
menuActions.onClose();
|
||||
console.info('DELETE', item.name);
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
menuActions.onClose();
|
||||
console.info('EDIT', item.name);
|
||||
};
|
||||
|
||||
const renderMenuActions = () => (
|
||||
<CustomPopover
|
||||
open={menuActions.open}
|
||||
anchorEl={menuActions.anchorEl}
|
||||
onClose={menuActions.onClose}
|
||||
slotProps={{ arrow: { placement: 'right-top' } }}
|
||||
>
|
||||
<MenuList>
|
||||
<MenuItem onClick={handleDelete} sx={{ color: 'error.main' }}>
|
||||
<Iconify icon="solar:trash-bin-trash-bold" />
|
||||
Delete
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem onClick={handleEdit}>
|
||||
<Iconify icon="solar:pen-bold" />
|
||||
Edit
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</CustomPopover>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
sx={{
|
||||
py: 5,
|
||||
display: 'flex',
|
||||
position: 'relative',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Avatar alt={item.name} src={item.avatarUrl} sx={{ width: 64, height: 64, mb: 3 }} />
|
||||
|
||||
<Link variant="subtitle1" color="text.primary">
|
||||
{item.name}
|
||||
</Link>
|
||||
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1, mt: 0.5 }}>
|
||||
{item.role}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{_socials.map((social) => (
|
||||
<IconButton key={social.label}>
|
||||
{social.value === 'twitter' && <Iconify icon="socials:twitter" />}
|
||||
{social.value === 'facebook' && <Iconify icon="socials:facebook" />}
|
||||
{social.value === 'instagram' && <Iconify icon="socials:instagram" />}
|
||||
{social.value === 'linkedin' && <Iconify icon="socials:linkedin" />}
|
||||
</IconButton>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<IconButton
|
||||
color={menuActions.open ? 'inherit' : 'default'}
|
||||
onClick={menuActions.onOpen}
|
||||
sx={{ top: 8, right: 8, position: 'absolute' }}
|
||||
>
|
||||
<Iconify icon="eva:more-vertical-fill" />
|
||||
</IconButton>
|
||||
</Card>
|
||||
|
||||
{renderMenuActions()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type ApplyFilterProps = {
|
||||
query: string;
|
||||
inputData: IPartyUserProfileFriend[];
|
||||
};
|
||||
|
||||
function applyFilter({ inputData, query }: ApplyFilterProps) {
|
||||
if (!query) return inputData;
|
||||
|
||||
return inputData.filter(({ name, role }) =>
|
||||
[name, role].some((field) => field?.toLowerCase().includes(query.toLowerCase()))
|
||||
);
|
||||
}
|
@@ -0,0 +1,89 @@
|
||||
import Box from '@mui/material/Box';
|
||||
import Card from '@mui/material/Card';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
import { Image } from 'src/components/image';
|
||||
import { Lightbox, useLightBox } from 'src/components/lightbox';
|
||||
import type { IPartyUserProfileGallery } from 'src/types/party-user';
|
||||
import { fDate } from 'src/utils/format-time';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
gallery: IPartyUserProfileGallery[];
|
||||
};
|
||||
|
||||
export function ProfileGallery({ gallery }: Props) {
|
||||
const slides = gallery.map((slide) => ({ src: slide.imageUrl }));
|
||||
const lightbox = useLightBox(slides);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography variant="h4" sx={{ my: 5 }}>
|
||||
Gallery
|
||||
</Typography>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
gap: 3,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: 'repeat(1, 1fr)', sm: 'repeat(2, 1fr)', md: 'repeat(3, 1fr)' },
|
||||
}}
|
||||
>
|
||||
{gallery.map((image) => (
|
||||
<Card key={image.id} sx={{ cursor: 'pointer', color: 'common.white' }}>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
sx={{
|
||||
top: 8,
|
||||
right: 8,
|
||||
zIndex: 9,
|
||||
position: 'absolute',
|
||||
}}
|
||||
>
|
||||
<Iconify icon="eva:more-vertical-fill" />
|
||||
</IconButton>
|
||||
|
||||
<ListItemText
|
||||
sx={{ p: 3, left: 0, width: 1, bottom: 0, zIndex: 9, position: 'absolute' }}
|
||||
primary={image.title}
|
||||
secondary={fDate(image.postedAt)}
|
||||
slotProps={{
|
||||
primary: {
|
||||
noWrap: true,
|
||||
sx: { typography: 'subtitle1' },
|
||||
},
|
||||
secondary: {
|
||||
sx: { mt: 0.5, opacity: 0.48, color: 'inherit' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Image
|
||||
alt="Gallery"
|
||||
ratio="1/1"
|
||||
src={image.imageUrl}
|
||||
onClick={() => lightbox.onOpen(image.imageUrl)}
|
||||
slotProps={{
|
||||
overlay: {
|
||||
sx: (theme) => ({
|
||||
backgroundImage: `linear-gradient(to bottom, transparent 0%, ${theme.vars.palette.common.black} 75%)`,
|
||||
}),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Lightbox
|
||||
index={lightbox.selected}
|
||||
slides={slides}
|
||||
open={lightbox.open}
|
||||
close={lightbox.onClose}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
208
03_source/frontend/src/sections/party-user/profile-home.tsx
Normal file
208
03_source/frontend/src/sections/party-user/profile-home.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardHeader from '@mui/material/CardHeader';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import Fab from '@mui/material/Fab';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import InputBase from '@mui/material/InputBase';
|
||||
import Link from '@mui/material/Link';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { varAlpha } from 'minimal-shared/utils';
|
||||
import { useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { _socials } from 'src/_mock';
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
import type { IPartyUserProfile, IPartyUserProfilePost } from 'src/types/party-user';
|
||||
import { fNumber } from 'src/utils/format-number';
|
||||
import { ProfilePostItem } from './profile-post-item';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
info: IPartyUserProfile;
|
||||
posts: IPartyUserProfilePost[];
|
||||
};
|
||||
|
||||
export function ProfileHome({ info, posts }: Props) {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleAttach = () => {
|
||||
if (fileRef.current) {
|
||||
fileRef.current.click();
|
||||
}
|
||||
};
|
||||
|
||||
const renderFollows = () => (
|
||||
<Card sx={{ py: 3, textAlign: 'center', typography: 'h4' }}>
|
||||
<Stack
|
||||
divider={<Divider orientation="vertical" flexItem sx={{ borderStyle: 'dashed' }} />}
|
||||
sx={{ flexDirection: 'row' }}
|
||||
>
|
||||
<Stack sx={{ width: 1 }}>
|
||||
{fNumber(info.totalFollowers)}
|
||||
<Box component="span" sx={{ color: 'text.secondary', typography: 'body2' }}>
|
||||
{t('Follower')}
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Stack sx={{ width: 1 }}>
|
||||
{fNumber(info.totalFollowing)}
|
||||
<Box component="span" sx={{ color: 'text.secondary', typography: 'body2' }}>
|
||||
{t('Following')}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const renderAbout = () => (
|
||||
<Card>
|
||||
<CardHeader title={t('About')} />
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
p: 3,
|
||||
gap: 2,
|
||||
display: 'flex',
|
||||
typography: 'body2',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<div>{info.quote}</div>
|
||||
|
||||
<Box sx={{ gap: 2, display: 'flex', lineHeight: '24px' }}>
|
||||
<Iconify width={24} icon="mingcute:location-fill" />
|
||||
<span>
|
||||
Live at
|
||||
<Link variant="subtitle2" color="inherit">
|
||||
{info.country}
|
||||
</Link>
|
||||
</span>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ gap: 2, display: 'flex', lineHeight: '24px' }}>
|
||||
<Iconify width={24} icon="solar:letter-bold" />
|
||||
{info.email}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ gap: 2, display: 'flex', lineHeight: '24px' }}>
|
||||
<Iconify width={24} icon="solar:case-minimalistic-bold" />
|
||||
<span>
|
||||
{info.role} at
|
||||
<Link variant="subtitle2" color="inherit">
|
||||
{info.company}
|
||||
</Link>
|
||||
</span>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ gap: 2, display: 'flex', lineHeight: '24px' }}>
|
||||
<Iconify width={24} icon="solar:case-minimalistic-bold" />
|
||||
<span>
|
||||
Studied at
|
||||
<Link variant="subtitle2" color="inherit">
|
||||
{info.school}
|
||||
</Link>
|
||||
</span>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const renderPostInput = () => (
|
||||
<Card sx={{ p: 3 }}>
|
||||
<InputBase
|
||||
multiline
|
||||
fullWidth
|
||||
rows={4}
|
||||
placeholder="Share what you are thinking here..."
|
||||
inputProps={{ id: 'post-input' }}
|
||||
sx={[
|
||||
(theme) => ({
|
||||
p: 2,
|
||||
mb: 3,
|
||||
borderRadius: 1,
|
||||
border: `solid 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.2)}`,
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box
|
||||
sx={{
|
||||
gap: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
<Fab size="small" color="inherit" variant="softExtended" onClick={handleAttach}>
|
||||
<Iconify icon="solar:gallery-wide-bold" width={24} sx={{ color: 'success.main' }} />
|
||||
{t('Image/Video')}
|
||||
</Fab>
|
||||
|
||||
<Fab size="small" color="inherit" variant="softExtended">
|
||||
<Iconify icon="solar:videocamera-record-bold" width={24} sx={{ color: 'error.main' }} />
|
||||
{t('Streaming')}
|
||||
</Fab>
|
||||
</Box>
|
||||
|
||||
<Button variant="contained">{t('Post')}</Button>
|
||||
</Box>
|
||||
|
||||
<input ref={fileRef} type="file" style={{ display: 'none' }} />
|
||||
</Card>
|
||||
);
|
||||
|
||||
const renderSocials = () => (
|
||||
<Card>
|
||||
<CardHeader title={t('Social')} />
|
||||
|
||||
<Box sx={{ p: 3, gap: 2, display: 'flex', flexDirection: 'column', typography: 'body2' }}>
|
||||
{_socials.map((social) => (
|
||||
<Box
|
||||
key={social.label}
|
||||
sx={{
|
||||
gap: 2,
|
||||
display: 'flex',
|
||||
lineHeight: '20px',
|
||||
wordBreak: 'break-all',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
{social.value === 'twitter' && <Iconify icon="socials:twitter" />}
|
||||
{social.value === 'facebook' && <Iconify icon="socials:facebook" />}
|
||||
{social.value === 'instagram' && <Iconify icon="socials:instagram" />}
|
||||
{social.value === 'linkedin' && <Iconify icon="socials:linkedin" />}
|
||||
|
||||
<Link color="inherit">
|
||||
{social.value === 'facebook' && info.socialLinks.facebook}
|
||||
{social.value === 'instagram' && info.socialLinks.instagram}
|
||||
{social.value === 'linkedin' && info.socialLinks.linkedin}
|
||||
{social.value === 'twitter' && info.socialLinks.twitter}
|
||||
</Link>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<Grid container spacing={3}>
|
||||
<Grid size={{ xs: 12, md: 4 }} sx={{ gap: 3, display: 'flex', flexDirection: 'column' }}>
|
||||
{renderFollows()}
|
||||
{renderAbout()}
|
||||
{renderSocials()}
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 8 }} sx={{ gap: 3, display: 'flex', flexDirection: 'column' }}>
|
||||
{renderPostInput()}
|
||||
|
||||
{posts.map((post) => (
|
||||
<ProfilePostItem key={post.id} post={post} />
|
||||
))}
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
}
|
221
03_source/frontend/src/sections/party-user/profile-post-item.tsx
Normal file
221
03_source/frontend/src/sections/party-user/profile-post-item.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import AvatarGroup, { avatarGroupClasses } from '@mui/material/AvatarGroup';
|
||||
import Box from '@mui/material/Box';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardHeader from '@mui/material/CardHeader';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import InputBase from '@mui/material/InputBase';
|
||||
import Link from '@mui/material/Link';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { varAlpha } from 'minimal-shared/utils';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useMockedUser } from 'src/auth/hooks';
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
import { Image } from 'src/components/image';
|
||||
import type { IPartyUserProfilePost } from 'src/types/party-user';
|
||||
import { fShortenNumber } from 'src/utils/format-number';
|
||||
import { fDate } from 'src/utils/format-time';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
post: IPartyUserProfilePost;
|
||||
};
|
||||
|
||||
export function ProfilePostItem({ post }: Props) {
|
||||
const { user } = useMockedUser();
|
||||
|
||||
const commentRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
const handleChangeMessage = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setMessage(event.target.value);
|
||||
}, []);
|
||||
|
||||
const handleAttach = useCallback(() => {
|
||||
if (fileRef.current) {
|
||||
fileRef.current.click();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClickComment = useCallback(() => {
|
||||
if (commentRef.current) {
|
||||
commentRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const renderHead = () => (
|
||||
<CardHeader
|
||||
disableTypography
|
||||
avatar={
|
||||
<Avatar src={user?.photoURL} alt={user?.displayName}>
|
||||
{user?.displayName?.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
}
|
||||
title={
|
||||
<Link color="inherit" variant="subtitle1">
|
||||
{user?.displayName}
|
||||
</Link>
|
||||
}
|
||||
subheader={
|
||||
<Box sx={{ color: 'text.disabled', typography: 'caption', mt: 0.5 }}>
|
||||
{fDate(post.createdAt)}
|
||||
</Box>
|
||||
}
|
||||
action={
|
||||
<IconButton>
|
||||
<Iconify icon="eva:more-vertical-fill" />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderCommentList = () => (
|
||||
<Stack spacing={1.5} sx={{ px: 3, pb: 2 }}>
|
||||
{post.comments.map((comment) => (
|
||||
<Box key={comment.id} sx={{ gap: 2, display: 'flex' }}>
|
||||
<Avatar alt={comment.author.name} src={comment.author.avatarUrl} />
|
||||
|
||||
<Paper sx={{ p: 1.5, flexGrow: 1, bgcolor: 'background.neutral' }}>
|
||||
<Box
|
||||
sx={{
|
||||
mb: 0.5,
|
||||
display: 'flex',
|
||||
alignItems: { sm: 'center' },
|
||||
justifyContent: 'space-between',
|
||||
flexDirection: { xs: 'column', sm: 'row' },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ typography: 'subtitle2' }}>{comment.author.name}</Box>
|
||||
|
||||
<Box sx={{ typography: 'caption', color: 'text.disabled' }}>
|
||||
{fDate(comment.createdAt)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ typography: 'body2', color: 'text.secondary' }}>{comment.message}</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const renderInput = () => (
|
||||
<Box
|
||||
sx={[
|
||||
(theme) => ({
|
||||
gap: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
p: theme.spacing(0, 3, 3, 3),
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Avatar src={user?.photoURL} alt={user?.displayName}>
|
||||
{user?.displayName?.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
|
||||
<InputBase
|
||||
fullWidth
|
||||
value={message}
|
||||
inputRef={commentRef}
|
||||
placeholder="Write a comment…"
|
||||
onChange={handleChangeMessage}
|
||||
endAdornment={
|
||||
<InputAdornment position="end" sx={{ mr: 1 }}>
|
||||
<IconButton size="small" onClick={handleAttach}>
|
||||
<Iconify icon="solar:gallery-add-bold" />
|
||||
</IconButton>
|
||||
|
||||
<IconButton size="small">
|
||||
<Iconify icon="eva:smiling-face-fill" />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
}
|
||||
inputProps={{
|
||||
id: `comment-${post.id}-input`,
|
||||
'aria-label': `Comment ${post.id} input`,
|
||||
}}
|
||||
sx={[
|
||||
(theme) => ({
|
||||
pl: 1.5,
|
||||
height: 40,
|
||||
borderRadius: 1,
|
||||
border: `solid 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.32)}`,
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
|
||||
<input type="file" ref={fileRef} style={{ display: 'none' }} />
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderActions = () => (
|
||||
<Box
|
||||
sx={[(theme) => ({ display: 'flex', alignItems: 'center', p: theme.spacing(2, 3, 3, 3) })]}
|
||||
>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
defaultChecked
|
||||
color="error"
|
||||
icon={<Iconify icon="solar:heart-bold" />}
|
||||
checkedIcon={<Iconify icon="solar:heart-bold" />}
|
||||
slotProps={{
|
||||
input: {
|
||||
id: `favorite-${post.id}-checkbox`,
|
||||
'aria-label': `Favorite ${post.id} checkbox`,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={fShortenNumber(post.personLikes.length)}
|
||||
sx={{ mr: 1 }}
|
||||
/>
|
||||
|
||||
{!!post.personLikes.length && (
|
||||
<AvatarGroup sx={{ [`& .${avatarGroupClasses.avatar}`]: { width: 32, height: 32 } }}>
|
||||
{post.personLikes.map((person) => (
|
||||
<Avatar key={person.name} alt={person.name} src={person.avatarUrl} />
|
||||
))}
|
||||
</AvatarGroup>
|
||||
)}
|
||||
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
|
||||
<IconButton onClick={handleClickComment}>
|
||||
<Iconify icon="solar:chat-round-dots-bold" />
|
||||
</IconButton>
|
||||
|
||||
<IconButton>
|
||||
<Iconify icon="solar:share-bold" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
{renderHead()}
|
||||
|
||||
<Typography variant="body2" sx={[(theme) => ({ p: theme.spacing(3, 3, 2, 3) })]}>
|
||||
{post.message}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Image alt={post.media} src={post.media} ratio="16/9" sx={{ borderRadius: 1.5 }} />
|
||||
</Box>
|
||||
|
||||
{renderActions()}
|
||||
{!!post.comments.length && renderCommentList()}
|
||||
{renderInput()}
|
||||
</Card>
|
||||
);
|
||||
}
|
9
03_source/frontend/src/sections/party-user/view/index.ts
Normal file
9
03_source/frontend/src/sections/party-user/view/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export * from './party-user-edit-view';
|
||||
|
||||
export * from './party-user-list-view';
|
||||
|
||||
export * from './party-user-cards-view';
|
||||
|
||||
export * from './party-user-create-view';
|
||||
|
||||
export * from './party-user-profile-view';
|
@@ -0,0 +1,41 @@
|
||||
import Button from '@mui/material/Button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { _userCards } from 'src/_mock';
|
||||
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
import { DashboardContent } from 'src/layouts/dashboard';
|
||||
import { RouterLink } from 'src/routes/components';
|
||||
import { paths } from 'src/routes/paths';
|
||||
import { UserCardList } from '../party-user-card-list';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export function UserCardsView() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DashboardContent>
|
||||
<CustomBreadcrumbs
|
||||
heading="User cards"
|
||||
links={[
|
||||
//
|
||||
{ name: t('Dashboard'), href: paths.dashboard.root },
|
||||
{ name: t('User'), href: paths.dashboard.user.root },
|
||||
{ name: t('Cards') },
|
||||
]}
|
||||
action={
|
||||
<Button
|
||||
component={RouterLink}
|
||||
href={paths.dashboard.user.new}
|
||||
variant="contained"
|
||||
startIcon={<Iconify icon="mingcute:add-line" />}
|
||||
>
|
||||
{t('New user')}
|
||||
</Button>
|
||||
}
|
||||
sx={{ mb: { xs: 3, md: 5 } }}
|
||||
/>
|
||||
|
||||
<UserCardList users={_userCards} />
|
||||
</DashboardContent>
|
||||
);
|
||||
}
|
@@ -0,0 +1,28 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
|
||||
import { DashboardContent } from 'src/layouts/dashboard';
|
||||
import { paths } from 'src/routes/paths';
|
||||
import { UserNewEditForm } from '../party-user-new-edit-form';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export function UserCreateView() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<DashboardContent>
|
||||
<CustomBreadcrumbs
|
||||
heading={t('Create a new user')}
|
||||
links={[
|
||||
//
|
||||
{ name: t('Dashboard'), href: paths.dashboard.root },
|
||||
{ name: t('User'), href: paths.dashboard.user.root },
|
||||
{ name: t('New user') },
|
||||
]}
|
||||
sx={{ mb: { xs: 3, md: 5 } }}
|
||||
/>
|
||||
|
||||
<UserNewEditForm />
|
||||
</DashboardContent>
|
||||
);
|
||||
}
|
@@ -0,0 +1,33 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
|
||||
import { DashboardContent } from 'src/layouts/dashboard';
|
||||
import { paths } from 'src/routes/paths';
|
||||
import type { IPartyUserItem } from 'src/types/party-user';
|
||||
import { UserNewEditForm } from '../party-user-new-edit-form';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
user?: IPartyUserItem;
|
||||
};
|
||||
|
||||
export function PartyUserEditView({ user: currentUser }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<DashboardContent>
|
||||
<CustomBreadcrumbs
|
||||
heading="Edit"
|
||||
backHref={paths.dashboard.user.list}
|
||||
links={[
|
||||
{ name: t('Dashboard'), href: paths.dashboard.root },
|
||||
{ name: t('User'), href: paths.dashboard.user.root },
|
||||
{ name: currentUser?.name },
|
||||
]}
|
||||
sx={{ mb: { xs: 3, md: 5 } }}
|
||||
/>
|
||||
|
||||
<UserNewEditForm currentUser={currentUser} />
|
||||
</DashboardContent>
|
||||
);
|
||||
}
|
@@ -0,0 +1,353 @@
|
||||
// src/sections/user/view/user-list-view.tsx
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Card from '@mui/material/Card';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Tab from '@mui/material/Tab';
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import Tabs from '@mui/material/Tabs';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import { useBoolean, useSetState } from 'minimal-shared/hooks';
|
||||
import { varAlpha } from 'minimal-shared/utils';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { _roles, USER_STATUS_OPTIONS } from 'src/_mock';
|
||||
import { deletePartyUser, useGetPartyUsers } from 'src/actions/party-user';
|
||||
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
|
||||
import { ConfirmDialog } from 'src/components/custom-dialog';
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
import { Label } from 'src/components/label';
|
||||
import { Scrollbar } from 'src/components/scrollbar';
|
||||
import { toast } from 'src/components/snackbar';
|
||||
import type { TableHeadCellProps } from 'src/components/table';
|
||||
import {
|
||||
emptyRows,
|
||||
getComparator,
|
||||
rowInPage,
|
||||
TableEmptyRows,
|
||||
TableHeadCustom,
|
||||
TableNoData,
|
||||
TablePaginationCustom,
|
||||
TableSelectedAction,
|
||||
useTable,
|
||||
} from 'src/components/table';
|
||||
import { DashboardContent } from 'src/layouts/dashboard';
|
||||
import { useRouter } from 'src/routes/hooks';
|
||||
import { paths } from 'src/routes/paths';
|
||||
import type { IPartyUserItem, IPartyUserTableFilters } from 'src/types/party-user';
|
||||
import { UserTableFiltersResult } from '../party-user-table-filters-result';
|
||||
import { UserTableRow } from '../party-user-table-row';
|
||||
import { UserTableToolbar } from '../party-user-table-toolbar';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const STATUS_OPTIONS = [{ value: 'all', label: 'All' }, ...USER_STATUS_OPTIONS];
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export function PartyUserListView() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const TABLE_HEAD: TableHeadCellProps[] = [
|
||||
{ id: 'name', label: t('Name') },
|
||||
{ id: 'phoneNumber', label: t('Phone number'), width: 180 },
|
||||
{ id: 'company', label: t('Company'), width: 220 },
|
||||
{ id: 'role', label: t('Role'), width: 180 },
|
||||
{ id: 'status', label: t('Status'), width: 100 },
|
||||
{ id: '', width: 88 },
|
||||
];
|
||||
|
||||
const { partyUsers } = useGetPartyUsers();
|
||||
const [processNewUser, setProcessNewUser] = useState<boolean>(false);
|
||||
|
||||
const table = useTable();
|
||||
|
||||
const confirmDialog = useBoolean();
|
||||
|
||||
const [tableData, setTableData] = useState<IPartyUserItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableData(partyUsers);
|
||||
}, [partyUsers]);
|
||||
|
||||
const filters = useSetState<IPartyUserTableFilters>({ name: '', role: [], status: 'all' });
|
||||
const { state: currentFilters, setState: updateFilters } = filters;
|
||||
|
||||
const dataFiltered = applyFilter({
|
||||
inputData: tableData,
|
||||
comparator: getComparator(table.order, table.orderBy),
|
||||
filters: currentFilters,
|
||||
});
|
||||
|
||||
const dataInPage = rowInPage(dataFiltered, table.page, table.rowsPerPage);
|
||||
|
||||
const canReset =
|
||||
!!currentFilters.name || currentFilters.role.length > 0 || currentFilters.status !== 'all';
|
||||
|
||||
const notFound = (!dataFiltered.length && canReset) || !dataFiltered.length;
|
||||
|
||||
const handleDeleteRow = useCallback(
|
||||
async (id: string) => {
|
||||
// const deleteRow = tableData.filter((row) => row.id !== id);
|
||||
// toast.success('Delete success!');
|
||||
// setTableData(deleteRow);
|
||||
|
||||
try {
|
||||
await deletePartyUser(id);
|
||||
toast.success('Delete success!');
|
||||
|
||||
table.onUpdatePageDeleteRow(dataInPage.length);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Delete failed!');
|
||||
}
|
||||
},
|
||||
[table, tableData]
|
||||
);
|
||||
|
||||
const handleDeleteRows = useCallback(() => {
|
||||
const deleteRows = tableData.filter((row) => !table.selected.includes(row.id));
|
||||
|
||||
toast.success('Delete success!');
|
||||
|
||||
setTableData(deleteRows);
|
||||
|
||||
table.onUpdatePageDeleteRows(dataInPage.length, dataFiltered.length);
|
||||
}, [dataFiltered.length, dataInPage.length, table, tableData]);
|
||||
|
||||
const handleFilterStatus = useCallback(
|
||||
(event: React.SyntheticEvent, newValue: string) => {
|
||||
table.onResetPage();
|
||||
updateFilters({ status: newValue });
|
||||
},
|
||||
[updateFilters, table]
|
||||
);
|
||||
|
||||
const renderConfirmDialog = () => (
|
||||
<ConfirmDialog
|
||||
open={confirmDialog.value}
|
||||
onClose={confirmDialog.onFalse}
|
||||
title={t('Delete')}
|
||||
content={
|
||||
<>
|
||||
Are you sure want to delete <strong> {table.selected.length} </strong> items?
|
||||
</>
|
||||
}
|
||||
action={
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={() => {
|
||||
handleDeleteRows();
|
||||
// confirmDialog.onFalse();
|
||||
}}
|
||||
>
|
||||
{t('Delete')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardContent>
|
||||
<CustomBreadcrumbs
|
||||
heading="List"
|
||||
links={[
|
||||
//
|
||||
{ name: t('Dashboard'), href: paths.dashboard.root },
|
||||
{ name: t('PartyUserListView'), href: paths.dashboard.partyUser.root },
|
||||
{ name: t('List') },
|
||||
]}
|
||||
action={
|
||||
<Button
|
||||
disabled={processNewUser}
|
||||
loading={processNewUser}
|
||||
// component={RouterLink}
|
||||
// href={paths.dashboard.partyUser.new}
|
||||
variant="contained"
|
||||
startIcon={<Iconify icon="mingcute:add-line" />}
|
||||
onClick={() => {
|
||||
setProcessNewUser(true);
|
||||
router.push(paths.dashboard.partyUser.new);
|
||||
}}
|
||||
>
|
||||
{t('New user')}
|
||||
</Button>
|
||||
}
|
||||
sx={{ mb: { xs: 3, md: 5 } }}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<Tabs
|
||||
value={currentFilters.status}
|
||||
onChange={handleFilterStatus}
|
||||
sx={[
|
||||
(theme) => ({
|
||||
px: 2.5,
|
||||
boxShadow: `inset 0 -2px 0 0 ${varAlpha(theme.vars.palette.grey['500Channel'], 0.08)}`,
|
||||
}),
|
||||
]}
|
||||
>
|
||||
{STATUS_OPTIONS.map((tab) => (
|
||||
<Tab
|
||||
key={tab.value}
|
||||
iconPosition="end"
|
||||
value={tab.value}
|
||||
label={t(tab.label)}
|
||||
icon={
|
||||
<Label
|
||||
variant={
|
||||
((tab.value === 'all' || tab.value === currentFilters.status) && 'filled') ||
|
||||
'soft'
|
||||
}
|
||||
color={
|
||||
(tab.value === 'active' && 'success') ||
|
||||
(tab.value === 'pending' && 'warning') ||
|
||||
(tab.value === 'banned' && 'error') ||
|
||||
'default'
|
||||
}
|
||||
>
|
||||
{['active', 'pending', 'banned', 'rejected'].includes(tab.value)
|
||||
? tableData.filter((partyUser) => partyUser.status === tab.value).length
|
||||
: tableData.length}
|
||||
</Label>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
<UserTableToolbar
|
||||
filters={filters}
|
||||
onResetPage={table.onResetPage}
|
||||
options={{ roles: _roles }}
|
||||
/>
|
||||
|
||||
{canReset && (
|
||||
<UserTableFiltersResult
|
||||
filters={filters}
|
||||
totalResults={dataFiltered.length}
|
||||
onResetPage={table.onResetPage}
|
||||
sx={{ p: 2.5, pt: 0 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<TableSelectedAction
|
||||
dense={table.dense}
|
||||
numSelected={table.selected.length}
|
||||
rowCount={dataFiltered.length}
|
||||
onSelectAllRows={(checked) =>
|
||||
table.onSelectAllRows(
|
||||
checked,
|
||||
dataFiltered.map((row) => row.id)
|
||||
)
|
||||
}
|
||||
action={
|
||||
<Tooltip title={t('Delete')}>
|
||||
<IconButton color="primary" onClick={confirmDialog.onTrue}>
|
||||
<Iconify icon="solar:trash-bin-trash-bold" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
|
||||
<Scrollbar>
|
||||
<Table size={table.dense ? 'small' : 'medium'} sx={{ minWidth: 960 }}>
|
||||
<TableHeadCustom
|
||||
order={table.order}
|
||||
orderBy={table.orderBy}
|
||||
headCells={TABLE_HEAD}
|
||||
rowCount={dataFiltered.length}
|
||||
numSelected={table.selected.length}
|
||||
onSort={table.onSort}
|
||||
onSelectAllRows={(checked) =>
|
||||
table.onSelectAllRows(
|
||||
checked,
|
||||
dataFiltered.map((row) => row.id)
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<TableBody>
|
||||
{dataFiltered
|
||||
.slice(
|
||||
table.page * table.rowsPerPage,
|
||||
table.page * table.rowsPerPage + table.rowsPerPage
|
||||
)
|
||||
.map((row) => (
|
||||
<UserTableRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
selected={table.selected.includes(row.id)}
|
||||
onSelectRow={() => table.onSelectRow(row.id)}
|
||||
onDeleteRow={() => handleDeleteRow(row.id)}
|
||||
editHref={paths.dashboard.partyUser.edit(row.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<TableEmptyRows
|
||||
height={table.dense ? 56 : 56 + 20}
|
||||
emptyRows={emptyRows(table.page, table.rowsPerPage, dataFiltered.length)}
|
||||
/>
|
||||
|
||||
<TableNoData notFound={notFound} />
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Scrollbar>
|
||||
</Box>
|
||||
|
||||
<TablePaginationCustom
|
||||
page={table.page}
|
||||
dense={table.dense}
|
||||
count={dataFiltered.length}
|
||||
rowsPerPage={table.rowsPerPage}
|
||||
onPageChange={table.onChangePage}
|
||||
onChangeDense={table.onChangeDense}
|
||||
onRowsPerPageChange={table.onChangeRowsPerPage}
|
||||
/>
|
||||
</Card>
|
||||
</DashboardContent>
|
||||
|
||||
{renderConfirmDialog()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type ApplyFilterProps = {
|
||||
inputData: IPartyUserItem[];
|
||||
filters: IPartyUserTableFilters;
|
||||
comparator: (a: any, b: any) => number;
|
||||
};
|
||||
|
||||
function applyFilter({ inputData, comparator, filters }: ApplyFilterProps) {
|
||||
const { name, status, role } = filters;
|
||||
|
||||
const stabilizedThis = inputData.map((el, index) => [el, index] as const);
|
||||
|
||||
stabilizedThis.sort((a, b) => {
|
||||
const order = comparator(a[0], b[0]);
|
||||
if (order !== 0) return order;
|
||||
return a[1] - b[1];
|
||||
});
|
||||
|
||||
inputData = stabilizedThis.map((el) => el[0]);
|
||||
|
||||
if (name) {
|
||||
inputData = inputData.filter((user) => user.name.toLowerCase().includes(name.toLowerCase()));
|
||||
}
|
||||
|
||||
if (status !== 'all') {
|
||||
inputData = inputData.filter((user) => user.status === status);
|
||||
}
|
||||
|
||||
if (role.length) {
|
||||
inputData = inputData.filter((user) => role.includes(user.role));
|
||||
}
|
||||
|
||||
return inputData;
|
||||
}
|
@@ -0,0 +1,132 @@
|
||||
import Box from '@mui/material/Box';
|
||||
import Card from '@mui/material/Card';
|
||||
import Tab from '@mui/material/Tab';
|
||||
import Tabs from '@mui/material/Tabs';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { _userAbout, _userFeeds, _userFollowers, _userFriends, _userGallery } from 'src/_mock';
|
||||
import { useMockedUser } from 'src/auth/hooks';
|
||||
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
import { DashboardContent } from 'src/layouts/dashboard';
|
||||
import { RouterLink } from 'src/routes/components';
|
||||
import { usePathname, useSearchParams } from 'src/routes/hooks';
|
||||
import { paths } from 'src/routes/paths';
|
||||
import { ProfileCover } from '../profile-cover';
|
||||
import { ProfileFollowers } from '../profile-followers';
|
||||
import { ProfileFriends } from '../profile-friends';
|
||||
import { ProfileGallery } from '../profile-gallery';
|
||||
import { ProfileHome } from '../profile-home';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{
|
||||
value: '',
|
||||
label: 'Profile',
|
||||
icon: <Iconify width={24} icon="solar:user-id-bold" />,
|
||||
},
|
||||
{
|
||||
value: 'followers',
|
||||
label: 'Followers',
|
||||
icon: <Iconify width={24} icon="solar:heart-bold" />,
|
||||
},
|
||||
{
|
||||
value: 'friends',
|
||||
label: 'Friends',
|
||||
icon: <Iconify width={24} icon="solar:users-group-rounded-bold" />,
|
||||
},
|
||||
{
|
||||
value: 'gallery',
|
||||
label: 'Gallery',
|
||||
icon: <Iconify width={24} icon="solar:gallery-wide-bold" />,
|
||||
},
|
||||
];
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const TAB_PARAM = 'tab';
|
||||
|
||||
export function UserProfileView() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const selectedTab = searchParams.get(TAB_PARAM) ?? '';
|
||||
|
||||
const { user } = useMockedUser();
|
||||
|
||||
const [searchFriends, setSearchFriends] = useState('');
|
||||
|
||||
const handleSearchFriends = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchFriends(event.target.value);
|
||||
}, []);
|
||||
|
||||
const createRedirectPath = (currentPath: string, query: string) => {
|
||||
const queryString = new URLSearchParams({ [TAB_PARAM]: query }).toString();
|
||||
return query ? `${currentPath}?${queryString}` : currentPath;
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardContent>
|
||||
<CustomBreadcrumbs
|
||||
heading="Profile"
|
||||
links={[
|
||||
{ name: t('Dashboard'), href: paths.dashboard.root },
|
||||
{ name: t('User'), href: paths.dashboard.user.root },
|
||||
{ name: user?.displayName },
|
||||
]}
|
||||
sx={{ mb: { xs: 3, md: 5 } }}
|
||||
/>
|
||||
|
||||
<Card sx={{ mb: 3, height: 290 }}>
|
||||
<ProfileCover
|
||||
role={_userAbout.role}
|
||||
name={user?.displayName}
|
||||
avatarUrl={user?.photoURL}
|
||||
coverUrl={_userAbout.coverUrl}
|
||||
/>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
width: 1,
|
||||
bottom: 0,
|
||||
zIndex: 9,
|
||||
px: { md: 3 },
|
||||
display: 'flex',
|
||||
position: 'absolute',
|
||||
bgcolor: 'background.paper',
|
||||
justifyContent: { xs: 'center', md: 'flex-end' },
|
||||
}}
|
||||
>
|
||||
<Tabs value={selectedTab}>
|
||||
{NAV_ITEMS.map((tab) => (
|
||||
<Tab
|
||||
component={RouterLink}
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
icon={tab.icon}
|
||||
label={t(tab.label)}
|
||||
href={createRedirectPath(pathname, tab.value)}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{selectedTab === '' && <ProfileHome info={_userAbout} posts={_userFeeds} />}
|
||||
|
||||
{selectedTab === 'followers' && <ProfileFollowers followers={_userFollowers} />}
|
||||
|
||||
{selectedTab === 'friends' && (
|
||||
<ProfileFriends
|
||||
friends={_userFriends}
|
||||
searchFriends={searchFriends}
|
||||
onSearchFriends={handleSearchFriends}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedTab === 'gallery' && <ProfileGallery gallery={_userGallery} />}
|
||||
</DashboardContent>
|
||||
);
|
||||
}
|
102
03_source/frontend/src/types/party-user.ts
Normal file
102
03_source/frontend/src/types/party-user.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { IDateValue, ISocialLink } from './common';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export type IPartyUserTableFilters = {
|
||||
name: string;
|
||||
role: string[];
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type IPartyUserProfileCover = {
|
||||
name: string;
|
||||
role: string;
|
||||
coverUrl: string;
|
||||
avatarUrl: string;
|
||||
};
|
||||
|
||||
export type IPartyUserProfile = {
|
||||
id: string;
|
||||
role: string;
|
||||
quote: string;
|
||||
email: string;
|
||||
school: string;
|
||||
country: string;
|
||||
company: string;
|
||||
totalFollowers: number;
|
||||
totalFollowing: number;
|
||||
socialLinks: ISocialLink;
|
||||
};
|
||||
|
||||
export type IPartyUserProfileFollower = {
|
||||
id: string;
|
||||
name: string;
|
||||
country: string;
|
||||
avatarUrl: string;
|
||||
};
|
||||
|
||||
export type IPartyUserProfileGallery = {
|
||||
id: string;
|
||||
title: string;
|
||||
imageUrl: string;
|
||||
postedAt: IDateValue;
|
||||
};
|
||||
|
||||
export type IPartyUserProfileFriend = {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
avatarUrl: string;
|
||||
};
|
||||
|
||||
export type IPartyUserProfilePost = {
|
||||
id: string;
|
||||
media: string;
|
||||
message: string;
|
||||
createdAt: IDateValue;
|
||||
personLikes: { name: string; avatarUrl: string }[];
|
||||
comments: {
|
||||
id: string;
|
||||
message: string;
|
||||
createdAt: IDateValue;
|
||||
author: { id: string; name: string; avatarUrl: string };
|
||||
}[];
|
||||
};
|
||||
|
||||
export type IPartyUserCard = {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
coverUrl: string;
|
||||
avatarUrl: string;
|
||||
totalPosts: number;
|
||||
totalFollowers: number;
|
||||
totalFollowing: number;
|
||||
};
|
||||
|
||||
export type IPartyUserItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
city: string;
|
||||
role: string;
|
||||
email: string;
|
||||
state: string;
|
||||
status: string;
|
||||
address: string;
|
||||
country: string;
|
||||
zipCode: string;
|
||||
company: string;
|
||||
avatarUrl: string;
|
||||
phoneNumber: string;
|
||||
isVerified: boolean;
|
||||
//
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export type IPartyUserAccountBillingHistory = {
|
||||
id: string;
|
||||
price: number;
|
||||
invoiceNumber: string;
|
||||
createdAt: IDateValue;
|
||||
};
|
Reference in New Issue
Block a user