From e93c5dcf626c4e2dda1474e4fc963a4287d74abd Mon Sep 17 00:00:00 2001 From: louiscklaw Date: Mon, 16 Jun 2025 03:03:54 +0800 Subject: [PATCH] in the middle of party-user frontend, --- .../cms_backend/prisma/seeds/partyUser.ts | 14 + .../src/app/api/party-user/list/route.ts | 4 +- 03_source/frontend/src/actions/party-user.ts | 195 ++++++++++ .../src/layouts/nav-config-dashboard.tsx | 13 + 03_source/frontend/src/lib/axios.ts | 8 + .../dashboard/party-user/account/billing.tsx | 16 + .../party-user/account/change-password.tsx | 16 + .../dashboard/party-user/account/general.tsx | 16 + .../party-user/account/notifications.tsx | 18 + .../dashboard/party-user/account/socials.tsx | 16 + .../src/pages/dashboard/party-user/cards.tsx | 16 + .../src/pages/dashboard/party-user/edit.tsx | 25 ++ .../src/pages/dashboard/party-user/list.tsx | 16 + .../src/pages/dashboard/party-user/new.tsx | 16 + .../pages/dashboard/party-user/profile.tsx | 16 + 03_source/frontend/src/routes/paths.ts | 10 + .../src/routes/sections/dashboard.tsx | 29 ++ .../frontend/src/routes/sections/index.tsx | 2 + .../party-user/party-user-card-list.tsx | 47 +++ .../sections/party-user/party-user-card.tsx | 119 ++++++ .../party-user/party-user-new-edit-form.tsx | 297 +++++++++++++++ .../party-user/party-user-quick-edit-form.tsx | 173 +++++++++ .../party-user-table-filters-result.tsx | 65 ++++ .../party-user/party-user-table-row.tsx | 183 +++++++++ .../party-user/party-user-table-toolbar.tsx | 150 ++++++++ .../src/sections/party-user/profile-cover.tsx | 75 ++++ .../sections/party-user/profile-followers.tsx | 123 ++++++ .../sections/party-user/profile-friends.tsx | 183 +++++++++ .../sections/party-user/profile-gallery.tsx | 89 +++++ .../src/sections/party-user/profile-home.tsx | 208 +++++++++++ .../sections/party-user/profile-post-item.tsx | 221 +++++++++++ .../src/sections/party-user/view/index.ts | 9 + .../party-user/view/party-user-cards-view.tsx | 41 ++ .../view/party-user-create-view.tsx | 28 ++ .../party-user/view/party-user-edit-view.tsx | 33 ++ .../party-user/view/party-user-list-view.tsx | 353 ++++++++++++++++++ .../view/party-user-profile-view.tsx | 132 +++++++ 03_source/frontend/src/types/party-user.ts | 102 +++++ 38 files changed, 3074 insertions(+), 3 deletions(-) create mode 100644 03_source/frontend/src/actions/party-user.ts create mode 100644 03_source/frontend/src/pages/dashboard/party-user/account/billing.tsx create mode 100644 03_source/frontend/src/pages/dashboard/party-user/account/change-password.tsx create mode 100644 03_source/frontend/src/pages/dashboard/party-user/account/general.tsx create mode 100644 03_source/frontend/src/pages/dashboard/party-user/account/notifications.tsx create mode 100644 03_source/frontend/src/pages/dashboard/party-user/account/socials.tsx create mode 100644 03_source/frontend/src/pages/dashboard/party-user/cards.tsx create mode 100644 03_source/frontend/src/pages/dashboard/party-user/edit.tsx create mode 100644 03_source/frontend/src/pages/dashboard/party-user/list.tsx create mode 100644 03_source/frontend/src/pages/dashboard/party-user/new.tsx create mode 100644 03_source/frontend/src/pages/dashboard/party-user/profile.tsx create mode 100644 03_source/frontend/src/sections/party-user/party-user-card-list.tsx create mode 100644 03_source/frontend/src/sections/party-user/party-user-card.tsx create mode 100644 03_source/frontend/src/sections/party-user/party-user-new-edit-form.tsx create mode 100644 03_source/frontend/src/sections/party-user/party-user-quick-edit-form.tsx create mode 100644 03_source/frontend/src/sections/party-user/party-user-table-filters-result.tsx create mode 100644 03_source/frontend/src/sections/party-user/party-user-table-row.tsx create mode 100644 03_source/frontend/src/sections/party-user/party-user-table-toolbar.tsx create mode 100644 03_source/frontend/src/sections/party-user/profile-cover.tsx create mode 100644 03_source/frontend/src/sections/party-user/profile-followers.tsx create mode 100644 03_source/frontend/src/sections/party-user/profile-friends.tsx create mode 100644 03_source/frontend/src/sections/party-user/profile-gallery.tsx create mode 100644 03_source/frontend/src/sections/party-user/profile-home.tsx create mode 100644 03_source/frontend/src/sections/party-user/profile-post-item.tsx create mode 100644 03_source/frontend/src/sections/party-user/view/index.ts create mode 100644 03_source/frontend/src/sections/party-user/view/party-user-cards-view.tsx create mode 100644 03_source/frontend/src/sections/party-user/view/party-user-create-view.tsx create mode 100644 03_source/frontend/src/sections/party-user/view/party-user-edit-view.tsx create mode 100644 03_source/frontend/src/sections/party-user/view/party-user-list-view.tsx create mode 100644 03_source/frontend/src/sections/party-user/view/party-user-profile-view.tsx create mode 100644 03_source/frontend/src/types/party-user.ts diff --git a/03_source/cms_backend/prisma/seeds/partyUser.ts b/03_source/cms_backend/prisma/seeds/partyUser.ts index 243e8a4..be13518 100644 --- a/03_source/cms_backend/prisma/seeds/partyUser.ts +++ b/03_source/cms_backend/prisma/seeds/partyUser.ts @@ -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'); } diff --git a/03_source/cms_backend/src/app/api/party-user/list/route.ts b/03_source/cms_backend/src/app/api/party-user/list/route.ts index 14eee11..135fe87 100644 --- a/03_source/cms_backend/src/app/api/party-user/list/route.ts +++ b/03_source/cms_backend/src/app/api/party-user/list/route.ts @@ -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); } diff --git a/03_source/frontend/src/actions/party-user.ts b/03_source/frontend/src/actions/party-user.ts new file mode 100644 index 0000000..40dbee2 --- /dev/null +++ b/03_source/frontend/src/actions/party-user.ts @@ -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(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(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(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 + ); +} diff --git a/03_source/frontend/src/layouts/nav-config-dashboard.tsx b/03_source/frontend/src/layouts/nav-config-dashboard.tsx index e031c09..d539cfc 100644 --- a/03_source/frontend/src/layouts/nav-config-dashboard.tsx +++ b/03_source/frontend/src/layouts/nav-config-dashboard.tsx @@ -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, diff --git a/03_source/frontend/src/lib/axios.ts b/03_source/frontend/src/lib/axios.ts index 538d394..95f5013 100644 --- a/03_source/frontend/src/lib/axios.ts +++ b/03_source/frontend/src/lib/axios.ts @@ -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', + }, }; diff --git a/03_source/frontend/src/pages/dashboard/party-user/account/billing.tsx b/03_source/frontend/src/pages/dashboard/party-user/account/billing.tsx new file mode 100644 index 0000000..49ffa31 --- /dev/null +++ b/03_source/frontend/src/pages/dashboard/party-user/account/billing.tsx @@ -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 ( + <> + {metadata.title} + + + + ); +} diff --git a/03_source/frontend/src/pages/dashboard/party-user/account/change-password.tsx b/03_source/frontend/src/pages/dashboard/party-user/account/change-password.tsx new file mode 100644 index 0000000..5374b5e --- /dev/null +++ b/03_source/frontend/src/pages/dashboard/party-user/account/change-password.tsx @@ -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 ( + <> + {metadata.title} + + + + ); +} diff --git a/03_source/frontend/src/pages/dashboard/party-user/account/general.tsx b/03_source/frontend/src/pages/dashboard/party-user/account/general.tsx new file mode 100644 index 0000000..2f1a9ba --- /dev/null +++ b/03_source/frontend/src/pages/dashboard/party-user/account/general.tsx @@ -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 ( + <> + {metadata.title} + + + + ); +} diff --git a/03_source/frontend/src/pages/dashboard/party-user/account/notifications.tsx b/03_source/frontend/src/pages/dashboard/party-user/account/notifications.tsx new file mode 100644 index 0000000..a29c28f --- /dev/null +++ b/03_source/frontend/src/pages/dashboard/party-user/account/notifications.tsx @@ -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 ( + <> + {metadata.title} + + + + ); +} diff --git a/03_source/frontend/src/pages/dashboard/party-user/account/socials.tsx b/03_source/frontend/src/pages/dashboard/party-user/account/socials.tsx new file mode 100644 index 0000000..c708d2f --- /dev/null +++ b/03_source/frontend/src/pages/dashboard/party-user/account/socials.tsx @@ -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 ( + <> + {metadata.title} + + + + ); +} diff --git a/03_source/frontend/src/pages/dashboard/party-user/cards.tsx b/03_source/frontend/src/pages/dashboard/party-user/cards.tsx new file mode 100644 index 0000000..35a0158 --- /dev/null +++ b/03_source/frontend/src/pages/dashboard/party-user/cards.tsx @@ -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 ( + <> + {metadata.title} + + + + ); +} diff --git a/03_source/frontend/src/pages/dashboard/party-user/edit.tsx b/03_source/frontend/src/pages/dashboard/party-user/edit.tsx new file mode 100644 index 0000000..38ef5e0 --- /dev/null +++ b/03_source/frontend/src/pages/dashboard/party-user/edit.tsx @@ -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 ( + <> + {metadata.title} + + + + ); +} diff --git a/03_source/frontend/src/pages/dashboard/party-user/list.tsx b/03_source/frontend/src/pages/dashboard/party-user/list.tsx new file mode 100644 index 0000000..5a94fd9 --- /dev/null +++ b/03_source/frontend/src/pages/dashboard/party-user/list.tsx @@ -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 ( + <> + {metadata.title} + + + + ); +} diff --git a/03_source/frontend/src/pages/dashboard/party-user/new.tsx b/03_source/frontend/src/pages/dashboard/party-user/new.tsx new file mode 100644 index 0000000..9f23679 --- /dev/null +++ b/03_source/frontend/src/pages/dashboard/party-user/new.tsx @@ -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 ( + <> + {metadata.title} + + + + ); +} diff --git a/03_source/frontend/src/pages/dashboard/party-user/profile.tsx b/03_source/frontend/src/pages/dashboard/party-user/profile.tsx new file mode 100644 index 0000000..051ccec --- /dev/null +++ b/03_source/frontend/src/pages/dashboard/party-user/profile.tsx @@ -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 ( + <> + {metadata.title} + + + + ); +} diff --git a/03_source/frontend/src/routes/paths.ts b/03_source/frontend/src/routes/paths.ts index 66d237e..cdae1c5 100644 --- a/03_source/frontend/src/routes/paths.ts +++ b/03_source/frontend/src/routes/paths.ts @@ -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` }, + }, }, }; diff --git a/03_source/frontend/src/routes/sections/dashboard.tsx b/03_source/frontend/src/routes/sections/dashboard.tsx index f3b044b..8592e2c 100644 --- a/03_source/frontend/src/routes/sections/dashboard.tsx +++ b/03_source/frontend/src/routes/sections/dashboard.tsx @@ -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: }, ], }, + { + path: 'party-user', + children: [ + { index: true, element: }, + { path: 'profile', element: }, + { path: 'cards', element: }, + { path: 'list', element: }, + { path: 'new', element: }, + { path: ':id/edit', element: }, + { + path: 'account', + element: accountLayout(), + children: [ + { index: true, element: }, + { path: 'billing', element: }, + { path: 'notifications', element: }, + { path: 'socials', element: }, + { path: 'change-password', element: }, + ], + }, + ], + }, ], }, ]; diff --git a/03_source/frontend/src/routes/sections/index.tsx b/03_source/frontend/src/routes/sections/index.tsx index acc9cc6..b6d1b06 100644 --- a/03_source/frontend/src/routes/sections/index.tsx +++ b/03_source/frontend/src/routes/sections/index.tsx @@ -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'; diff --git a/03_source/frontend/src/sections/party-user/party-user-card-list.tsx b/03_source/frontend/src/sections/party-user/party-user-card-list.tsx new file mode 100644 index 0000000..b4b423f --- /dev/null +++ b/03_source/frontend/src/sections/party-user/party-user-card-list.tsx @@ -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, newPage: number) => { + setPage(newPage); + }, []); + + return ( + <> + + {users + .slice((page - 1) * rowsPerPage, (page - 1) * rowsPerPage + rowsPerPage) + .map((user) => ( + + ))} + + + + + ); +} diff --git a/03_source/frontend/src/sections/party-user/party-user-card.tsx b/03_source/frontend/src/sections/party-user/party-user-card.tsx new file mode 100644 index 0000000..0bacaf2 --- /dev/null +++ b/03_source/frontend/src/sections/party-user/party-user-card.tsx @@ -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 ( + + + + + + + {user.coverUrl} ({ + bgcolor: varAlpha(theme.vars.palette.common.blackChannel, 0.48), + }), + }, + }} + /> + + + + + + {_socials.map((social) => ( + + {social.value === 'twitter' && } + {social.value === 'facebook' && } + {social.value === 'instagram' && } + {social.value === 'linkedin' && } + + ))} + + + + + + {[ + { label: 'Follower', value: user.totalFollowers }, + { label: 'Following', value: user.totalFollowing }, + { label: 'Total post', value: user.totalPosts }, + ].map((stat) => ( + + + {stat.label} + + {fShortenNumber(stat.value)} + + ))} + + + ); +} diff --git a/03_source/frontend/src/sections/party-user/party-user-new-edit-form.tsx b/03_source/frontend/src/sections/party-user/party-user-new-edit-form.tsx new file mode 100644 index 0000000..f920951 --- /dev/null +++ b/03_source/frontend/src/sections/party-user/party-user-new-edit-form.tsx @@ -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; + +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({ + mode: 'onSubmit', + resolver: zodResolver(NewUserSchema), + defaultValues, + values: currentUser, + }); + + const { + reset, + watch, + control, + handleSubmit, + formState: { errors, isSubmitting }, + } = methods; + + const values = watch(); + + const [disableDeleteUserButton, setDisableDeleteUserButton] = useState(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 ( +
+ + + + {currentUser && ( + + )} + + + + {t('Allowed')} *.jpeg, *.jpg, *.png, *.gif +
{t('max size of')} {fData(3145728)} + + } + /> +
+ + {currentUser && ( + ( + + field.onChange(event.target.checked ? 'banned' : 'active') + } + /> + )} + /> + } + label={ + <> + + {t('Banned')} + + + {t('Apply disable account')} + + + } + sx={{ + mx: 0, + mb: 3, + width: 1, + justifyContent: 'space-between', + }} + /> + )} + + + + {t('Email verified')} + + + {t('Disabling this will automatically send the user a verification email')} + + + } + sx={{ mx: 0, width: 1, justifyContent: 'space-between' }} + /> + + {currentUser && ( + + + + )} +
+
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+ ); +} diff --git a/03_source/frontend/src/sections/party-user/party-user-quick-edit-form.tsx b/03_source/frontend/src/sections/party-user/party-user-quick-edit-form.tsx new file mode 100644 index 0000000..ba39cc5 --- /dev/null +++ b/03_source/frontend/src/sections/party-user/party-user-quick-edit-form.tsx @@ -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; + +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({ + 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 ( + + {t('Quick update')} + +
+ + + Account is waiting for confirmation + + + + + {USER_STATUS_OPTIONS.map((status) => ( + + {status.label} + + ))} + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ ); +} diff --git a/03_source/frontend/src/sections/party-user/party-user-table-filters-result.tsx b/03_source/frontend/src/sections/party-user/party-user-table-filters-result.tsx new file mode 100644 index 0000000..e0297b2 --- /dev/null +++ b/03_source/frontend/src/sections/party-user/party-user-table-filters-result.tsx @@ -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; +}; + +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 ( + + + + + + + {currentFilters.role.map((item) => ( + handleRemoveRole(item)} /> + ))} + + + + + + + ); +} diff --git a/03_source/frontend/src/sections/party-user/party-user-table-row.tsx b/03_source/frontend/src/sections/party-user/party-user-table-row.tsx new file mode 100644 index 0000000..efd51c5 --- /dev/null +++ b/03_source/frontend/src/sections/party-user/party-user-table-row.tsx @@ -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 = () => ( + + ); + + const renderMenuActions = () => ( + + +
  • + menuActions.onClose()}> + + {t('Edit')} + +
  • + + { + confirmDialog.onTrue(); + menuActions.onClose(); + }} + sx={{ color: 'error.main' }} + > + + {t('Delete')} + +
    +
    + ); + + const [disableDeleteButton, setDisableDeleteButton] = useState(false); + const renderConfirmDialog = () => ( + { + setDisableDeleteButton(true); + onDeleteRow(); + }} + > + {t('Delete')} + + } + /> + ); + + return ( + <> + + + + + + + + + + + + {row.name} + + + {row.email} + + + + + + {row.phoneNumber} + + {row.company} + + {row.role} + + + + + + + + + + + + + + + + + + + + + {renderQuickEditForm()} + {renderMenuActions()} + {renderConfirmDialog()} + + ); +} diff --git a/03_source/frontend/src/sections/party-user/party-user-table-toolbar.tsx b/03_source/frontend/src/sections/party-user/party-user-table-toolbar.tsx new file mode 100644 index 0000000..498a50e --- /dev/null +++ b/03_source/frontend/src/sections/party-user/party-user-table-toolbar.tsx @@ -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; + 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) => { + onResetPage(); + updateFilters({ name: event.target.value }); + }, + [onResetPage, updateFilters] + ); + + const handleFilterRole = useCallback( + (event: SelectChangeEvent) => { + const newValue = + typeof event.target.value === 'string' ? event.target.value.split(',') : event.target.value; + + onResetPage(); + updateFilters({ role: newValue }); + }, + [onResetPage, updateFilters] + ); + + const renderMenuActions = () => ( + + + menuActions.onClose()}> + + Print + + + menuActions.onClose()}> + + Import + + + menuActions.onClose()}> + + Export + + + + ); + + return ( + <> + + + Role + + + + + + + + ), + }, + }} + /> + + + + + + + + {renderMenuActions()} + + ); +} diff --git a/03_source/frontend/src/sections/party-user/profile-cover.tsx b/03_source/frontend/src/sections/party-user/profile-cover.tsx new file mode 100644 index 0000000..e9e528a --- /dev/null +++ b/03_source/frontend/src/sections/party-user/profile-cover.tsx @@ -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 ( + ({ + ...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} + > + + ({ + mx: 'auto', + width: { xs: 64, md: 128 }, + height: { xs: 64, md: 128 }, + border: `solid 2px ${theme.vars.palette.common.white}`, + }), + ]} + > + {name?.charAt(0).toUpperCase()} + + + + + + ); +} diff --git a/03_source/frontend/src/sections/party-user/profile-followers.tsx b/03_source/frontend/src/sections/party-user/profile-followers.tsx new file mode 100644 index 0000000..a28dff3 --- /dev/null +++ b/03_source/frontend/src/sections/party-user/profile-followers.tsx @@ -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(_mockFollowed); + + const handleClick = useCallback( + (item: string) => { + const selected = followed.includes(item) + ? followed.filter((value) => value !== item) + : [...followed, item]; + + setFollowed(selected); + }, + [followed] + ); + + return ( + <> + + Followers + + + + {followers.map((follower) => ( + handleClick(follower.id)} + /> + ))} + + + ); +} + +// ---------------------------------------------------------------------- + +type CardItemProps = CardProps & { + selected: boolean; + onSelected: () => void; + follower: IPartyUserProfileFollower; +}; + +function CardItem({ follower, selected, onSelected, sx, ...other }: CardItemProps) { + return ( + ({ + display: 'flex', + alignItems: 'center', + p: theme.spacing(3, 2, 3, 3), + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + + + + + {follower?.country} + + } + slotProps={{ + primary: { noWrap: true }, + secondary: { + noWrap: true, + sx: { + mt: 0.5, + display: 'flex', + alignItems: 'center', + typography: 'caption', + color: 'text.disabled', + }, + }, + }} + /> + + + ); +} diff --git a/03_source/frontend/src/sections/party-user/profile-friends.tsx b/03_source/frontend/src/sections/party-user/profile-friends.tsx new file mode 100644 index 0000000..07b5307 --- /dev/null +++ b/03_source/frontend/src/sections/party-user/profile-friends.tsx @@ -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) => void; +}; + +export function ProfileFriends({ friends, searchFriends, onSearchFriends }: Props) { + const dataFiltered = applyFilter({ inputData: friends, query: searchFriends }); + + const notFound = !dataFiltered.length && !!searchFriends; + + return ( + <> + + Friends + + + + + ), + }, + }} + sx={{ width: { xs: 1, sm: 260 } }} + /> + + + {notFound ? ( + + ) : ( + + {dataFiltered.map((item) => ( + + ))} + + )} + + ); +} + +// ---------------------------------------------------------------------- + +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 = () => ( + + + + + Delete + + + + + Edit + + + + ); + + return ( + <> + + + + + {item.name} + + + + {item.role} + + + + {_socials.map((social) => ( + + {social.value === 'twitter' && } + {social.value === 'facebook' && } + {social.value === 'instagram' && } + {social.value === 'linkedin' && } + + ))} + + + + + + + + {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())) + ); +} diff --git a/03_source/frontend/src/sections/party-user/profile-gallery.tsx b/03_source/frontend/src/sections/party-user/profile-gallery.tsx new file mode 100644 index 0000000..ed03f05 --- /dev/null +++ b/03_source/frontend/src/sections/party-user/profile-gallery.tsx @@ -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 ( + <> + + Gallery + + + + {gallery.map((image) => ( + + + + + + + + Gallery lightbox.onOpen(image.imageUrl)} + slotProps={{ + overlay: { + sx: (theme) => ({ + backgroundImage: `linear-gradient(to bottom, transparent 0%, ${theme.vars.palette.common.black} 75%)`, + }), + }, + }} + /> + + ))} + + + + + ); +} diff --git a/03_source/frontend/src/sections/party-user/profile-home.tsx b/03_source/frontend/src/sections/party-user/profile-home.tsx new file mode 100644 index 0000000..3b1f69c --- /dev/null +++ b/03_source/frontend/src/sections/party-user/profile-home.tsx @@ -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(null); + const { t } = useTranslation(); + + const handleAttach = () => { + if (fileRef.current) { + fileRef.current.click(); + } + }; + + const renderFollows = () => ( + + } + sx={{ flexDirection: 'row' }} + > + + {fNumber(info.totalFollowers)} + + {t('Follower')} + + + + + {fNumber(info.totalFollowing)} + + {t('Following')} + + + + + ); + + const renderAbout = () => ( + + + + +
    {info.quote}
    + + + + + Live at + +  {info.country} + + + + + + + {info.email} + + + + + + {info.role} at + +  {info.company} + + + + + + + + Studied at + +  {info.school} + + + +
    +
    + ); + + const renderPostInput = () => ( + + ({ + p: 2, + mb: 3, + borderRadius: 1, + border: `solid 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.2)}`, + }), + ]} + /> + + + + + + {t('Image/Video')} + + + + + {t('Streaming')} + + + + + + + + + ); + + const renderSocials = () => ( + + + + + {_socials.map((social) => ( + + {social.value === 'twitter' && } + {social.value === 'facebook' && } + {social.value === 'instagram' && } + {social.value === 'linkedin' && } + + + {social.value === 'facebook' && info.socialLinks.facebook} + {social.value === 'instagram' && info.socialLinks.instagram} + {social.value === 'linkedin' && info.socialLinks.linkedin} + {social.value === 'twitter' && info.socialLinks.twitter} + + + ))} + + + ); + + return ( + + + {renderFollows()} + {renderAbout()} + {renderSocials()} + + + + {renderPostInput()} + + {posts.map((post) => ( + + ))} + + + ); +} diff --git a/03_source/frontend/src/sections/party-user/profile-post-item.tsx b/03_source/frontend/src/sections/party-user/profile-post-item.tsx new file mode 100644 index 0000000..9813cc3 --- /dev/null +++ b/03_source/frontend/src/sections/party-user/profile-post-item.tsx @@ -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(null); + + const fileRef = useRef(null); + + const [message, setMessage] = useState(''); + + const handleChangeMessage = useCallback((event: React.ChangeEvent) => { + setMessage(event.target.value); + }, []); + + const handleAttach = useCallback(() => { + if (fileRef.current) { + fileRef.current.click(); + } + }, []); + + const handleClickComment = useCallback(() => { + if (commentRef.current) { + commentRef.current.focus(); + } + }, []); + + const renderHead = () => ( + + {user?.displayName?.charAt(0).toUpperCase()} + + } + title={ + + {user?.displayName} + + } + subheader={ + + {fDate(post.createdAt)} + + } + action={ + + + + } + /> + ); + + const renderCommentList = () => ( + + {post.comments.map((comment) => ( + + + + + + {comment.author.name} + + + {fDate(comment.createdAt)} + + + + {comment.message} + + + ))} + + ); + + const renderInput = () => ( + ({ + gap: 2, + display: 'flex', + alignItems: 'center', + p: theme.spacing(0, 3, 3, 3), + }), + ]} + > + + {user?.displayName?.charAt(0).toUpperCase()} + + + + + + + + + + + + } + 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)}`, + }), + ]} + /> + + + + ); + + const renderActions = () => ( + ({ display: 'flex', alignItems: 'center', p: theme.spacing(2, 3, 3, 3) })]} + > + } + checkedIcon={} + slotProps={{ + input: { + id: `favorite-${post.id}-checkbox`, + 'aria-label': `Favorite ${post.id} checkbox`, + }, + }} + /> + } + label={fShortenNumber(post.personLikes.length)} + sx={{ mr: 1 }} + /> + + {!!post.personLikes.length && ( + + {post.personLikes.map((person) => ( + + ))} + + )} + + + + + + + + + + + + ); + + return ( + + {renderHead()} + + ({ p: theme.spacing(3, 3, 2, 3) })]}> + {post.message} + + + + {post.media} + + + {renderActions()} + {!!post.comments.length && renderCommentList()} + {renderInput()} + + ); +} diff --git a/03_source/frontend/src/sections/party-user/view/index.ts b/03_source/frontend/src/sections/party-user/view/index.ts new file mode 100644 index 0000000..da32d81 --- /dev/null +++ b/03_source/frontend/src/sections/party-user/view/index.ts @@ -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'; diff --git a/03_source/frontend/src/sections/party-user/view/party-user-cards-view.tsx b/03_source/frontend/src/sections/party-user/view/party-user-cards-view.tsx new file mode 100644 index 0000000..3826cb3 --- /dev/null +++ b/03_source/frontend/src/sections/party-user/view/party-user-cards-view.tsx @@ -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 ( + + } + > + {t('New user')} + + } + sx={{ mb: { xs: 3, md: 5 } }} + /> + + + + ); +} diff --git a/03_source/frontend/src/sections/party-user/view/party-user-create-view.tsx b/03_source/frontend/src/sections/party-user/view/party-user-create-view.tsx new file mode 100644 index 0000000..4cb9aff --- /dev/null +++ b/03_source/frontend/src/sections/party-user/view/party-user-create-view.tsx @@ -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 ( + + + + + + ); +} diff --git a/03_source/frontend/src/sections/party-user/view/party-user-edit-view.tsx b/03_source/frontend/src/sections/party-user/view/party-user-edit-view.tsx new file mode 100644 index 0000000..c9b9d25 --- /dev/null +++ b/03_source/frontend/src/sections/party-user/view/party-user-edit-view.tsx @@ -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 ( + + + + + + ); +} diff --git a/03_source/frontend/src/sections/party-user/view/party-user-list-view.tsx b/03_source/frontend/src/sections/party-user/view/party-user-list-view.tsx new file mode 100644 index 0000000..864ef81 --- /dev/null +++ b/03_source/frontend/src/sections/party-user/view/party-user-list-view.tsx @@ -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(false); + + const table = useTable(); + + const confirmDialog = useBoolean(); + + const [tableData, setTableData] = useState([]); + + useEffect(() => { + setTableData(partyUsers); + }, [partyUsers]); + + const filters = useSetState({ 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 = () => ( + + Are you sure want to delete {table.selected.length} items? + + } + action={ + + } + /> + ); + + return ( + <> + + } + onClick={() => { + setProcessNewUser(true); + router.push(paths.dashboard.partyUser.new); + }} + > + {t('New user')} + + } + sx={{ mb: { xs: 3, md: 5 } }} + /> + + + ({ + px: 2.5, + boxShadow: `inset 0 -2px 0 0 ${varAlpha(theme.vars.palette.grey['500Channel'], 0.08)}`, + }), + ]} + > + {STATUS_OPTIONS.map((tab) => ( + + {['active', 'pending', 'banned', 'rejected'].includes(tab.value) + ? tableData.filter((partyUser) => partyUser.status === tab.value).length + : tableData.length} + + } + /> + ))} + + + + + {canReset && ( + + )} + + + + table.onSelectAllRows( + checked, + dataFiltered.map((row) => row.id) + ) + } + action={ + + + + + + } + /> + + + + + table.onSelectAllRows( + checked, + dataFiltered.map((row) => row.id) + ) + } + /> + + + {dataFiltered + .slice( + table.page * table.rowsPerPage, + table.page * table.rowsPerPage + table.rowsPerPage + ) + .map((row) => ( + table.onSelectRow(row.id)} + onDeleteRow={() => handleDeleteRow(row.id)} + editHref={paths.dashboard.partyUser.edit(row.id)} + /> + ))} + + + + + +
    +
    +
    + + +
    +
    + + {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; +} diff --git a/03_source/frontend/src/sections/party-user/view/party-user-profile-view.tsx b/03_source/frontend/src/sections/party-user/view/party-user-profile-view.tsx new file mode 100644 index 0000000..5b9f0c7 --- /dev/null +++ b/03_source/frontend/src/sections/party-user/view/party-user-profile-view.tsx @@ -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: , + }, + { + value: 'followers', + label: 'Followers', + icon: , + }, + { + value: 'friends', + label: 'Friends', + icon: , + }, + { + value: 'gallery', + label: 'Gallery', + icon: , + }, +]; + +// ---------------------------------------------------------------------- + +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) => { + setSearchFriends(event.target.value); + }, []); + + const createRedirectPath = (currentPath: string, query: string) => { + const queryString = new URLSearchParams({ [TAB_PARAM]: query }).toString(); + return query ? `${currentPath}?${queryString}` : currentPath; + }; + + return ( + + + + + + + + + {NAV_ITEMS.map((tab) => ( + + ))} + + + + + {selectedTab === '' && } + + {selectedTab === 'followers' && } + + {selectedTab === 'friends' && ( + + )} + + {selectedTab === 'gallery' && } + + ); +} diff --git a/03_source/frontend/src/types/party-user.ts b/03_source/frontend/src/types/party-user.ts new file mode 100644 index 0000000..236e534 --- /dev/null +++ b/03_source/frontend/src/types/party-user.ts @@ -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; +};