Compare commits
18 Commits
develop/fr
...
a4d0d8b746
Author | SHA1 | Date | |
---|---|---|---|
![]() |
a4d0d8b746 | ||
![]() |
f8919b8c84 | ||
![]() |
583e31fd4d | ||
![]() |
ae7f005236 | ||
![]() |
834f9360ba | ||
![]() |
1cb018d4d5 | ||
![]() |
448825545e | ||
![]() |
7c7a532381 | ||
![]() |
eb515dbe68 | ||
![]() |
7a793be610 | ||
![]() |
1d89134ea2 | ||
![]() |
44d324b40e | ||
![]() |
ee2a377bf6 | ||
![]() |
0941ab6dd1 | ||
![]() |
e93c5dcf62 | ||
![]() |
17aaf97722 | ||
![]() |
47660be0cd | ||
![]() |
cf5cfb8d63 |
20
01_Requirements/REQ0188/index.md
Normal file
20
01_Requirements/REQ0188/index.md
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
tags: frontend, party-user
|
||||
---
|
||||
|
||||
# REQ0188 frontend party-user
|
||||
|
||||
frontend page to handle party-user (CRUD)
|
||||
|
||||
edit page T.B.A.
|
||||
|
||||
## TODO
|
||||
|
||||
## sources
|
||||
|
||||
T.B.A.
|
||||
|
||||
## branch
|
||||
|
||||
develop/requirements/REQ0188
|
||||
develop/frontend/party-user/trunk
|
@@ -6,6 +6,7 @@ RUN npm install -g pnpm
|
||||
|
||||
RUN apt-get update -y
|
||||
RUN apt-get install -y openssl
|
||||
RUN apt-get install -qqy psmisc
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
@@ -31,17 +31,21 @@ model Account {
|
||||
oauth_token_secret String?
|
||||
oauth_token String?
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
PartyUser PartyUser? @relation(fields: [partyUserId], references: [id])
|
||||
partyUserId String?
|
||||
|
||||
@@unique([provider, providerAccountId])
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
sessionToken String @unique @map("session_token")
|
||||
userId String @map("user_id")
|
||||
id String @id @default(cuid())
|
||||
sessionToken String @unique @map("session_token")
|
||||
userId String @map("user_id")
|
||||
expires DateTime
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
PartyUser PartyUser? @relation(fields: [partyUserId], references: [id])
|
||||
partyUserId String?
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -1257,3 +1261,33 @@ model PartyOrderItem {
|
||||
// OrderPayment OrderPayment[]
|
||||
// OrderShippingAddress OrderShippingAddress[]
|
||||
}
|
||||
|
||||
model PartyUser {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
//
|
||||
username String? @unique
|
||||
password String?
|
||||
//
|
||||
name String?
|
||||
email String @unique
|
||||
emailVerified DateTime?
|
||||
avatarUrl String?
|
||||
bucketImage String?
|
||||
admin Boolean @default(false)
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
info Json?
|
||||
phoneNumber String @default("")
|
||||
company String @default("")
|
||||
status String @default("pending")
|
||||
role String @default("")
|
||||
isVerified Boolean @default(false)
|
||||
//
|
||||
country String @default("")
|
||||
state String @default("")
|
||||
city String @default("")
|
||||
address String @default("")
|
||||
zipCode String @default("")
|
||||
}
|
||||
|
@@ -31,7 +31,9 @@ import { EventReviewSeed } from './seeds/eventReview';
|
||||
import { appLogSeed } from './seeds/AppLog';
|
||||
import { accessLogSeed } from './seeds/AccessLog';
|
||||
import { userMetaSeed } from './seeds/userMeta';
|
||||
//
|
||||
import { partyOrderItemSeed } from './seeds/partyOrderItem';
|
||||
import { partyUserSeed } from './seeds/partyUser';
|
||||
|
||||
//
|
||||
// import { Blog } from './seeds/blog';
|
||||
@@ -60,8 +62,9 @@ import { partyOrderItemSeed } from './seeds/partyOrderItem';
|
||||
//
|
||||
await appLogSeed;
|
||||
await accessLogSeed;
|
||||
|
||||
//
|
||||
await partyOrderItemSeed;
|
||||
await partyUserSeed;
|
||||
|
||||
// await Blog;
|
||||
// await Mail;
|
||||
|
122
03_source/cms_backend/prisma/seeds/partyUser.ts
Normal file
122
03_source/cms_backend/prisma/seeds/partyUser.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { faker as enFaker } from '@faker-js/faker/locale/en_US';
|
||||
import { faker as zhFaker } from '@faker-js/faker/locale/zh_CN';
|
||||
import { faker as jaFaker } from '@faker-js/faker/locale/ja';
|
||||
import { faker as koFaker } from '@faker-js/faker/locale/ko';
|
||||
import { faker as twFaker } from '@faker-js/faker/locale/zh_TW';
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const ROLE = [
|
||||
`CEO`,
|
||||
`CTO`,
|
||||
`Project Coordinator`,
|
||||
`Team Leader`,
|
||||
`Software Developer`,
|
||||
`Marketing Strategist`,
|
||||
`Data Analyst`,
|
||||
`Product Owner`,
|
||||
`Graphic Designer`,
|
||||
`Operations Manager`,
|
||||
`Customer Support Specialist`,
|
||||
`Sales Manager`,
|
||||
`HR Recruiter`,
|
||||
`Business Consultant`,
|
||||
`Financial Planner`,
|
||||
`Network Engineer`,
|
||||
`Content Creator`,
|
||||
`Quality Assurance Tester`,
|
||||
`Public Relations Officer`,
|
||||
`IT Administrator`,
|
||||
`Compliance Officer`,
|
||||
`Event Planner`,
|
||||
`Legal Counsel`,
|
||||
`Training Coordinator`,
|
||||
];
|
||||
|
||||
const STATUS = ['active', 'pending', 'banned'];
|
||||
|
||||
async function partyUser() {
|
||||
const alice = await prisma.partyUser.upsert({
|
||||
where: { email: 'alice@prisma.io' },
|
||||
update: {},
|
||||
create: {
|
||||
email: 'alice@prisma.io',
|
||||
name: 'Alice',
|
||||
username: 'pualice',
|
||||
password: 'Aa12345678',
|
||||
emailVerified: new Date(),
|
||||
phoneNumber: '+85291234567',
|
||||
company: 'helloworld company',
|
||||
status: STATUS[0],
|
||||
role: ROLE[0],
|
||||
isVerified: true,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.partyUser.upsert({
|
||||
where: { email: 'demo@minimals.cc' },
|
||||
update: {},
|
||||
create: {
|
||||
email: 'demo@minimals.cc',
|
||||
name: 'Demo',
|
||||
username: 'pudemo',
|
||||
password: '@2Minimal',
|
||||
emailVerified: new Date(),
|
||||
phoneNumber: '+85291234568',
|
||||
company: 'helloworld company',
|
||||
status: STATUS[1],
|
||||
role: ROLE[1],
|
||||
isVerified: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const CJK_LOCALES = {
|
||||
en: enFaker,
|
||||
zh: zhFaker,
|
||||
ja: jaFaker,
|
||||
ko: koFaker,
|
||||
tw: twFaker,
|
||||
};
|
||||
|
||||
function getRandomCJKFaker() {
|
||||
const locales = Object.keys(CJK_LOCALES);
|
||||
const randomKey = locales[Math.floor(Math.random() * locales.length)] as keyof typeof CJK_LOCALES;
|
||||
return CJK_LOCALES[randomKey];
|
||||
}
|
||||
|
||||
const randomFaker = getRandomCJKFaker();
|
||||
|
||||
await prisma.partyUser.upsert({
|
||||
where: { email: `party_user${i}@prisma.io` },
|
||||
update: {},
|
||||
create: {
|
||||
email: `party_user${i}@prisma.io`,
|
||||
name: `Party Dummy ${i}`,
|
||||
username: `pu${i.toString()}`,
|
||||
password: 'Aa12345678',
|
||||
emailVerified: new Date(),
|
||||
phoneNumber: `+8529123456${i.toString()}`,
|
||||
company: randomFaker.company.name(),
|
||||
role: ROLE[Math.floor(Math.random() * ROLE.length)],
|
||||
status: STATUS[Math.floor(Math.random() * STATUS.length)],
|
||||
isVerified: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log('seed partyUser done');
|
||||
}
|
||||
|
||||
const partyUserSeed = partyUser()
|
||||
.then(async () => {
|
||||
await prisma.$disconnect();
|
||||
})
|
||||
.catch(async (e) => {
|
||||
console.error(e);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
export { partyUserSeed };
|
@@ -7,9 +7,13 @@ clear
|
||||
while true; do
|
||||
yarn db:studio &
|
||||
|
||||
npx nodemon --ext ts,tsx,prisma --exec "yarn db:push && yarn seed && yarn dev"
|
||||
npx nodemon --ext ts,tsx,prisma --exec "yarn dev"
|
||||
# npx nodemon --ext ts,tsx,prisma --exec "yarn db:push && yarn seed && yarn dev"
|
||||
# yarn dev
|
||||
|
||||
killall node
|
||||
killall yarn
|
||||
|
||||
echo "restarting..."
|
||||
sleep 1
|
||||
done
|
||||
|
@@ -4,8 +4,6 @@ yarn --dev
|
||||
|
||||
clear
|
||||
|
||||
while true; do
|
||||
npx nodemon --ext prisma --exec "yarn db:push && yarn seed"
|
||||
echo "restarting..."
|
||||
sleep 1
|
||||
done
|
||||
yarn db:push && yarn seed
|
||||
|
||||
echo "done"
|
||||
|
@@ -2,6 +2,8 @@
|
||||
|
||||
set -x
|
||||
|
||||
killall node
|
||||
killall yarn
|
||||
rm -rf ./**/*Zone.Identifier
|
||||
|
||||
# yarn db:push
|
||||
|
60
03_source/cms_backend/src/app/api/party-user/_GUIDELINES.md
Normal file
60
03_source/cms_backend/src/app/api/party-user/_GUIDELINES.md
Normal file
@@ -0,0 +1,60 @@
|
||||
<!-- NOTES to AI: please maintain same format and level of detail when edit this file -->
|
||||
|
||||
# GUIDELINE
|
||||
|
||||
- Party-user Management API endpoint for managing party-user accounts
|
||||
- Handles party-user CRUD operations and profile updates
|
||||
- Follows Next.js API route conventions
|
||||
|
||||
## Endpoints
|
||||
|
||||
### `create/route.ts`
|
||||
|
||||
Creates new party-user account with required details
|
||||
|
||||
### `delete/route.ts`
|
||||
|
||||
Deletes party-user account by ID
|
||||
|
||||
### `details/route.ts`
|
||||
|
||||
Gets detailed party-user information
|
||||
|
||||
### `helloworld/route.ts`
|
||||
|
||||
Simple test endpoint returning "Hello World"
|
||||
|
||||
### `list/route.ts`
|
||||
|
||||
Lists all party-users with pagination support
|
||||
|
||||
### `search/route.ts`
|
||||
|
||||
Searches party-users by name or email
|
||||
|
||||
### `update/route.ts`
|
||||
|
||||
Updates party-user profile information
|
||||
|
||||
## Testing
|
||||
|
||||
Test files are available per endpoint in their respective directories:
|
||||
|
||||
- `create/test.http`
|
||||
- `delete/test.http`
|
||||
- `details/test.http`
|
||||
- `helloworld/test.http`
|
||||
- `list/test.http`
|
||||
- `update/test.http`
|
||||
|
||||
## Related Services
|
||||
|
||||
`../../services/party-user.service.ts` (assumed) would handle:
|
||||
|
||||
`createUser` - Register new party-user account
|
||||
`updateUser` - Update party-user profile
|
||||
`deleteUser` - Remove party-user account
|
||||
`listUsers` - Get paginated party-user list
|
||||
`searchUsers` - Search party-users by criteria
|
||||
`changeUserRole` - Modify party-user permissions
|
||||
`uploadUserImage` - Handle profile picture uploads
|
34
03_source/cms_backend/src/app/api/party-user/create/route.ts
Normal file
34
03_source/cms_backend/src/app/api/party-user/create/route.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// src/app/api/party-user/create/route.ts
|
||||
//
|
||||
// PURPOSE:
|
||||
// create new party user in db
|
||||
//
|
||||
// RULES:
|
||||
// 1. Validates input data shape
|
||||
// 2. Returns created party user data
|
||||
//
|
||||
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
import { STATUS, response, handleError } from 'src/utils/response';
|
||||
|
||||
import { createPartyUser } from 'src/app/services/party-user.service';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
***************************************
|
||||
* POST - create PartyUser
|
||||
***************************************
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const { partyUserData } = await req.json();
|
||||
|
||||
try {
|
||||
const partyUser = await createPartyUser(partyUserData);
|
||||
|
||||
return response({ partyUser }, STATUS.OK);
|
||||
} catch (error) {
|
||||
return handleError('PartyUser - Create', error);
|
||||
}
|
||||
}
|
@@ -0,0 +1,19 @@
|
||||
###
|
||||
|
||||
POST http://localhost:7272/api/party-user/create
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"partyUserData": {
|
||||
"name": "Alice 123321",
|
||||
"username": null,
|
||||
"email": "alice@123111321.io",
|
||||
"emailVerified": "2025-06-15T17:47:23.919Z",
|
||||
"password": "Aa12345678",
|
||||
"bucketImage": null,
|
||||
"admin": false,
|
||||
"info": null,
|
||||
"phoneNumber": "+85291234567",
|
||||
"avatarUrl": ""
|
||||
}
|
||||
}
|
35
03_source/cms_backend/src/app/api/party-user/delete/route.ts
Normal file
35
03_source/cms_backend/src/app/api/party-user/delete/route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// src/app/api/party-user/delete/route.ts
|
||||
//
|
||||
// PURPOSE:
|
||||
// delete party user from db by id
|
||||
//
|
||||
// RULES:
|
||||
// 1. Requires valid party user ID
|
||||
// 2. Returns deleted party user ID
|
||||
// 3. Handles errors appropriately
|
||||
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
import { STATUS, response, handleError } from 'src/utils/response';
|
||||
|
||||
import { deletePartyUser } from 'src/app/services/party-user.service';
|
||||
|
||||
/**
|
||||
**************************************
|
||||
* PATCH - Delete party user
|
||||
***************************************
|
||||
*/
|
||||
|
||||
export async function PATCH(req: NextRequest) {
|
||||
try {
|
||||
const { partyUserId } = await req.json();
|
||||
|
||||
if (!partyUserId) throw new Error('partyUserId cannot be null');
|
||||
|
||||
await deletePartyUser(partyUserId);
|
||||
|
||||
return response({ partyUserId }, STATUS.OK);
|
||||
} catch (error) {
|
||||
return handleError('PartyUser - Delete', error);
|
||||
}
|
||||
}
|
@@ -0,0 +1,8 @@
|
||||
###
|
||||
|
||||
PATCH http://localhost:7272/api/party-user/delete
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"partyUserId": "cmbxz8t2b000oiigxib3o4jla"
|
||||
}
|
@@ -0,0 +1,43 @@
|
||||
// src/app/api/party-user/details/route.ts
|
||||
//
|
||||
// PURPOSE:
|
||||
// get party user details from db by id
|
||||
//
|
||||
// RULES:
|
||||
// 1. Requires valid partyUserId parameter
|
||||
// 2. Returns party user details if found
|
||||
// 3. Handles not found case appropriately
|
||||
// 4. Logs the operation
|
||||
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
import { logger } from 'src/utils/logger';
|
||||
import { STATUS, response, handleError } from 'src/utils/response';
|
||||
|
||||
import { getPartyUser } from 'src/app/services/party-user.service';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
/** **************************************
|
||||
* GET PartyUser detail
|
||||
*************************************** */
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = req.nextUrl;
|
||||
|
||||
// RULES: userId must exist
|
||||
const partyUserId = searchParams.get('partyUserId');
|
||||
if (!partyUserId) return response({ message: 'partyUserId is required!' }, STATUS.BAD_REQUEST);
|
||||
|
||||
// NOTE: userId confirmed exist, run below
|
||||
const partyUser = await getPartyUser(partyUserId);
|
||||
|
||||
if (!partyUser) return response({ message: 'User not found!' }, STATUS.NOT_FOUND);
|
||||
|
||||
logger('[User] details', partyUser.id);
|
||||
|
||||
return response({ partyUser }, STATUS.OK);
|
||||
} catch (error) {
|
||||
return handleError('PartyUser - Get details', error);
|
||||
}
|
||||
}
|
@@ -0,0 +1,4 @@
|
||||
###
|
||||
|
||||
GET http://localhost:7272/api/party-user/details?partyUserId=cmbxziat6000b715hmhrnwlx2
|
||||
|
@@ -0,0 +1,11 @@
|
||||
import type { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { STATUS, response, handleError } from 'src/utils/response';
|
||||
|
||||
export async function GET(req: NextRequest, res: NextResponse) {
|
||||
try {
|
||||
return response({ helloworld: 'party-user' }, STATUS.OK);
|
||||
} catch (error) {
|
||||
return handleError('GET - helloworld', error);
|
||||
}
|
||||
}
|
@@ -0,0 +1,3 @@
|
||||
###
|
||||
|
||||
GET http://localhost:7272/api/party-user/helloworld
|
33
03_source/cms_backend/src/app/api/party-user/list/route.ts
Normal file
33
03_source/cms_backend/src/app/api/party-user/list/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// src/app/api/party-user/update/route.ts
|
||||
//
|
||||
// PURPOSE:
|
||||
// update existing party user in db
|
||||
//
|
||||
// RULES:
|
||||
// 1. Requires valid party user ID
|
||||
// 2. Validates input data shape
|
||||
//
|
||||
|
||||
import { logger } from 'src/utils/logger';
|
||||
import { STATUS, response, handleError } from 'src/utils/response';
|
||||
|
||||
import { listPartyUsers } from 'src/app/services/party-user.service';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
***************************************
|
||||
* GET - Products
|
||||
***************************************
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const partyUsers = await listPartyUsers();
|
||||
|
||||
logger('[User] list', partyUsers.length);
|
||||
|
||||
return response({ partyUsers }, STATUS.OK);
|
||||
} catch (error) {
|
||||
return handleError('Product - Get list', error);
|
||||
}
|
||||
}
|
@@ -0,0 +1,2 @@
|
||||
###
|
||||
GET http://localhost:7272/api/party-user/list
|
35
03_source/cms_backend/src/app/api/party-user/search/route.ts
Normal file
35
03_source/cms_backend/src/app/api/party-user/search/route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
import { logger } from 'src/utils/logger';
|
||||
import { STATUS, response, handleError } from 'src/utils/response';
|
||||
|
||||
import { _products } from 'src/_mock/_product';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
/** **************************************
|
||||
* GET - Search products
|
||||
*************************************** */
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = req.nextUrl;
|
||||
const query = searchParams.get('query')?.trim().toLowerCase();
|
||||
|
||||
if (!query) {
|
||||
return response({ results: [] }, STATUS.OK);
|
||||
}
|
||||
|
||||
const products = _products();
|
||||
|
||||
// Accept search by name or sku
|
||||
const results = products.filter(({ name, sku }) => name.toLowerCase().includes(query) || sku?.toLowerCase().includes(query));
|
||||
|
||||
logger('[Product] search-results', results.length);
|
||||
|
||||
return response({ results }, STATUS.OK);
|
||||
} catch (error) {
|
||||
return handleError('Product - Get search', error);
|
||||
}
|
||||
}
|
88
03_source/cms_backend/src/app/api/party-user/update/route.ts
Normal file
88
03_source/cms_backend/src/app/api/party-user/update/route.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
// src/app/api/party-user/update/route.ts
|
||||
//
|
||||
// PURPOSE:
|
||||
// update existing party user in db
|
||||
//
|
||||
// RULES:
|
||||
// 1. Requires valid party user ID
|
||||
// 2. Validates input data shape
|
||||
// 3. Returns updated party user data
|
||||
// 4. Handles errors appropriately
|
||||
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
import { STATUS, response, handleError } from 'src/utils/response';
|
||||
|
||||
import { updatePartyUser } from 'src/app/services/party-user.service';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
/** **************************************
|
||||
* PUT - Update PartyUser
|
||||
*************************************** */
|
||||
export async function PUT(req: NextRequest) {
|
||||
// logger('[Product] list', products.length);
|
||||
const { partyUserData } = await req.json();
|
||||
|
||||
try {
|
||||
await updatePartyUser(partyUserData.id, partyUserData);
|
||||
return response({ partyUserData }, STATUS.OK);
|
||||
} catch (error) {
|
||||
return handleError('PartyUser - Update', error);
|
||||
}
|
||||
}
|
||||
|
||||
export type IProductItem = {
|
||||
id: string;
|
||||
sku: string;
|
||||
name: string;
|
||||
code: string;
|
||||
price: number;
|
||||
taxes: number;
|
||||
tags: string[];
|
||||
sizes: string[];
|
||||
publish: string;
|
||||
gender: string[];
|
||||
coverUrl: string;
|
||||
images: string[];
|
||||
colors: string[];
|
||||
quantity: number;
|
||||
category: string;
|
||||
available: number;
|
||||
totalSold: number;
|
||||
description: string;
|
||||
totalRatings: number;
|
||||
totalReviews: number;
|
||||
// createdAt: IDateValue;
|
||||
inventoryType: string;
|
||||
subDescription: string;
|
||||
priceSale: number | null;
|
||||
// reviews: IProductReview[];
|
||||
newLabel: {
|
||||
content: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
saleLabel: {
|
||||
content: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
ratings: {
|
||||
name: string;
|
||||
starCount: number;
|
||||
reviewCount: number;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type IDateValue = string | number | null;
|
||||
|
||||
export type IProductReview = {
|
||||
id: string;
|
||||
name: string;
|
||||
rating: number;
|
||||
comment: string;
|
||||
helpful: number;
|
||||
avatarUrl: string;
|
||||
postedAt: IDateValue;
|
||||
isPurchased: boolean;
|
||||
attachments?: string[];
|
||||
};
|
@@ -0,0 +1,22 @@
|
||||
###
|
||||
|
||||
PUT http://localhost:7272/api/party-user/update
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"partyUserData": {
|
||||
"id": "cmc0cedkx000boln3viy77598",
|
||||
"createdAt": "2025-06-15T17:47:24.547Z",
|
||||
"updatedAt": "2025-06-15T17:47:24.547Z",
|
||||
"name": "Alice 123321",
|
||||
"username": null,
|
||||
"email": "alice@prisma.io",
|
||||
"emailVerified": "2025-06-15T17:47:23.919Z",
|
||||
"password": "Aa12345678",
|
||||
"image": null,
|
||||
"bucketImage": null,
|
||||
"admin": false,
|
||||
"info": null,
|
||||
"phoneNumber": "+85291234567"
|
||||
}
|
||||
}
|
@@ -13,10 +13,7 @@ import type { NextRequest } from 'next/server';
|
||||
import { STATUS, response, handleError } from 'src/utils/response';
|
||||
|
||||
import { isDev } from 'src/constants';
|
||||
|
||||
import prisma from '../../../lib/prisma';
|
||||
import { createProduct } from 'src/app/services/product.service';
|
||||
// import { createProduct } from 'src/app/services/product.service';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
|
81
03_source/cms_backend/src/app/api/user/_GUIDELINES.md
Normal file
81
03_source/cms_backend/src/app/api/user/_GUIDELINES.md
Normal file
@@ -0,0 +1,81 @@
|
||||
<!-- AI: please maintain same format and level of detail when edit this file -->
|
||||
|
||||
# GUIDELINE
|
||||
|
||||
- User Management API endpoint for managing user accounts
|
||||
- Handles user CRUD operations, role management and profile updates
|
||||
- Follows Next.js API route conventions
|
||||
|
||||
## `route.ts`
|
||||
|
||||
Main user route handler with HTTP methods:
|
||||
|
||||
- `GET` - Get current user details
|
||||
- `POST` - Create new user account
|
||||
- `PUT` - Update user profile
|
||||
- `DELETE` - Delete user account
|
||||
|
||||
## Sub-routes
|
||||
|
||||
### `changeToAdmin/route.ts`
|
||||
|
||||
Changes user role to admin
|
||||
|
||||
### `changeToUser/route.ts`
|
||||
|
||||
Changes user role to regular user
|
||||
|
||||
### `checkAdmin/route.ts`
|
||||
|
||||
Verifies if user has admin privileges
|
||||
|
||||
### `createUser/route.ts`
|
||||
|
||||
Creates new user account with required details
|
||||
|
||||
### `deleteUser/route.ts`
|
||||
|
||||
Deletes user account by ID
|
||||
|
||||
### `details/route.ts`
|
||||
|
||||
Gets detailed user information
|
||||
|
||||
### `list/route.ts`
|
||||
|
||||
Lists all users with pagination support
|
||||
|
||||
### `saveUser/route.ts`
|
||||
|
||||
Saves user profile changes
|
||||
|
||||
### `search/route.ts`
|
||||
|
||||
Searches users by name or email
|
||||
|
||||
### `image/upload/route.ts`
|
||||
|
||||
Handles user profile picture uploads
|
||||
|
||||
## `test.http`
|
||||
|
||||
Contains test requests for:
|
||||
|
||||
- User authentication
|
||||
- Role changes (admin/user)
|
||||
- Profile updates
|
||||
- Account creation/deletion
|
||||
- User listing/searching
|
||||
- Image uploads
|
||||
|
||||
## Related Services
|
||||
|
||||
`../../services/user.service.ts` (assumed) would handle:
|
||||
|
||||
`createUser` - Register new user account
|
||||
`updateUser` - Update user profile
|
||||
`deleteUser` - Remove user account
|
||||
`listUsers` - Get paginated user list
|
||||
`searchUsers` - Search users by criteria
|
||||
`changeUserRole` - Modify user permissions
|
||||
`uploadUserImage` - Handle profile picture uploads
|
@@ -1,13 +1,13 @@
|
||||
Hi,
|
||||
|
||||
i copied from
|
||||
`03_source/cms_backend/src/app/services/party-event.service.ts`
|
||||
`03_source/cms_backend/src/app/services/user.service.ts`
|
||||
to
|
||||
`03_source/cms_backend/src/app/services/party-order.service.ts`
|
||||
`03_source/cms_backend/src/app/services/party-user.service.ts`
|
||||
|
||||
with knowledge in `schema.prisma` file, and reference to the sibling files in same folder
|
||||
|
||||
i want you to update `party-order.service.ts` content to handle party order (the purchase order of the party)
|
||||
please use the model `PartyOrderItem` to handle it.
|
||||
i want you to update `party-user.service.ts` content to handle `party user` (the user joining party)
|
||||
please use the model `PartyUser` to handle it.
|
||||
|
||||
thanks.
|
||||
|
76
03_source/cms_backend/src/app/services/party-user.service.ts
Normal file
76
03_source/cms_backend/src/app/services/party-user.service.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
// src/app/services/user.service.ts
|
||||
//
|
||||
// PURPOSE:
|
||||
// - Handle User Record CRUD operations
|
||||
//
|
||||
// RULES:
|
||||
// - Follow Prisma best practices for database operations
|
||||
// - Validate input data before processing
|
||||
//
|
||||
|
||||
import type { User, PartyUser } from '@prisma/client';
|
||||
|
||||
import prisma from '../lib/prisma';
|
||||
|
||||
type CreateUser = {
|
||||
email: string;
|
||||
// name?: string;
|
||||
// password: string;
|
||||
// role?: Role;
|
||||
// isEmailVerified?: boolean;
|
||||
// admin?: boolean;
|
||||
};
|
||||
|
||||
type UpdateUser = {
|
||||
email?: string;
|
||||
// name?: string;
|
||||
// password?: string;
|
||||
// role?: Role;
|
||||
// isEmailVerified?: boolean;
|
||||
isAdmin?: boolean;
|
||||
};
|
||||
|
||||
async function listPartyUsers(): Promise<PartyUser[]> {
|
||||
return prisma.partyUser.findMany({});
|
||||
}
|
||||
|
||||
async function getPartyUser(partyUserId: string): Promise<PartyUser | null> {
|
||||
return prisma.partyUser.findUnique({
|
||||
where: { id: partyUserId },
|
||||
// include: { reviews: true },
|
||||
});
|
||||
}
|
||||
|
||||
async function getUserById(id: string): Promise<User | null> {
|
||||
return prisma.user.findFirst({ where: { id } });
|
||||
}
|
||||
|
||||
async function createPartyUser(partyUserData: any): Promise<PartyUser> {
|
||||
return prisma.partyUser.create({ data: partyUserData });
|
||||
}
|
||||
|
||||
async function updatePartyUser(partyUserId: string, partyUserData: any): Promise<PartyUser | null> {
|
||||
return prisma.partyUser.update({
|
||||
where: { id: partyUserId },
|
||||
data: partyUserData,
|
||||
});
|
||||
}
|
||||
|
||||
async function deletePartyUser(partyUserId: string): Promise<PartyUser | null> {
|
||||
return prisma.partyUser.delete({
|
||||
where: { id: partyUserId },
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
getUserById,
|
||||
getPartyUser,
|
||||
listPartyUsers,
|
||||
createPartyUser,
|
||||
updatePartyUser,
|
||||
deletePartyUser,
|
||||
//
|
||||
type CreateUser,
|
||||
type UpdateUser,
|
||||
//
|
||||
};
|
@@ -6,6 +6,7 @@ set -ex
|
||||
DOCKER_COMPOSE_FILES=" -f docker-compose.yml -f docker-compose.dev.yml"
|
||||
|
||||
# docker compose $DOCKER_COMPOSE_FILES build
|
||||
# docker compose $DOCKER_COMPOSE_FILES push
|
||||
docker compose $DOCKER_COMPOSE_FILES up -d
|
||||
|
||||
# cd ../api_server
|
||||
|
40
03_source/frontend/.env.example
Normal file
40
03_source/frontend/.env.example
Normal file
@@ -0,0 +1,40 @@
|
||||
# Server url
|
||||
# Local server configuration at: https://docs.minimals.cc/mock-server/
|
||||
VITE_SERVER_URL=http://localhost:7272
|
||||
# VITE_SERVER_URL=https://api-dev-minimal-v630.pages.dev
|
||||
|
||||
# Public resource directory
|
||||
VITE_ASSETS_DIR=
|
||||
|
||||
# Mapbox
|
||||
VITE_MAPBOX_API_KEY=
|
||||
|
||||
# Firebase
|
||||
VITE_FIREBASE_API_KEY=
|
||||
VITE_FIREBASE_AUTH_DOMAIN=
|
||||
VITE_FIREBASE_PROJECT_ID=
|
||||
VITE_FIREBASE_STORAGE_BUCKET=
|
||||
VITE_FIREBASE_MESSAGING_SENDER_ID=
|
||||
VITE_FIREBASE_APPID=
|
||||
|
||||
# Aws
|
||||
VITE_AWS_AMPLIFY_USER_POOL_ID=
|
||||
VITE_AWS_AMPLIFY_USER_POOL_WEB_CLIENT_ID=
|
||||
VITE_AWS_AMPLIFY_REGION=
|
||||
|
||||
# Auth0
|
||||
VITE_AUTH0_DOMAIN=
|
||||
VITE_AUTH0_CLIENT_ID=
|
||||
VITE_AUTH0_CALLBACK_URL=
|
||||
|
||||
# Supabase
|
||||
VITE_SUPABASE_URL=https://prhsilyjzxbkufchywxt.supabase.co
|
||||
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InByaHNpbHlqenhia3VmY2h5d3h0Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzY2NjM5MTEsImV4cCI6MjA1MjIzOTkxMX0.UBvxEhKEMtsRuDWBSDTglfnupKf9fyPI9IvQBxS5F6U
|
||||
|
||||
# Google Calendar
|
||||
GOOGLE_CLIENT_ID=463956183839-i7j5nt5rkpbm4npukg21vfnhav5vvgeh.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=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
|
||||
GOOGLE_CALENDAR_API_KEY=AIzaSyDh4-SOuKTopXe45oDM9nQA7R4cNJ1So0c
|
||||
|
||||
VITE_GOOGLE_CLIENT_ID=463956183839-i7j5nt5rkpbm4npukg21vfnhav5vvgeh.apps.googleusercontent.com
|
||||
VITE_GOOGLE_CALENDAR_API_KEY=AIzaSyDh4-SOuKTopXe45oDM9nQA7R4cNJ1So0c
|
@@ -3,6 +3,8 @@ FROM node:20-slim
|
||||
|
||||
# Install pnpm globally
|
||||
RUN npm install -g pnpm
|
||||
RUN apt-get update
|
||||
RUN apt-get install -qqy psmisc
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
@@ -10,6 +10,9 @@ while true; do
|
||||
|
||||
yarn dev --force --clearScreen
|
||||
|
||||
killall node
|
||||
killall yarn
|
||||
|
||||
echo "restarting..."
|
||||
sleep 1
|
||||
done
|
||||
|
@@ -2,6 +2,8 @@
|
||||
|
||||
set -x
|
||||
|
||||
killall node
|
||||
killall yarn
|
||||
rm -rf ./**/*Zone.Identifier
|
||||
|
||||
set -ex
|
||||
|
@@ -1,4 +1,4 @@
|
||||
// src/actions/product.ts
|
||||
// src/actions/party-event.ts
|
||||
//
|
||||
import { useMemo } from 'react';
|
||||
import axiosInstance, { endpoints, fetcher } from 'src/lib/axios';
|
||||
@@ -74,6 +74,7 @@ type SearchResultsData = {
|
||||
results: IPartyEventItem[];
|
||||
};
|
||||
|
||||
// TODO: update useSearchPartyEvents
|
||||
export function useSearchProducts(query: string) {
|
||||
const url = query ? [endpoints.product.search, { params: { query } }] : '';
|
||||
|
||||
@@ -135,7 +136,6 @@ export async function updatePartyEvent(partyEventData: Partial<IPartyEventItem>)
|
||||
/**
|
||||
* Work in local
|
||||
*/
|
||||
|
||||
mutate(
|
||||
endpoints.partyEvent.list,
|
||||
(currentData: any) => {
|
||||
@@ -163,7 +163,6 @@ export async function deletePartyEvent(partyEventId: string) {
|
||||
/**
|
||||
* Work in local
|
||||
*/
|
||||
|
||||
mutate(
|
||||
endpoints.partyEvent.list,
|
||||
(currentData: any) => {
|
||||
|
237
03_source/frontend/src/actions/party-user.ts
Normal file
237
03_source/frontend/src/actions/party-user.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
// src/actions/party-user.ts
|
||||
//
|
||||
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 { SWRConfiguration } from 'swr';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const swrOptions: SWRConfiguration = {
|
||||
revalidateIfStale: false,
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type PartyUsersData = {
|
||||
partyUsers: IPartyUserItem[];
|
||||
};
|
||||
|
||||
// TODO: i want to refactor / tidy here
|
||||
export function useGetPartyUsers() {
|
||||
const url = endpoints.partyUser.list;
|
||||
|
||||
const { data, isLoading, error, isValidating } = useSWR<PartyUsersData>(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 { data, isLoading, error, isValidating } = useSWR<PartyUserData>(
|
||||
endpoints.partyUser.detailsByPartyUserId(partyUserId),
|
||||
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[];
|
||||
};
|
||||
|
||||
// TODO: update useSearchProducts
|
||||
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 createPartyUser(partyUserData: CreateUserData) {
|
||||
/**
|
||||
* Work on server
|
||||
*/
|
||||
const data = { partyUserData };
|
||||
const {
|
||||
data: { id },
|
||||
} = await axiosInstance.post(endpoints.partyUser.create, data);
|
||||
|
||||
/**
|
||||
* Work in local
|
||||
*/
|
||||
mutate(
|
||||
endpoints.partyUser.list,
|
||||
(currentData: any) => {
|
||||
const currentPartyUsers: IPartyUserItem[] = currentData?.partyUsers;
|
||||
|
||||
const partyUsers = [...currentPartyUsers, { ...partyUserData, id }];
|
||||
|
||||
return { ...currentData, partyUsers };
|
||||
},
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export async function updatePartyUser(partyUserData: Partial<IPartyUserItem>) {
|
||||
/**
|
||||
* Work on server
|
||||
*/
|
||||
const data = { partyUserData };
|
||||
await axiosInstance.put(endpoints.partyUser.update, data);
|
||||
|
||||
/**
|
||||
* Work in local
|
||||
*/
|
||||
mutate(
|
||||
endpoints.partyUser.list,
|
||||
(currentData: any) => {
|
||||
const currentPartyUsers: IPartyUserItem[] = currentData?.partyUsers;
|
||||
|
||||
const partyUsers = currentPartyUsers.map((partyUser) =>
|
||||
partyUser.id === partyUserData.id ? { ...partyUser, ...partyUserData } : partyUser
|
||||
);
|
||||
|
||||
return { ...currentData, partyUsers };
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
const partyUserId: string = partyUserData.id || '';
|
||||
mutate(
|
||||
endpoints.partyUser.detailsByPartyUserId(partyUserId),
|
||||
(currentData: any) => {
|
||||
const currentPartyUser: IPartyUserItem = currentData?.partyUser;
|
||||
|
||||
console.log({ currentPartyUser });
|
||||
const partyUser = partyUserData;
|
||||
|
||||
return { ...currentData, partyUser };
|
||||
},
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
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 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 currentPartyUsers: IPartyUserItem[] = currentData?.partyUsers;
|
||||
|
||||
const partyUsers = currentPartyUsers.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,15 @@ 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',
|
||||
//
|
||||
detailsByPartyUserId: (partyUserId: string) =>
|
||||
`/api/party-user/details?partyUserId=${partyUserId}`,
|
||||
},
|
||||
};
|
||||
|
@@ -22,7 +22,7 @@ i18next
|
||||
.init({
|
||||
...i18nOptions(lng),
|
||||
detection: { caches: ['localStorage'] },
|
||||
debug: isDev,
|
||||
debug: false,
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
@@ -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/party-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,304 @@
|
||||
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 { createPartyUser, deletePartyUser, updatePartyUser } 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!' }).optional().or(zod.literal('')),
|
||||
city: zod.string().min(1, { message: 'City is required!' }).optional().or(zod.literal('')),
|
||||
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!' }).optional().or(zod.literal('')),
|
||||
status: zod.string(),
|
||||
address: zod.string().min(1, { message: 'Address is required!' }).optional().or(zod.literal('')),
|
||||
country: schemaHelper
|
||||
.nullableInput(zod.string().min(1, { message: 'Country is required!' }), {
|
||||
// message for null value
|
||||
message: 'Country is required!',
|
||||
})
|
||||
.optional()
|
||||
.or(zod.literal('')),
|
||||
zipCode: zod.string().min(1, { message: 'Zip code is required!' }).optional().or(zod.literal('')),
|
||||
company: zod.string().min(1, { message: 'Company is required!' }).optional().or(zod.literal('')),
|
||||
avatarUrl: zod.string().optional().or(zod.literal('')),
|
||||
phoneNumber: zod.string().optional().or(zod.literal('')),
|
||||
isVerified: zod.boolean().default(true),
|
||||
//
|
||||
username: zod.string().optional().or(zod.literal('')),
|
||||
password: zod.string().optional().or(zod.literal('')),
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
currentUser?: IPartyUserItem;
|
||||
};
|
||||
|
||||
export function PartyUserNewEditForm({ currentUser }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const defaultValues: NewUserSchemaType = {
|
||||
status: '',
|
||||
avatarUrl: '',
|
||||
name: '新用戶名字',
|
||||
email: 'user@123.com',
|
||||
phoneNumber: '',
|
||||
country: '',
|
||||
state: '',
|
||||
city: '',
|
||||
address: '',
|
||||
zipCode: '',
|
||||
company: '',
|
||||
role: 'user',
|
||||
// Email is verified
|
||||
isVerified: true,
|
||||
//
|
||||
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('party user deleted'));
|
||||
router.push(paths.dashboard.partyUser.list);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
setDisableDeleteUserButton(false);
|
||||
};
|
||||
|
||||
const onSubmit = handleSubmit(async (data: any) => {
|
||||
try {
|
||||
const temp: any = data.avatarUrl;
|
||||
if (temp instanceof File) {
|
||||
data.avatarUrl = await fileToBase64(temp);
|
||||
}
|
||||
|
||||
const sanitizedValues: IPartyUserItem = values as unknown as IPartyUserItem;
|
||||
|
||||
if (currentUser) {
|
||||
// perform
|
||||
await updatePartyUser(sanitizedValues);
|
||||
} else {
|
||||
// perform create
|
||||
await createPartyUser(sanitizedValues);
|
||||
}
|
||||
|
||||
toast.success(currentUser ? t('Update success!') : t('Create success!'));
|
||||
|
||||
router.push(paths.dashboard.partyUser.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="username" label={t('username')} />
|
||||
<Field.Text name="password" label={t('password')} />
|
||||
|
||||
<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')} required={false} />
|
||||
<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 PartyUserQuickEditForm({ 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 PartyUserTableFiltersResult({ 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 { PartyUserQuickEditForm } from './party-user-quick-edit-form';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type Props = {
|
||||
row: IPartyUserItem;
|
||||
selected: boolean;
|
||||
editHref: string;
|
||||
onSelectRow: () => void;
|
||||
onDeleteRow: () => void;
|
||||
};
|
||||
|
||||
export function PartyUserTableRow({ row, selected, editHref, onSelectRow, onDeleteRow }: Props) {
|
||||
const menuActions = usePopover();
|
||||
const confirmDialog = useBoolean();
|
||||
const quickEditForm = useBoolean();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const renderQuickEditForm = () => (
|
||||
<PartyUserQuickEditForm
|
||||
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 PartyUserTableToolbar({ 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 { PartyUserNewEditForm } from '../party-user-new-edit-form';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export function UserCreateView() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<DashboardContent>
|
||||
<CustomBreadcrumbs
|
||||
heading={t('Create a new party user')}
|
||||
links={[
|
||||
//
|
||||
{ name: t('Dashboard'), href: paths.dashboard.root },
|
||||
{ name: t('Party User'), href: paths.dashboard.user.root },
|
||||
{ name: t('New') },
|
||||
]}
|
||||
sx={{ mb: { xs: 3, md: 5 } }}
|
||||
/>
|
||||
|
||||
<PartyUserNewEditForm />
|
||||
</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 { PartyUserNewEditForm } 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.partyUser.list}
|
||||
links={[
|
||||
{ name: t('Dashboard'), href: paths.dashboard.root },
|
||||
{ name: t('Party User'), href: paths.dashboard.partyUser.root },
|
||||
{ name: currentUser?.name },
|
||||
]}
|
||||
sx={{ mb: { xs: 3, md: 5 } }}
|
||||
/>
|
||||
|
||||
<PartyUserNewEditForm currentUser={currentUser} />
|
||||
</DashboardContent>
|
||||
);
|
||||
}
|
@@ -0,0 +1,354 @@
|
||||
// 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 { PartyUserTableFiltersResult } from '../party-user-table-filters-result';
|
||||
import { PartyUserTableRow } from '../party-user-table-row';
|
||||
import { PartyUserTableToolbar } 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('PartyUser'), 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>
|
||||
|
||||
<PartyUserTableToolbar
|
||||
filters={filters}
|
||||
onResetPage={table.onResetPage}
|
||||
options={{ roles: _roles }}
|
||||
/>
|
||||
|
||||
{canReset && (
|
||||
<PartyUserTableFiltersResult
|
||||
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) => (
|
||||
<PartyUserTableRow
|
||||
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>
|
||||
);
|
||||
}
|
@@ -188,10 +188,11 @@ export function ProductNewEditForm({ currentProduct }: Props) {
|
||||
const values = watch();
|
||||
|
||||
const onSubmit = handleSubmit(async (data) => {
|
||||
const updatedData = {
|
||||
...data,
|
||||
taxes: includeTaxes ? defaultValues.taxes : data.taxes,
|
||||
};
|
||||
// TODO: remove unused code
|
||||
// const updatedData = {
|
||||
// ...data,
|
||||
// taxes: includeTaxes ? defaultValues.taxes : data.taxes,
|
||||
// };
|
||||
|
||||
try {
|
||||
// sanitize file field
|
||||
|
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;
|
||||
};
|
17
03_source/mobile/dockerfile
Normal file
17
03_source/mobile/dockerfile
Normal file
@@ -0,0 +1,17 @@
|
||||
# Use official Node 18 base image
|
||||
FROM node:20-slim
|
||||
|
||||
# Install pnpm globally
|
||||
# RUN npm install -g pnpm
|
||||
RUN apt update
|
||||
RUN apt-get install -qqy psmisc
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy your application code (optional, comment out if not needed)
|
||||
# COPY . /app
|
||||
# RUN yarn
|
||||
|
||||
# Default command (optional)
|
||||
CMD ["npm run start"]
|
@@ -7,6 +7,9 @@ clear
|
||||
while true; do
|
||||
npm run dev
|
||||
|
||||
killall node
|
||||
killall yarn
|
||||
|
||||
echo "restarting..."
|
||||
sleep 1
|
||||
done
|
||||
|
@@ -2,10 +2,12 @@
|
||||
|
||||
set -x
|
||||
|
||||
npm i -D
|
||||
|
||||
killall node
|
||||
killall yarn
|
||||
rm -rf ./**/*Zone.Identifier
|
||||
|
||||
npm i -D
|
||||
|
||||
set -ex
|
||||
|
||||
npm run format
|
||||
|
@@ -23,3 +23,9 @@ A: No, no don't need to, user will handle the remaining modifications. please re
|
||||
|
||||
Q: when user want you to replace something, where should you start ?
|
||||
A: you should look for a `helloworld` example and start with it when available. by doing this, you can get familiar to the user coding style and convention.
|
||||
|
||||
Q: shall AI introduce new format or ideas?
|
||||
A: No, AI should not. Most of the time the user passing the update job. Unless user mentioned explicitly. AI only need to do the replacement following the format and convention is good enough.
|
||||
|
||||
Q: What should I do when user ask to update git staged files ?
|
||||
A: You only need to update the comment with same format and detail levels of the staged files. Do not change any other code. The sibling files in the same directory is a good reference.
|
||||
|
Reference in New Issue
Block a user